mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-08 08:07:53 -10:00
Compare commits
867
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a8c60b6df | ||
|
|
4693954aa1 | ||
|
|
0208c75ef4 | ||
|
|
71527fa938 | ||
|
|
d6adbe1cb5 | ||
|
|
bc93f7da59 | ||
|
|
8b843008eb | ||
|
|
8bd015284b | ||
|
|
b97e677cbc | ||
|
|
117fbcd1aa | ||
|
|
5bafa93fa0 | ||
|
|
ae4660dfdf | ||
|
|
141b1e0cf5 | ||
|
|
bf4d46df7e | ||
|
|
4bf451bd2d | ||
|
|
4c8178f4fd | ||
|
|
f38db18183 | ||
|
|
919c6803c5 | ||
|
|
abce5d7864 | ||
|
|
54ac508d90 | ||
|
|
2f6babf098 | ||
|
|
df051f7089 | ||
|
|
706fc7aa13 | ||
|
|
db0a98d645 | ||
|
|
b8eb63db68 | ||
|
|
ce18dafd4e | ||
|
|
971af532f9 | ||
|
|
bdf9cb8eb0 | ||
|
|
1b90753fab | ||
|
|
b890329854 | ||
|
|
e618347e38 | ||
|
|
dbf445db2d | ||
|
|
bf702cfe22 | ||
|
|
03805b8eeb | ||
|
|
c081525853 | ||
|
|
6a6acac27c | ||
|
|
c5e9265bf0 | ||
|
|
421834b82e | ||
|
|
a98cf88458 | ||
|
|
79aca92478 | ||
|
|
48d29b20f1 | ||
|
|
0229716f22 | ||
|
|
a3809490f6 | ||
|
|
8e49a414c2 | ||
|
|
33e56c7a56 | ||
|
|
37a1e8859f | ||
|
|
6d666c30e2 | ||
|
|
4bd1313677 | ||
|
|
6eeeb454f5 | ||
|
|
8eb2ecfa76 | ||
|
|
c3071c7b61 | ||
|
|
80cb00a494 | ||
|
|
fcea81151c | ||
|
|
14d453048e | ||
|
|
224b99b724 | ||
|
|
e8ecd77cc8 | ||
|
|
97b6e16684 | ||
|
|
fdc7593180 | ||
|
|
b1dcbdc0eb | ||
|
|
450ff69b4e | ||
|
|
49aa9dd00b | ||
|
|
b3fdebcb92 | ||
|
|
3ffaf67e22 | ||
|
|
3d68cb4d71 | ||
|
|
bd665f28ad | ||
|
|
2982f6eb9a | ||
|
|
499a08ba81 | ||
|
|
7f54b8abb4 | ||
|
|
573140cc5a | ||
|
|
bdd6275570 | ||
|
|
588c0ea77a | ||
|
|
3f08e59ad2 | ||
|
|
518ecfb511 | ||
|
|
6daad36de2 | ||
|
|
91635b6852 | ||
|
|
58cdd754cd | ||
|
|
0e15c274da | ||
|
|
3b6972a40a | ||
|
|
829c4021a3 | ||
|
|
7a12e4444a | ||
|
|
c3e9ec54a9 | ||
|
|
4b09c9d7d1 | ||
|
|
1884052eda | ||
|
|
0801c954b5 | ||
|
|
5200d36a7b | ||
|
|
2204e39515 | ||
|
|
cfff58e66f | ||
|
|
c1255761d6 | ||
|
|
a88e84f3c9 | ||
|
|
33f0fa8c94 | ||
|
|
42ff9e6482 | ||
|
|
4f3457a4dc | ||
|
|
117241eebb | ||
|
|
3d23a9ff95 | ||
|
|
4bf65778b6 | ||
|
|
cd44c4f495 | ||
|
|
768099d32c | ||
|
|
9d53e6ce35 | ||
|
|
194970773d | ||
|
|
c8012dff3a | ||
|
|
04da927a44 | ||
|
|
67c4e5eb1a | ||
|
|
12930d6237 | ||
|
|
945ad05579 | ||
|
|
9bba7215f5 | ||
|
|
f822855c64 | ||
|
|
0fff625c22 | ||
|
|
d52705984a | ||
|
|
73c8eb9b60 | ||
|
|
a305d38fc5 | ||
|
|
14b7b252ac | ||
|
|
393ff56184 | ||
|
|
e515379b04 | ||
|
|
eb19c2723b | ||
|
|
ccc323a1b7 | ||
|
|
d242616d68 | ||
|
|
d1a62e68ec | ||
|
|
6175960663 | ||
|
|
7eaa770716 | ||
|
|
7d6a05e18f | ||
|
|
fab10b1922 | ||
|
|
8a363878ee | ||
|
|
d7f9535406 | ||
|
|
c770074449 | ||
|
|
9226f507a3 | ||
|
|
8169314c66 | ||
|
|
7b581fb43c | ||
|
|
c32dddb1fe | ||
|
|
5b1d64d4f7 | ||
|
|
55db71a9ff | ||
|
|
9751bdf0ab | ||
|
|
bf0d6dd423 | ||
|
|
91523637ac | ||
|
|
d64f34c96c | ||
|
|
74904e32c1 | ||
|
|
7c8e0d8e9b | ||
|
|
8974e07eec | ||
|
|
6da84cf195 | ||
|
|
2403ca40a2 | ||
|
|
e7b19995ea | ||
|
|
f47c14d93f | ||
|
|
a5f4e32bfc | ||
|
|
ca0888663e | ||
|
|
cdd3254dbb | ||
|
|
410bde729b | ||
|
|
90aded96a3 | ||
|
|
7b33479e15 | ||
|
|
7c1e0f9794 | ||
|
|
0c9e995257 | ||
|
|
97032398df | ||
|
|
a8f0a64bd6 | ||
|
|
d3e0774ca3 | ||
|
|
c3fdf7192d | ||
|
|
59b1c33bb2 | ||
|
|
d1d99be74f | ||
|
|
cc4c49e8f9 | ||
|
|
f9e5264df7 | ||
|
|
668419b92a | ||
|
|
f5de793b38 | ||
|
|
bfda2bf165 | ||
|
|
5782df1c03 | ||
|
|
d3c6f0d937 | ||
|
|
baa379d717 | ||
|
|
52a380cd2f | ||
|
|
150412d353 | ||
|
|
5fb9092073 | ||
|
|
28a65af5fc | ||
|
|
a6751f2aa9 | ||
|
|
ea60e1e062 | ||
|
|
047815c5d7 | ||
|
|
d22aecd526 | ||
|
|
5f931e3659 | ||
|
|
b9ed9d58de | ||
|
|
e40ea748d1 | ||
|
|
9afb898e80 | ||
|
|
01b4e5cbc4 | ||
|
|
84c3af5122 | ||
|
|
b6b9a5a307 | ||
|
|
7ebf7d26cb | ||
|
|
d8e912738b | ||
|
|
cbea22fbb6 | ||
|
|
c7aca8489f | ||
|
|
319876a186 | ||
|
|
9093960719 | ||
|
|
68e57e86c5 | ||
|
|
67a960b045 | ||
|
|
36ca9df57b | ||
|
|
ab0309581c | ||
|
|
20acff0b3c | ||
|
|
1ea5ec5964 | ||
|
|
8168d96082 | ||
|
|
46ac49ad05 | ||
|
|
d23ab5ae50 | ||
|
|
9ad4c9fb8b | ||
|
|
27e16c3e01 | ||
|
|
c7065027b1 | ||
|
|
b97e39e074 | ||
|
|
2fb276d5f6 | ||
|
|
1deaac7263 | ||
|
|
26f099d168 | ||
|
|
ef66d5b892 | ||
|
|
b732e582b9 | ||
|
|
f145c903b6 | ||
|
|
d009ebd140 | ||
|
|
7830c6de4a | ||
|
|
0c49146fc8 | ||
|
|
cf558b0c0a | ||
|
|
1fb8e84561 | ||
|
|
0f9adac090 | ||
|
|
cbbdf773e2 | ||
|
|
e1207bb017 | ||
|
|
94a737918a | ||
|
|
7ed5855e4a | ||
|
|
e3cbe2688f | ||
|
|
7dc72d6c33 | ||
|
|
f4e1a98127 | ||
|
|
e5087bdb5a | ||
|
|
0e7918fb88 | ||
|
|
e5076cd6ab | ||
|
|
73558e3673 | ||
|
|
3d4cade73b | ||
|
|
1332c90868 | ||
|
|
1b38710e92 | ||
|
|
aaa301b2e5 | ||
|
|
3052227936 | ||
|
|
95a366fe89 | ||
|
|
fc0e275414 | ||
|
|
5dc0515248 | ||
|
|
20a3c3e399 | ||
|
|
0a6020695b | ||
|
|
dfde0ac091 | ||
|
|
dc274d681c | ||
|
|
246a071dbc | ||
|
|
1e965ec5a8 | ||
|
|
bc5fd427ab | ||
|
|
a7ff35cf7b | ||
|
|
3e31a55950 | ||
|
|
4e910746ce | ||
|
|
06254caea3 | ||
|
|
7b78be8d4e | ||
|
|
73602536bb | ||
|
|
d601fc2e54 | ||
|
|
9ca1b032ce | ||
|
|
5b8898d018 | ||
|
|
28a4e5443f | ||
|
|
dc05ba0cc7 | ||
|
|
2c8b7ee172 | ||
|
|
74cf4f8c53 | ||
|
|
335d11e35e | ||
|
|
cc7bcbf4e5 | ||
|
|
182b9a1473 | ||
|
|
cb3f4387db | ||
|
|
db0d65ee3c | ||
|
|
b328f4d660 | ||
|
|
37f8b6a525 | ||
|
|
3a9d71b2db | ||
|
|
0a1a6908c0 | ||
|
|
95c234cc1e | ||
|
|
201ae4d255 | ||
|
|
918ff50c7a | ||
|
|
e2ce8e42a6 | ||
|
|
5815bf4f11 | ||
|
|
9c21a95409 | ||
|
|
91ae68828a | ||
|
|
a6bcea674c | ||
|
|
938ae3ca47 | ||
|
|
5b87045fee | ||
|
|
25776d1a4c | ||
|
|
247b67a77d | ||
|
|
47e8375fd1 | ||
|
|
e4a131f5c7 | ||
|
|
4f792eb7de | ||
|
|
e431a41ab1 | ||
|
|
dfabf62567 | ||
|
|
7dd05db9be | ||
|
|
d0f6223a90 | ||
|
|
c457259ae2 | ||
|
|
3e1f39899c | ||
|
|
088107b407 | ||
|
|
ec86a343c5 | ||
|
|
21fdd33cdf | ||
|
|
07cd6c90a8 | ||
|
|
ee6cf6acb8 | ||
|
|
b0831e54c8 | ||
|
|
f1341139ff | ||
|
|
4f9cc17e91 | ||
|
|
8913deaa76 | ||
|
|
66d731cbf0 | ||
|
|
acb209dbf7 | ||
|
|
74f0265db1 | ||
|
|
fe47804f28 | ||
|
|
e8214a02aa | ||
|
|
49f4359fcf | ||
|
|
f12d87b5d9 | ||
|
|
01bf07dd5d | ||
|
|
0b63d040ea | ||
|
|
b19d8659ff | ||
|
|
f9b002f804 | ||
|
|
1ab99bec27 | ||
|
|
64e298952d | ||
|
|
2286b52f01 | ||
|
|
cad2a29d2a | ||
|
|
a7fa9ad7bf | ||
|
|
c9cfa4a192 | ||
|
|
8d4196a29b | ||
|
|
7c681e0ed2 | ||
|
|
a11e54e7b8 | ||
|
|
42e7d94082 | ||
|
|
5f7341487f | ||
|
|
a5fba2ddac | ||
|
|
f93b7cd0fa | ||
|
|
8b5145eaa5 | ||
|
|
bda07e1a84 | ||
|
|
f065bab2e4 | ||
|
|
cefa6153d1 | ||
|
|
ae175cc968 | ||
|
|
42ad9192e3 | ||
|
|
8065b2830a | ||
|
|
0b3cf8d4f0 | ||
|
|
93921eccc4 | ||
|
|
d1f4cd22ce | ||
|
|
29c7ce8c7c | ||
|
|
869ffec2a1 | ||
|
|
adfff67c88 | ||
|
|
732f6eff1b | ||
|
|
15469c5501 | ||
|
|
2f804ed55f | ||
|
|
0ac347d5b0 | ||
|
|
80405878b5 | ||
|
|
52cc53a4f9 | ||
|
|
267b92182e | ||
|
|
1cb3aa5af6 | ||
|
|
240ac19e3c | ||
|
|
7db3ecb67f | ||
|
|
32967a2f0f | ||
|
|
62a23ba025 | ||
|
|
fff16ae2ed | ||
|
|
2cacd5561c | ||
|
|
bf9928423c | ||
|
|
8fdbad2d4b | ||
|
|
39b6aa46bc | ||
|
|
f9cac972ad | ||
|
|
f5811c797a | ||
|
|
34c5dce056 | ||
|
|
38da7d8d6e | ||
|
|
5e6bed2e42 | ||
|
|
8bb23aa1f7 | ||
|
|
a491181961 | ||
|
|
4f69f45167 | ||
|
|
2f9d889dcc | ||
|
|
a459a00584 | ||
|
|
ede6d6dfbd | ||
|
|
2cf38dd095 | ||
|
|
89f4b34c1f | ||
|
|
929e75181b | ||
|
|
647e7c2aca | ||
|
|
db34a746d1 | ||
|
|
84c5e0b63b | ||
|
|
77a6cf7182 | ||
|
|
7e42cc8ece | ||
|
|
bb6305da25 | ||
|
|
978c42216a | ||
|
|
0a95d1c047 | ||
|
|
dd7414486b | ||
|
|
d385d188f3 | ||
|
|
d81f99ba8f | ||
|
|
0dea3a89e2 | ||
|
|
2286d42b7f | ||
|
|
ddc2a6d7e6 | ||
|
|
b869bdcacd | ||
|
|
ba6575314c | ||
|
|
d3e2fe8bf3 | ||
|
|
f383fdf74c | ||
|
|
cef8fa7e6d | ||
|
|
1582cc638f | ||
|
|
5c66b27c9a | ||
|
|
1a01f5ef59 | ||
|
|
a717d6d583 | ||
|
|
1ffe686350 | ||
|
|
217216e263 | ||
|
|
825f8f8845 | ||
|
|
0771f59bca | ||
|
|
de8ffbb294 | ||
|
|
c177fef871 | ||
|
|
c455bb6ec7 | ||
|
|
a91952a8c0 | ||
|
|
45ecf7bef0 | ||
|
|
181040539d | ||
|
|
17864b867d | ||
|
|
09146bfecf | ||
|
|
f2337d0a73 | ||
|
|
e852db5e60 | ||
|
|
f781769177 | ||
|
|
c5a734eace | ||
|
|
3e83f8a48a | ||
|
|
57d4801bff | ||
|
|
0050a65600 | ||
|
|
0914c90455 | ||
|
|
2dd4903b5e | ||
|
|
a3b4d135aa | ||
|
|
f9a97f264f | ||
|
|
8474ac55c9 | ||
|
|
bcb3d50624 | ||
|
|
9c62d4f850 | ||
|
|
894848c17b | ||
|
|
4c1f8f24bd | ||
|
|
ed45e27d15 | ||
|
|
f4f9897c9e | ||
|
|
edd340a621 | ||
|
|
201ea8b393 | ||
|
|
9c15e18c6f | ||
|
|
e5338ff202 | ||
|
|
b13b4f2c7f | ||
|
|
9cbde2b525 | ||
|
|
35eb60e2f1 | ||
|
|
558904775f | ||
|
|
01125c3023 | ||
|
|
8537e29c92 | ||
|
|
191b66303a | ||
|
|
2730949b41 | ||
|
|
73cbd863af | ||
|
|
9c67dd3b76 | ||
|
|
702f7e2b93 | ||
|
|
5a1c095724 | ||
|
|
765cdb6bb0 | ||
|
|
bc78cba5b9 | ||
|
|
398b1dd2d8 | ||
|
|
62c0834880 | ||
|
|
92b8883039 | ||
|
|
4103382062 | ||
|
|
7f9cc4084d | ||
|
|
c784e572d7 | ||
|
|
479d10e794 | ||
|
|
89f949155c | ||
|
|
129ad4fdf8 | ||
|
|
ea3c3532b6 | ||
|
|
46a5e18630 | ||
|
|
703ba156da | ||
|
|
dd40744480 | ||
|
|
cca5298864 | ||
|
|
98c0883c8f | ||
|
|
44c88b6c7b | ||
|
|
6a9ff4a38f | ||
|
|
4c33a0fdfe | ||
|
|
2e01c336a3 | ||
|
|
d26740a103 | ||
|
|
fdcefc0d90 | ||
|
|
1c87226ac9 | ||
|
|
9c86860e40 | ||
|
|
ca3d2d32af | ||
|
|
1b0923d8cf | ||
|
|
68f50bbd41 | ||
|
|
47bce76078 | ||
|
|
125e540126 | ||
|
|
1578a97c79 | ||
|
|
dbd5fb7f62 | ||
|
|
2a8dc29c69 | ||
|
|
81a0e7e9a5 | ||
|
|
ce980a90de | ||
|
|
dd32d3dc38 | ||
|
|
5667e9ebd1 | ||
|
|
0c06082dfa | ||
|
|
e0fecd8b8e | ||
|
|
e2de446128 | ||
|
|
39592697c7 | ||
|
|
d785041833 | ||
|
|
f7efb0d710 | ||
|
|
75600ca046 | ||
|
|
a839b0cef1 | ||
|
|
9da77d9d4b | ||
|
|
01791aba51 | ||
|
|
b5b762b840 | ||
|
|
08c0fc1286 | ||
|
|
e81e23e64e | ||
|
|
0a770a0e14 | ||
|
|
2cd11c926f | ||
|
|
a7f1f70508 | ||
|
|
5005a7ad9f | ||
|
|
4808d801fe | ||
|
|
0d0411fc7a | ||
|
|
8c739d2bc2 | ||
|
|
dc66e37847 | ||
|
|
b25d588c91 | ||
|
|
6e8ee3ed07 | ||
|
|
9039a268f6 | ||
|
|
c566e69c6c | ||
|
|
461a8757e3 | ||
|
|
a5a0e1bb98 | ||
|
|
d9e70d5ca7 | ||
|
|
3b261d1081 | ||
|
|
c487bbce9e | ||
|
|
9c5377a571 | ||
|
|
63b8018505 | ||
|
|
9c6325c1f8 | ||
|
|
16090d3d4a | ||
|
|
31cfdb4b95 | ||
|
|
dc8f01ea19 | ||
|
|
b65c319b2a | ||
|
|
8fb51b0c4a | ||
|
|
0a00df7640 | ||
|
|
675298eac2 | ||
|
|
8b8ab7fd5d | ||
|
|
2f3f8d7a7f | ||
|
|
4e5b26f9fb | ||
|
|
7e459e5fb1 | ||
|
|
0dcc9a15fd | ||
|
|
17e0143745 | ||
|
|
9828599067 | ||
|
|
8469dcc953 | ||
|
|
dffd3973bc | ||
|
|
84185641bc | ||
|
|
e60455d12c | ||
|
|
043f9c7b7b | ||
|
|
9f28fe8903 | ||
|
|
c0b3b5b3e9 | ||
|
|
88a001b7fa | ||
|
|
e932a2da61 | ||
|
|
edd11b6103 | ||
|
|
73ae9f6cf1 | ||
|
|
69fc342a1a | ||
|
|
ad7904946e | ||
|
|
2677a397e5 | ||
|
|
be2d3582e7 | ||
|
|
2445108f10 | ||
|
|
3c6bdc2e5b | ||
|
|
99071a1160 | ||
|
|
e0f11804f2 | ||
|
|
88ee948bc2 | ||
|
|
e7e5413667 | ||
|
|
c6fbd42aee | ||
|
|
ca1b200c79 | ||
|
|
b603db6176 | ||
|
|
779b773530 | ||
|
|
7fb1f77870 | ||
|
|
8a9db2dc7b | ||
|
|
ff0c089ac5 | ||
|
|
4d13a4ebcc | ||
|
|
a20aaf5396 | ||
|
|
1d01a2d696 | ||
|
|
5c18ae844b | ||
|
|
584a59ad8c | ||
|
|
d9f40c6942 | ||
|
|
eaa5549a3b | ||
|
|
0bf91b8822 | ||
|
|
c01f4f646b | ||
|
|
ea01f563b4 | ||
|
|
49cf77a312 | ||
|
|
d026f1eb37 | ||
|
|
cdefed7a19 | ||
|
|
5ff61e1c42 | ||
|
|
f44042e0eb | ||
|
|
4a2a5b9977 | ||
|
|
6534db4f6d | ||
|
|
479f53624c | ||
|
|
69689b3fdd | ||
|
|
5410e79811 | ||
|
|
c6dfae6dd7 | ||
|
|
db8fa8377d | ||
|
|
1eb34fa68e | ||
|
|
d7662a97da | ||
|
|
4623d2e59f | ||
|
|
04cd6ece23 | ||
|
|
9218dc3e7d | ||
|
|
5b66bd0ec0 | ||
|
|
70a6aa45aa | ||
|
|
8c0b674fe8 | ||
|
|
f75896ec45 | ||
|
|
a2e111a461 | ||
|
|
e4568abf6e | ||
|
|
64981e95b7 | ||
|
|
7d0e5aa953 | ||
|
|
a80973335c | ||
|
|
fe867a6132 | ||
|
|
d32e7ff887 | ||
|
|
ace27c11a9 | ||
|
|
8742e1768e | ||
|
|
c91ca07115 | ||
|
|
c001f56d0f | ||
|
|
e086b2cfdd | ||
|
|
b36a63fb39 | ||
|
|
65043ba092 | ||
|
|
0f4880bacd | ||
|
|
daa5415724 | ||
|
|
f2edc255d8 | ||
|
|
f6144df193 | ||
|
|
3451e4e29a | ||
|
|
4bd3210f78 | ||
|
|
bd59c0bab2 | ||
|
|
d3cb2f5c29 | ||
|
|
a30b9525d1 | ||
|
|
e851af06d0 | ||
|
|
6255c02096 | ||
|
|
81445f5218 | ||
|
|
cf0b5a8e63 | ||
|
|
ef2ec4c0dc | ||
|
|
11d3f08c12 | ||
|
|
6a59002283 | ||
|
|
113cafd2d1 | ||
|
|
b2a5abb885 | ||
|
|
1da38a5c46 | ||
|
|
2bb3903b49 | ||
|
|
c8915be58c | ||
|
|
e2146572a6 | ||
|
|
d94e4cb7a4 | ||
|
|
8dffd2b847 | ||
|
|
25e39a2f2e | ||
|
|
630f3feec5 | ||
|
|
4245540711 | ||
|
|
6bd34b64d6 | ||
|
|
2b32b9142f | ||
|
|
a690ccef3c | ||
|
|
a0964a8010 | ||
|
|
d91bd61281 | ||
|
|
289c2df527 | ||
|
|
5134f8459e | ||
|
|
84d49077c0 | ||
|
|
6b96bb96b6 | ||
|
|
0c9a49390e | ||
|
|
e7614d2197 | ||
|
|
2d98bf8dae | ||
|
|
451720bb56 | ||
|
|
5485e189f8 | ||
|
|
3c7e4ba574 | ||
|
|
bc33470d5c | ||
|
|
c48c2f2167 | ||
|
|
cef862471f | ||
|
|
8ce20ba5bd | ||
|
|
74d986ca44 | ||
|
|
cd1d9ba23b | ||
|
|
168bc8ffd5 | ||
|
|
0352ad0e19 | ||
|
|
526887be89 | ||
|
|
a5ae075a5d | ||
|
|
14a4d2726d | ||
|
|
44d9bddebd | ||
|
|
984273f5c8 | ||
|
|
9f666edc44 | ||
|
|
48e731483a | ||
|
|
cd70f518e9 | ||
|
|
d372e9ece9 | ||
|
|
aded903dd2 | ||
|
|
cc243fabd8 | ||
|
|
f44af70080 | ||
|
|
f7300bd96b | ||
|
|
21ba38e633 | ||
|
|
8202f02f4e | ||
|
|
940b642233 | ||
|
|
d192946a6f | ||
|
|
b6cd09442e | ||
|
|
af3aa00377 | ||
|
|
8428ff0f59 | ||
|
|
e3b1ae512f | ||
|
|
5b4dd8b72e | ||
|
|
5f269772d9 | ||
|
|
955e7fe48d | ||
|
|
7211d029ab | ||
|
|
48342a7ef3 | ||
|
|
bf7179c48b | ||
|
|
25e00a0ecf | ||
|
|
1d2d495686 | ||
|
|
3ea82790ea | ||
|
|
fb0454d001 | ||
|
|
dc44f98818 | ||
|
|
f9494ef93e | ||
|
|
90014f63d2 | ||
|
|
a849545f63 | ||
|
|
b0e65dec06 | ||
|
|
d1ef7b4fb0 | ||
|
|
25662576ff | ||
|
|
31ead52d21 | ||
|
|
ad644778bc | ||
|
|
ee90b3b34b | ||
|
|
7ef092fabc | ||
|
|
2882f0764d | ||
|
|
8740d3897c | ||
|
|
326719539d | ||
|
|
a72f06cfab | ||
|
|
e8896336da | ||
|
|
fab9c37413 | ||
|
|
dc418c7623 | ||
|
|
57a6f4e9aa | ||
|
|
ac7e491f3b | ||
|
|
d3559e2a5f | ||
|
|
c90893c29c | ||
|
|
b85a9e8350 | ||
|
|
99207f16b1 | ||
|
|
e7f746782f | ||
|
|
cb1c3499bf | ||
|
|
4a094bee90 | ||
|
|
66e1fc5079 | ||
|
|
8f00332979 | ||
|
|
c404ef5e2a | ||
|
|
0e2b244120 | ||
|
|
835674f45d | ||
|
|
85bfbc7d46 | ||
|
|
f983223354 | ||
|
|
3d39bbdaf1 | ||
|
|
2aca6fe06c | ||
|
|
598668e4bc | ||
|
|
a1a249e2f6 | ||
|
|
eb5e0bb914 | ||
|
|
d93c3cdd82 | ||
|
|
81b3803017 | ||
|
|
91ed783e8d | ||
|
|
bc3a724c01 | ||
|
|
51244a64f6 | ||
|
|
1d25065363 | ||
|
|
22f0e30d07 | ||
|
|
c8f484e040 | ||
|
|
d4a1ebaa33 | ||
|
|
5a75089f61 | ||
|
|
483ad5209c | ||
|
|
91369d11c5 | ||
|
|
82eeba7753 | ||
|
|
2acf1d7798 | ||
|
|
c9bd057d70 | ||
|
|
2ae5640704 | ||
|
|
de25583ee5 | ||
|
|
2f95ce431c | ||
|
|
0536ebbe98 | ||
|
|
dd130f1902 | ||
|
|
8170b8742b | ||
|
|
59538c7dc1 | ||
|
|
24e316855e | ||
|
|
e41443c64c | ||
|
|
1a9040ad79 | ||
|
|
2c10febff1 | ||
|
|
a32e0ac4ec | ||
|
|
22a4620643 | ||
|
|
8422225047 | ||
|
|
fd8f1a02a1 | ||
|
|
a1cf544de7 | ||
|
|
8d8737a266 | ||
|
|
c045245e8e | ||
|
|
91358d8088 | ||
|
|
d46bed5c36 | ||
|
|
9b9d045150 | ||
|
|
1e5cc9031f | ||
|
|
ee351caed8 | ||
|
|
f52af64e89 | ||
|
|
8ebcf6c13d | ||
|
|
08b9765278 | ||
|
|
969749da3d | ||
|
|
380d5ebe9f | ||
|
|
5a8b9687e5 | ||
|
|
0f7036f9b1 | ||
|
|
12c503e154 | ||
|
|
2dd963a539 | ||
|
|
cd42938b88 | ||
|
|
70e3fe006d | ||
|
|
18c23a6df3 | ||
|
|
445183a7df | ||
|
|
380d71d8a8 | ||
|
|
85f53198ce | ||
|
|
5479b0362f | ||
|
|
0543cf0345 | ||
|
|
ef304f30c9 | ||
|
|
e36fd24008 | ||
|
|
818a2ec52a | ||
|
|
13e441e235 | ||
|
|
f852985693 | ||
|
|
41d3890a3c | ||
|
|
2f13cb3ac6 | ||
|
|
c9a8361493 | ||
|
|
f52e94cfb9 | ||
|
|
345f64e47c | ||
|
|
8f84c2cd48 | ||
|
|
e0a48b3f66 | ||
|
|
d35efa25ec | ||
|
|
b8b15b1682 | ||
|
|
05f8ec650d | ||
|
|
2702542c73 | ||
|
|
113d694a87 | ||
|
|
f049a08e48 | ||
|
|
3e930fc74b | ||
|
|
04ec03745d | ||
|
|
6a6b5957eb | ||
|
|
8c18d9429f | ||
|
|
150a29974d | ||
|
|
eff8ae41c6 | ||
|
|
1944dd45e4 | ||
|
|
c5dead28a0 | ||
|
|
15099724ed | ||
|
|
52a820e708 | ||
|
|
85f2a9ad87 | ||
|
|
16d71b0a24 | ||
|
|
f83310a891 | ||
|
|
44448d941f | ||
|
|
2ee238e5f1 | ||
|
|
8237c15741 | ||
|
|
7711bfcdc5 | ||
|
|
2ac08efcb2 | ||
|
|
559b83626f | ||
|
|
c2915208fd | ||
|
|
04bc30606d | ||
|
|
6893bcff88 | ||
|
|
aae98d3db5 | ||
|
|
6bb648db1e | ||
|
|
b4de9dc23c | ||
|
|
4a4cad6864 | ||
|
|
fece4c6a76 | ||
|
|
a0b19a4820 | ||
|
|
02b1d1f430 | ||
|
|
75c149afde | ||
|
|
6f785d1a28 | ||
|
|
e1d825312f | ||
|
|
5ea61ed3d8 | ||
|
|
b910cdd257 | ||
|
|
3934833369 | ||
|
|
231b309634 | ||
|
|
621473f60e | ||
|
|
2b2dc1929f | ||
|
|
052dac817a | ||
|
|
d321b656f6 | ||
|
|
3dc958611c | ||
|
|
33c15a6d79 | ||
|
|
42388d2512 | ||
|
|
2deaab2602 | ||
|
|
797482183c | ||
|
|
ca28a0edad | ||
|
|
f6209003de | ||
|
|
41f60562fd | ||
|
|
136b352e4f | ||
|
|
ff1b01616f | ||
|
|
694568a89c | ||
|
|
20d7d111e5 | ||
|
|
fb769e55e0 | ||
|
|
01d0170821 | ||
|
|
e71f98adde | ||
|
|
80df20837b | ||
|
|
2092c5a2e8 | ||
|
|
e319dd578f | ||
|
|
0f596d9e3c | ||
|
|
7671219f1a | ||
|
|
fbab7d29a5 | ||
|
|
b901415c80 | ||
|
|
da877fe1db | ||
|
|
6143e258ab | ||
|
|
690033b71b | ||
|
|
f9ca0a76cf | ||
|
|
4fea5fafe5 | ||
|
|
1232745064 | ||
|
|
3382b666ce | ||
|
|
66969d8a93 | ||
|
|
92c073de61 | ||
|
|
139de824aa | ||
|
|
523dd491c4 | ||
|
|
ed01a93df6 | ||
|
|
1a56e74324 | ||
|
|
25b84dfc2e | ||
|
|
a2098c2ffa | ||
|
|
d98dc8edc9 | ||
|
|
bf05176692 | ||
|
|
4a9bb0ce17 | ||
|
|
cd5216e012 | ||
|
|
786c2482a1 | ||
|
|
0cac3a9153 | ||
|
|
9a84148376 | ||
|
|
5678030da2 | ||
|
|
758d66bf44 | ||
|
|
58f2ea700e | ||
|
|
9da81f9293 | ||
|
|
3788dff6a1 | ||
|
|
9458e6e99e | ||
|
|
4e96037c78 | ||
|
|
5b066b1b78 | ||
|
|
4eb95c4d3e |
No files matched your search
@@ -1,7 +1,21 @@
|
||||
<!-- Opened by an AI agent? First line: "> 🤖 Opened by <your GitHub login>'s AI agent (<tool, model>), not by <your GitHub login> personally." Use your own login, not someone else's. -->
|
||||
|
||||
## What and why
|
||||
|
||||
<!-- What this changes, and why. Link the issue it fixes ("Fixes #123"). -->
|
||||
|
||||
## Kind
|
||||
|
||||
<!-- Tick one: it sets the pull request's label (CONTRIBUTING.md "Pull requests"). -->
|
||||
|
||||
- [ ] parity: Horizon now does what vanilla 26.3 does
|
||||
- [ ] bug: a crash or a breakage
|
||||
- [ ] perf: faster, or less memory
|
||||
- [ ] feature: something new for players (listed under New in the release notes)
|
||||
- [ ] extra: Horizon-only, off by default
|
||||
- [ ] tooling: tools, CI, tests, harnesses
|
||||
- [ ] docs
|
||||
|
||||
## Vanilla reference
|
||||
|
||||
<!-- The vanilla classes and methods this follows (tools/rea_vanilla.py), and the harness that checks it.
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
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 --target wasm32-unknown-unknown
|
||||
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).
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
@@ -62,8 +62,7 @@ jobs:
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: |
|
||||
cargo +stable fmt --all -- --check
|
||||
# With the game's code mods (on in every release build, off by default).
|
||||
cargo +stable clippy --workspace --all-targets --features horizon-client/mods -- -D warnings
|
||||
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
|
||||
@@ -71,11 +70,11 @@ jobs:
|
||||
- name: Test Rust core and voxel kernel correctness
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: |
|
||||
cargo +stable test --workspace --lib --bins --tests --features horizon-client/mods,horizon-mods/pack
|
||||
cargo +stable test --workspace --lib --bins --tests
|
||||
# The full content-root tests (#[ignore]d: minutes each on a laptop). The same build as above; they need the
|
||||
# prepared vanilla data, so without it they pass at once.
|
||||
cargo +stable test --workspace --lib --features horizon-client/mods,horizon-mods/pack mod_content::tests:: -- --ignored
|
||||
cargo +stable test --workspace --doc --features horizon-client/mods,horizon-mods/pack
|
||||
cargo +stable test --workspace --lib mod_content::tests:: -- --ignored
|
||||
cargo +stable test --workspace --doc
|
||||
# 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).
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
name: claim command
|
||||
# `/claim` and `/unclaim` comments: anyone takes an issue before working on it, and a daily run releases the claims
|
||||
# older than 5 days that no open pull request links (CONTRIBUTING.md "Claim before you start",
|
||||
# tools/ci/claim_command.py).
|
||||
#
|
||||
# Why no pull request's code runs here, though the token can write:
|
||||
# - `issue_comment` and `schedule` always run this file from main, whoever comments and wherever.
|
||||
# - It checks out main's script and list only, never a pull request; it reads threads through the API.
|
||||
# - The comment's text and author reach the script as env vars, never in a shell line. The token stays in the step
|
||||
# and can only write issues (assignees, comments, reactions).
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
schedule:
|
||||
- cron: "23 5 * * *"
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
command:
|
||||
# Every other comment skips here, before a runner starts. Pull requests are answered by the script.
|
||||
if: >-
|
||||
github.event_name == 'issue_comment' && github.event.comment.user.type != 'Bot' && (
|
||||
startsWith(github.event.comment.body, '/claim') ||
|
||||
startsWith(github.event.comment.body, '/unclaim'))
|
||||
# One command per issue at a time, so two `/claim`s can't both find it free. On the job, not the workflow:
|
||||
# the skipped runs of ordinary comments must not cancel a waiting one.
|
||||
concurrency:
|
||||
group: claim-${{ github.event.issue.number }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
.github/trusted-reviewers.txt
|
||||
tools/ci/nightly_set.py
|
||||
tools/ci/claim_command.py
|
||||
sparse-checkout-cone-mode: false
|
||||
- name: Run the command
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
BODY: ${{ github.event.comment.body }}
|
||||
AUTHOR: ${{ github.event.comment.user.login }}
|
||||
ISSUE: ${{ github.event.issue.number }}
|
||||
COMMENT_ID: ${{ github.event.comment.id }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: python3 tools/ci/claim_command.py command
|
||||
release:
|
||||
if: github.event_name != 'issue_comment' && github.repository == 'horizon-rust/horizon'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
tools/ci/nightly_set.py
|
||||
tools/ci/claim_command.py
|
||||
sparse-checkout-cone-mode: false
|
||||
- name: Release stale claims
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: python3 tools/ci/claim_command.py release
|
||||
@@ -138,8 +138,7 @@ 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
|
||||
# `horizon-client/mods`: every released game runs code mods (the feature is off in dev builds).
|
||||
run: cargo build --release --locked --features horizon-client/mods -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 -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
|
||||
@@ -267,9 +266,8 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# After the client is packaged: target/release/horizon-client is rebuilt without the audio and
|
||||
# controllers features, so it needs neither libasound nor libudev (minimal server images lack them),
|
||||
# and with code mods like the client.
|
||||
cargo build --release --locked -p horizon-client --no-default-features --features mods --bin horizon-client
|
||||
# controllers features, so it needs neither libasound nor libudev (minimal server images lack them).
|
||||
cargo build --release --locked -p horizon-client --no-default-features --bin horizon-client
|
||||
cargo run --locked -p xtask -- package-server target/release/horizon-client "dist/${{ matrix.server_dir }}"
|
||||
folder="dist/${{ matrix.server_dir }}"
|
||||
python3 tools/check_imports.py --server "$folder/horizon-engine" "$folder/horizon-server" "$folder/horizon-prepare"
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
name: overlap note
|
||||
# One comment on a pull request naming the other open pull requests that close the same issue or change the same
|
||||
# functions, edited on every push (tools/ci/overlap_note.py, issue #3859).
|
||||
#
|
||||
# Why no pull request's code runs here, though `pull_request_target` gives a token that can write:
|
||||
# - `pull_request_target` runs this file and the script from main, never the pull request's copy.
|
||||
# - The checkout is main's (`ref: main`), blobless, with the script alone on disk. The pull requests' heads are fetched
|
||||
# as git objects and only diffed, never checked out; git runs with hooks, fsmonitor, external diff and textconv off.
|
||||
# - The rest comes from the API. The token writes the comment only.
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened, synchronize, ready_for_review]
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
note:
|
||||
if: github.event.pull_request.base.ref == 'main'
|
||||
# A newer push replaces the note being computed.
|
||||
concurrency:
|
||||
group: overlap-note-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
filter: blob:none
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
tools/ci/overlap_note.py
|
||||
sparse-checkout-cone-mode: false
|
||||
- name: Note
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: python3 tools/ci/overlap_note.py event
|
||||
@@ -0,0 +1,36 @@
|
||||
name: pr labels
|
||||
# A pull request's kind label (the box ticked in the template) and area labels (the paths it changes):
|
||||
# tools/ci/pr_labels.py, CONTRIBUTING.md "Pull requests".
|
||||
#
|
||||
# Why no pull request's code runs here, though `pull_request_target` gives a token that can write to fork PRs:
|
||||
# - `pull_request_target` runs this file and the script from main, never the pull request's copy.
|
||||
# - It checks out main's script only (`ref: main`), never the pull request's head or merge commit.
|
||||
# - The script reads the event file (the description is data in JSON, never in a shell line) and the API's list of
|
||||
# changed file names; it runs, builds and imports nothing of theirs. The token writes labels only.
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened, synchronize, edited, ready_for_review]
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
label:
|
||||
# Title-only edits change nothing: skip them before a runner starts.
|
||||
if: github.event.action != 'edited' || github.event.changes.body != null
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
tools/ci/pr_labels.py
|
||||
sparse-checkout-cone-mode: false
|
||||
- name: Label
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: python3 tools/ci/pr_labels.py event
|
||||
@@ -13,13 +13,15 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
|
||||
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`, and, for the
|
||||
launcher, `HORIZON_LAUNCHER_TRASH`, `HORIZON_LAUNCHER_CLIPBOARD` and `HORIZON_LAUNCHER_DESKTOP` pointing into the scratch home. Never write to
|
||||
`~/.local/share/{applications,horizon,horizon-launcher}` or `~/.local/bin`.
|
||||
- **Mark what an agent posts.** A PR body starts with a line naming whose agent opened it and with what.
|
||||
Ludovic's agents write `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, and a
|
||||
contributor's agent names its own person and tool (issues: "Filed by", reviews: "Review by"). Every
|
||||
comment starts with `🤖 Agent:`. Never write as Ludovic or as anyone else.
|
||||
- **Mark what an agent posts.** A PR body starts with a line naming the person whose agent opened it and
|
||||
the tool: `> 🤖 Opened by <login>'s AI agent (<tool, model>), not by <login> personally.`, where `<login>`
|
||||
is the GitHub account the PR is opened from (`gh api user --jq .login`). Issues say "Filed by", reviews
|
||||
"Review by". Every comment starts with `🤖 Agent:`. Write only as the person you work for: the lines on
|
||||
this page that name Ludovic are for his own agents (account `ludovic111`), never for anyone else's.
|
||||
- **Attribution.** Commits end with a `Co-Authored-By:` line for the agent that wrote them, and PR bodies
|
||||
with that agent's own "Generated with" line. Ludovic's agents use
|
||||
`Co-Authored-By: Claude <model> <[email protected]>` and
|
||||
with that agent's own "Generated with" line. Example, Ludovic's agents (Claude Code on `ludovic111`):
|
||||
first line `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, commits
|
||||
`Co-Authored-By: Claude <model> <[email protected]>`, last line
|
||||
`🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
|
||||
- Everything players run is Rust. Python/Java only for dev tools and harness drivers in `tools/`.
|
||||
- Horizon-only features follow [docs/design-system.md](docs/design-system.md) and are off by default where
|
||||
@@ -28,6 +30,8 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
|
||||
change), none for a fix. Agents never add `release-now`: the maintainer decides it. Merges ship in batches of 5, or after
|
||||
6 hours ([docs/release-flow.md](docs/release-flow.md)).
|
||||
- Contributors first: `gh pr list --state open --search <area>` before you start; never duplicate theirs.
|
||||
- **Claim before you start**: comment `/claim` on the issue (no issue yet: open one, then claim it). An assigned
|
||||
issue is taken: pick another. `/unclaim` when you stop; a claim with no open PR linking it is released after 5 days.
|
||||
|
||||
## Crates
|
||||
|
||||
@@ -74,7 +78,7 @@ cargo build --profile fast -p horizon-client # optimized, quick to link:
|
||||
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
|
||||
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
|
||||
python3 tools/parity.py mob_ai sculk # named harnesses
|
||||
python3 tools/lan_e2e.py --check chest # two real games over LAN (server-thread host); --in-frame compares the fallback
|
||||
python3 tools/lan_e2e.py --check chest # two real games over LAN (server-thread host)
|
||||
python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
```
|
||||
|
||||
@@ -167,17 +171,18 @@ the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/s
|
||||
"Server thread" with one level per dimension; the window joins it as a client through memory (`remote`),
|
||||
exactly as a LAN guest or a dedicated server's player does. This is what players run. Open
|
||||
`[server-thread]` issues list the gaps left.
|
||||
2. **In-frame (`--in-frame`, `--no-server-thread` or `HORIZON_SERVER_THREAD=0`):** the window's `Game` ticks
|
||||
the world itself, one level that follows the player (`fn enter_dimension`). Deterministic rule fixtures still use this path; player-facing integration uses the default server thread.
|
||||
2. **In-frame (`--in-frame`, `--no-server-thread` or `HORIZON_SERVER_THREAD=0`): DEPRECATED, never use it, it
|
||||
will be removed soon.** The window's `Game` ticks the world itself, one level that follows the player
|
||||
(`fn enter_dimension`). Compatibility does not matter: don't add features or fixes for it, don't write new
|
||||
tests against it, and move code off it when you touch it. The deterministic rule fixtures still run on it
|
||||
until they are ported.
|
||||
|
||||
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test both paths
|
||||
when you touch player-facing gameplay:
|
||||
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test the server
|
||||
thread when you touch player-facing gameplay:
|
||||
|
||||
```sh
|
||||
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # server thread
|
||||
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T --in-frame # in-frame
|
||||
python3 tools/lan_e2e.py --check <name> # host on its server thread, guest is a server client
|
||||
python3 tools/lan_e2e.py --in-frame --check <name> # comparison (portals, levels, distance, lava need server-thread mode)
|
||||
python3 tools/parity.py dedicated_server server_entities server_player_damage
|
||||
```
|
||||
|
||||
|
||||
+20
-3
@@ -74,6 +74,19 @@ including examples. The dependency scope includes optional, platform, build and
|
||||
Tests run library, binary, integration and doc tests without building unrelated example executables. Shared Cargo
|
||||
configuration and toolchain changes select the whole workspace; CI also keeps its workspace-wide checks.
|
||||
|
||||
## Claim before you start
|
||||
|
||||
Work is tracked in GitHub issues, and an issue that is assigned is taken (so is one with the `in progress` label:
|
||||
claimed on Discord with Kira's `/claim`): pick another, or ask its holder in the thread. Before you start, comment
|
||||
**`/claim`** on the issue; you are assigned at once, no role needed. No issue for your work yet? Open one (a line is
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## Pull requests
|
||||
|
||||
- **Small.** One change per pull request, under about 500 lines of code where you can; split bigger work into
|
||||
@@ -82,8 +95,12 @@ configuration and toolchain changes select the whole workspace; CI also keeps it
|
||||
can see.
|
||||
- **Tick "Allow edits by maintainers".** When something small is left (a lint, a rebase, a nit) we push the fixup
|
||||
ourselves instead of sending it back to you; only design questions come back.
|
||||
- **Labels** (we set them if you can't): `feature` when players get something new (it appears under "New" in the
|
||||
release notes), `skip-release` when players see no change (tooling, docs, tests), nothing for a fix.
|
||||
- **Tick its kind** in the template: `parity` (Horizon now does what vanilla does), `bug` (a crash or breakage),
|
||||
`perf`, `feature` (new for players), `extra` (Horizon-only, off by default), `tooling` or `docs`. A workflow sets
|
||||
that label, and area labels (`launcher`, `mobs`, `worldgen`, `rendering`...) from the paths you change.
|
||||
`label:parity` is the list of real progress toward vanilla.
|
||||
- **Release labels** (we set them if you can't): `feature` also puts it under "New" in the release notes,
|
||||
`skip-release` when players see no change (tooling, docs, tests), nothing for a fix.
|
||||
`release-now` is for crash fixes that shouldn't wait. pr_ready suggests one.
|
||||
|
||||
Who reviews and merges: the Horizon Team, on CI for ordinary pull requests; risky ones (saves, network, world
|
||||
@@ -102,4 +119,4 @@ 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)
|
||||
for bugs. If you want to take something on, say so in its issue so nobody else starts the same work.
|
||||
for bugs. To take something on, `/claim` its issue ([above](#claim-before-you-start)).
|
||||
Generated
+8
-815
File diff suppressed because it is too large.
Load diff
-13
@@ -23,26 +23,13 @@ members = [
|
||||
"crates/horizon-bench",
|
||||
"crates/horizon-friends",
|
||||
"crates/horizon-relay",
|
||||
"crates/horizon-mod-sdk",
|
||||
"crates/horizon-mods",
|
||||
"crates/horizon-mods/tests/guest",
|
||||
"crates/horizon-mods/tests/ui_guest",
|
||||
"crates/cargo-horizon-mod",
|
||||
"crates/horizon-port-pack",
|
||||
"crates/horizon-shaders",
|
||||
"crates/horizon-shader-api",
|
||||
"crates/horizon-release-sign",
|
||||
"crates/horizon-mod-sdk/examples/coords-hud",
|
||||
"crates/horizon-mod-sdk/examples/predator-missile",
|
||||
"crates/horizon-mod-sdk/examples/welcome",
|
||||
"crates/horizon-mod-sdk/examples/beacons",
|
||||
"mods/official/fps-overlay",
|
||||
"mods/official/keystrokes",
|
||||
"mods/official/far-view",
|
||||
"mods/official/minimap",
|
||||
"mods/official/zoom",
|
||||
"xtask",
|
||||
"mods/official/recipes",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ The [documentation](https://playhorizon.dev/docs/) covers each of these.
|
||||
|
||||
- **The game:** singleplayer with vanilla's world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on saves in vanilla's format. Worlds from 1.8 to 26.2 are upgraded as vanilla does. [Status table](#project-status).
|
||||
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
|
||||
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates and item modifiers.
|
||||
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates, item modifiers and world generation (vanilla's heights and biomes for now).
|
||||
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining servers, online-mode ones with the Microsoft account the launcher signs in with. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
|
||||
- **Mods:** Horizon has its own mods, installed from the launcher. The official ones (Zoom, FPS Counter, Keystrokes and Minimap) are native Rust that the launcher builds into your game, with no Rust to install; older WebAssembly mods still run while they move. Java mod jars do not run in Horizon. See [Mods and data packs](https://playhorizon.dev/docs/mods).
|
||||
|
||||
|
||||
+307
-452
@@ -12,11 +12,11 @@ Horizon is Minecraft: Java Edition 26.3 rewritten from scratch in Rust, so it ru
|
||||
|
||||
- **Steady frames at long render distances**: Making Horizon faster than vanilla on every computer, with smooth camera movement even at 32 chunks, by spreading the work better over the processor's cores.
|
||||
- **Modpacks' world generation, natively**: Making the world-generation mods of popular modpacks (new structures, YUNG's dungeons and mineshafts, packs built on Lithostitched) generate their worlds in Horizon, natively and fast.
|
||||
- **Playing on servers: everything shows as it should**: When you play on a server, other players, mobs, items and menus look and behave as in vanilla: skins, poses, item names, inventories and server networks.
|
||||
- **Single player as a server: the last differences**: Single player now runs a small server in the background, as vanilla does. We are fixing what still behaves differently there; every fix also works for LAN and server players.
|
||||
- **Horizon's mod registry**: One place for mods, modpacks and resource packs made for Horizon: browse them on the website and install them from the launcher in one click.
|
||||
- **Vanilla parity: the player-visible gaps, closed**: Every method of the Java game is tracked with where Horizon does it, and the differences players can see are being closed, list by list: mobs, blocks, items, screens, rendering, servers.
|
||||
- **Mods**: Mods for Horizon, as one line of work led by the modding team: native mods the launcher builds into your game, mods on servers, mod screens and settings, tools for mod makers, the registry and machines.
|
||||
|
||||
**Next**: A new launcher design; CurseForge in the mod browser (coming soon); Fresh benchmarks against vanilla; Pistons push players and mobs; Horizon on Intel Macs; Faster world creation and entry on small CPUs; Servers that hold 20 TPS with 50 to 100 players; Low-end computers: Steam Deck, old Macs, 8 GB; Native upscaling; A batch of targeted optimisations; A Convert to Rust button for what isn't rewritten yet; Mods with code: machines, magic, mobs; Data packs in the mod browser; Prepare a version without Java; Server console, bans and a full admin API; Hosting polish: Windows service, safe updates; Guided hosting with Tailscale and an invite button; An official Docker image for servers; One single-player path: remove the in-frame world; Villages and combat, exactly as in vanilla; Choose the audio output device; Mods that read and change the world; Machines and blocks with inventories (API 0.4); Well-known shader looks, rewritten for Horizon; A visible beta label; Wayland and Hyprland polish; A versioned agent-control protocol; Port mods with your own AI agent; Horizon on X (coming soon); Pipeline protection and a GitHub App for agents; Signed updates everywhere, installers included; A server you install with one command; Ready-made setups for hosting panels.
|
||||
**Next**: Fresh benchmarks against vanilla; Faster world creation and entry on small CPUs; Low-end computers: Steam Deck, old Macs, 8 GB; Native upscaling; Server console, bans and a full admin API; Hosting polish: Windows service, safe updates; One single-player path: remove the in-frame world; Faster block lookups; Playing on servers: everything shows as it should; Shader packs, written again from scratch; A new launcher design; Splitscreen.
|
||||
|
||||
**How to help**: Play it and tell us what differs from vanilla (Discord or GitHub), run the benchmark on your computer, vote for what you want next, or pick one of the Help wanted items.
|
||||
|
||||
@@ -30,19 +30,25 @@ Horizon is Minecraft: Java Edition 26.3 rewritten from scratch in Rust, so it ru
|
||||
|
||||
Making Horizon faster than vanilla on every computer, with smooth camera movement even at 32 chunks, by spreading the work better over the processor's cores.
|
||||
|
||||
Owner: [ludovic111](https://github.com/ludovic111) · Tracking: [#3909](https://github.com/horizon-rust/horizon/issues/3909)
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Frame pacing at render distance 32 ([#678](https://github.com/horizon-rust/horizon/issues/678)): long frames come from work competing with the render thread. In review: world generation workers build free structure starts instead of queueing them ([#1760](https://github.com/horizon-rust/horizon/issues/1760)), the worker pool leaves one core as vanilla does ([#1778](https://github.com/horizon-rust/horizon/issues/1778)), and the terrain vertex arena is read as storage where flat draws run ([#1742](https://github.com/horizon-rust/horizon/issues/1742)). Still open: far terrain at 48 to 64 chunks ([#718](https://github.com/horizon-rust/horizon/issues/718)) and CPU-bound fields of block entities ([#717](https://github.com/horizon-rust/horizon/issues/717)). Every change shows before and after numbers from `tools/perf_bench.py` against vanilla 26.3 ([docs/benchmark.md](docs/benchmark.md)).
|
||||
Frame pacing at render distance 32 ([#678](https://github.com/horizon-rust/horizon/issues/678)): long frames come from work competing with the render thread. In review: no main-thread tick stutter at high render distances ([#3827](https://github.com/horizon-rust/horizon/issues/3827)), each proto chunk's light view built once ([#3815](https://github.com/horizon-rust/horizon/issues/3815)), video memory handed back by the vertex arena ([#3466](https://github.com/horizon-rust/horizon/issues/3466)), chunk batches assembled on helper threads ([#3036](https://github.com/horizon-rust/horizon/issues/3036)), free structure starts in the generation workers ([#1760](https://github.com/horizon-rust/horizon/issues/1760)). Still open: far terrain at 48 to 64 chunks ([#718](https://github.com/horizon-rust/horizon/issues/718)) and CPU-bound fields of block entities ([#717](https://github.com/horizon-rust/horizon/issues/717)). Every change shows before and after numbers from `tools/perf_bench.py` against vanilla 26.3 ([docs/benchmark.md](docs/benchmark.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Faster block lookups
|
||||
#### Servers that hold 20 TPS with 50 to 100 players
|
||||
|
||||
Speeding up the code that reads and writes the blocks of the world, which almost everything in the game goes through: generation, lighting, meshing and ticking.
|
||||
Servers that keep 20 ticks per second with 50 to 100 players. Today Horizon holds it up to 50 bots and drops to about 10 at 100.
|
||||
|
||||
Owner: [ludovic111](https://github.com/ludovic111) · Tracking: [#3910](https://github.com/horizon-rust/horizon/issues/3910)
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`PalettedContainer` and `PackedStorage` find an entry's word by multiply and shift instead of an integer division ([#1790](https://github.com/horizon-rust/horizon/issues/1790), [#1794](https://github.com/horizon-rust/horizon/issues/1794)); meshing looks up baked models by index, not by name ([#1747](https://github.com/horizon-rust/horizon/issues/1747)). Each change keeps the paletted storage harnesses identical and shows its microbenchmark ([docs/performance/paletted-container-access-results.md](docs/performance/paletted-container-access-results.md)).
|
||||
Measured against vanilla on equal work ([docs/server.md](docs/server.md), Scale): Horizon is ahead with few players and when exploring, but vanilla's tick is shorter with 25 to 50 players near spawn. Ideas on the list: send entity changes instead of rebuilding every entity each tick (about 15 percent), fewer memory reads in random ticks and collision scans, cheaper item collision checks, mob AI and natural spawning. The bot benchmark (`horizon-bots`) is the proof.
|
||||
|
||||
More: [#3847](https://github.com/horizon-rust/horizon/issues/3847) · [#2608](https://github.com/horizon-rust/horizon/issues/2608)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -52,101 +58,27 @@ Speeding up the code that reads and writes the blocks of the world, which almost
|
||||
|
||||
Making the world-generation mods of popular modpacks (new structures, YUNG's dungeons and mineshafts, packs built on Lithostitched) generate their worlds in Horizon, natively and fast.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`horizon-port-pack` carries a pack's data-driven content over; structure mods that need code get a native port: YUNG's worldgen as written ([#1746](https://github.com/horizon-rust/horizon/issues/1746)), Lithostitched's limited and guaranteed elements with pool and processor modifiers ([#1777](https://github.com/horizon-rust/horizon/issues/1777)), its processors as the game's own types ([#1765](https://github.com/horizon-rust/horizon/issues/1765)). Proved by the structure blocks and layout harnesses ([#1753](https://github.com/horizon-rust/horizon/issues/1753), [#1757](https://github.com/horizon-rust/horizon/issues/1757)). Mods whose Java code does more than world generation don't run ([docs/modpacks.md](docs/modpacks.md)).
|
||||
|
||||
</details>
|
||||
|
||||
### Servers and multiplayer
|
||||
|
||||
#### Playing on servers: everything shows as it should
|
||||
|
||||
When you play on a server, other players, mobs, items and menus look and behave as in vanilla: skins, poses, item names, inventories and server networks.
|
||||
Owner: [ThatCakeIsAPie](https://github.com/ThatCakeIsAPie) · Tracking: [#3913](https://github.com/horizon-rust/horizon/issues/3913)
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Client-of-a-server fixes in `game/remote*.rs` and `horizon-protocol`: mobs' aggressive and item-use flags ([#1802](https://github.com/horizon-rust/horizon/issues/1802), [#1798](https://github.com/horizon-rust/horizon/issues/1798)), ghost items after a remote menu ([#1795](https://github.com/horizon-rust/horizon/issues/1795)), skins ([#1652](https://github.com/horizon-rust/horizon/issues/1652)), cookies and transfer packets for proxy networks ([#1650](https://github.com/horizon-rust/horizon/issues/1650)), firework boosts ([#1671](https://github.com/horizon-rust/horizon/issues/1671)), living-entity attributes and simulation distance ([#1731](https://github.com/horizon-rust/horizon/issues/1731)), styled item names ([#1702](https://github.com/horizon-rust/horizon/issues/1702)), primed TNT between updates ([#1710](https://github.com/horizon-rust/horizon/issues/1710)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Server dialogs and login screens
|
||||
|
||||
Servers that show dialogs, such as the register and login screens of AuthMe, will work in Horizon.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
26.3's dialog packets and model ([#1720](https://github.com/horizon-rust/horizon/issues/1720)), the dialog screen with its buttons and actions ([#1734](https://github.com/horizon-rust/horizon/issues/1734)), then text and boolean inputs ([#1735](https://github.com/horizon-rust/horizon/issues/1735)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Pre-generate an area with /pregen
|
||||
|
||||
A /pregen command that builds an area of the world ahead of time, so players don't wait for new terrain when they explore.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Generates, lights and saves an area before anyone walks there ([#1805](https://github.com/horizon-rust/horizon/issues/1805), a contributor's pull request, in review). Useful on servers, and it moves the generation cost away from the first visit; the generation speed-ups are under 'Steady frames at long render distances'.
|
||||
`horizon-port-pack` carries a pack's data-driven content over, and 26.2 data packs are upgraded to 26.3's format ([#3369](https://github.com/horizon-rust/horizon/issues/3369)). In review: worlds taller than 32 sections and each level's shape from its dimension type ([#3480](https://github.com/horizon-rust/horizon/issues/3480), [#3488](https://github.com/horizon-rust/horizon/issues/3488)), data-pack biomes in local and remote worlds ([#3491](https://github.com/horizon-rust/horizon/issues/3491)), block state providers ([#3802](https://github.com/horizon-rust/horizon/issues/3802)), surface rules by biome id ([#3366](https://github.com/horizon-rust/horizon/issues/3366)), Lithostitched's pool and processor modifiers ([#1777](https://github.com/horizon-rust/horizon/issues/1777)). Mods whose Java code does more than world generation don't run ([docs/modpacks.md](docs/modpacks.md)).
|
||||
|
||||
</details>
|
||||
|
||||
### Mods and API
|
||||
|
||||
#### Horizon's mod registry
|
||||
#### Mods
|
||||
|
||||
One place for mods, modpacks and resource packs made for Horizon: browse them on the website and install them from the launcher in one click.
|
||||
Mods for Horizon, as one line of work led by the modding team: native mods the launcher builds into your game, mods on servers, mod screens and settings, tools for mod makers, the registry and machines.
|
||||
|
||||
Owner: [levzzz5154](https://github.com/levzzz5154) · Tracking: [#3911](https://github.com/horizon-rust/horizon/issues/3911)
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Merged: the registry index (schema 1, `.hpack`, checks before install, [#1634](https://github.com/horizon-rust/horizon/issues/1634)), `cargo horizon-mod check` ([#1642](https://github.com/horizon-rust/horizon/issues/1642)) and the submission service (`crates/horizon-mod-registry`, [#1659](https://github.com/horizon-rust/horizon/issues/1659)). In review: the website's /mods ([#1596](https://github.com/horizon-rust/horizon/issues/1596)) and /mods/submit ([#1635](https://github.com/horizon-rust/horizon/issues/1635)), and in the launcher the registry as a source ([#1636](https://github.com/horizon-rust/horizon/issues/1636)), install, enable and takedowns ([#1637](https://github.com/horizon-rust/horizon/issues/1637)), the Mods page ([#1638](https://github.com/horizon-rust/horizon/issues/1638)) and modpacks as their own profiles ([#1640](https://github.com/horizon-rust/horizon/issues/1640)).
|
||||
One item for all modding work ([#3911](https://github.com/horizon-rust/horizon/issues/3911)), led by levzzz5154 with vladislav_hagen; the order between the parts is theirs. Native mods on stable hook points (game/native_hooks.rs, [#3345](https://github.com/horizon-rust/horizon/issues/3345), [#3346](https://github.com/horizon-rust/horizon/issues/3346), [#3434](https://github.com/horizon-rust/horizon/issues/3434), [#3435](https://github.com/horizon-rust/horizon/issues/3435)) built by the launcher ([#3388](https://github.com/horizon-rust/horizon/issues/3388), [#3855](https://github.com/horizon-rust/horizon/issues/3855)); server-side hooks ([#3878](https://github.com/horizon-rust/horizon/issues/3878), [#3706](https://github.com/horizon-rust/horizon/issues/3706)); mod-owned screens, input and drawing; tools for mod makers (templates, `native.py check`); the registry ([#1634](https://github.com/horizon-rust/horizon/issues/1634), [#1642](https://github.com/horizon-rust/horizon/issues/1642), [#1659](https://github.com/horizon-rust/horizon/issues/1659)); machines with inventories and menus ([#1189](https://github.com/horizon-rust/horizon/issues/1189)); mods that read and change the world through the server. The WebAssembly runtime is being removed ([#3351](https://github.com/horizon-rust/horizon/issues/3351), [#3353](https://github.com/horizon-rust/horizon/issues/3353)). [docs/native-mods.md](docs/native-mods.md).
|
||||
|
||||
More: [docs/mods.md](docs/mods.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Native mods, built by the launcher
|
||||
|
||||
Horizon's own mods become native code that the launcher builds into your game, with no Rust to install: Zoom, FPS Counter, Keystrokes and Minimap already are, and Recipes is next.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Stable hook points in the game (game/native_hooks.rs, [#3345](https://github.com/horizon-rust/horizon/issues/3345), [#3346](https://github.com/horizon-rust/horizon/issues/3346); v2 with the item panel and recipe pages, [#3434](https://github.com/horizon-rust/horizon/issues/3434), [#3435](https://github.com/horizon-rust/horizon/issues/3435)), horizon-mixins applied by the launcher with its own portable Rust ([#3388](https://github.com/horizon-rust/horizon/issues/3388)), on/off in Options > Mods. The official mods live in horizon-mods/native/ and are in the registry as native mods. Next: Recipes, native mods on automatically when a player installs one (with a notice about the first build), then the WebAssembly runtime is removed ([#3350](https://github.com/horizon-rust/horizon/issues/3350), [#3351](https://github.com/horizon-rust/horizon/issues/3351), [#3353](https://github.com/horizon-rust/horizon/issues/3353), [#3371](https://github.com/horizon-rust/horizon/issues/3371)).
|
||||
|
||||
More: [docs/native-mods.md](docs/native-mods.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Mods that run on the server
|
||||
|
||||
Mods that run on the server side of a world, so a dedicated server or a LAN world can have mods that react to players joining, chat and blocks being broken or placed, and that add commands.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
The same hook points on the server thread and the dedicated server: world events (block broken or placed, entity spawned or killed, player joined or left), game rules and server commands with typed arguments. The earlier WebAssembly API 0.3 ([#1833](https://github.com/horizon-rust/horizon/issues/1833)) is the reference for what to cover.
|
||||
|
||||
More: [docs/native-mods.md](docs/native-mods.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Screens, input and drawing for mods
|
||||
|
||||
Mods will be able to open their own screens and settings pages, react to keys and item use, and draw, so bigger mods, such as the Orbital Laser, run on the normal game.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
On native hook points ([docs/native-mods.md](docs/native-mods.md)): settings pages under Options > Mods and the item panel are there ([#3346](https://github.com/horizon-rust/horizon/issues/3346), [#3435](https://github.com/horizon-rust/horizon/issues/3435)). Next: mod-owned screens built from vanilla's widgets, an item's use, key binds and the crosshair target, and a budgeted draw list on the native shader API. Orbital Laser is the acceptance test.
|
||||
|
||||
</details>
|
||||
|
||||
#### Tools for people who make mods
|
||||
|
||||
Better tools for mod makers: templates for each kind of mod, a test run with no game window, and clear promises about which hook points keep working from one version to the next.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Native mod template and the content mod template (horizon-mods), `native.py check` that applies mods on a chosen Horizon and builds them together, a weekly check of every official mod on the newest Nightly. Left: a versioned list of stable hook points (NATIVE_HOOKS_API), a test runner without a window, and docs on the site for native mods.
|
||||
|
||||
More: [#3388](https://github.com/horizon-rust/horizon/issues/3388) · [docs/native-mods.md](docs/native-mods.md)
|
||||
More: [#3360](https://github.com/horizon-rust/horizon/issues/3360) · [#1596](https://github.com/horizon-rust/horizon/issues/1596) · [#1635](https://github.com/horizon-rust/horizon/issues/1635)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -156,103 +88,11 @@ More: [#3388](https://github.com/horizon-rust/horizon/issues/3388) · [docs/nati
|
||||
|
||||
Single player now runs a small server in the background, as vanilla does. We are fixing what still behaves differently there; every fix also works for LAN and server players.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Since [#1514](https://github.com/horizon-rust/horizon/issues/1514) single player runs on the server thread (`game/integrated.rs`) and the window joins it as a client; `--in-frame` keeps the old path. The open `[server-thread]` issues list the gaps. In review: a tool that changes a block ends the click ([#1797](https://github.com/horizon-rust/horizon/issues/1797)), potions keep their potion over the connection ([#1796](https://github.com/horizon-rust/horizon/issues/1796)), mobs' aggression and arm swings ([#1802](https://github.com/horizon-rust/horizon/issues/1802)). [docs/server-levels.md](docs/server-levels.md).
|
||||
|
||||
</details>
|
||||
|
||||
#### Play in your language
|
||||
|
||||
Item names, container titles and server messages follow the language you pick, as in vanilla. Today parts of the game stay in English.
|
||||
Owner: [ludovic111](https://github.com/ludovic111) · Tracking: [#3914](https://github.com/horizon-rust/horizon/issues/3914)
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Translatable components resolved through the selected language for items, containers and server text ([#1806](https://github.com/horizon-rust/horizon/issues/1806)), on top of the bundled languages the prepared resources carry ([#1594](https://github.com/horizon-rust/horizon/issues/1594)). Left: the client's own screens ([#1672](https://github.com/horizon-rust/horizon/issues/1672)) and the main menu with zh_cn ([#1774](https://github.com/horizon-rust/horizon/issues/1774)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Cooldowns drawn on items
|
||||
|
||||
After you throw an ender pearl, its slot should show the cooldown sweeping away, as in vanilla.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Vanilla draws `ItemCooldowns`' percent as a white overlay over the slot (`GuiGraphicsExtractor.itemDecorations` and `itemCooldown`) in the hotbar and in inventories. Issue [#1763](https://github.com/horizon-rust/horizon/issues/1763).
|
||||
|
||||
More: [#1933](https://github.com/horizon-rust/horizon/issues/1933)
|
||||
|
||||
</details>
|
||||
|
||||
#### Chat settings and team prefixes
|
||||
|
||||
The Hidden and Commands Only chat settings should hide chat, and team prefixes should appear before names in chat.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Vanilla checks `ChatVisiblity` before it delivers chat and system messages ([#1376](https://github.com/horizon-rust/horizon/issues/1376)), and chat uses the team-formatted display name ([#1378](https://github.com/horizon-rust/horizon/issues/1378)). Both issues have exact steps to reproduce.
|
||||
|
||||
More: [#1932](https://github.com/horizon-rust/horizon/issues/1932) · [#1841](https://github.com/horizon-rust/horizon/issues/1841)
|
||||
|
||||
</details>
|
||||
|
||||
#### A pointing hand over buttons
|
||||
|
||||
The mouse pointer should turn into a pointing hand over menu buttons, as in vanilla 26.3.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
26.3's `AbstractWidget` asks for a cursor from `CursorTypes` when it is hovered; winit's `CursorIcon::Pointer` gives the same shape. Issue [#1347](https://github.com/horizon-rust/horizon/issues/1347).
|
||||
|
||||
More: [#1897](https://github.com/horizon-rust/horizon/issues/1897)
|
||||
|
||||
</details>
|
||||
|
||||
#### Ctrl+Backspace and scroll bars in menus
|
||||
|
||||
Ctrl+Backspace should delete a whole word in the creative search, and every list's scroll bar should be draggable.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`EditBox` deletes by word with Ctrl ([#1764](https://github.com/horizon-rust/horizon/issues/1764)); the Game Rules and Statistics lists' scroll bars can't be dragged ([#1782](https://github.com/horizon-rust/horizon/issues/1782), after [#978](https://github.com/horizon-rust/horizon/issues/978)).
|
||||
|
||||
More: [#1884](https://github.com/horizon-rust/horizon/issues/1884) · [#1931](https://github.com/horizon-rust/horizon/issues/1931)
|
||||
|
||||
</details>
|
||||
|
||||
#### Chickens flap while they fall
|
||||
|
||||
Chickens should flap their wings while they fall, not stand still in the air.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Vanilla's `Chicken.aiStep` updates `flap` and `flapSpeed` when the chicken isn't on the ground, and the model's wings follow them. Issue [#1706](https://github.com/horizon-rust/horizon/issues/1706).
|
||||
|
||||
More: [#1892](https://github.com/horizon-rust/horizon/issues/1892)
|
||||
|
||||
</details>
|
||||
|
||||
### Rendering
|
||||
|
||||
#### Shader packs, written again from scratch
|
||||
|
||||
Shader packs were removed and are being rebuilt from scratch. They come back, off by default, once the best-known packs draw properly.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A clean-room rewrite in `horizon-shaderpack`, from the packs' public format documentation only: reading packs, the preprocessor and `.properties` files are merged ([#1584](https://github.com/horizon-rust/horizon/issues/1584), [#1585](https://github.com/horizon-rust/horizon/issues/1585), [#1586](https://github.com/horizon-rust/horizon/issues/1586)); translating programs to WGSL through naga, buffers, uniforms and the terrain, entity, sky, weather and hand stages are in review ([#1698](https://github.com/horizon-rust/horizon/issues/1698) to [#1801](https://github.com/horizon-rust/horizon/issues/1801)).
|
||||
|
||||
More: [#1561](https://github.com/horizon-rust/horizon/issues/1561) · [#1771](https://github.com/horizon-rust/horizon/issues/1771) · [#1786](https://github.com/horizon-rust/horizon/issues/1786)
|
||||
|
||||
</details>
|
||||
|
||||
#### Horizon's own shader format and kit
|
||||
|
||||
Horizon's own way to make shader packs, with tools to write and test them. Every pack is checked before it runs, and the registry will list them.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`horizon-shader-api` (API 0.1, experimental): WGSL stages with typed inputs inside the renderer's passes, options as WGSL override constants, and the `horizon-shader new|check|pack` tool with templates and the example pack Golden Hour. Merged: the check every pack passes ([#1820](https://github.com/horizon-rust/horizon/issues/1820)) and the tool, templates and guide ([#1822](https://github.com/horizon-rust/horizon/issues/1822)). In review: the post stage running in the game with hot reload ([#1861](https://github.com/horizon-rust/horizon/issues/1861)), the sky stage ([#1863](https://github.com/horizon-rust/horizon/issues/1863)) and registry checks that use the game's own check ([#1855](https://github.com/horizon-rust/horizon/issues/1855)). Left: the material, lighting and shadow stages, a last-good fallback on reload, a GPU-time overlay per stage and budgets ([docs/shaders.md](docs/shaders.md), [docs/api/shaders.md](docs/api/shaders.md)). The Iris/OptiFine loader is a separate item, 'Shader packs, written again from scratch'.
|
||||
Since [#1514](https://github.com/horizon-rust/horizon/issues/1514) single player runs on the server thread (`game/integrated.rs`) and the window joins it as a client; `--in-frame` keeps the old path. The open `[server-thread]` issues list the gaps. In review: breeding's advancement and statistic ([#3873](https://github.com/horizon-rust/horizon/issues/3873)), the client's menu when the server replaces the player ([#3841](https://github.com/horizon-rust/horizon/issues/3841)), splashes when falling in water ([#3621](https://github.com/horizon-rust/horizon/issues/3621)). [docs/server-levels.md](docs/server-levels.md).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -262,6 +102,8 @@ Horizon's own way to make shader packs, with tools to write and test them. Every
|
||||
|
||||
Play the real Java game from the Horizon launcher too: vanilla and modded instances, any version, mods from Modrinth filtered to what fits, and your instances imported from other launchers.
|
||||
|
||||
Owner: [ludovic111](https://github.com/ludovic111) · Tracking: [#3915](https://github.com/horizon-rust/horizon/issues/3915)
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
For an account that owns the game: any version downloaded with the Java it needs ([#3406](https://github.com/horizon-rust/horizon/issues/3406)), Java instances, the version list and the CLI ([#3395](https://github.com/horizon-rust/horizon/issues/3395)), the start command read from the version JSON ([#3419](https://github.com/horizon-rust/horizon/issues/3419)). Next: Play for a Java instance, Fabric and Quilt, then NeoForge and Forge; Modrinth with compatibility filtering and dependencies; imports from Prism, MultiMC, the CurseForge app, the Modrinth App, ATLauncher and the official launcher, each from its documented on-disk format.
|
||||
@@ -270,37 +112,31 @@ More: [#3506](https://github.com/horizon-rust/horizon/issues/3506)
|
||||
|
||||
</details>
|
||||
|
||||
### Horizon extras
|
||||
|
||||
#### Discord Rich Presence
|
||||
|
||||
Your Discord profile can show that you are playing Horizon, with a switch to turn it off.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A Discord IPC client on its own thread ([#1787](https://github.com/horizon-rust/horizon/issues/1787)) and the presence with its setting ([#1788](https://github.com/horizon-rust/horizon/issues/1788)). Merged: it ships with the next release.
|
||||
|
||||
</details>
|
||||
|
||||
### Code and tools
|
||||
|
||||
#### A leaner codebase that builds faster
|
||||
|
||||
Cleaning up Horizon's code: removing what nothing uses and making it quicker to build, so fixes and new features land faster.
|
||||
|
||||
Owner: [ludovic111](https://github.com/ludovic111) · Tracking: [#3916](https://github.com/horizon-rust/horizon/issues/3916)
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A review of the whole tree: dead code and stubs removed ([#1432](https://github.com/horizon-rust/horizon/issues/1432), [#1658](https://github.com/horizon-rust/horizon/issues/1658)), one probe binary per crate ([#1453](https://github.com/horizon-rust/horizon/issues/1453), [#1454](https://github.com/horizon-rust/horizon/issues/1454)), shared harness helpers ([#1448](https://github.com/horizon-rust/horizon/issues/1448)), clippy clean on the whole tree ([#1707](https://github.com/horizon-rust/horizon/issues/1707)). [docs/architecture.md](docs/architecture.md) maps each feature to its files and harnesses.
|
||||
|
||||
</details>
|
||||
|
||||
#### Every vanilla method, tracked
|
||||
#### Vanilla parity: the player-visible gaps, closed
|
||||
|
||||
A public list of every method of the Java game and where Horizon does it, so anyone can see what is ported, what is missing and pick something to fix.
|
||||
Every method of the Java game is tracked with where Horizon does it, and the differences players can see are being closed, list by list: mobs, blocks, items, screens, rendering, servers.
|
||||
|
||||
Owner: [kacpherek](https://github.com/kacpherek) · Tracking: [#3912](https://github.com/horizon-rust/horizon/issues/3912)
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
[tools/port_ledger.py](tools/port_ledger.py) and tools/port_map/ ([#3139](https://github.com/horizon-rust/horizon/issues/3139)): every 26.3 method outside the skipped packages has a verdict (port, inline, missing, skip, trivial), linked to the harnesses that cover it ([#3526](https://github.com/horizon-rust/horizon/issues/3526)), with one tracking issue per area. Bug fixes start from it (.claude/skills/vanilla-parity).
|
||||
The port ledger ([tools/port_ledger.py](tools/port_ledger.py), tools/port_map/, [#3139](https://github.com/horizon-rust/horizon/issues/3139)) gives every 26.3 method a verdict and links the harnesses that cover it ([#3526](https://github.com/horizon-rust/horizon/issues/3526)). The player-visible gaps are ten lists, one per area ([#3512](https://github.com/horizon-rust/horizon/issues/3512) to [#3521](https://github.com/horizon-rust/horizon/issues/3521)); a fix translates the vanilla method, ticks its row and carries the `parity` label. Start from .claude/skills/vanilla-parity.
|
||||
|
||||
More: [#3513](https://github.com/horizon-rust/horizon/issues/3513) · [#3514](https://github.com/horizon-rust/horizon/issues/3514) · [#3515](https://github.com/horizon-rust/horizon/issues/3515) · [#3516](https://github.com/horizon-rust/horizon/issues/3516) · [#3517](https://github.com/horizon-rust/horizon/issues/3517) · [#3518](https://github.com/horizon-rust/horizon/issues/3518) · [#3519](https://github.com/horizon-rust/horizon/issues/3519) · [#3520](https://github.com/horizon-rust/horizon/issues/3520)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -328,16 +164,6 @@ Cut the per-column generation cost (noise sampling, surface rules, aquifers) and
|
||||
|
||||
</details>
|
||||
|
||||
#### Servers that hold 20 TPS with 50 to 100 players
|
||||
|
||||
Servers that keep 20 ticks per second with 50 to 100 players. Today Horizon holds it up to 50 bots and drops to about 10 at 100.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Measured against vanilla on equal work ([docs/server.md](docs/server.md), Scale): Horizon is ahead with few players and when exploring, but vanilla's tick is shorter with 25 to 50 players near spawn. Ideas on the list: send entity changes instead of rebuilding every entity each tick (about 15 percent), fewer memory reads in random ticks and collision scans, cheaper item collision checks, mob AI and natural spawning. The bot benchmark (`horizon-bots`) is the proof.
|
||||
|
||||
</details>
|
||||
|
||||
#### Low-end computers: Steam Deck, old Macs, 8 GB
|
||||
|
||||
Horizon should run well on small machines: integrated graphics, the Steam Deck, older Macs and 8 GB of memory, with a battery mode that saves power on laptops.
|
||||
@@ -358,74 +184,22 @@ Draw the game at a lower resolution and sharpen it to your screen's size, in the
|
||||
|
||||
A render-scale setting below the window size with an upscaler of our own that runs on every wgpu backend. Proof: frame time against native resolution on integrated GPUs, and screenshots for image quality. Issue [#1403](https://github.com/horizon-rust/horizon/issues/1403).
|
||||
|
||||
</details>
|
||||
|
||||
#### A batch of targeted optimisations
|
||||
|
||||
Several smaller speed-ups, each measured: idle block entities that sleep, cheaper particles, faster structure placement, high-rate mice and very large texture packs.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Block entities and hoppers that skip work when idle (the hopper item scan is already skipped when no item is near); a particle frustum test first (also a parity gap), then particle quads built on the GPU; jigsaw shortcuts that skip blocked connectors and pool elements known not to fit, with layouts unchanged (trial chambers and ancient cities first); batched raw mouse input on Windows for 8000 Hz mice; a better atlas packer for huge resource packs (a 64x pack builds a 704 MiB atlas for 452 MiB of textures); sign-heavy scenes ([#1461](https://github.com/horizon-rust/horizon/issues/1461)). Profile first, keep what pays, show before and after numbers.
|
||||
|
||||
More: [docs/profiling.md](docs/profiling.md)
|
||||
More: [#3762](https://github.com/horizon-rust/horizon/issues/3762) · [#3763](https://github.com/horizon-rust/horizon/issues/3763) · [#3764](https://github.com/horizon-rust/horizon/issues/3764)
|
||||
|
||||
</details>
|
||||
|
||||
### Modpacks
|
||||
#### Faster block lookups
|
||||
|
||||
#### CurseForge in the mod browser (coming soon)
|
||||
|
||||
Coming soon: search and download CurseForge mods and modpacks from the launcher, as you can with Modrinth today.
|
||||
Speeding up the code that reads and writes the blocks of the world, which almost everything in the game goes through: generation, lighting, meshing and ticking.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
The mod browser lists CurseForge as coming soon ([docs/mod-browser.md](docs/mod-browser.md)): it needs CurseForge API access. Meanwhile packs import from a CurseForge zip or instance folder, and jars only CurseForge hosts come from the player's own instance ([docs/modpacks.md](docs/modpacks.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### A Convert to Rust button for what isn't rewritten yet
|
||||
|
||||
When a mod, shader pack or modpack hasn't been rewritten for Horizon, one button converts what can be converted, so you don't have to wait for the rewrite.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Generalise the mod browser's Port step (`horizon-port-pack`: the data, assets, worldgen and block and item definitions of Java mods, .mrpack and CurseForge zips) to every kind of content: code mods where the API allows it (see 'Mods with code') and shader packs, converting an Iris/OptiFine pack into Horizon's format. The browser says what works before the install. Java code itself does not run ([docs/modpacks.md](docs/modpacks.md), [docs/mod-browser.md](docs/mod-browser.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Mods with code: machines, magic, mobs
|
||||
|
||||
Making mods that add machines, magic and mobs work in Horizon, in steps, starting with machines. They are most of what a big modpack is made of.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
333 of the 496 mods in a big pack (ATM10) contain code. Issue [#1854](https://github.com/horizon-rust/horizon/issues/1854) ranks the API gaps (ticking block entities, networking, rendering, menus, inventories) that unlock them in tiers, with machines first as the recommended path and a furnace ported tick for tick as proof. The first API step is a draft ([#1189](https://github.com/horizon-rust/horizon/issues/1189), see 'Machines and blocks with inventories'). The path isn't started yet ([docs/modpacks.md](docs/modpacks.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Data packs in the mod browser
|
||||
|
||||
Search and install data packs from the launcher's mod browser, for the world you choose, as you do mods and resource packs.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A Datapacks tab beside Mods and Resource packs: install into a chosen world's datapacks folder, with the same checks and 'works in Horizon' notes as the other kinds ([docs/mod-browser.md](docs/mod-browser.md)).
|
||||
`PalettedContainer` and `PackedStorage` find an entry's word by multiply and shift instead of an integer division ([#1790](https://github.com/horizon-rust/horizon/issues/1790), [#1794](https://github.com/horizon-rust/horizon/issues/1794)); meshing looks up baked models by index, not by name ([#1747](https://github.com/horizon-rust/horizon/issues/1747)). Each change keeps the paletted storage harnesses identical and shows its microbenchmark ([docs/performance/paletted-container-access-results.md](docs/performance/paletted-container-access-results.md)).
|
||||
|
||||
</details>
|
||||
|
||||
### Servers and multiplayer
|
||||
|
||||
#### Prepare a version without Java
|
||||
|
||||
Set up Horizon, a server first, without Java on your computer: published tables are combined with your own copy of the game.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Today `horizon-prepare` runs Mojang's exporters with Java. Issue [#1750](https://github.com/horizon-rust/horizon/issues/1750) proposes publishing the computed version tables once from CI and assembling the rest locally from the player's own jar and assets, provided the tables hold no Mojang content; a contributor offered a pull request, and it would help phones too. A server-only prepare from `server.jar` alone is the first step ([docs/server.md](docs/server.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Server console, bans and a full admin API
|
||||
|
||||
Manage a server from a console, ban and pardon players, and drive it from other tools through a full admin API and an MCP server for AI agents.
|
||||
@@ -446,86 +220,18 @@ A Windows service wrapper instead of the logon scheduled task; `backups.before_u
|
||||
|
||||
</details>
|
||||
|
||||
#### Guided hosting with Tailscale and an invite button
|
||||
#### Playing on servers: everything shows as it should
|
||||
|
||||
In the launcher, a guide and a button to host a server and invite friends over Tailscale, so nobody has to open ports on a router.
|
||||
When you play on a server, other players, mobs, items and menus look and behave as in vanilla: skins, poses, item names, inventories and server networks.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
On the Servers page (create, start, back up, update and invite are shipped) add a Tailscale setup that explains and checks each step, and an invite button that hands friends what they need to join over the tailnet. Buttons first, no docs needed, with the details in an Advanced section. Remote access over Tailscale is already described in [docs/server.md](docs/server.md).
|
||||
|
||||
More: [docs/hosting.md](docs/hosting.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### An official Docker image for servers
|
||||
|
||||
An official image to run a Horizon dedicated server in Docker with one command, your own copy of the game files mounted in.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
The repository has the Dockerfile (`packaging/server/Dockerfile`: a multi-stage build on Debian bookworm-slim, user `horizon`, volume `/data`, port 25565, game files mounted read-only, `HORIZON_EULA` and friends answering the first-start questions), and anyone can build it ([docs/hosting.md](docs/hosting.md), section 9). Left: publish the image to a public registry from the release workflow, tagged per release, so it follows the same checks, and update the docs and the launcher. The image never holds Mojang's files.
|
||||
|
||||
More: [docs/server.md](docs/server.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### A server you install with one command
|
||||
|
||||
Install and run a Horizon dedicated server from a terminal with a single command, no launcher needed, for VPSes and headless machines.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A player's idea (#horizon-ideas). `horizon-server` already runs headless; left: an install script (Linux/macOS shell, Windows PowerShell) that fetches the signed release, checks it, asks for or points at the player's own game files and starts the server, documented in [docs/hosting.md](docs/hosting.md).
|
||||
|
||||
</details>
|
||||
|
||||
#### Ready-made setups for hosting panels
|
||||
|
||||
Install a Horizon server from the panels hosts use, such as Pterodactyl and AMP, the usual way for each panel.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Asked in the suggestion queue for Pterodactyl eggs and AMP templates. Builds on the one-command install ('A server you install with one command') and horizon-server's server.toml ([docs/hosting.md](docs/hosting.md)).
|
||||
|
||||
</details>
|
||||
|
||||
### Mods and API
|
||||
|
||||
#### Mods that read and change the world
|
||||
|
||||
Mods that can see and change blocks, read inventories and items, and find and affect entities, all through the server so every player sees the same thing.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Hook points for reading blocks, states and biomes and changing them through the server's block log, so clients and the parity harnesses see vanilla's updates; the player's inventory and item stacks with components; entity queries near a position. Batched per tick, never per-block calls in hot paths.
|
||||
|
||||
More: [docs/native-mods.md](docs/native-mods.md) · [docs/modpacks.md](docs/modpacks.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Machines and blocks with inventories (API 0.4)
|
||||
|
||||
Mods that add machines: blocks that tick, hold items and have their own menus. This unlocks the technology mods of big modpacks.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Block entities and menus: ticking machines with slots, server menu logic and the menu protocol to clients, energy, item and fluid transfer, data attachments, accessory slots and custom recipe types, with a typed client and server channel and typed config beside them. A draft ([#1189](https://github.com/horizon-rust/horizon/issues/1189)) proves a furnace ported tick for tick and holds the ATM10 code-mods plan; the design is redone through the API policy before it merges ([docs/modpacks.md](docs/modpacks.md), docs/api/stability.md).
|
||||
Client-of-a-server fixes in `game/remote*.rs` and `horizon-protocol`: mobs' aggressive and item-use flags ([#1802](https://github.com/horizon-rust/horizon/issues/1802), [#1798](https://github.com/horizon-rust/horizon/issues/1798)), ghost items after a remote menu ([#1795](https://github.com/horizon-rust/horizon/issues/1795)), skins ([#1652](https://github.com/horizon-rust/horizon/issues/1652)), cookies and transfer packets for proxy networks ([#1650](https://github.com/horizon-rust/horizon/issues/1650)), firework boosts ([#1671](https://github.com/horizon-rust/horizon/issues/1671)), living-entity attributes and simulation distance ([#1731](https://github.com/horizon-rust/horizon/issues/1731)), styled item names ([#1702](https://github.com/horizon-rust/horizon/issues/1702)), primed TNT between updates ([#1710](https://github.com/horizon-rust/horizon/issues/1710)).
|
||||
|
||||
</details>
|
||||
|
||||
### Gameplay parity
|
||||
|
||||
#### Pistons push players and mobs
|
||||
|
||||
Pistons and slime blocks will push players, mobs and items as in vanilla. Today moving blocks pass through them.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Port `PistonMovingBlockEntity.moveCollidedEntities` and `moveStuckEntities` (honey), the slime bounce and `Entity.limitPistonMovement` for `MoverType.PISTON`, in single player and for a server's players. Issues [#1496](https://github.com/horizon-rust/horizon/issues/1496), [#1609](https://github.com/horizon-rust/horizon/issues/1609), [#1608](https://github.com/horizon-rust/horizon/issues/1608), [#1704](https://github.com/horizon-rust/horizon/issues/1704), [#1758](https://github.com/horizon-rust/horizon/issues/1758).
|
||||
|
||||
</details>
|
||||
|
||||
#### One single-player path: remove the in-frame world
|
||||
|
||||
Single player will have one way to run, the server thread, so every fix works everywhere and there are fewer bugs. The older in-frame mode goes once everything passes without it.
|
||||
@@ -536,37 +242,31 @@ Single player will have one way to run, the server thread, so every fix works ev
|
||||
|
||||
</details>
|
||||
|
||||
#### Villages and combat, exactly as in vanilla
|
||||
#### Splitscreen
|
||||
|
||||
Villages and fights behaving as in vanilla: iron golems defending villages, villagers remembering their homes, mace smashes and knockback.
|
||||
Play with a friend on the same screen, each with a controller.
|
||||
|
||||
Owner: [martipops](https://github.com/martipops)
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Villages: iron golem village goals, villager memories across saves, villager age, job-site claiming on dedicated servers, flower offers ([#1639](https://github.com/horizon-rust/horizon/issues/1639) is one report). Combat: the mobs' `attack_knockback` attribute, mace smash logic, the hit knockback source on the host, the fire burnout biome tag. Each is checked against vanilla by a harness ([docs/rust-parity.md](docs/rust-parity.md)).
|
||||
Issue [#2054](https://github.com/horizon-rust/horizon/issues/2054), built by martipops as a series: up to 16 local players on the server thread's multiplayer ([#3541](https://github.com/horizon-rust/horizon/issues/3541)), each controller driving its own player ([#3794](https://github.com/horizon-rust/horizon/issues/3794)), joining with a controller ([#3801](https://github.com/horizon-rust/horizon/issues/3801)), players in windows of their own ([#3825](https://github.com/horizon-rust/horizon/issues/3825)) and the profile page ([#3826](https://github.com/horizon-rust/horizon/issues/3826)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Choose the audio output device
|
||||
|
||||
Pick the speakers or headphones Horizon plays through, in Music & Sounds.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
An output device list in Music & Sounds, falling back to the system default. [#1035](https://github.com/horizon-rust/horizon/issues/1035) started it: a player on an older Windows computer had no sound at all, and a device choice is how such cases are found and fixed.
|
||||
More: [#3777](https://github.com/horizon-rust/horizon/issues/3777)
|
||||
|
||||
</details>
|
||||
|
||||
### Rendering
|
||||
|
||||
#### Well-known shader looks, rewritten for Horizon
|
||||
#### Shader packs, written again from scratch
|
||||
|
||||
Popular shader packs rewritten in Horizon's format, so good packs exist from the start. Only packs whose licence is open source and compatible with ours are rewritten, never anything else.
|
||||
Shader packs were removed and are being rebuilt from scratch. They come back, off by default, once the best-known packs draw properly.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
The first candidates are Photon, Chocapic13 and MakeUp Ultra Fast, each only after its licence is read and recorded: a pack is rewritten only if it is open source and compatible with Horizon's licence, and the rewrite is written from the shader format's documentation and the look on screen, not from anyone's code. Today only the Golden Hour example exists (`[crates/horizon-shader-api/packs/golden_hour](crates/horizon-shader-api/packs/golden_hour)`).
|
||||
A clean-room rewrite in `horizon-shaderpack`, from the packs' public format documentation only: reading packs, the preprocessor and `.properties` files are merged ([#1584](https://github.com/horizon-rust/horizon/issues/1584), [#1585](https://github.com/horizon-rust/horizon/issues/1585), [#1586](https://github.com/horizon-rust/horizon/issues/1586)); translating programs to WGSL through naga, buffers, uniforms and the terrain, entity, sky, weather and hand stages are in review ([#1698](https://github.com/horizon-rust/horizon/issues/1698) to [#1801](https://github.com/horizon-rust/horizon/issues/1801)).
|
||||
|
||||
More: [docs/shaders.md](docs/shaders.md)
|
||||
More: [#1561](https://github.com/horizon-rust/horizon/issues/1561) · [#1771](https://github.com/horizon-rust/horizon/issues/1771) · [#1786](https://github.com/horizon-rust/horizon/issues/1786)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -576,101 +276,13 @@ More: [docs/shaders.md](docs/shaders.md)
|
||||
|
||||
A redesigned launcher: every button where you expect it, the fewest clicks to play, add an instance or add mods, smooth animations, and instant startup.
|
||||
|
||||
Owner: [ludovic111](https://github.com/ludovic111)
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Based on the site concept by the Team (Bricolage Grotesque, a night sky and an animated pixel sunset). Screens and flows are designed first, then small PRs; tokens in [crates/horizon-theme](crates/horizon-theme) and rules in [docs/design-system.md](docs/design-system.md), the game's vanilla look unchanged. Measured before and after: startup, frame time and idle CPU/GPU, reduced motion honoured. Mods screens separate Horizon native mods from Java mods; a running instance can be stopped from the launcher.
|
||||
|
||||
</details>
|
||||
|
||||
#### A visible beta label
|
||||
|
||||
A clear 'beta' label in the launcher and the game, so nobody is surprised that Horizon is still being built.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Show Beta in the launcher's header and about page, on the game's title screen and on the website, beside the version number ([docs/versioning.md](docs/versioning.md)).
|
||||
|
||||
</details>
|
||||
|
||||
### Platforms
|
||||
|
||||
#### Horizon on Intel Macs
|
||||
|
||||
Builds of the game and the launcher for Macs with an Intel processor.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
x86_64 game and launcher builds, picked per processor ([#1171](https://github.com/horizon-rust/horizon/issues/1171)). Before it ships it needs a run on a real Intel Mac: Rosetta on Apple Silicon only proves the CPU code. Requests: [#1161](https://github.com/horizon-rust/horizon/issues/1161), [#1792](https://github.com/horizon-rust/horizon/issues/1792).
|
||||
|
||||
</details>
|
||||
|
||||
#### Wayland and Hyprland polish
|
||||
|
||||
Fixes for Linux desktops that use Wayland and Hyprland: fullscreen, the clipboard, the GUI scale after a restart and the like.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Open: the GUI Scale is clamped to 2 after a restart under Hyprland, while XWayland keeps it ([#1866](https://github.com/horizon-rust/horizon/issues/1866)). Earlier reports covered fullscreen dropping on menu clicks, the clipboard and a white overlay ([#1007](https://github.com/horizon-rust/horizon/issues/1007), closed). Checked on a real Wayland session.
|
||||
|
||||
</details>
|
||||
|
||||
### AI and agents
|
||||
|
||||
#### A versioned agent-control protocol
|
||||
|
||||
A stable, documented way for AI agents and scripts to drive the game and the launcher, with versions and an event stream so tools keep working as Horizon changes.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`hello` answers with the API version, the release and the features; `horizon-ctl --api`; MCP serverInfo; reply schemas generated into the reference; a `test.` namespace for hooks; transcript compatibility tests; `subscribe` as a stream of events on one connection; Horizon option files by key and saved-server commands. Agent control itself (`horizon-ctl`, both MCP servers, the No AI switch) is done: [docs/agent-control.md](docs/agent-control.md), [docs/api/agent-control.md](docs/api/agent-control.md).
|
||||
|
||||
More: [docs/api/stability.md](docs/api/stability.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Port mods with your own AI agent
|
||||
|
||||
You can port a mod to Horizon with your own AI agent on your own computer. Horizon documents how and gives you the tools, but provides no hosted agents.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A player runs their own agent locally with `horizon-ctl` and the MCP servers ([docs/agent-control.md](docs/agent-control.md)), `cargo horizon-mod`, `horizon-port-pack` and the parity harnesses as the check. Horizon writes the guide (playhorizon.dev/docs/agents, [docs/modpacks.md](docs/modpacks.md)) and runs no agents, accounts or keys for players. The No AI switch turns all of it off.
|
||||
|
||||
More: [https://playhorizon.dev/docs/agents](https://playhorizon.dev/docs/agents)
|
||||
|
||||
</details>
|
||||
|
||||
### Community
|
||||
|
||||
#### Horizon on X (coming soon)
|
||||
|
||||
Coming soon: an official Horizon account on X, used to announce releases and the weekly summary.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Kira already posts the roadmap items marked `announce = true` when they ship, and the weekly summary, as soon as the account's credentials exist ([roadmap/README.md](roadmap/README.md), 'The journal'). Waiting for the account itself.
|
||||
|
||||
</details>
|
||||
|
||||
### Code and tools
|
||||
|
||||
#### Pipeline protection and a GitHub App for agents
|
||||
|
||||
Safer releases: changes to the release machinery need the owner's review, and AI agents use a smaller identity of their own instead of a personal account.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`.github/CODEOWNERS` lists the workflows, the release and signing tools, the update-checking code and the installers ([#1015](https://github.com/horizon-rust/horizon/issues/1015)); the branch protection that makes it count waits for the `horizon-agent` GitHub App, which reads and writes code, pull requests and issues but can't change settings, workflows, secrets or members, and whose tokens last an hour. It needs the owner to create the App and switch the protection on ([docs/security.md](docs/security.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Signed updates everywhere, installers included
|
||||
|
||||
Every download Horizon makes is checked against the project's signature, including the first install with install.sh and install.ps1.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Releases carry signed checksums, and the launcher and `horizon-server update` refuse anything unsigned ([#1011](https://github.com/horizon-rust/horizon/issues/1011), [#1014](https://github.com/horizon-rust/horizon/issues/1014)). Left: the first install through `install.sh` and `install.ps1` still trusts only the checksum GitHub publishes, because verifying Ed25519 signatures needs OpenSSL 3, which macOS doesn't ship ([docs/security.md](docs/security.md)).
|
||||
More: [#3635](https://github.com/horizon-rust/horizon/issues/3635) · [#3636](https://github.com/horizon-rust/horizon/issues/3636) · [#3637](https://github.com/horizon-rust/horizon/issues/3637)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -688,6 +300,18 @@ Keep a 3D mip store on disk built from real chunks, drawn by the GPU beside the
|
||||
|
||||
</details>
|
||||
|
||||
#### A batch of targeted optimisations
|
||||
|
||||
Several smaller speed-ups, each measured: idle block entities that sleep, cheaper particles, faster structure placement, high-rate mice and very large texture packs.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Block entities and hoppers that skip work when idle (the hopper item scan is already skipped when no item is near); a particle frustum test first (also a parity gap), then particle quads built on the GPU; jigsaw shortcuts that skip blocked connectors and pool elements known not to fit, with layouts unchanged (trial chambers and ancient cities first); batched raw mouse input on Windows for 8000 Hz mice; a better atlas packer for huge resource packs (a 64x pack builds a 704 MiB atlas for 452 MiB of textures); sign-heavy scenes ([#1461](https://github.com/horizon-rust/horizon/issues/1461)). Profile first, keep what pays, show before and after numbers.
|
||||
|
||||
More: [docs/profiling.md](docs/profiling.md)
|
||||
|
||||
</details>
|
||||
|
||||
### Modpacks
|
||||
|
||||
#### Older Fabric and Forge mods
|
||||
@@ -730,6 +354,46 @@ Earlier research ([#527](https://github.com/horizon-rust/horizon/issues/527), cl
|
||||
|
||||
</details>
|
||||
|
||||
#### CurseForge in the mod browser (coming soon)
|
||||
|
||||
Coming soon: search and download CurseForge mods and modpacks from the launcher, as you can with Modrinth today.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
The mod browser lists CurseForge as coming soon ([docs/mod-browser.md](docs/mod-browser.md)): it needs CurseForge API access. Meanwhile packs import from a CurseForge zip or instance folder, and jars only CurseForge hosts come from the player's own instance ([docs/modpacks.md](docs/modpacks.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### A Convert to Rust button for what isn't rewritten yet
|
||||
|
||||
When a mod, shader pack or modpack hasn't been rewritten for Horizon, one button converts what can be converted, so you don't have to wait for the rewrite.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Generalise the mod browser's Port step (`horizon-port-pack`: the data, assets, worldgen and block and item definitions of Java mods, .mrpack and CurseForge zips) to every kind of content: code mods where the API allows it (see 'Mods with code') and shader packs, converting an Iris/OptiFine pack into Horizon's format. The browser says what works before the install. Java code itself does not run ([docs/modpacks.md](docs/modpacks.md), [docs/mod-browser.md](docs/mod-browser.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Mods with code: machines, magic, mobs
|
||||
|
||||
Making mods that add machines, magic and mobs work in Horizon, in steps, starting with machines. They are most of what a big modpack is made of.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
333 of the 496 mods in a big pack (ATM10) contain code. Issue [#1854](https://github.com/horizon-rust/horizon/issues/1854) ranks the API gaps (ticking block entities, networking, rendering, menus, inventories) that unlock them in tiers, with machines first as the recommended path and a furnace ported tick for tick as proof. The first API step is a draft ([#1189](https://github.com/horizon-rust/horizon/issues/1189), see 'Machines and blocks with inventories'). The path isn't started yet ([docs/modpacks.md](docs/modpacks.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Data packs in the mod browser
|
||||
|
||||
Search and install data packs from the launcher's mod browser, for the world you choose, as you do mods and resource packs.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A Datapacks tab beside Mods and Resource packs: install into a chosen world's datapacks folder, with the same checks and 'works in Horizon' notes as the other kinds ([docs/mod-browser.md](docs/mod-browser.md)).
|
||||
|
||||
</details>
|
||||
|
||||
### Servers and multiplayer
|
||||
|
||||
#### Ely.by and other sign-in providers
|
||||
@@ -764,6 +428,60 @@ Play with Friends joins over an encrypted tunnel with an invite code ([docs/play
|
||||
|
||||
</details>
|
||||
|
||||
#### Prepare a version without Java
|
||||
|
||||
Set up Horizon, a server first, without Java on your computer: published tables are combined with your own copy of the game.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Today `horizon-prepare` runs Mojang's exporters with Java. Issue [#1750](https://github.com/horizon-rust/horizon/issues/1750) proposes publishing the computed version tables once from CI and assembling the rest locally from the player's own jar and assets, provided the tables hold no Mojang content; a contributor offered a pull request, and it would help phones too. A server-only prepare from `server.jar` alone is the first step ([docs/server.md](docs/server.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Guided hosting with Tailscale and an invite button
|
||||
|
||||
In the launcher, a guide and a button to host a server and invite friends over Tailscale, so nobody has to open ports on a router.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
On the Servers page (create, start, back up, update and invite are shipped) add a Tailscale setup that explains and checks each step, and an invite button that hands friends what they need to join over the tailnet. Buttons first, no docs needed, with the details in an Advanced section. Remote access over Tailscale is already described in [docs/server.md](docs/server.md).
|
||||
|
||||
More: [docs/hosting.md](docs/hosting.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### An official Docker image for servers
|
||||
|
||||
An official image to run a Horizon dedicated server in Docker with one command, your own copy of the game files mounted in.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
The repository has the Dockerfile (`packaging/server/Dockerfile`: a multi-stage build on Debian bookworm-slim, user `horizon`, volume `/data`, port 25565, game files mounted read-only, `HORIZON_EULA` and friends answering the first-start questions), and anyone can build it ([docs/hosting.md](docs/hosting.md), section 9). Left: publish the image to a public registry from the release workflow, tagged per release, so it follows the same checks, and update the docs and the launcher. The image never holds Mojang's files.
|
||||
|
||||
More: [docs/server.md](docs/server.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### A server you install with one command
|
||||
|
||||
Install and run a Horizon dedicated server from a terminal with a single command, no launcher needed, for VPSes and headless machines.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A player's idea (#horizon-ideas). `horizon-server` already runs headless; left: an install script (Linux/macOS shell, Windows PowerShell) that fetches the signed release, checks it, asks for or points at the player's own game files and starts the server, documented in [docs/hosting.md](docs/hosting.md).
|
||||
|
||||
</details>
|
||||
|
||||
#### Ready-made setups for hosting panels
|
||||
|
||||
Install a Horizon server from the panels hosts use, such as Pterodactyl and AMP, the usual way for each panel.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Asked in the suggestion queue for Pterodactyl eggs and AMP templates. Builds on the one-command install ('A server you install with one command') and horizon-server's server.toml ([docs/hosting.md](docs/hosting.md)).
|
||||
|
||||
</details>
|
||||
|
||||
### Mods and API
|
||||
|
||||
#### Scripts for mods
|
||||
@@ -842,16 +560,6 @@ The game side is merged ([docs/controllers.md](docs/controllers.md)): pads read
|
||||
|
||||
</details>
|
||||
|
||||
#### Splitscreen
|
||||
|
||||
Play with a friend on the same screen, each with a controller.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Issue [#2054](https://github.com/horizon-rust/horizon/issues/2054); a contributor is working on it. Two local players on the same world, each with their own camera and HUD, built on the server thread's multiplayer.
|
||||
|
||||
</details>
|
||||
|
||||
#### The full debug screen
|
||||
|
||||
Vanilla's complete F3 screen: debug options, a profiler chart, ten-second captures and an atlas dump.
|
||||
@@ -862,6 +570,46 @@ Today F3 shows position, chunk, light, biome and FPS ([docs/native-client.md](do
|
||||
|
||||
</details>
|
||||
|
||||
#### Pistons push players and mobs
|
||||
|
||||
Pistons and slime blocks will push players, mobs and items as in vanilla. Today moving blocks pass through them.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Port `PistonMovingBlockEntity.moveCollidedEntities` and `moveStuckEntities` (honey), the slime bounce and `Entity.limitPistonMovement` for `MoverType.PISTON`, in single player and for a server's players. Issues [#1496](https://github.com/horizon-rust/horizon/issues/1496), [#1609](https://github.com/horizon-rust/horizon/issues/1609), [#1608](https://github.com/horizon-rust/horizon/issues/1608), [#1704](https://github.com/horizon-rust/horizon/issues/1704), [#1758](https://github.com/horizon-rust/horizon/issues/1758).
|
||||
|
||||
</details>
|
||||
|
||||
#### Villages and combat, exactly as in vanilla
|
||||
|
||||
Villages and fights behaving as in vanilla: iron golems defending villages, villagers remembering their homes, mace smashes and knockback.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Villages: iron golem village goals, villager memories across saves, villager age, job-site claiming on dedicated servers, flower offers ([#1639](https://github.com/horizon-rust/horizon/issues/1639) is one report). Combat: the mobs' `attack_knockback` attribute, mace smash logic, the hit knockback source on the host, the fire burnout biome tag. Each is checked against vanilla by a harness ([docs/rust-parity.md](docs/rust-parity.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Choose the audio output device
|
||||
|
||||
Pick the speakers or headphones Horizon plays through, in Music & Sounds.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
An output device list in Music & Sounds, falling back to the system default. [#1035](https://github.com/horizon-rust/horizon/issues/1035) started it: a player on an older Windows computer had no sound at all, and a device choice is how such cases are found and fixed.
|
||||
|
||||
</details>
|
||||
|
||||
#### Play in your language
|
||||
|
||||
Item names, container titles and server messages follow the language you pick, as in vanilla. Today parts of the game stay in English.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Translatable components resolved through the selected language for items, containers and server text ([#1806](https://github.com/horizon-rust/horizon/issues/1806)), on top of the bundled languages the prepared resources carry ([#1594](https://github.com/horizon-rust/horizon/issues/1594)). Left: the client's own screens ([#1672](https://github.com/horizon-rust/horizon/issues/1672)) and the main menu with zh_cn.
|
||||
|
||||
</details>
|
||||
|
||||
### Rendering
|
||||
|
||||
#### A light default shader pack
|
||||
@@ -906,6 +654,18 @@ Not implemented ([docs/graphics.md](docs/graphics.md)). Anisotropic filtering ne
|
||||
|
||||
</details>
|
||||
|
||||
#### Well-known shader looks, rewritten for Horizon
|
||||
|
||||
Popular shader packs rewritten in Horizon's format, so good packs exist from the start. Only packs whose licence is open source and compatible with ours are rewritten, never anything else.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
The first candidates are Photon, Chocapic13 and MakeUp Ultra Fast, each only after its licence is read and recorded: a pack is rewritten only if it is open source and compatible with Horizon's licence, and the rewrite is written from the shader format's documentation and the look on screen, not from anyone's code. Today only the Golden Hour example exists (`[crates/horizon-shader-api/packs/golden_hour](crates/horizon-shader-api/packs/golden_hour)`).
|
||||
|
||||
More: [docs/shaders.md](docs/shaders.md)
|
||||
|
||||
</details>
|
||||
|
||||
### Launcher
|
||||
|
||||
#### Lean installs that use less disk
|
||||
@@ -918,6 +678,16 @@ A shared resource store keyed by the preparer's digest, no prepare intermediates
|
||||
|
||||
</details>
|
||||
|
||||
#### A visible beta label
|
||||
|
||||
A clear 'beta' label in the launcher and the game, so nobody is surprised that Horizon is still being built.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Show Beta in the launcher's header and about page, on the game's title screen and on the website, beside the version number ([docs/versioning.md](docs/versioning.md)).
|
||||
|
||||
</details>
|
||||
|
||||
### Platforms
|
||||
|
||||
#### Horizon on Linux ARM64
|
||||
@@ -952,6 +722,26 @@ No official build. Android was first closed as not planned, with touch controls,
|
||||
|
||||
</details>
|
||||
|
||||
#### Horizon on Intel Macs
|
||||
|
||||
Builds of the game and the launcher for Macs with an Intel processor.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
x86_64 game and launcher builds, picked per processor ([#1171](https://github.com/horizon-rust/horizon/issues/1171)). Before it ships it needs a run on a real Intel Mac: Rosetta on Apple Silicon only proves the CPU code. Requests: [#1161](https://github.com/horizon-rust/horizon/issues/1161), [#1792](https://github.com/horizon-rust/horizon/issues/1792).
|
||||
|
||||
</details>
|
||||
|
||||
#### Wayland and Hyprland polish
|
||||
|
||||
Fixes for Linux desktops that use Wayland and Hyprland: fullscreen, the clipboard, the GUI scale after a restart and the like.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Open: the GUI Scale is clamped to 2 after a restart under Hyprland, while XWayland keeps it ([#1866](https://github.com/horizon-rust/horizon/issues/1866)). Earlier reports covered fullscreen dropping on menu clicks, the clipboard and a white overlay ([#1007](https://github.com/horizon-rust/horizon/issues/1007), closed). Checked on a real Wayland session.
|
||||
|
||||
</details>
|
||||
|
||||
#### AppImage and a Nix flake
|
||||
|
||||
Install Horizon on any Linux distribution as an AppImage, and on NixOS through a Nix flake.
|
||||
@@ -996,6 +786,30 @@ Draft pull requests: the companion ([#1068](https://github.com/horizon-rust/hori
|
||||
|
||||
</details>
|
||||
|
||||
#### A versioned agent-control protocol
|
||||
|
||||
A stable, documented way for AI agents and scripts to drive the game and the launcher, with versions and an event stream so tools keep working as Horizon changes.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`hello` answers with the API version, the release and the features; `horizon-ctl --api`; MCP serverInfo; reply schemas generated into the reference; a `test.` namespace for hooks; transcript compatibility tests; `subscribe` as a stream of events on one connection; Horizon option files by key and saved-server commands. Agent control itself (`horizon-ctl`, both MCP servers, the No AI switch) is done: [docs/agent-control.md](docs/agent-control.md), [docs/api/agent-control.md](docs/api/agent-control.md).
|
||||
|
||||
More: [docs/api/stability.md](docs/api/stability.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Port mods with your own AI agent
|
||||
|
||||
You can port a mod to Horizon with your own AI agent on your own computer. Horizon documents how and gives you the tools, but provides no hosted agents.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A player runs their own agent locally with `horizon-ctl` and the MCP servers ([docs/agent-control.md](docs/agent-control.md)), `cargo horizon-mod`, `horizon-port-pack` and the parity harnesses as the check. Horizon writes the guide (playhorizon.dev/docs/agents, [docs/modpacks.md](docs/modpacks.md)) and runs no agents, accounts or keys for players. The No AI switch turns all of it off.
|
||||
|
||||
More: [https://playhorizon.dev/docs/agents](https://playhorizon.dev/docs/agents)
|
||||
|
||||
</details>
|
||||
|
||||
### Community
|
||||
|
||||
#### A video series about Horizon
|
||||
@@ -1008,6 +822,38 @@ Kira already shows new uploads in a read-only Discord channel (done). The series
|
||||
|
||||
</details>
|
||||
|
||||
#### Horizon on X (coming soon)
|
||||
|
||||
Coming soon: an official Horizon account on X, used to announce releases and the weekly summary.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Kira already posts the roadmap items marked `announce = true` when they ship, and the weekly summary, as soon as the account's credentials exist ([roadmap/README.md](roadmap/README.md), 'The journal'). Waiting for the account itself.
|
||||
|
||||
</details>
|
||||
|
||||
### Code and tools
|
||||
|
||||
#### Pipeline protection and a GitHub App for agents
|
||||
|
||||
Safer releases: changes to the release machinery need the owner's review, and AI agents use a smaller identity of their own instead of a personal account.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`.github/CODEOWNERS` lists the workflows, the release and signing tools, the update-checking code and the installers ([#1015](https://github.com/horizon-rust/horizon/issues/1015)); the branch protection that makes it count waits for the `horizon-agent` GitHub App, which reads and writes code, pull requests and issues but can't change settings, workflows, secrets or members, and whose tokens last an hour. It needs the owner to create the App and switch the protection on ([docs/security.md](docs/security.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Signed updates everywhere, installers included
|
||||
|
||||
Every download Horizon makes is checked against the project's signature, including the first install with install.sh and install.ps1.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Releases carry signed checksums, and the launcher and `horizon-server update` refuse anything unsigned ([#1011](https://github.com/horizon-rust/horizon/issues/1011), [#1014](https://github.com/horizon-rust/horizon/issues/1014)). Left: the first install through `install.sh` and `install.ps1` still trusts only the checksum GitHub publishes, because verifying Ed25519 signatures needs OpenSSL 3, which macOS doesn't ship ([docs/security.md](docs/security.md)).
|
||||
|
||||
</details>
|
||||
|
||||
## Help wanted
|
||||
|
||||
### Performance
|
||||
@@ -1048,6 +894,15 @@ Armour and tools in End city chests should come enchanted, as in vanilla.
|
||||
|
||||
## Recently shipped
|
||||
|
||||
- **Chickens flap while they fall** (v0.2.67): Chickens should flap their wings while they fall, not stand still in the air. [#1706](https://github.com/horizon-rust/horizon/issues/1706) · [#1892](https://github.com/horizon-rust/horizon/issues/1892)
|
||||
- **Ctrl+Backspace and scroll bars in menus** (v0.2.67): Ctrl+Backspace should delete a whole word in the creative search, and every list's scroll bar should be draggable. [#1764](https://github.com/horizon-rust/horizon/issues/1764) · [#1782](https://github.com/horizon-rust/horizon/issues/1782) · [#1884](https://github.com/horizon-rust/horizon/issues/1884) · [#1931](https://github.com/horizon-rust/horizon/issues/1931)
|
||||
- **A pointing hand over buttons** (v0.2.67): The mouse pointer should turn into a pointing hand over menu buttons, as in vanilla 26.3. [#1347](https://github.com/horizon-rust/horizon/issues/1347) · [#1897](https://github.com/horizon-rust/horizon/issues/1897)
|
||||
- **Chat settings and team prefixes** (v0.2.67): The Hidden and Commands Only chat settings should hide chat, and team prefixes should appear before names in chat. [#1376](https://github.com/horizon-rust/horizon/issues/1376) · [#1378](https://github.com/horizon-rust/horizon/issues/1378) · [#1932](https://github.com/horizon-rust/horizon/issues/1932) · [#1841](https://github.com/horizon-rust/horizon/issues/1841)
|
||||
- **Cooldowns drawn on items** (v0.2.67): After you throw an ender pearl, its slot should show the cooldown sweeping away, as in vanilla. [#1763](https://github.com/horizon-rust/horizon/issues/1763) · [#1933](https://github.com/horizon-rust/horizon/issues/1933)
|
||||
- **Pre-generate an area with /pregen** (v0.2.67): A /pregen command that builds an area of the world ahead of time, so players don't wait for new terrain when they explore. [#1805](https://github.com/horizon-rust/horizon/issues/1805)
|
||||
- **Horizon's own shader format and kit** (v0.2.70): Horizon's own way to make shader packs, with tools to write and test them. Every pack is checked before it runs, and the registry will list them. [#1820](https://github.com/horizon-rust/horizon/issues/1820) · [#1822](https://github.com/horizon-rust/horizon/issues/1822) · [#1861](https://github.com/horizon-rust/horizon/issues/1861) · [#1863](https://github.com/horizon-rust/horizon/issues/1863) · [#1855](https://github.com/horizon-rust/horizon/issues/1855) · [docs/shaders.md](docs/shaders.md) · [docs/api/shaders.md](docs/api/shaders.md)
|
||||
- **Discord Rich Presence** (v0.2.58): Your Discord profile can show that you are playing Horizon, with a switch to turn it off. [#1787](https://github.com/horizon-rust/horizon/issues/1787) · [#1788](https://github.com/horizon-rust/horizon/issues/1788)
|
||||
- **Server dialogs and login screens** (v0.2.67): Servers that show dialogs, such as the register and login screens of AuthMe, will work in Horizon. [#1720](https://github.com/horizon-rust/horizon/issues/1720) · [#1734](https://github.com/horizon-rust/horizon/issues/1734) · [#1735](https://github.com/horizon-rust/horizon/issues/1735)
|
||||
- **Microsoft sign-in** (launcher@0.2.17): Sign in with your Microsoft account in the launcher to play on online-mode servers. [docs/microsoft-account.md](docs/microsoft-account.md) · [#2879](https://github.com/horizon-rust/horizon/issues/2879)
|
||||
- **OpenGL for old computers** (v0.2.63): Horizon now starts on old PCs whose graphics card has no Vulkan or DirectX 12 driver: it switches to OpenGL by itself, and Options > Graphics can pick OpenGL by hand. [#1917](https://github.com/horizon-rust/horizon/issues/1917) · [#1920](https://github.com/horizon-rust/horizon/issues/1920) · [#1936](https://github.com/horizon-rust/horizon/issues/1936)
|
||||
- **Server resource packs** (v0.2.48): Servers can send you their resource pack: Horizon asks, downloads it and applies it, like vanilla. [#1562](https://github.com/horizon-rust/horizon/issues/1562) · [#1569](https://github.com/horizon-rust/horizon/issues/1569) · [#1570](https://github.com/horizon-rust/horizon/issues/1570)
|
||||
|
||||
@@ -0,0 +1,773 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-10-29T01:13:01Z",
|
||||
"app": {
|
||||
"name": "horizon-client",
|
||||
"version": "v0.2.97",
|
||||
"commit": null
|
||||
},
|
||||
"hardware": {
|
||||
"os": "windows",
|
||||
"arch": "x86_64",
|
||||
"cpu_threads": 16,
|
||||
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
|
||||
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
|
||||
"ram_mb": 95943,
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 5080",
|
||||
"vendor": "nvidia",
|
||||
"driver": "NVIDIA 617.42",
|
||||
"backend": "vulkan",
|
||||
"kind": "discrete"
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 16,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 3158.208,
|
||||
"stdev": 215.77,
|
||||
"min": 3023.046,
|
||||
"max": 3407.049,
|
||||
"ci95": 536.046
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 7170.164,
|
||||
"stdev": 557.958,
|
||||
"min": 6703.242,
|
||||
"max": 7788.08,
|
||||
"ci95": 1386.157
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 1694.647,
|
||||
"stdev": 41.285,
|
||||
"min": 1653.122,
|
||||
"max": 1735.687,
|
||||
"ci95": 102.566
|
||||
},
|
||||
"chunks.village.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 23544.0,
|
||||
"stdev": 47.127,
|
||||
"min": 23495.0,
|
||||
"max": 23589.0,
|
||||
"ci95": 117.081
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1711.652,
|
||||
"stdev": 77.412,
|
||||
"min": 1623.403,
|
||||
"max": 1768.093,
|
||||
"ci95": 192.317
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 593.407,
|
||||
"stdev": 32.279,
|
||||
"min": 571.485,
|
||||
"max": 630.473,
|
||||
"ci95": 80.191
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 358.473,
|
||||
"stdev": 27.832,
|
||||
"min": 326.35,
|
||||
"max": 375.353,
|
||||
"ci95": 69.143
|
||||
},
|
||||
"memory.village.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 631.813,
|
||||
"stdev": 8.381,
|
||||
"min": 626.803,
|
||||
"max": 641.489,
|
||||
"ci95": 20.822
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 2521.363,
|
||||
"stdev": 291.624,
|
||||
"min": 2336.881,
|
||||
"max": 2857.57,
|
||||
"ci95": 724.493
|
||||
},
|
||||
"chunks.forest.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 27346.667,
|
||||
"stdev": 57.934,
|
||||
"min": 27306.0,
|
||||
"max": 27413.0,
|
||||
"ci95": 143.927
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2423.783,
|
||||
"stdev": 55.746,
|
||||
"min": 2386.353,
|
||||
"max": 2487.851,
|
||||
"ci95": 138.493
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 709.165,
|
||||
"stdev": 66.309,
|
||||
"min": 638.666,
|
||||
"max": 770.286,
|
||||
"ci95": 164.734
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 401.039,
|
||||
"stdev": 40.413,
|
||||
"min": 354.472,
|
||||
"max": 426.929,
|
||||
"ci95": 100.399
|
||||
},
|
||||
"memory.forest.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 705.008,
|
||||
"stdev": 6.062,
|
||||
"min": 698.466,
|
||||
"max": 710.435,
|
||||
"ci95": 15.06
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 2094.909,
|
||||
"stdev": 521.225,
|
||||
"min": 1758.024,
|
||||
"max": 2695.274,
|
||||
"ci95": 1294.9
|
||||
},
|
||||
"chunks.ocean.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 24312.667,
|
||||
"stdev": 261.934,
|
||||
"min": 24116.0,
|
||||
"max": 24610.0,
|
||||
"ci95": 650.732
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2201.125,
|
||||
"stdev": 73.595,
|
||||
"min": 2153.6,
|
||||
"max": 2285.897,
|
||||
"ci95": 182.834
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 553.968,
|
||||
"stdev": 100.955,
|
||||
"min": 445.599,
|
||||
"max": 645.355,
|
||||
"ci95": 250.806
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 325.078,
|
||||
"stdev": 131.154,
|
||||
"min": 177.4,
|
||||
"max": 427.981,
|
||||
"ci95": 325.831
|
||||
},
|
||||
"memory.ocean.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 652.944,
|
||||
"stdev": 5.177,
|
||||
"min": 647.51,
|
||||
"max": 657.818,
|
||||
"ci95": 12.862
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 3304.58,
|
||||
"stdev": 134.041,
|
||||
"min": 3155.996,
|
||||
"max": 3416.409,
|
||||
"ci95": 333.003
|
||||
},
|
||||
"chunks.mountain.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 26591.0,
|
||||
"stdev": 57.472,
|
||||
"min": 26525.0,
|
||||
"max": 26630.0,
|
||||
"ci95": 142.779
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1961.086,
|
||||
"stdev": 39.725,
|
||||
"min": 1921.241,
|
||||
"max": 2000.69,
|
||||
"ci95": 98.69
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 536.973,
|
||||
"stdev": 38.828,
|
||||
"min": 495.61,
|
||||
"max": 572.635,
|
||||
"ci95": 96.461
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 296.586,
|
||||
"stdev": 30.236,
|
||||
"min": 275.859,
|
||||
"max": 331.281,
|
||||
"ci95": 75.118
|
||||
},
|
||||
"memory.mountain.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 896.941,
|
||||
"stdev": 5.177,
|
||||
"min": 891.506,
|
||||
"max": 901.814,
|
||||
"ci95": 12.862
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 1458.578,
|
||||
"stdev": 3.229,
|
||||
"min": 1455.32,
|
||||
"max": 1461.777,
|
||||
"ci95": 8.022
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1236.28,
|
||||
"stdev": 4.869,
|
||||
"min": 1230.701,
|
||||
"max": 1239.672,
|
||||
"ci95": 12.096
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 21218,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1768.093,
|
||||
"low_1pct_fps": 630.473,
|
||||
"low_01pct_fps": 375.353,
|
||||
"p50_ms": 0.537,
|
||||
"p95_ms": 0.846,
|
||||
"p99_ms": 1.222,
|
||||
"p999_ms": 2.081,
|
||||
"max_ms": 6.256,
|
||||
"cpu_p50_ms": 0.38,
|
||||
"gpu_p50_ms": 0.104,
|
||||
"histogram": [
|
||||
20695,
|
||||
499,
|
||||
22,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 19482,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1623.403,
|
||||
"low_1pct_fps": 578.262,
|
||||
"low_01pct_fps": 373.717,
|
||||
"p50_ms": 0.586,
|
||||
"p95_ms": 0.95,
|
||||
"p99_ms": 1.357,
|
||||
"p999_ms": 2.158,
|
||||
"max_ms": 7.35,
|
||||
"cpu_p50_ms": 0.445,
|
||||
"gpu_p50_ms": 0.104,
|
||||
"histogram": [
|
||||
18729,
|
||||
713,
|
||||
38,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 20922,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1743.46,
|
||||
"low_1pct_fps": 571.485,
|
||||
"low_01pct_fps": 326.35,
|
||||
"p50_ms": 0.526,
|
||||
"p95_ms": 0.883,
|
||||
"p99_ms": 1.313,
|
||||
"p999_ms": 2.14,
|
||||
"max_ms": 12.027,
|
||||
"cpu_p50_ms": 0.379,
|
||||
"gpu_p50_ms": 0.104,
|
||||
"histogram": [
|
||||
20308,
|
||||
582,
|
||||
28,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 29855,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2487.851,
|
||||
"low_1pct_fps": 770.286,
|
||||
"low_01pct_fps": 421.717,
|
||||
"p50_ms": 0.365,
|
||||
"p95_ms": 0.619,
|
||||
"p99_ms": 0.948,
|
||||
"p999_ms": 1.717,
|
||||
"max_ms": 9.446,
|
||||
"cpu_p50_ms": 0.232,
|
||||
"gpu_p50_ms": 0.157,
|
||||
"histogram": [
|
||||
29607,
|
||||
233,
|
||||
13,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 28766,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2397.145,
|
||||
"low_1pct_fps": 718.542,
|
||||
"low_01pct_fps": 426.929,
|
||||
"p50_ms": 0.374,
|
||||
"p95_ms": 0.662,
|
||||
"p99_ms": 1.016,
|
||||
"p999_ms": 1.858,
|
||||
"max_ms": 6.379,
|
||||
"cpu_p50_ms": 0.239,
|
||||
"gpu_p50_ms": 0.157,
|
||||
"histogram": [
|
||||
28457,
|
||||
293,
|
||||
14,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 28637,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2386.353,
|
||||
"low_1pct_fps": 638.666,
|
||||
"low_01pct_fps": 354.472,
|
||||
"p50_ms": 0.373,
|
||||
"p95_ms": 0.704,
|
||||
"p99_ms": 1.159,
|
||||
"p999_ms": 1.952,
|
||||
"max_ms": 14.388,
|
||||
"cpu_p50_ms": 0.233,
|
||||
"gpu_p50_ms": 0.157,
|
||||
"histogram": [
|
||||
28198,
|
||||
415,
|
||||
18,
|
||||
5,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 25968,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 2163.877,
|
||||
"low_1pct_fps": 570.949,
|
||||
"low_01pct_fps": 369.854,
|
||||
"p50_ms": 0.408,
|
||||
"p95_ms": 0.767,
|
||||
"p99_ms": 1.339,
|
||||
"p999_ms": 2.38,
|
||||
"max_ms": 3.329,
|
||||
"cpu_p50_ms": 0.271,
|
||||
"gpu_p50_ms": 0.141,
|
||||
"histogram": [
|
||||
25383,
|
||||
534,
|
||||
47,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 25843,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2153.6,
|
||||
"low_1pct_fps": 445.599,
|
||||
"low_01pct_fps": 177.4,
|
||||
"p50_ms": 0.413,
|
||||
"p95_ms": 0.725,
|
||||
"p99_ms": 1.309,
|
||||
"p999_ms": 3.451,
|
||||
"max_ms": 12.071,
|
||||
"cpu_p50_ms": 0.275,
|
||||
"gpu_p50_ms": 0.142,
|
||||
"histogram": [
|
||||
25375,
|
||||
373,
|
||||
56,
|
||||
22,
|
||||
1,
|
||||
7,
|
||||
3,
|
||||
5,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 27431,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2285.897,
|
||||
"low_1pct_fps": 645.355,
|
||||
"low_01pct_fps": 427.981,
|
||||
"p50_ms": 0.388,
|
||||
"p95_ms": 0.693,
|
||||
"p99_ms": 1.163,
|
||||
"p999_ms": 2.041,
|
||||
"max_ms": 3.068,
|
||||
"cpu_p50_ms": 0.254,
|
||||
"gpu_p50_ms": 0.141,
|
||||
"histogram": [
|
||||
27036,
|
||||
363,
|
||||
31,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 23536,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1961.326,
|
||||
"low_1pct_fps": 542.675,
|
||||
"low_01pct_fps": 331.281,
|
||||
"p50_ms": 0.436,
|
||||
"p95_ms": 0.856,
|
||||
"p99_ms": 1.425,
|
||||
"p999_ms": 2.22,
|
||||
"max_ms": 9.458,
|
||||
"cpu_p50_ms": 0.296,
|
||||
"gpu_p50_ms": 0.14,
|
||||
"histogram": [
|
||||
22819,
|
||||
671,
|
||||
40,
|
||||
5,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 23056,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1921.241,
|
||||
"low_1pct_fps": 495.61,
|
||||
"low_01pct_fps": 282.617,
|
||||
"p50_ms": 0.44,
|
||||
"p95_ms": 0.911,
|
||||
"p99_ms": 1.516,
|
||||
"p999_ms": 2.543,
|
||||
"max_ms": 14.342,
|
||||
"cpu_p50_ms": 0.299,
|
||||
"gpu_p50_ms": 0.141,
|
||||
"histogram": [
|
||||
22198,
|
||||
783,
|
||||
65,
|
||||
7,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 24009,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2000.69,
|
||||
"low_1pct_fps": 572.635,
|
||||
"low_01pct_fps": 275.859,
|
||||
"p50_ms": 0.436,
|
||||
"p95_ms": 0.819,
|
||||
"p99_ms": 1.274,
|
||||
"p999_ms": 2.085,
|
||||
"max_ms": 14.003,
|
||||
"cpu_p50_ms": 0.297,
|
||||
"gpu_p50_ms": 0.14,
|
||||
"histogram": [
|
||||
23421,
|
||||
560,
|
||||
22,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "horizon-win-9800x3d-5080"
|
||||
}
|
||||
@@ -0,0 +1,773 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-10-29T18:55:59Z",
|
||||
"app": {
|
||||
"name": "horizon-client",
|
||||
"version": "v0.2.97",
|
||||
"commit": null
|
||||
},
|
||||
"hardware": {
|
||||
"os": "windows",
|
||||
"arch": "x86_64",
|
||||
"cpu_threads": 16,
|
||||
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
|
||||
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
|
||||
"ram_mb": 95943,
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 5080",
|
||||
"vendor": "nvidia",
|
||||
"driver": "NVIDIA 617.42",
|
||||
"backend": "vulkan",
|
||||
"kind": "discrete"
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 32,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 2897.812,
|
||||
"stdev": 82.455,
|
||||
"min": 2844.677,
|
||||
"max": 2992.8,
|
||||
"ci95": 204.846
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 10503.454,
|
||||
"stdev": 493.592,
|
||||
"min": 9934.491,
|
||||
"max": 10816.992,
|
||||
"ci95": 1226.251
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 3717.47,
|
||||
"stdev": 1330.649,
|
||||
"min": 2942.872,
|
||||
"max": 5253.954,
|
||||
"ci95": 3305.783
|
||||
},
|
||||
"chunks.village.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 47525.333,
|
||||
"stdev": 1369.122,
|
||||
"min": 46133.0,
|
||||
"max": 48870.0,
|
||||
"ci95": 3401.363
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1163.541,
|
||||
"stdev": 3.485,
|
||||
"min": 1159.52,
|
||||
"max": 1165.693,
|
||||
"ci95": 8.658
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 415.992,
|
||||
"stdev": 15.742,
|
||||
"min": 399.062,
|
||||
"max": 430.188,
|
||||
"ci95": 39.108
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 215.549,
|
||||
"stdev": 34.678,
|
||||
"min": 177.857,
|
||||
"max": 246.101,
|
||||
"ci95": 86.151
|
||||
},
|
||||
"memory.village.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 1833.432,
|
||||
"stdev": 4.415,
|
||||
"min": 1828.334,
|
||||
"max": 1835.997,
|
||||
"ci95": 10.969
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 5455.049,
|
||||
"stdev": 593.897,
|
||||
"min": 4973.659,
|
||||
"max": 6118.723,
|
||||
"ci95": 1475.44
|
||||
},
|
||||
"chunks.forest.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 82758.0,
|
||||
"stdev": 413.547,
|
||||
"min": 82487.0,
|
||||
"max": 83234.0,
|
||||
"ci95": 1027.39
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1389.46,
|
||||
"stdev": 28.128,
|
||||
"min": 1371.096,
|
||||
"max": 1421.843,
|
||||
"ci95": 69.88
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 504.377,
|
||||
"stdev": 13.251,
|
||||
"min": 492.444,
|
||||
"max": 518.637,
|
||||
"ci95": 32.919
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 329.682,
|
||||
"stdev": 47.451,
|
||||
"min": 294.872,
|
||||
"max": 383.732,
|
||||
"ci95": 117.885
|
||||
},
|
||||
"memory.forest.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 1985.175,
|
||||
"stdev": 6.354,
|
||||
"min": 1978.907,
|
||||
"max": 1991.612,
|
||||
"ci95": 15.786
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 4504.145,
|
||||
"stdev": 745.393,
|
||||
"min": 3650.589,
|
||||
"max": 5026.8,
|
||||
"ci95": 1851.809
|
||||
},
|
||||
"chunks.ocean.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 74927.333,
|
||||
"stdev": 399.375,
|
||||
"min": 74476.0,
|
||||
"max": 75235.0,
|
||||
"ci95": 992.182
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1188.639,
|
||||
"stdev": 10.807,
|
||||
"min": 1177.005,
|
||||
"max": 1198.365,
|
||||
"ci95": 26.849
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 154.773,
|
||||
"stdev": 3.712,
|
||||
"min": 151.38,
|
||||
"max": 158.738,
|
||||
"ci95": 9.223
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 67.809,
|
||||
"stdev": 3.032,
|
||||
"min": 65.903,
|
||||
"max": 71.305,
|
||||
"ci95": 7.532
|
||||
},
|
||||
"memory.ocean.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 2084.845,
|
||||
"stdev": 9.674,
|
||||
"min": 2074.009,
|
||||
"max": 2092.615,
|
||||
"ci95": 24.034
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 9048.431,
|
||||
"stdev": 344.33,
|
||||
"min": 8821.41,
|
||||
"max": 9444.624,
|
||||
"ci95": 855.432
|
||||
},
|
||||
"chunks.mountain.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 89659.667,
|
||||
"stdev": 87.123,
|
||||
"min": 89561.0,
|
||||
"max": 89726.0,
|
||||
"ci95": 216.442
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1085.847,
|
||||
"stdev": 13.819,
|
||||
"min": 1070.378,
|
||||
"max": 1096.974,
|
||||
"ci95": 34.332
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 257.7,
|
||||
"stdev": 31.437,
|
||||
"min": 224.366,
|
||||
"max": 286.814,
|
||||
"ci95": 78.101
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 107.783,
|
||||
"stdev": 14.883,
|
||||
"min": 91.541,
|
||||
"max": 120.768,
|
||||
"ci95": 36.975
|
||||
},
|
||||
"memory.mountain.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 2708.833,
|
||||
"stdev": 9.674,
|
||||
"min": 2697.997,
|
||||
"max": 2716.603,
|
||||
"ci95": 24.034
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2044.895,
|
||||
"stdev": 10.562,
|
||||
"min": 2032.875,
|
||||
"max": 2052.691,
|
||||
"ci95": 26.238
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1730.107,
|
||||
"stdev": 14.764,
|
||||
"min": 1717.52,
|
||||
"max": 1746.357,
|
||||
"ci95": 36.678
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 13986,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1165.41,
|
||||
"low_1pct_fps": 399.062,
|
||||
"low_01pct_fps": 177.857,
|
||||
"p50_ms": 0.822,
|
||||
"p95_ms": 1.21,
|
||||
"p99_ms": 1.691,
|
||||
"p999_ms": 3.499,
|
||||
"max_ms": 13.089,
|
||||
"cpu_p50_ms": 0.665,
|
||||
"gpu_p50_ms": 0.193,
|
||||
"histogram": [
|
||||
12380,
|
||||
1527,
|
||||
58,
|
||||
13,
|
||||
2,
|
||||
2,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 13915,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1159.52,
|
||||
"low_1pct_fps": 430.188,
|
||||
"low_01pct_fps": 246.101,
|
||||
"p50_ms": 0.809,
|
||||
"p95_ms": 1.212,
|
||||
"p99_ms": 1.702,
|
||||
"p999_ms": 3.291,
|
||||
"max_ms": 6.894,
|
||||
"cpu_p50_ms": 0.648,
|
||||
"gpu_p50_ms": 0.192,
|
||||
"histogram": [
|
||||
11996,
|
||||
1839,
|
||||
59,
|
||||
17,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 13989,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1165.693,
|
||||
"low_1pct_fps": 418.725,
|
||||
"low_01pct_fps": 222.688,
|
||||
"p50_ms": 0.816,
|
||||
"p95_ms": 1.22,
|
||||
"p99_ms": 1.692,
|
||||
"p999_ms": 3.264,
|
||||
"max_ms": 7.781,
|
||||
"cpu_p50_ms": 0.669,
|
||||
"gpu_p50_ms": 0.193,
|
||||
"histogram": [
|
||||
12321,
|
||||
1578,
|
||||
72,
|
||||
11,
|
||||
2,
|
||||
4,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 17063,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1421.843,
|
||||
"low_1pct_fps": 502.049,
|
||||
"low_01pct_fps": 294.872,
|
||||
"p50_ms": 0.666,
|
||||
"p95_ms": 0.935,
|
||||
"p99_ms": 1.549,
|
||||
"p999_ms": 2.692,
|
||||
"max_ms": 6.831,
|
||||
"cpu_p50_ms": 0.533,
|
||||
"gpu_p50_ms": 0.362,
|
||||
"histogram": [
|
||||
16417,
|
||||
596,
|
||||
40,
|
||||
7,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 16454,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1371.096,
|
||||
"low_1pct_fps": 492.444,
|
||||
"low_01pct_fps": 310.443,
|
||||
"p50_ms": 0.688,
|
||||
"p95_ms": 0.979,
|
||||
"p99_ms": 1.556,
|
||||
"p999_ms": 2.693,
|
||||
"max_ms": 4.596,
|
||||
"cpu_p50_ms": 0.553,
|
||||
"gpu_p50_ms": 0.36,
|
||||
"histogram": [
|
||||
15692,
|
||||
697,
|
||||
57,
|
||||
7,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 16506,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1375.442,
|
||||
"low_1pct_fps": 518.637,
|
||||
"low_01pct_fps": 383.732,
|
||||
"p50_ms": 0.675,
|
||||
"p95_ms": 1.017,
|
||||
"p99_ms": 1.605,
|
||||
"p999_ms": 2.369,
|
||||
"max_ms": 3.201,
|
||||
"cpu_p50_ms": 0.54,
|
||||
"gpu_p50_ms": 0.364,
|
||||
"histogram": [
|
||||
15628,
|
||||
833,
|
||||
44,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 14381,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1198.365,
|
||||
"low_1pct_fps": 154.2,
|
||||
"low_01pct_fps": 66.219,
|
||||
"p50_ms": 0.731,
|
||||
"p95_ms": 1.023,
|
||||
"p99_ms": 3.003,
|
||||
"p999_ms": 12.478,
|
||||
"max_ms": 18.863,
|
||||
"cpu_p50_ms": 0.596,
|
||||
"gpu_p50_ms": 0.315,
|
||||
"histogram": [
|
||||
13586,
|
||||
546,
|
||||
105,
|
||||
60,
|
||||
13,
|
||||
13,
|
||||
8,
|
||||
16,
|
||||
8,
|
||||
11,
|
||||
5,
|
||||
7,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 14287,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1190.548,
|
||||
"low_1pct_fps": 158.738,
|
||||
"low_01pct_fps": 71.305,
|
||||
"p50_ms": 0.741,
|
||||
"p95_ms": 1.041,
|
||||
"p99_ms": 2.9,
|
||||
"p999_ms": 12.0,
|
||||
"max_ms": 17.23,
|
||||
"cpu_p50_ms": 0.602,
|
||||
"gpu_p50_ms": 0.315,
|
||||
"histogram": [
|
||||
13450,
|
||||
591,
|
||||
109,
|
||||
57,
|
||||
13,
|
||||
8,
|
||||
10,
|
||||
11,
|
||||
14,
|
||||
10,
|
||||
7,
|
||||
6,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 14124,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1177.005,
|
||||
"low_1pct_fps": 151.38,
|
||||
"low_01pct_fps": 65.903,
|
||||
"p50_ms": 0.739,
|
||||
"p95_ms": 1.096,
|
||||
"p99_ms": 2.928,
|
||||
"p999_ms": 11.884,
|
||||
"max_ms": 19.375,
|
||||
"cpu_p50_ms": 0.604,
|
||||
"gpu_p50_ms": 0.314,
|
||||
"histogram": [
|
||||
13052,
|
||||
822,
|
||||
114,
|
||||
54,
|
||||
16,
|
||||
8,
|
||||
4,
|
||||
14,
|
||||
11,
|
||||
15,
|
||||
3,
|
||||
8,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 12845,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1070.378,
|
||||
"low_1pct_fps": 224.366,
|
||||
"low_01pct_fps": 91.541,
|
||||
"p50_ms": 0.825,
|
||||
"p95_ms": 1.362,
|
||||
"p99_ms": 2.325,
|
||||
"p999_ms": 9.621,
|
||||
"max_ms": 13.309,
|
||||
"cpu_p50_ms": 0.689,
|
||||
"gpu_p50_ms": 0.307,
|
||||
"histogram": [
|
||||
10937,
|
||||
1699,
|
||||
143,
|
||||
25,
|
||||
5,
|
||||
6,
|
||||
4,
|
||||
8,
|
||||
8,
|
||||
8,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 13083,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1090.188,
|
||||
"low_1pct_fps": 261.921,
|
||||
"low_01pct_fps": 111.039,
|
||||
"p50_ms": 0.816,
|
||||
"p95_ms": 1.335,
|
||||
"p99_ms": 2.14,
|
||||
"p999_ms": 8.222,
|
||||
"max_ms": 11.95,
|
||||
"cpu_p50_ms": 0.681,
|
||||
"gpu_p50_ms": 0.306,
|
||||
"histogram": [
|
||||
11105,
|
||||
1812,
|
||||
112,
|
||||
21,
|
||||
5,
|
||||
5,
|
||||
5,
|
||||
6,
|
||||
11,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 13164,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1096.974,
|
||||
"low_1pct_fps": 286.814,
|
||||
"low_01pct_fps": 120.768,
|
||||
"p50_ms": 0.822,
|
||||
"p95_ms": 1.307,
|
||||
"p99_ms": 1.989,
|
||||
"p999_ms": 7.701,
|
||||
"max_ms": 9.429,
|
||||
"cpu_p50_ms": 0.687,
|
||||
"gpu_p50_ms": 0.307,
|
||||
"histogram": [
|
||||
11280,
|
||||
1758,
|
||||
82,
|
||||
13,
|
||||
5,
|
||||
5,
|
||||
6,
|
||||
8,
|
||||
7,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "horizon-win-9800x3d-5080"
|
||||
}
|
||||
@@ -0,0 +1,773 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-10-29T18:37:03Z",
|
||||
"app": {
|
||||
"name": "horizon-client",
|
||||
"version": "v0.2.97",
|
||||
"commit": null
|
||||
},
|
||||
"hardware": {
|
||||
"os": "windows",
|
||||
"arch": "x86_64",
|
||||
"cpu_threads": 16,
|
||||
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
|
||||
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
|
||||
"ram_mb": 95943,
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 5080",
|
||||
"vendor": "nvidia",
|
||||
"driver": "NVIDIA 617.42",
|
||||
"backend": "vulkan",
|
||||
"kind": "discrete"
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 8,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 2950.343,
|
||||
"stdev": 162.732,
|
||||
"min": 2822.925,
|
||||
"max": 3133.657,
|
||||
"ci95": 404.282
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 6135.1,
|
||||
"stdev": 863.831,
|
||||
"min": 5547.861,
|
||||
"max": 7126.98,
|
||||
"ci95": 2146.05
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 985.519,
|
||||
"stdev": 28.184,
|
||||
"min": 956.244,
|
||||
"max": 1012.469,
|
||||
"ci95": 70.019
|
||||
},
|
||||
"chunks.village.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 6908.0,
|
||||
"stdev": 9.849,
|
||||
"min": 6897.0,
|
||||
"max": 6916.0,
|
||||
"ci95": 24.468
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2941.608,
|
||||
"stdev": 79.283,
|
||||
"min": 2850.416,
|
||||
"max": 2994.188,
|
||||
"ci95": 196.965
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 1271.318,
|
||||
"stdev": 58.797,
|
||||
"min": 1203.758,
|
||||
"max": 1310.917,
|
||||
"ci95": 146.072
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 839.261,
|
||||
"stdev": 37.982,
|
||||
"min": 795.415,
|
||||
"max": 862.067,
|
||||
"ci95": 94.36
|
||||
},
|
||||
"memory.village.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 363.984,
|
||||
"stdev": 0.926,
|
||||
"min": 363.449,
|
||||
"max": 365.053,
|
||||
"ci95": 2.299
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 1350.752,
|
||||
"stdev": 293.128,
|
||||
"min": 1106.764,
|
||||
"max": 1675.913,
|
||||
"ci95": 728.23
|
||||
},
|
||||
"chunks.forest.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 8066.0,
|
||||
"stdev": 108.885,
|
||||
"min": 7986.0,
|
||||
"max": 8190.0,
|
||||
"ci95": 270.508
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 3494.693,
|
||||
"stdev": 110.997,
|
||||
"min": 3389.894,
|
||||
"max": 3610.993,
|
||||
"ci95": 275.755
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 1295.14,
|
||||
"stdev": 159.638,
|
||||
"min": 1148.468,
|
||||
"max": 1465.171,
|
||||
"ci95": 396.596
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 859.326,
|
||||
"stdev": 119.845,
|
||||
"min": 721.994,
|
||||
"max": 942.743,
|
||||
"ci95": 297.734
|
||||
},
|
||||
"memory.forest.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 381.189,
|
||||
"stdev": 5.222,
|
||||
"min": 376.423,
|
||||
"max": 386.77,
|
||||
"ci95": 12.972
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 1123.386,
|
||||
"stdev": 604.175,
|
||||
"min": 734.302,
|
||||
"max": 1819.414,
|
||||
"ci95": 1500.976
|
||||
},
|
||||
"chunks.ocean.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 6613.0,
|
||||
"stdev": 135.632,
|
||||
"min": 6523.0,
|
||||
"max": 6769.0,
|
||||
"ci95": 336.955
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 3346.003,
|
||||
"stdev": 59.312,
|
||||
"min": 3303.535,
|
||||
"max": 3413.77,
|
||||
"ci95": 147.352
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 1357.991,
|
||||
"stdev": 95.364,
|
||||
"min": 1248.887,
|
||||
"max": 1425.451,
|
||||
"ci95": 236.917
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 911.822,
|
||||
"stdev": 94.717,
|
||||
"min": 802.528,
|
||||
"max": 969.989,
|
||||
"ci95": 235.308
|
||||
},
|
||||
"memory.ocean.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 391.636,
|
||||
"stdev": 5.435,
|
||||
"min": 387.424,
|
||||
"max": 397.771,
|
||||
"ci95": 13.502
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 1588.954,
|
||||
"stdev": 458.924,
|
||||
"min": 1309.548,
|
||||
"max": 2118.606,
|
||||
"ci95": 1140.123
|
||||
},
|
||||
"chunks.mountain.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 7887.667,
|
||||
"stdev": 41.041,
|
||||
"min": 7862.0,
|
||||
"max": 7935.0,
|
||||
"ci95": 101.959
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 3375.623,
|
||||
"stdev": 26.82,
|
||||
"min": 3352.309,
|
||||
"max": 3404.933,
|
||||
"ci95": 66.629
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 1383.745,
|
||||
"stdev": 67.628,
|
||||
"min": 1324.071,
|
||||
"max": 1457.203,
|
||||
"ci95": 168.01
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 850.213,
|
||||
"stdev": 103.149,
|
||||
"min": 790.305,
|
||||
"max": 969.318,
|
||||
"ci95": 256.256
|
||||
},
|
||||
"memory.mountain.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 448.64,
|
||||
"stdev": 5.435,
|
||||
"min": 444.428,
|
||||
"max": 454.775,
|
||||
"ci95": 13.502
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 1171.629,
|
||||
"stdev": 0.337,
|
||||
"min": 1171.398,
|
||||
"max": 1172.016,
|
||||
"ci95": 0.837
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1082.078,
|
||||
"stdev": 12.494,
|
||||
"min": 1069.117,
|
||||
"max": 1094.047,
|
||||
"ci95": 31.04
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 35764,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2980.22,
|
||||
"low_1pct_fps": 1310.917,
|
||||
"low_01pct_fps": 860.301,
|
||||
"p50_ms": 0.317,
|
||||
"p95_ms": 0.433,
|
||||
"p99_ms": 0.633,
|
||||
"p999_ms": 0.937,
|
||||
"max_ms": 2.807,
|
||||
"cpu_p50_ms": 0.192,
|
||||
"gpu_p50_ms": 0.063,
|
||||
"histogram": [
|
||||
35741,
|
||||
22,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 35931,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2994.188,
|
||||
"low_1pct_fps": 1299.278,
|
||||
"low_01pct_fps": 795.415,
|
||||
"p50_ms": 0.318,
|
||||
"p95_ms": 0.428,
|
||||
"p99_ms": 0.62,
|
||||
"p999_ms": 0.943,
|
||||
"max_ms": 5.797,
|
||||
"cpu_p50_ms": 0.191,
|
||||
"gpu_p50_ms": 0.063,
|
||||
"histogram": [
|
||||
35906,
|
||||
23,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 34206,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2850.416,
|
||||
"low_1pct_fps": 1203.758,
|
||||
"low_01pct_fps": 862.067,
|
||||
"p50_ms": 0.326,
|
||||
"p95_ms": 0.508,
|
||||
"p99_ms": 0.705,
|
||||
"p999_ms": 1.025,
|
||||
"max_ms": 1.398,
|
||||
"cpu_p50_ms": 0.196,
|
||||
"gpu_p50_ms": 0.063,
|
||||
"histogram": [
|
||||
34167,
|
||||
39,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 41800,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 3483.193,
|
||||
"low_1pct_fps": 1271.782,
|
||||
"low_01pct_fps": 913.242,
|
||||
"p50_ms": 0.264,
|
||||
"p95_ms": 0.415,
|
||||
"p99_ms": 0.657,
|
||||
"p999_ms": 0.935,
|
||||
"max_ms": 1.482,
|
||||
"cpu_p50_ms": 0.144,
|
||||
"gpu_p50_ms": 0.075,
|
||||
"histogram": [
|
||||
41768,
|
||||
32,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 43333,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 3610.993,
|
||||
"low_1pct_fps": 1465.171,
|
||||
"low_01pct_fps": 942.743,
|
||||
"p50_ms": 0.263,
|
||||
"p95_ms": 0.355,
|
||||
"p99_ms": 0.561,
|
||||
"p999_ms": 0.859,
|
||||
"max_ms": 1.694,
|
||||
"cpu_p50_ms": 0.143,
|
||||
"gpu_p50_ms": 0.074,
|
||||
"histogram": [
|
||||
43310,
|
||||
23,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 40679,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 3389.894,
|
||||
"low_1pct_fps": 1148.468,
|
||||
"low_01pct_fps": 721.994,
|
||||
"p50_ms": 0.267,
|
||||
"p95_ms": 0.446,
|
||||
"p99_ms": 0.67,
|
||||
"p999_ms": 1.183,
|
||||
"max_ms": 2.434,
|
||||
"cpu_p50_ms": 0.146,
|
||||
"gpu_p50_ms": 0.076,
|
||||
"histogram": [
|
||||
40590,
|
||||
87,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 39849,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 3320.704,
|
||||
"low_1pct_fps": 1399.634,
|
||||
"low_01pct_fps": 969.989,
|
||||
"p50_ms": 0.281,
|
||||
"p95_ms": 0.404,
|
||||
"p99_ms": 0.601,
|
||||
"p999_ms": 0.875,
|
||||
"max_ms": 1.494,
|
||||
"cpu_p50_ms": 0.159,
|
||||
"gpu_p50_ms": 0.069,
|
||||
"histogram": [
|
||||
39831,
|
||||
18,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 40966,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 3413.77,
|
||||
"low_1pct_fps": 1425.451,
|
||||
"low_01pct_fps": 962.948,
|
||||
"p50_ms": 0.278,
|
||||
"p95_ms": 0.372,
|
||||
"p99_ms": 0.581,
|
||||
"p999_ms": 0.857,
|
||||
"max_ms": 1.576,
|
||||
"cpu_p50_ms": 0.157,
|
||||
"gpu_p50_ms": 0.069,
|
||||
"histogram": [
|
||||
40947,
|
||||
19,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 39643,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 3303.535,
|
||||
"low_1pct_fps": 1248.887,
|
||||
"low_01pct_fps": 802.528,
|
||||
"p50_ms": 0.28,
|
||||
"p95_ms": 0.413,
|
||||
"p99_ms": 0.642,
|
||||
"p999_ms": 1.087,
|
||||
"max_ms": 2.215,
|
||||
"cpu_p50_ms": 0.158,
|
||||
"gpu_p50_ms": 0.069,
|
||||
"histogram": [
|
||||
39582,
|
||||
60,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 40436,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 3369.626,
|
||||
"low_1pct_fps": 1369.962,
|
||||
"low_01pct_fps": 790.305,
|
||||
"p50_ms": 0.279,
|
||||
"p95_ms": 0.403,
|
||||
"p99_ms": 0.604,
|
||||
"p999_ms": 0.812,
|
||||
"max_ms": 10.313,
|
||||
"cpu_p50_ms": 0.159,
|
||||
"gpu_p50_ms": 0.069,
|
||||
"histogram": [
|
||||
40425,
|
||||
8,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 40860,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 3404.933,
|
||||
"low_1pct_fps": 1457.203,
|
||||
"low_01pct_fps": 969.318,
|
||||
"p50_ms": 0.278,
|
||||
"p95_ms": 0.389,
|
||||
"p99_ms": 0.574,
|
||||
"p999_ms": 0.814,
|
||||
"max_ms": 2.714,
|
||||
"cpu_p50_ms": 0.158,
|
||||
"gpu_p50_ms": 0.069,
|
||||
"histogram": [
|
||||
40847,
|
||||
11,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 40229,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 3352.309,
|
||||
"low_1pct_fps": 1324.071,
|
||||
"low_01pct_fps": 791.016,
|
||||
"p50_ms": 0.28,
|
||||
"p95_ms": 0.402,
|
||||
"p99_ms": 0.617,
|
||||
"p999_ms": 0.968,
|
||||
"max_ms": 3.746,
|
||||
"cpu_p50_ms": 0.159,
|
||||
"gpu_p50_ms": 0.069,
|
||||
"histogram": [
|
||||
40192,
|
||||
35,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "horizon-win-9800x3d-5080"
|
||||
}
|
||||
@@ -0,0 +1,706 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-10-29T01:25:53Z",
|
||||
"app": {
|
||||
"name": "minecraft",
|
||||
"version": "26.3",
|
||||
"mods": [
|
||||
{
|
||||
"name": "fabric-loader",
|
||||
"version": "0.19.5"
|
||||
},
|
||||
{
|
||||
"name": "sodium",
|
||||
"version": "mc26.3-0.9.2-fabric"
|
||||
},
|
||||
{
|
||||
"name": "lithium",
|
||||
"version": "mc26.3-0.26.2-fabric"
|
||||
},
|
||||
{
|
||||
"name": "ferrite-core",
|
||||
"version": "9.0.0-fabric"
|
||||
},
|
||||
{
|
||||
"name": "immediatelyfast",
|
||||
"version": "1.17.1+26.3-fabric"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hardware": {
|
||||
"os": "windows",
|
||||
"arch": "x86_64",
|
||||
"cpu_threads": 16,
|
||||
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
|
||||
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
|
||||
"ram_mb": 95943,
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 5080/PCIe/SSE2",
|
||||
"vendor": "NVIDIA Corporation",
|
||||
"driver": "3.3.0 NVIDIA 617.42",
|
||||
"backend": "opengl",
|
||||
"kind": ""
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 16,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 10777.756,
|
||||
"stdev": 129.67,
|
||||
"min": 10636.92,
|
||||
"max": 10892.2,
|
||||
"ci95": 322.144
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 23477.423,
|
||||
"stdev": 346.614,
|
||||
"min": 23130.92,
|
||||
"max": 23824.148,
|
||||
"ci95": 861.106
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 6007.033,
|
||||
"stdev": 104.227,
|
||||
"min": 5891.431,
|
||||
"max": 6093.825,
|
||||
"ci95": 258.936
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1282.11,
|
||||
"stdev": 65.207,
|
||||
"min": 1228.13,
|
||||
"max": 1354.56,
|
||||
"ci95": 161.997
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 343.465,
|
||||
"stdev": 9.96,
|
||||
"min": 335.223,
|
||||
"max": 354.532,
|
||||
"ci95": 24.744
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 131.669,
|
||||
"stdev": 4.692,
|
||||
"min": 126.427,
|
||||
"max": 135.476,
|
||||
"ci95": 11.656
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 8669.311,
|
||||
"stdev": 434.39,
|
||||
"min": 8280.854,
|
||||
"max": 9138.35,
|
||||
"ci95": 1079.173
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1745.757,
|
||||
"stdev": 13.014,
|
||||
"min": 1734.41,
|
||||
"max": 1759.963,
|
||||
"ci95": 32.332
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 460.958,
|
||||
"stdev": 10.859,
|
||||
"min": 449.051,
|
||||
"max": 470.316,
|
||||
"ci95": 26.978
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 178.955,
|
||||
"stdev": 3.777,
|
||||
"min": 175.213,
|
||||
"max": 182.766,
|
||||
"ci95": 9.383
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 7056.368,
|
||||
"stdev": 117.329,
|
||||
"min": 6986.912,
|
||||
"max": 7191.833,
|
||||
"ci95": 291.485
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1815.056,
|
||||
"stdev": 47.875,
|
||||
"min": 1759.788,
|
||||
"max": 1843.745,
|
||||
"ci95": 118.937
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 514.215,
|
||||
"stdev": 57.205,
|
||||
"min": 452.445,
|
||||
"max": 565.368,
|
||||
"ci95": 142.117
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 191.842,
|
||||
"stdev": 23.217,
|
||||
"min": 175.546,
|
||||
"max": 218.426,
|
||||
"ci95": 57.68
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 14094.582,
|
||||
"stdev": 399.624,
|
||||
"min": 13641.534,
|
||||
"max": 14397.0,
|
||||
"ci95": 992.801
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1499.768,
|
||||
"stdev": 22.197,
|
||||
"min": 1477.269,
|
||||
"max": 1521.651,
|
||||
"ci95": 55.146
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 408.466,
|
||||
"stdev": 16.114,
|
||||
"min": 395.097,
|
||||
"max": 426.358,
|
||||
"ci95": 40.032
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 139.743,
|
||||
"stdev": 3.969,
|
||||
"min": 136.465,
|
||||
"max": 144.156,
|
||||
"ci95": 9.86
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2495.607,
|
||||
"stdev": 75.339,
|
||||
"min": 2412.738,
|
||||
"max": 2559.965,
|
||||
"ci95": 187.166
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 2326.611,
|
||||
"stdev": 15.702,
|
||||
"min": 2314.98,
|
||||
"max": 2344.473,
|
||||
"ci95": 39.01
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 14738,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1228.13,
|
||||
"low_1pct_fps": 335.223,
|
||||
"low_01pct_fps": 133.103,
|
||||
"p50_ms": 0.722,
|
||||
"p95_ms": 1.384,
|
||||
"p99_ms": 2.043,
|
||||
"p999_ms": 5.224,
|
||||
"max_ms": 11.459,
|
||||
"histogram": [
|
||||
12159,
|
||||
2421,
|
||||
128,
|
||||
14,
|
||||
1,
|
||||
5,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 16255,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1354.56,
|
||||
"low_1pct_fps": 354.532,
|
||||
"low_01pct_fps": 135.476,
|
||||
"p50_ms": 0.672,
|
||||
"p95_ms": 1.136,
|
||||
"p99_ms": 1.841,
|
||||
"p999_ms": 4.913,
|
||||
"max_ms": 16.94,
|
||||
"histogram": [
|
||||
14841,
|
||||
1298,
|
||||
86,
|
||||
13,
|
||||
2,
|
||||
5,
|
||||
4,
|
||||
1,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 15164,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1263.64,
|
||||
"low_1pct_fps": 340.639,
|
||||
"low_01pct_fps": 126.427,
|
||||
"p50_ms": 0.708,
|
||||
"p95_ms": 1.299,
|
||||
"p99_ms": 1.895,
|
||||
"p999_ms": 5.173,
|
||||
"max_ms": 16.748,
|
||||
"histogram": [
|
||||
13026,
|
||||
2014,
|
||||
94,
|
||||
13,
|
||||
1,
|
||||
3,
|
||||
3,
|
||||
6,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 20915,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1742.897,
|
||||
"low_1pct_fps": 470.316,
|
||||
"low_01pct_fps": 182.766,
|
||||
"p50_ms": 0.5,
|
||||
"p95_ms": 0.936,
|
||||
"p99_ms": 1.437,
|
||||
"p999_ms": 2.77,
|
||||
"max_ms": 13.414,
|
||||
"histogram": [
|
||||
20067,
|
||||
788,
|
||||
44,
|
||||
4,
|
||||
0,
|
||||
4,
|
||||
3,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 21120,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1759.963,
|
||||
"low_1pct_fps": 449.051,
|
||||
"low_01pct_fps": 178.887,
|
||||
"p50_ms": 0.49,
|
||||
"p95_ms": 0.901,
|
||||
"p99_ms": 1.531,
|
||||
"p999_ms": 3.005,
|
||||
"max_ms": 12.907,
|
||||
"histogram": [
|
||||
20414,
|
||||
644,
|
||||
40,
|
||||
10,
|
||||
1,
|
||||
3,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 20813,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1734.41,
|
||||
"low_1pct_fps": 463.506,
|
||||
"low_01pct_fps": 175.213,
|
||||
"p50_ms": 0.496,
|
||||
"p95_ms": 0.924,
|
||||
"p99_ms": 1.414,
|
||||
"p999_ms": 2.733,
|
||||
"max_ms": 16.832,
|
||||
"histogram": [
|
||||
20049,
|
||||
707,
|
||||
41,
|
||||
5,
|
||||
0,
|
||||
1,
|
||||
5,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 22100,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1841.634,
|
||||
"low_1pct_fps": 565.368,
|
||||
"low_01pct_fps": 218.426,
|
||||
"p50_ms": 0.483,
|
||||
"p95_ms": 0.835,
|
||||
"p99_ms": 1.209,
|
||||
"p999_ms": 2.082,
|
||||
"max_ms": 14.627,
|
||||
"histogram": [
|
||||
21540,
|
||||
532,
|
||||
18,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 21118,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1759.788,
|
||||
"low_1pct_fps": 452.445,
|
||||
"low_01pct_fps": 175.546,
|
||||
"p50_ms": 0.489,
|
||||
"p95_ms": 0.915,
|
||||
"p99_ms": 1.532,
|
||||
"p999_ms": 2.811,
|
||||
"max_ms": 11.846,
|
||||
"histogram": [
|
||||
20308,
|
||||
755,
|
||||
36,
|
||||
9,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
3,
|
||||
2,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 22125,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1843.745,
|
||||
"low_1pct_fps": 524.832,
|
||||
"low_01pct_fps": 181.555,
|
||||
"p50_ms": 0.477,
|
||||
"p95_ms": 0.855,
|
||||
"p99_ms": 1.241,
|
||||
"p999_ms": 2.236,
|
||||
"max_ms": 12.478,
|
||||
"histogram": [
|
||||
21487,
|
||||
608,
|
||||
18,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
2,
|
||||
1,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 18005,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1500.383,
|
||||
"low_1pct_fps": 403.944,
|
||||
"low_01pct_fps": 136.465,
|
||||
"p50_ms": 0.591,
|
||||
"p95_ms": 1.058,
|
||||
"p99_ms": 1.592,
|
||||
"p999_ms": 3.186,
|
||||
"max_ms": 12.608,
|
||||
"histogram": [
|
||||
16864,
|
||||
1084,
|
||||
32,
|
||||
8,
|
||||
0,
|
||||
5,
|
||||
3,
|
||||
5,
|
||||
1,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 17728,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1477.269,
|
||||
"low_1pct_fps": 395.097,
|
||||
"low_01pct_fps": 138.609,
|
||||
"p50_ms": 0.598,
|
||||
"p95_ms": 1.042,
|
||||
"p99_ms": 1.561,
|
||||
"p999_ms": 5.033,
|
||||
"max_ms": 11.462,
|
||||
"histogram": [
|
||||
16612,
|
||||
1051,
|
||||
39,
|
||||
7,
|
||||
1,
|
||||
6,
|
||||
5,
|
||||
3,
|
||||
1,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 18260,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1521.651,
|
||||
"low_1pct_fps": 426.358,
|
||||
"low_01pct_fps": 144.156,
|
||||
"p50_ms": 0.585,
|
||||
"p95_ms": 1.015,
|
||||
"p99_ms": 1.458,
|
||||
"p999_ms": 3.609,
|
||||
"max_ms": 11.945,
|
||||
"histogram": [
|
||||
17267,
|
||||
943,
|
||||
26,
|
||||
6,
|
||||
5,
|
||||
2,
|
||||
3,
|
||||
3,
|
||||
2,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "vanilla-mods-win-9800x3d-5080"
|
||||
}
|
||||
@@ -0,0 +1,706 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-10-29T19:27:56Z",
|
||||
"app": {
|
||||
"name": "minecraft",
|
||||
"version": "26.3",
|
||||
"mods": [
|
||||
{
|
||||
"name": "fabric-loader",
|
||||
"version": "0.19.5"
|
||||
},
|
||||
{
|
||||
"name": "sodium",
|
||||
"version": "mc26.3-0.9.2-fabric"
|
||||
},
|
||||
{
|
||||
"name": "lithium",
|
||||
"version": "mc26.3-0.26.2-fabric"
|
||||
},
|
||||
{
|
||||
"name": "ferrite-core",
|
||||
"version": "9.0.0-fabric"
|
||||
},
|
||||
{
|
||||
"name": "immediatelyfast",
|
||||
"version": "1.17.1+26.3-fabric"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hardware": {
|
||||
"os": "windows",
|
||||
"arch": "x86_64",
|
||||
"cpu_threads": 16,
|
||||
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
|
||||
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
|
||||
"ram_mb": 95943,
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 5080/PCIe/SSE2",
|
||||
"vendor": "NVIDIA Corporation",
|
||||
"driver": "3.3.0 NVIDIA 617.42",
|
||||
"backend": "opengl",
|
||||
"kind": ""
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 32,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 11038.922,
|
||||
"stdev": 118.058,
|
||||
"min": 10935.424,
|
||||
"max": 11167.506,
|
||||
"ci95": 293.295
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 39248.588,
|
||||
"stdev": 479.755,
|
||||
"min": 38964.834,
|
||||
"max": 39802.506,
|
||||
"ci95": 1191.875
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 11176.953,
|
||||
"stdev": 356.144,
|
||||
"min": 10836.65,
|
||||
"max": 11547.064,
|
||||
"ci95": 884.781
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 925.977,
|
||||
"stdev": 6.727,
|
||||
"min": 918.832,
|
||||
"max": 932.189,
|
||||
"ci95": 16.713
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 250.092,
|
||||
"stdev": 1.868,
|
||||
"min": 248.431,
|
||||
"max": 252.114,
|
||||
"ci95": 4.64
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 92.692,
|
||||
"stdev": 4.466,
|
||||
"min": 88.423,
|
||||
"max": 97.332,
|
||||
"ci95": 11.095
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 17335.638,
|
||||
"stdev": 687.588,
|
||||
"min": 16544.168,
|
||||
"max": 17785.772,
|
||||
"ci95": 1708.201
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 929.034,
|
||||
"stdev": 3.11,
|
||||
"min": 926.32,
|
||||
"max": 932.428,
|
||||
"ci95": 7.727
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 312.69,
|
||||
"stdev": 16.386,
|
||||
"min": 297.667,
|
||||
"max": 330.163,
|
||||
"ci95": 40.708
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 129.331,
|
||||
"stdev": 12.786,
|
||||
"min": 115.486,
|
||||
"max": 140.694,
|
||||
"ci95": 31.765
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 12601.063,
|
||||
"stdev": 148.042,
|
||||
"min": 12430.182,
|
||||
"max": 12690.506,
|
||||
"ci95": 367.785
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1016.598,
|
||||
"stdev": 5.481,
|
||||
"min": 1010.482,
|
||||
"max": 1021.068,
|
||||
"ci95": 13.618
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 325.361,
|
||||
"stdev": 15.112,
|
||||
"min": 315.682,
|
||||
"max": 342.774,
|
||||
"ci95": 37.543
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 117.042,
|
||||
"stdev": 11.095,
|
||||
"min": 107.807,
|
||||
"max": 129.349,
|
||||
"ci95": 27.563
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 38513.008,
|
||||
"stdev": 958.119,
|
||||
"min": 37447.333,
|
||||
"max": 39303.226,
|
||||
"ci95": 2380.292
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 644.782,
|
||||
"stdev": 3.94,
|
||||
"min": 640.866,
|
||||
"max": 648.745,
|
||||
"ci95": 9.788
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 166.029,
|
||||
"stdev": 4.758,
|
||||
"min": 162.326,
|
||||
"max": 171.395,
|
||||
"ci95": 11.819
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 86.231,
|
||||
"stdev": 8.587,
|
||||
"min": 76.632,
|
||||
"max": 93.184,
|
||||
"ci95": 21.334
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2964.911,
|
||||
"stdev": 8.27,
|
||||
"min": 2959.813,
|
||||
"max": 2974.453,
|
||||
"ci95": 20.545
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 2894.541,
|
||||
"stdev": 12.847,
|
||||
"min": 2879.928,
|
||||
"max": 2904.059,
|
||||
"ci95": 31.917
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 11123,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 926.911,
|
||||
"low_1pct_fps": 249.732,
|
||||
"low_01pct_fps": 92.322,
|
||||
"p50_ms": 0.989,
|
||||
"p95_ms": 1.492,
|
||||
"p99_ms": 2.33,
|
||||
"p999_ms": 7.409,
|
||||
"max_ms": 15.258,
|
||||
"histogram": [
|
||||
6224,
|
||||
4701,
|
||||
147,
|
||||
28,
|
||||
3,
|
||||
2,
|
||||
5,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
1,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 11187,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 932.189,
|
||||
"low_1pct_fps": 252.114,
|
||||
"low_01pct_fps": 88.423,
|
||||
"p50_ms": 0.984,
|
||||
"p95_ms": 1.496,
|
||||
"p99_ms": 2.328,
|
||||
"p999_ms": 7.858,
|
||||
"max_ms": 16.843,
|
||||
"histogram": [
|
||||
6518,
|
||||
4486,
|
||||
138,
|
||||
20,
|
||||
9,
|
||||
1,
|
||||
1,
|
||||
6,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 11026,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 918.832,
|
||||
"low_1pct_fps": 248.431,
|
||||
"low_01pct_fps": 97.332,
|
||||
"p50_ms": 0.989,
|
||||
"p95_ms": 1.524,
|
||||
"p99_ms": 2.37,
|
||||
"p999_ms": 7.446,
|
||||
"max_ms": 16.573,
|
||||
"histogram": [
|
||||
6075,
|
||||
4740,
|
||||
162,
|
||||
23,
|
||||
5,
|
||||
2,
|
||||
4,
|
||||
7,
|
||||
4,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 11117,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 926.32,
|
||||
"low_1pct_fps": 310.239,
|
||||
"low_01pct_fps": 131.814,
|
||||
"p50_ms": 1.011,
|
||||
"p95_ms": 1.34,
|
||||
"p99_ms": 2.045,
|
||||
"p999_ms": 4.711,
|
||||
"max_ms": 14.077,
|
||||
"histogram": [
|
||||
3849,
|
||||
7150,
|
||||
78,
|
||||
26,
|
||||
5,
|
||||
1,
|
||||
3,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 11190,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 932.428,
|
||||
"low_1pct_fps": 330.163,
|
||||
"low_01pct_fps": 140.694,
|
||||
"p50_ms": 1.008,
|
||||
"p95_ms": 1.307,
|
||||
"p99_ms": 1.869,
|
||||
"p999_ms": 4.341,
|
||||
"max_ms": 13.489,
|
||||
"histogram": [
|
||||
4273,
|
||||
6822,
|
||||
60,
|
||||
20,
|
||||
7,
|
||||
0,
|
||||
3,
|
||||
3,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 11141,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 928.354,
|
||||
"low_1pct_fps": 297.667,
|
||||
"low_01pct_fps": 115.486,
|
||||
"p50_ms": 1.005,
|
||||
"p95_ms": 1.352,
|
||||
"p99_ms": 2.054,
|
||||
"p999_ms": 5.292,
|
||||
"max_ms": 14.339,
|
||||
"histogram": [
|
||||
4901,
|
||||
6118,
|
||||
83,
|
||||
23,
|
||||
3,
|
||||
2,
|
||||
4,
|
||||
3,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 12126,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1010.482,
|
||||
"low_1pct_fps": 317.626,
|
||||
"low_01pct_fps": 113.971,
|
||||
"p50_ms": 0.933,
|
||||
"p95_ms": 1.23,
|
||||
"p99_ms": 1.935,
|
||||
"p999_ms": 4.393,
|
||||
"max_ms": 14.824,
|
||||
"histogram": [
|
||||
9183,
|
||||
2830,
|
||||
82,
|
||||
17,
|
||||
3,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
0,
|
||||
1,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 12219,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1018.243,
|
||||
"low_1pct_fps": 342.774,
|
||||
"low_01pct_fps": 129.349,
|
||||
"p50_ms": 0.929,
|
||||
"p95_ms": 1.222,
|
||||
"p99_ms": 1.867,
|
||||
"p999_ms": 4.329,
|
||||
"max_ms": 10.33,
|
||||
"histogram": [
|
||||
9421,
|
||||
2710,
|
||||
60,
|
||||
15,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
5,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 12253,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1021.068,
|
||||
"low_1pct_fps": 315.682,
|
||||
"low_01pct_fps": 107.807,
|
||||
"p50_ms": 0.923,
|
||||
"p95_ms": 1.225,
|
||||
"p99_ms": 1.969,
|
||||
"p999_ms": 5.235,
|
||||
"max_ms": 19.524,
|
||||
"histogram": [
|
||||
9231,
|
||||
2904,
|
||||
92,
|
||||
10,
|
||||
2,
|
||||
5,
|
||||
0,
|
||||
4,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 7691,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 640.866,
|
||||
"low_1pct_fps": 164.367,
|
||||
"low_01pct_fps": 93.184,
|
||||
"p50_ms": 1.454,
|
||||
"p95_ms": 1.95,
|
||||
"p99_ms": 3.4,
|
||||
"p999_ms": 9.754,
|
||||
"max_ms": 14.995,
|
||||
"histogram": [
|
||||
36,
|
||||
7331,
|
||||
233,
|
||||
26,
|
||||
17,
|
||||
13,
|
||||
20,
|
||||
3,
|
||||
6,
|
||||
5,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 7737,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 644.736,
|
||||
"low_1pct_fps": 162.326,
|
||||
"low_01pct_fps": 76.632,
|
||||
"p50_ms": 1.454,
|
||||
"p95_ms": 1.905,
|
||||
"p99_ms": 3.676,
|
||||
"p999_ms": 9.926,
|
||||
"max_ms": 18.459,
|
||||
"histogram": [
|
||||
46,
|
||||
7399,
|
||||
199,
|
||||
24,
|
||||
21,
|
||||
24,
|
||||
10,
|
||||
6,
|
||||
1,
|
||||
1,
|
||||
4,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 7785,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 648.745,
|
||||
"low_1pct_fps": 171.395,
|
||||
"low_01pct_fps": 88.876,
|
||||
"p50_ms": 1.439,
|
||||
"p95_ms": 1.886,
|
||||
"p99_ms": 3.562,
|
||||
"p999_ms": 10.342,
|
||||
"max_ms": 12.927,
|
||||
"histogram": [
|
||||
57,
|
||||
7432,
|
||||
186,
|
||||
44,
|
||||
23,
|
||||
20,
|
||||
9,
|
||||
3,
|
||||
3,
|
||||
7,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "vanilla-mods-win-9800x3d-5080"
|
||||
}
|
||||
@@ -0,0 +1,706 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-10-29T18:49:40Z",
|
||||
"app": {
|
||||
"name": "minecraft",
|
||||
"version": "26.3",
|
||||
"mods": [
|
||||
{
|
||||
"name": "fabric-loader",
|
||||
"version": "0.19.5"
|
||||
},
|
||||
{
|
||||
"name": "sodium",
|
||||
"version": "mc26.3-0.9.2-fabric"
|
||||
},
|
||||
{
|
||||
"name": "lithium",
|
||||
"version": "mc26.3-0.26.2-fabric"
|
||||
},
|
||||
{
|
||||
"name": "ferrite-core",
|
||||
"version": "9.0.0-fabric"
|
||||
},
|
||||
{
|
||||
"name": "immediatelyfast",
|
||||
"version": "1.17.1+26.3-fabric"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hardware": {
|
||||
"os": "windows",
|
||||
"arch": "x86_64",
|
||||
"cpu_threads": 16,
|
||||
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
|
||||
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
|
||||
"ram_mb": 95943,
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 5080/PCIe/SSE2",
|
||||
"vendor": "NVIDIA Corporation",
|
||||
"driver": "3.3.0 NVIDIA 617.42",
|
||||
"backend": "opengl",
|
||||
"kind": ""
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 8,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 11089.831,
|
||||
"stdev": 46.336,
|
||||
"min": 11036.36,
|
||||
"max": 11118.192,
|
||||
"ci95": 115.114
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 17643.831,
|
||||
"stdev": 47.672,
|
||||
"min": 17589.192,
|
||||
"max": 17676.942,
|
||||
"ci95": 118.433
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 3603.773,
|
||||
"stdev": 161.342,
|
||||
"min": 3488.106,
|
||||
"max": 3788.086,
|
||||
"ci95": 400.828
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2103.54,
|
||||
"stdev": 40.465,
|
||||
"min": 2078.464,
|
||||
"max": 2150.222,
|
||||
"ci95": 100.529
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 577.326,
|
||||
"stdev": 33.693,
|
||||
"min": 557.613,
|
||||
"max": 616.23,
|
||||
"ci95": 83.704
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 195.53,
|
||||
"stdev": 18.899,
|
||||
"min": 181.495,
|
||||
"max": 217.019,
|
||||
"ci95": 46.951
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 3282.612,
|
||||
"stdev": 48.569,
|
||||
"min": 3233.16,
|
||||
"max": 3330.246,
|
||||
"ci95": 120.661
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2424.739,
|
||||
"stdev": 26.074,
|
||||
"min": 2394.789,
|
||||
"max": 2442.383,
|
||||
"ci95": 64.777
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 732.987,
|
||||
"stdev": 11.926,
|
||||
"min": 724.144,
|
||||
"max": 746.55,
|
||||
"ci95": 29.627
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 249.926,
|
||||
"stdev": 8.826,
|
||||
"min": 241.293,
|
||||
"max": 258.934,
|
||||
"ci95": 21.928
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 2705.954,
|
||||
"stdev": 173.487,
|
||||
"min": 2538.486,
|
||||
"max": 2884.891,
|
||||
"ci95": 431.0
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2431.99,
|
||||
"stdev": 33.071,
|
||||
"min": 2394.02,
|
||||
"max": 2454.493,
|
||||
"ci95": 82.159
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 764.065,
|
||||
"stdev": 14.782,
|
||||
"min": 747.287,
|
||||
"max": 775.168,
|
||||
"ci95": 36.723
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 261.954,
|
||||
"stdev": 14.724,
|
||||
"min": 252.457,
|
||||
"max": 278.916,
|
||||
"ci95": 36.58
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 4982.865,
|
||||
"stdev": 115.523,
|
||||
"min": 4849.837,
|
||||
"max": 5057.932,
|
||||
"ci95": 286.997
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2538.05,
|
||||
"stdev": 67.405,
|
||||
"min": 2462.815,
|
||||
"max": 2592.938,
|
||||
"ci95": 167.458
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 760.16,
|
||||
"stdev": 13.503,
|
||||
"min": 746.001,
|
||||
"max": 772.895,
|
||||
"ci95": 33.547
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 240.897,
|
||||
"stdev": 17.872,
|
||||
"min": 224.391,
|
||||
"max": 259.878,
|
||||
"ci95": 44.401
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2052.982,
|
||||
"stdev": 57.756,
|
||||
"min": 1992.574,
|
||||
"max": 2107.66,
|
||||
"ci95": 143.487
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1903.921,
|
||||
"stdev": 62.254,
|
||||
"min": 1838.307,
|
||||
"max": 1962.156,
|
||||
"ci95": 154.659
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 25803,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2150.222,
|
||||
"low_1pct_fps": 616.23,
|
||||
"low_01pct_fps": 217.019,
|
||||
"p50_ms": 0.433,
|
||||
"p95_ms": 0.642,
|
||||
"p99_ms": 1.04,
|
||||
"p999_ms": 2.307,
|
||||
"max_ms": 7.466,
|
||||
"histogram": [
|
||||
25503,
|
||||
269,
|
||||
8,
|
||||
6,
|
||||
7,
|
||||
6,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 24942,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2078.464,
|
||||
"low_1pct_fps": 557.613,
|
||||
"low_01pct_fps": 181.495,
|
||||
"p50_ms": 0.45,
|
||||
"p95_ms": 0.665,
|
||||
"p99_ms": 1.1,
|
||||
"p999_ms": 2.305,
|
||||
"max_ms": 9.819,
|
||||
"histogram": [
|
||||
24615,
|
||||
288,
|
||||
17,
|
||||
2,
|
||||
4,
|
||||
9,
|
||||
2,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 24984,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2081.934,
|
||||
"low_1pct_fps": 558.135,
|
||||
"low_01pct_fps": 188.076,
|
||||
"p50_ms": 0.444,
|
||||
"p95_ms": 0.682,
|
||||
"p99_ms": 1.101,
|
||||
"p999_ms": 2.412,
|
||||
"max_ms": 11.567,
|
||||
"histogram": [
|
||||
24645,
|
||||
308,
|
||||
9,
|
||||
3,
|
||||
5,
|
||||
5,
|
||||
7,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 29245,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2437.044,
|
||||
"low_1pct_fps": 728.266,
|
||||
"low_01pct_fps": 249.55,
|
||||
"p50_ms": 0.38,
|
||||
"p95_ms": 0.555,
|
||||
"p99_ms": 0.892,
|
||||
"p999_ms": 1.851,
|
||||
"max_ms": 6.973,
|
||||
"histogram": [
|
||||
29066,
|
||||
154,
|
||||
4,
|
||||
6,
|
||||
7,
|
||||
6,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 28738,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2394.789,
|
||||
"low_1pct_fps": 724.144,
|
||||
"low_01pct_fps": 241.293,
|
||||
"p50_ms": 0.384,
|
||||
"p95_ms": 0.571,
|
||||
"p99_ms": 0.907,
|
||||
"p999_ms": 1.833,
|
||||
"max_ms": 8.35,
|
||||
"histogram": [
|
||||
28573,
|
||||
140,
|
||||
6,
|
||||
3,
|
||||
6,
|
||||
5,
|
||||
3,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 29309,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2442.383,
|
||||
"low_1pct_fps": 746.55,
|
||||
"low_01pct_fps": 258.934,
|
||||
"p50_ms": 0.378,
|
||||
"p95_ms": 0.566,
|
||||
"p99_ms": 0.876,
|
||||
"p999_ms": 1.744,
|
||||
"max_ms": 10.291,
|
||||
"histogram": [
|
||||
29138,
|
||||
148,
|
||||
5,
|
||||
6,
|
||||
5,
|
||||
2,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 29454,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2454.493,
|
||||
"low_1pct_fps": 769.741,
|
||||
"low_01pct_fps": 252.457,
|
||||
"p50_ms": 0.378,
|
||||
"p95_ms": 0.55,
|
||||
"p99_ms": 0.853,
|
||||
"p999_ms": 1.471,
|
||||
"max_ms": 6.629,
|
||||
"histogram": [
|
||||
29323,
|
||||
110,
|
||||
2,
|
||||
2,
|
||||
7,
|
||||
7,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 28729,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2394.02,
|
||||
"low_1pct_fps": 747.287,
|
||||
"low_01pct_fps": 254.49,
|
||||
"p50_ms": 0.383,
|
||||
"p95_ms": 0.579,
|
||||
"p99_ms": 0.896,
|
||||
"p999_ms": 1.56,
|
||||
"max_ms": 7.367,
|
||||
"histogram": [
|
||||
28567,
|
||||
141,
|
||||
3,
|
||||
5,
|
||||
4,
|
||||
4,
|
||||
4,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 29370,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2447.458,
|
||||
"low_1pct_fps": 775.168,
|
||||
"low_01pct_fps": 278.916,
|
||||
"p50_ms": 0.376,
|
||||
"p95_ms": 0.559,
|
||||
"p99_ms": 0.887,
|
||||
"p999_ms": 1.567,
|
||||
"max_ms": 9.65,
|
||||
"histogram": [
|
||||
29209,
|
||||
141,
|
||||
3,
|
||||
9,
|
||||
0,
|
||||
5,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 31116,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2592.938,
|
||||
"low_1pct_fps": 761.584,
|
||||
"low_01pct_fps": 224.391,
|
||||
"p50_ms": 0.356,
|
||||
"p95_ms": 0.511,
|
||||
"p99_ms": 0.767,
|
||||
"p999_ms": 1.843,
|
||||
"max_ms": 8.478,
|
||||
"histogram": [
|
||||
31011,
|
||||
76,
|
||||
4,
|
||||
3,
|
||||
13,
|
||||
4,
|
||||
3,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 29554,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2462.815,
|
||||
"low_1pct_fps": 746.001,
|
||||
"low_01pct_fps": 238.423,
|
||||
"p50_ms": 0.366,
|
||||
"p95_ms": 0.579,
|
||||
"p99_ms": 0.849,
|
||||
"p999_ms": 1.75,
|
||||
"max_ms": 7.607,
|
||||
"histogram": [
|
||||
29430,
|
||||
101,
|
||||
2,
|
||||
3,
|
||||
9,
|
||||
4,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 30701,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2558.396,
|
||||
"low_1pct_fps": 772.895,
|
||||
"low_01pct_fps": 259.878,
|
||||
"p50_ms": 0.357,
|
||||
"p95_ms": 0.537,
|
||||
"p99_ms": 0.81,
|
||||
"p999_ms": 1.702,
|
||||
"max_ms": 6.682,
|
||||
"histogram": [
|
||||
30568,
|
||||
112,
|
||||
1,
|
||||
6,
|
||||
5,
|
||||
3,
|
||||
6,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "vanilla-mods-win-9800x3d-5080"
|
||||
}
|
||||
@@ -0,0 +1,690 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-10-29T01:19:23Z",
|
||||
"app": {
|
||||
"name": "minecraft",
|
||||
"version": "26.3",
|
||||
"mods": [
|
||||
{
|
||||
"name": "fabric-loader",
|
||||
"version": "0.19.5"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hardware": {
|
||||
"os": "windows",
|
||||
"arch": "x86_64",
|
||||
"cpu_threads": 16,
|
||||
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
|
||||
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
|
||||
"ram_mb": 95943,
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 5080/PCIe/SSE2",
|
||||
"vendor": "NVIDIA Corporation",
|
||||
"driver": "3.3.0 NVIDIA 617.42",
|
||||
"backend": "opengl",
|
||||
"kind": ""
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 16,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 7681.834,
|
||||
"stdev": 189.48,
|
||||
"min": 7538.777,
|
||||
"max": 7896.728,
|
||||
"ci95": 470.732
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 19898.834,
|
||||
"stdev": 192.981,
|
||||
"min": 19695.728,
|
||||
"max": 20079.777,
|
||||
"ci95": 479.431
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 6127.947,
|
||||
"stdev": 26.756,
|
||||
"min": 6097.19,
|
||||
"max": 6145.854,
|
||||
"ci95": 66.471
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1218.486,
|
||||
"stdev": 26.337,
|
||||
"min": 1188.13,
|
||||
"max": 1235.248,
|
||||
"ci95": 65.43
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 318.468,
|
||||
"stdev": 6.292,
|
||||
"min": 313.2,
|
||||
"max": 325.435,
|
||||
"ci95": 15.632
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 126.59,
|
||||
"stdev": 1.844,
|
||||
"min": 124.744,
|
||||
"max": 128.431,
|
||||
"ci95": 4.58
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 8548.814,
|
||||
"stdev": 152.302,
|
||||
"min": 8380.912,
|
||||
"max": 8678.071,
|
||||
"ci95": 378.37
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1912.359,
|
||||
"stdev": 130.238,
|
||||
"min": 1791.386,
|
||||
"max": 2050.215,
|
||||
"ci95": 323.555
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 401.907,
|
||||
"stdev": 35.911,
|
||||
"min": 379.849,
|
||||
"max": 443.345,
|
||||
"ci95": 89.216
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 149.26,
|
||||
"stdev": 9.983,
|
||||
"min": 139.809,
|
||||
"max": 159.701,
|
||||
"ci95": 24.801
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 6953.478,
|
||||
"stdev": 276.636,
|
||||
"min": 6635.649,
|
||||
"max": 7140.072,
|
||||
"ci95": 687.258
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1860.512,
|
||||
"stdev": 116.512,
|
||||
"min": 1726.149,
|
||||
"max": 1933.597,
|
||||
"ci95": 289.454
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 446.336,
|
||||
"stdev": 56.993,
|
||||
"min": 382.26,
|
||||
"max": 491.369,
|
||||
"ci95": 141.589
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 164.934,
|
||||
"stdev": 11.839,
|
||||
"min": 152.493,
|
||||
"max": 176.062,
|
||||
"ci95": 29.413
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 15211.386,
|
||||
"stdev": 256.348,
|
||||
"min": 14938.008,
|
||||
"max": 15446.375,
|
||||
"ci95": 636.856
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1653.223,
|
||||
"stdev": 61.705,
|
||||
"min": 1584.652,
|
||||
"max": 1704.273,
|
||||
"ci95": 153.297
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 331.929,
|
||||
"stdev": 10.858,
|
||||
"min": 319.394,
|
||||
"max": 338.396,
|
||||
"ci95": 26.974
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 113.436,
|
||||
"stdev": 3.255,
|
||||
"min": 110.869,
|
||||
"max": 117.097,
|
||||
"ci95": 8.086
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2540.129,
|
||||
"stdev": 152.057,
|
||||
"min": 2373.152,
|
||||
"max": 2670.633,
|
||||
"ci95": 377.761
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 2299.514,
|
||||
"stdev": 81.451,
|
||||
"min": 2207.129,
|
||||
"max": 2360.973,
|
||||
"ci95": 202.352
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 14785,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1232.081,
|
||||
"low_1pct_fps": 325.435,
|
||||
"low_01pct_fps": 128.431,
|
||||
"p50_ms": 0.742,
|
||||
"p95_ms": 1.265,
|
||||
"p99_ms": 2.032,
|
||||
"p999_ms": 5.653,
|
||||
"max_ms": 12.791,
|
||||
"histogram": [
|
||||
12845,
|
||||
1784,
|
||||
127,
|
||||
7,
|
||||
1,
|
||||
8,
|
||||
4,
|
||||
3,
|
||||
5,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 14258,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1188.13,
|
||||
"low_1pct_fps": 313.2,
|
||||
"low_01pct_fps": 126.595,
|
||||
"p50_ms": 0.757,
|
||||
"p95_ms": 1.349,
|
||||
"p99_ms": 2.12,
|
||||
"p999_ms": 5.541,
|
||||
"max_ms": 13.998,
|
||||
"histogram": [
|
||||
11994,
|
||||
2076,
|
||||
157,
|
||||
9,
|
||||
2,
|
||||
8,
|
||||
2,
|
||||
4,
|
||||
5,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 14823,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1235.248,
|
||||
"low_1pct_fps": 316.768,
|
||||
"low_01pct_fps": 124.744,
|
||||
"p50_ms": 0.744,
|
||||
"p95_ms": 1.199,
|
||||
"p99_ms": 2.047,
|
||||
"p999_ms": 5.628,
|
||||
"max_ms": 15.519,
|
||||
"histogram": [
|
||||
13214,
|
||||
1453,
|
||||
118,
|
||||
16,
|
||||
2,
|
||||
8,
|
||||
6,
|
||||
3,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 21497,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1791.386,
|
||||
"low_1pct_fps": 382.527,
|
||||
"low_01pct_fps": 139.809,
|
||||
"p50_ms": 0.474,
|
||||
"p95_ms": 0.931,
|
||||
"p99_ms": 1.646,
|
||||
"p999_ms": 5.274,
|
||||
"max_ms": 13.459,
|
||||
"histogram": [
|
||||
20598,
|
||||
799,
|
||||
66,
|
||||
9,
|
||||
3,
|
||||
8,
|
||||
8,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 24603,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2050.215,
|
||||
"low_1pct_fps": 443.345,
|
||||
"low_01pct_fps": 159.701,
|
||||
"p50_ms": 0.426,
|
||||
"p95_ms": 0.752,
|
||||
"p99_ms": 1.442,
|
||||
"p999_ms": 3.134,
|
||||
"max_ms": 13.282,
|
||||
"histogram": [
|
||||
24041,
|
||||
487,
|
||||
48,
|
||||
6,
|
||||
5,
|
||||
6,
|
||||
2,
|
||||
3,
|
||||
3,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 22746,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1895.476,
|
||||
"low_1pct_fps": 379.849,
|
||||
"low_01pct_fps": 148.269,
|
||||
"p50_ms": 0.456,
|
||||
"p95_ms": 0.829,
|
||||
"p99_ms": 1.561,
|
||||
"p999_ms": 4.176,
|
||||
"max_ms": 10.671,
|
||||
"histogram": [
|
||||
22092,
|
||||
523,
|
||||
91,
|
||||
17,
|
||||
4,
|
||||
7,
|
||||
3,
|
||||
4,
|
||||
4,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 23062,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1921.791,
|
||||
"low_1pct_fps": 465.379,
|
||||
"low_01pct_fps": 166.247,
|
||||
"p50_ms": 0.463,
|
||||
"p95_ms": 0.809,
|
||||
"p99_ms": 1.45,
|
||||
"p999_ms": 2.398,
|
||||
"max_ms": 11.808,
|
||||
"histogram": [
|
||||
22454,
|
||||
561,
|
||||
27,
|
||||
3,
|
||||
2,
|
||||
6,
|
||||
0,
|
||||
4,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 23204,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1933.597,
|
||||
"low_1pct_fps": 491.369,
|
||||
"low_01pct_fps": 176.062,
|
||||
"p50_ms": 0.466,
|
||||
"p95_ms": 0.766,
|
||||
"p99_ms": 1.406,
|
||||
"p999_ms": 2.229,
|
||||
"max_ms": 15.102,
|
||||
"histogram": [
|
||||
22699,
|
||||
469,
|
||||
19,
|
||||
4,
|
||||
1,
|
||||
1,
|
||||
4,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 20714,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1726.149,
|
||||
"low_1pct_fps": 382.26,
|
||||
"low_01pct_fps": 152.493,
|
||||
"p50_ms": 0.5,
|
||||
"p95_ms": 0.956,
|
||||
"p99_ms": 1.742,
|
||||
"p999_ms": 3.852,
|
||||
"max_ms": 12.888,
|
||||
"histogram": [
|
||||
19814,
|
||||
787,
|
||||
83,
|
||||
11,
|
||||
2,
|
||||
7,
|
||||
3,
|
||||
5,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 20049,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1670.745,
|
||||
"low_1pct_fps": 338.396,
|
||||
"low_01pct_fps": 117.097,
|
||||
"p50_ms": 0.502,
|
||||
"p95_ms": 0.949,
|
||||
"p99_ms": 1.673,
|
||||
"p999_ms": 6.456,
|
||||
"max_ms": 16.791,
|
||||
"histogram": [
|
||||
19187,
|
||||
753,
|
||||
70,
|
||||
8,
|
||||
1,
|
||||
6,
|
||||
12,
|
||||
4,
|
||||
3,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 19016,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1584.652,
|
||||
"low_1pct_fps": 337.998,
|
||||
"low_01pct_fps": 112.343,
|
||||
"p50_ms": 0.531,
|
||||
"p95_ms": 1.082,
|
||||
"p99_ms": 1.717,
|
||||
"p999_ms": 5.939,
|
||||
"max_ms": 12.958,
|
||||
"histogram": [
|
||||
17849,
|
||||
1069,
|
||||
63,
|
||||
7,
|
||||
3,
|
||||
6,
|
||||
2,
|
||||
7,
|
||||
4,
|
||||
5,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 20452,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1704.273,
|
||||
"low_1pct_fps": 319.394,
|
||||
"low_01pct_fps": 110.869,
|
||||
"p50_ms": 0.496,
|
||||
"p95_ms": 0.911,
|
||||
"p99_ms": 1.706,
|
||||
"p999_ms": 5.938,
|
||||
"max_ms": 24.863,
|
||||
"histogram": [
|
||||
19694,
|
||||
626,
|
||||
82,
|
||||
14,
|
||||
3,
|
||||
14,
|
||||
5,
|
||||
4,
|
||||
5,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "vanilla-win-9800x3d-5080"
|
||||
}
|
||||
@@ -0,0 +1,690 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-10-29T19:06:49Z",
|
||||
"app": {
|
||||
"name": "minecraft",
|
||||
"version": "26.3",
|
||||
"mods": [
|
||||
{
|
||||
"name": "fabric-loader",
|
||||
"version": "0.19.5"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hardware": {
|
||||
"os": "windows",
|
||||
"arch": "x86_64",
|
||||
"cpu_threads": 16,
|
||||
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
|
||||
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
|
||||
"ram_mb": 95943,
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 5080/PCIe/SSE2",
|
||||
"vendor": "NVIDIA Corporation",
|
||||
"driver": "3.3.0 NVIDIA 617.42",
|
||||
"backend": "opengl",
|
||||
"kind": ""
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 32,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 7518.874,
|
||||
"stdev": 42.968,
|
||||
"min": 7469.532,
|
||||
"max": 7548.049,
|
||||
"ci95": 106.748
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 35424.207,
|
||||
"stdev": 144.711,
|
||||
"min": 35318.049,
|
||||
"max": 35589.041,
|
||||
"ci95": 359.511
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 11146.828,
|
||||
"stdev": 49.683,
|
||||
"min": 11094.798,
|
||||
"max": 11193.773,
|
||||
"ci95": 123.43
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 882.053,
|
||||
"stdev": 19.433,
|
||||
"min": 866.126,
|
||||
"max": 903.706,
|
||||
"ci95": 48.279
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 183.995,
|
||||
"stdev": 4.924,
|
||||
"min": 178.512,
|
||||
"max": 188.041,
|
||||
"ci95": 12.234
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 83.193,
|
||||
"stdev": 6.797,
|
||||
"min": 77.734,
|
||||
"max": 90.806,
|
||||
"ci95": 16.886
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 18184.002,
|
||||
"stdev": 266.879,
|
||||
"min": 17880.667,
|
||||
"max": 18382.735,
|
||||
"ci95": 663.017
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1202.907,
|
||||
"stdev": 20.426,
|
||||
"min": 1179.9,
|
||||
"max": 1218.906,
|
||||
"ci95": 50.744
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 228.139,
|
||||
"stdev": 5.166,
|
||||
"min": 222.394,
|
||||
"max": 232.401,
|
||||
"ci95": 12.833
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 107.956,
|
||||
"stdev": 4.675,
|
||||
"min": 105.185,
|
||||
"max": 113.354,
|
||||
"ci95": 11.614
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 13146.632,
|
||||
"stdev": 507.943,
|
||||
"min": 12572.118,
|
||||
"max": 13536.145,
|
||||
"ci95": 1261.902
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1223.169,
|
||||
"stdev": 13.11,
|
||||
"min": 1208.837,
|
||||
"max": 1234.556,
|
||||
"ci95": 32.569
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 236.27,
|
||||
"stdev": 6.77,
|
||||
"min": 228.686,
|
||||
"max": 241.702,
|
||||
"ci95": 16.818
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 99.089,
|
||||
"stdev": 8.354,
|
||||
"min": 91.897,
|
||||
"max": 108.253,
|
||||
"ci95": 20.755
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 41846.641,
|
||||
"stdev": 661.649,
|
||||
"min": 41435.666,
|
||||
"max": 42609.895,
|
||||
"ci95": 1643.76
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 815.008,
|
||||
"stdev": 24.644,
|
||||
"min": 797.565,
|
||||
"max": 843.201,
|
||||
"ci95": 61.224
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 55.371,
|
||||
"stdev": 5.128,
|
||||
"min": 50.389,
|
||||
"max": 60.633,
|
||||
"ci95": 12.739
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 7.966,
|
||||
"stdev": 1.103,
|
||||
"min": 6.848,
|
||||
"max": 9.053,
|
||||
"ci95": 2.74
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2892.492,
|
||||
"stdev": 29.54,
|
||||
"min": 2859.574,
|
||||
"max": 2916.691,
|
||||
"ci95": 73.387
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 2799.159,
|
||||
"stdev": 12.256,
|
||||
"min": 2786.234,
|
||||
"max": 2810.613,
|
||||
"ci95": 30.447
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 10845,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 903.706,
|
||||
"low_1pct_fps": 185.432,
|
||||
"low_01pct_fps": 81.039,
|
||||
"p50_ms": 0.98,
|
||||
"p95_ms": 1.708,
|
||||
"p99_ms": 3.082,
|
||||
"p999_ms": 9.304,
|
||||
"max_ms": 15.487,
|
||||
"histogram": [
|
||||
6030,
|
||||
4403,
|
||||
297,
|
||||
49,
|
||||
25,
|
||||
16,
|
||||
8,
|
||||
2,
|
||||
6,
|
||||
2,
|
||||
4,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 10516,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 876.328,
|
||||
"low_1pct_fps": 188.041,
|
||||
"low_01pct_fps": 90.806,
|
||||
"p50_ms": 1.012,
|
||||
"p95_ms": 1.806,
|
||||
"p99_ms": 3.354,
|
||||
"p999_ms": 8.714,
|
||||
"max_ms": 14.39,
|
||||
"histogram": [
|
||||
4927,
|
||||
5158,
|
||||
292,
|
||||
70,
|
||||
33,
|
||||
13,
|
||||
6,
|
||||
5,
|
||||
6,
|
||||
2,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 10394,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 866.126,
|
||||
"low_1pct_fps": 178.512,
|
||||
"low_01pct_fps": 77.734,
|
||||
"p50_ms": 1.026,
|
||||
"p95_ms": 1.789,
|
||||
"p99_ms": 3.399,
|
||||
"p999_ms": 10.6,
|
||||
"max_ms": 15.389,
|
||||
"histogram": [
|
||||
4437,
|
||||
5552,
|
||||
273,
|
||||
65,
|
||||
18,
|
||||
28,
|
||||
8,
|
||||
1,
|
||||
1,
|
||||
3,
|
||||
5,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 14519,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1209.916,
|
||||
"low_1pct_fps": 232.401,
|
||||
"low_01pct_fps": 105.185,
|
||||
"p50_ms": 0.726,
|
||||
"p95_ms": 1.306,
|
||||
"p99_ms": 2.25,
|
||||
"p999_ms": 7.033,
|
||||
"max_ms": 13.303,
|
||||
"histogram": [
|
||||
13121,
|
||||
1197,
|
||||
103,
|
||||
36,
|
||||
24,
|
||||
19,
|
||||
4,
|
||||
3,
|
||||
8,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 14159,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1179.9,
|
||||
"low_1pct_fps": 222.394,
|
||||
"low_01pct_fps": 105.33,
|
||||
"p50_ms": 0.732,
|
||||
"p95_ms": 1.366,
|
||||
"p99_ms": 2.499,
|
||||
"p999_ms": 8.061,
|
||||
"max_ms": 11.797,
|
||||
"histogram": [
|
||||
12501,
|
||||
1356,
|
||||
195,
|
||||
45,
|
||||
25,
|
||||
14,
|
||||
6,
|
||||
3,
|
||||
9,
|
||||
5,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 14627,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1218.906,
|
||||
"low_1pct_fps": 229.622,
|
||||
"low_01pct_fps": 113.354,
|
||||
"p50_ms": 0.713,
|
||||
"p95_ms": 1.292,
|
||||
"p99_ms": 2.317,
|
||||
"p999_ms": 7.136,
|
||||
"max_ms": 12.041,
|
||||
"histogram": [
|
||||
13194,
|
||||
1224,
|
||||
97,
|
||||
47,
|
||||
27,
|
||||
21,
|
||||
2,
|
||||
6,
|
||||
6,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 14714,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1226.113,
|
||||
"low_1pct_fps": 238.423,
|
||||
"low_01pct_fps": 97.117,
|
||||
"p50_ms": 0.722,
|
||||
"p95_ms": 1.223,
|
||||
"p99_ms": 2.243,
|
||||
"p999_ms": 7.032,
|
||||
"max_ms": 13.21,
|
||||
"histogram": [
|
||||
13490,
|
||||
1027,
|
||||
115,
|
||||
29,
|
||||
18,
|
||||
13,
|
||||
7,
|
||||
3,
|
||||
3,
|
||||
5,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 14507,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1208.837,
|
||||
"low_1pct_fps": 228.686,
|
||||
"low_01pct_fps": 91.897,
|
||||
"p50_ms": 0.719,
|
||||
"p95_ms": 1.256,
|
||||
"p99_ms": 2.354,
|
||||
"p999_ms": 7.473,
|
||||
"max_ms": 15.432,
|
||||
"histogram": [
|
||||
13124,
|
||||
1112,
|
||||
180,
|
||||
35,
|
||||
23,
|
||||
9,
|
||||
8,
|
||||
4,
|
||||
2,
|
||||
6,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 14815,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1234.556,
|
||||
"low_1pct_fps": 241.702,
|
||||
"low_01pct_fps": 108.253,
|
||||
"p50_ms": 0.721,
|
||||
"p95_ms": 1.2,
|
||||
"p99_ms": 2.197,
|
||||
"p999_ms": 6.689,
|
||||
"max_ms": 12.791,
|
||||
"histogram": [
|
||||
13565,
|
||||
1055,
|
||||
107,
|
||||
29,
|
||||
23,
|
||||
15,
|
||||
8,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 10119,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 843.201,
|
||||
"low_1pct_fps": 60.633,
|
||||
"low_01pct_fps": 9.053,
|
||||
"p50_ms": 0.891,
|
||||
"p95_ms": 1.845,
|
||||
"p99_ms": 3.91,
|
||||
"p999_ms": 8.209,
|
||||
"max_ms": 316.409,
|
||||
"histogram": [
|
||||
6956,
|
||||
2747,
|
||||
240,
|
||||
83,
|
||||
45,
|
||||
22,
|
||||
8,
|
||||
8,
|
||||
5,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
4,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 9693,
|
||||
"seconds": 12.052,
|
||||
"avg_fps": 804.258,
|
||||
"low_1pct_fps": 50.389,
|
||||
"low_01pct_fps": 6.848,
|
||||
"p50_ms": 0.906,
|
||||
"p95_ms": 1.924,
|
||||
"p99_ms": 4.157,
|
||||
"p999_ms": 8.578,
|
||||
"max_ms": 297.695,
|
||||
"histogram": [
|
||||
6578,
|
||||
2678,
|
||||
248,
|
||||
94,
|
||||
45,
|
||||
18,
|
||||
16,
|
||||
5,
|
||||
4,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
5,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 9571,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 797.565,
|
||||
"low_1pct_fps": 55.09,
|
||||
"low_01pct_fps": 7.997,
|
||||
"p50_ms": 0.929,
|
||||
"p95_ms": 1.944,
|
||||
"p99_ms": 4.495,
|
||||
"p999_ms": 7.764,
|
||||
"max_ms": 321.994,
|
||||
"histogram": [
|
||||
5954,
|
||||
3172,
|
||||
230,
|
||||
103,
|
||||
39,
|
||||
34,
|
||||
19,
|
||||
11,
|
||||
4,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
4,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "vanilla-win-9800x3d-5080"
|
||||
}
|
||||
@@ -0,0 +1,690 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-10-29T18:43:17Z",
|
||||
"app": {
|
||||
"name": "minecraft",
|
||||
"version": "26.3",
|
||||
"mods": [
|
||||
{
|
||||
"name": "fabric-loader",
|
||||
"version": "0.19.5"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hardware": {
|
||||
"os": "windows",
|
||||
"arch": "x86_64",
|
||||
"cpu_threads": 16,
|
||||
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
|
||||
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
|
||||
"ram_mb": 95943,
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 5080/PCIe/SSE2",
|
||||
"vendor": "NVIDIA Corporation",
|
||||
"driver": "3.3.0 NVIDIA 617.42",
|
||||
"backend": "opengl",
|
||||
"kind": ""
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 8,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 7676.526,
|
||||
"stdev": 305.763,
|
||||
"min": 7426.024,
|
||||
"max": 8017.247,
|
||||
"ci95": 759.617
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 13862.526,
|
||||
"stdev": 284.577,
|
||||
"min": 13569.024,
|
||||
"max": 14137.247,
|
||||
"ci95": 706.985
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 3808.014,
|
||||
"stdev": 61.275,
|
||||
"min": 3737.989,
|
||||
"max": 3851.799,
|
||||
"ci95": 152.227
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2216.576,
|
||||
"stdev": 45.247,
|
||||
"min": 2166.285,
|
||||
"max": 2253.985,
|
||||
"ci95": 112.409
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 545.307,
|
||||
"stdev": 14.876,
|
||||
"min": 528.15,
|
||||
"max": 554.607,
|
||||
"ci95": 36.957
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 215.412,
|
||||
"stdev": 5.692,
|
||||
"min": 209.522,
|
||||
"max": 220.882,
|
||||
"ci95": 14.14
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 3212.958,
|
||||
"stdev": 151.722,
|
||||
"min": 3076.655,
|
||||
"max": 3376.428,
|
||||
"ci95": 376.928
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2456.542,
|
||||
"stdev": 336.369,
|
||||
"min": 2068.162,
|
||||
"max": 2654.549,
|
||||
"ci95": 835.654
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 624.759,
|
||||
"stdev": 90.0,
|
||||
"min": 521.303,
|
||||
"max": 685.008,
|
||||
"ci95": 223.59
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 252.469,
|
||||
"stdev": 21.044,
|
||||
"min": 229.228,
|
||||
"max": 270.233,
|
||||
"ci95": 52.28
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 2983.236,
|
||||
"stdev": 168.416,
|
||||
"min": 2788.777,
|
||||
"max": 3082.249,
|
||||
"ci95": 418.403
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2469.831,
|
||||
"stdev": 333.258,
|
||||
"min": 2085.121,
|
||||
"max": 2669.9,
|
||||
"ci95": 827.925
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 688.684,
|
||||
"stdev": 96.943,
|
||||
"min": 576.803,
|
||||
"max": 747.759,
|
||||
"ci95": 240.839
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 286.444,
|
||||
"stdev": 21.36,
|
||||
"min": 262.183,
|
||||
"max": 302.422,
|
||||
"ci95": 53.066
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 6079.869,
|
||||
"stdev": 719.864,
|
||||
"min": 5542.196,
|
||||
"max": 6897.69,
|
||||
"ci95": 1788.385
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2605.12,
|
||||
"stdev": 326.424,
|
||||
"min": 2228.824,
|
||||
"max": 2812.075,
|
||||
"ci95": 810.948
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 699.63,
|
||||
"stdev": 87.846,
|
||||
"min": 599.44,
|
||||
"max": 763.45,
|
||||
"ci95": 218.239
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 264.461,
|
||||
"stdev": 28.459,
|
||||
"min": 239.602,
|
||||
"max": 295.503,
|
||||
"ci95": 70.701
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2120.426,
|
||||
"stdev": 116.406,
|
||||
"min": 2046.93,
|
||||
"max": 2254.637,
|
||||
"ci95": 289.191
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1912.642,
|
||||
"stdev": 38.925,
|
||||
"min": 1889.766,
|
||||
"max": 1957.586,
|
||||
"ci95": 96.702
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 27048,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2253.985,
|
||||
"low_1pct_fps": 553.164,
|
||||
"low_01pct_fps": 215.833,
|
||||
"p50_ms": 0.412,
|
||||
"p95_ms": 0.617,
|
||||
"p99_ms": 1.187,
|
||||
"p999_ms": 2.238,
|
||||
"max_ms": 9.792,
|
||||
"histogram": [
|
||||
26716,
|
||||
296,
|
||||
15,
|
||||
4,
|
||||
7,
|
||||
5,
|
||||
2,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 26754,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2229.458,
|
||||
"low_1pct_fps": 554.607,
|
||||
"low_01pct_fps": 220.882,
|
||||
"p50_ms": 0.417,
|
||||
"p95_ms": 0.626,
|
||||
"p99_ms": 1.212,
|
||||
"p999_ms": 2.448,
|
||||
"max_ms": 8.388,
|
||||
"histogram": [
|
||||
26423,
|
||||
293,
|
||||
16,
|
||||
9,
|
||||
4,
|
||||
5,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 25996,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2166.285,
|
||||
"low_1pct_fps": 528.15,
|
||||
"low_01pct_fps": 209.522,
|
||||
"p50_ms": 0.43,
|
||||
"p95_ms": 0.662,
|
||||
"p99_ms": 1.308,
|
||||
"p999_ms": 2.477,
|
||||
"max_ms": 11.033,
|
||||
"histogram": [
|
||||
25615,
|
||||
338,
|
||||
23,
|
||||
5,
|
||||
5,
|
||||
5,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 31855,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2654.549,
|
||||
"low_1pct_fps": 667.966,
|
||||
"low_01pct_fps": 257.945,
|
||||
"p50_ms": 0.341,
|
||||
"p95_ms": 0.519,
|
||||
"p99_ms": 0.932,
|
||||
"p999_ms": 1.761,
|
||||
"max_ms": 10.944,
|
||||
"histogram": [
|
||||
31558,
|
||||
275,
|
||||
6,
|
||||
2,
|
||||
7,
|
||||
0,
|
||||
4,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 31763,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2646.916,
|
||||
"low_1pct_fps": 685.008,
|
||||
"low_01pct_fps": 270.233,
|
||||
"p50_ms": 0.344,
|
||||
"p95_ms": 0.52,
|
||||
"p99_ms": 0.94,
|
||||
"p999_ms": 1.685,
|
||||
"max_ms": 11.406,
|
||||
"histogram": [
|
||||
31474,
|
||||
272,
|
||||
3,
|
||||
4,
|
||||
2,
|
||||
2,
|
||||
1,
|
||||
3,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 24818,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2068.162,
|
||||
"low_1pct_fps": 521.303,
|
||||
"low_01pct_fps": 229.228,
|
||||
"p50_ms": 0.439,
|
||||
"p95_ms": 0.767,
|
||||
"p99_ms": 1.284,
|
||||
"p999_ms": 2.351,
|
||||
"max_ms": 11.468,
|
||||
"histogram": [
|
||||
24356,
|
||||
410,
|
||||
36,
|
||||
4,
|
||||
4,
|
||||
3,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 32039,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2669.9,
|
||||
"low_1pct_fps": 741.491,
|
||||
"low_01pct_fps": 294.727,
|
||||
"p50_ms": 0.345,
|
||||
"p95_ms": 0.501,
|
||||
"p99_ms": 0.86,
|
||||
"p999_ms": 1.431,
|
||||
"max_ms": 9.257,
|
||||
"histogram": [
|
||||
31771,
|
||||
250,
|
||||
3,
|
||||
2,
|
||||
4,
|
||||
6,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 31854,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2654.471,
|
||||
"low_1pct_fps": 747.759,
|
||||
"low_01pct_fps": 302.422,
|
||||
"p50_ms": 0.347,
|
||||
"p95_ms": 0.503,
|
||||
"p99_ms": 0.853,
|
||||
"p999_ms": 1.404,
|
||||
"max_ms": 13.925,
|
||||
"histogram": [
|
||||
31580,
|
||||
261,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
3,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 25022,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2085.121,
|
||||
"low_1pct_fps": 576.803,
|
||||
"low_01pct_fps": 262.183,
|
||||
"p50_ms": 0.428,
|
||||
"p95_ms": 0.758,
|
||||
"p99_ms": 1.214,
|
||||
"p999_ms": 2.109,
|
||||
"max_ms": 9.206,
|
||||
"histogram": [
|
||||
24600,
|
||||
390,
|
||||
20,
|
||||
3,
|
||||
2,
|
||||
3,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 33745,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2812.075,
|
||||
"low_1pct_fps": 736.0,
|
||||
"low_01pct_fps": 258.279,
|
||||
"p50_ms": 0.327,
|
||||
"p95_ms": 0.471,
|
||||
"p99_ms": 0.808,
|
||||
"p999_ms": 1.689,
|
||||
"max_ms": 10.07,
|
||||
"histogram": [
|
||||
33516,
|
||||
205,
|
||||
7,
|
||||
0,
|
||||
7,
|
||||
4,
|
||||
3,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 33294,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2774.461,
|
||||
"low_1pct_fps": 763.45,
|
||||
"low_01pct_fps": 295.503,
|
||||
"p50_ms": 0.334,
|
||||
"p95_ms": 0.471,
|
||||
"p99_ms": 0.813,
|
||||
"p999_ms": 1.63,
|
||||
"max_ms": 8.512,
|
||||
"histogram": [
|
||||
33056,
|
||||
221,
|
||||
3,
|
||||
5,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 26747,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2228.824,
|
||||
"low_1pct_fps": 599.44,
|
||||
"low_01pct_fps": 239.602,
|
||||
"p50_ms": 0.403,
|
||||
"p95_ms": 0.678,
|
||||
"p99_ms": 1.107,
|
||||
"p999_ms": 2.054,
|
||||
"max_ms": 9.178,
|
||||
"histogram": [
|
||||
26418,
|
||||
298,
|
||||
13,
|
||||
6,
|
||||
2,
|
||||
8,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "vanilla-win-9800x3d-5080"
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
# Horizon v0.2.97 vs vanilla 26.3 on Windows 11: Ryzen 7 9800X3D + RTX 5080 (2026-10-29, RD 8 and 32 rerun 2026-10-30)
|
||||
|
||||
The first `tools/perf_bench.py` run on Windows ([docs/benchmark.md](../../docs/benchmark.md)): Horizon, vanilla 26.3 and
|
||||
vanilla with performance mods, render distances 8, 16 and 32, 1280x720, three games each, every game from a fresh
|
||||
world. The reports are the `*-win-9800x3d-5080-rd*.json` files next to this page.
|
||||
|
||||
```sh
|
||||
python tools/perf_bench.py --table horizon-win-9800x3d-5080 vanilla-win-9800x3d-5080 vanilla-mods-win-9800x3d-5080
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
- **Machine:** AMD Ryzen 7 9800X3D (8 cores, 16 threads), 94 GB RAM (DDR5, EXPO off), NVIDIA GeForce RTX 5080
|
||||
(driver 617.42), Windows 11 Pro 26200, Balanced power plan, desktop. One 1920x1080 120 Hz display. The game windows
|
||||
were on the desktop of an unlocked session.
|
||||
- **Horizon:** release v0.2.97 as the launcher installs it (`--build %APPDATA%\horizon-launcher\versions\v0.2.97`,
|
||||
`--resources` its `.local\horizon-client\26.3`), Vulkan.
|
||||
- **Vanilla:** the 26.3 client jar with Fabric Loader 0.19.5 and the driver mod, Java 25.0.5 (Temurin), `-Xmx2G`
|
||||
with the launcher's G1 flags and the manifest's `-XX:StackShadowPages=32`, OpenGL 3.3.
|
||||
- **Vanilla + mods:** the same with Sodium mc26.3-0.9.2, Lithium mc26.3-0.26.2, FerriteCore 9.0.0 and
|
||||
ImmediatelyFast 1.17.1 (`tools/benchmark-deps/mods.lock.json`).
|
||||
- **Commands:** `python tools/perf_bench.py --game <game> --label <game>-win-9800x3d-5080 --distances <d>
|
||||
--sizes 1280x720 --repeats 3` (plus `--build` and `--resources` for Horizon), with the games taking turns at
|
||||
each distance.
|
||||
- **Memory** is the working set on Windows (what Task Manager shows), sampled from outside the process. Horizon's
|
||||
GPU figure is its own Vulkan allocator total. `nvidia-smi` gives no per-process figure under WDDM, so vanilla has
|
||||
none.
|
||||
|
||||
## Load
|
||||
|
||||
Windows has no load average, so the reports have no `load_avg_1m`. Every game ran on an otherwise idle machine:
|
||||
|
||||
- **RD 8 and RD 32** (rerun on 2026-10-30, after the first pass had builds running alongside): a script started each
|
||||
game only once no `rustc`, `cargo`, `link` (or other compiler) process and no other game (`horizon-client`, `java`,
|
||||
`javaw`, Minecraft) was running, and the machine's CPU load had stayed under 15 % in every 5-second sample for
|
||||
30 seconds. During each game it sampled every 2 seconds the machine's CPU load and the CPU of everything outside the
|
||||
benchmark's own processes. A game during which a compiler or another game ran, or other processes used over 15 % of
|
||||
the machine for 10 seconds, was discarded and run again: two of the 20 games were (vanilla + mods at RD 32, a
|
||||
build started during one, background processes during the other).
|
||||
- **RD 16** (2026-10-29): no `rustc`, `cargo` or `link` process at any point, the load logged every 5 seconds.
|
||||
|
||||
| Game | RD | Mean / max CPU load (machine, the game included) | Other processes' CPU, mean / max | Compiler processes |
|
||||
|---|---|---|---|---|
|
||||
| Horizon | 8 | 18 % / 49 % | 6 % / 12 % | 0 |
|
||||
| Vanilla | 8 | 28 % / 75 % | 7 % / 20 % | 0 |
|
||||
| Vanilla + mods | 8 | 30 % / 81 % | 7 % / 20 % | 0 |
|
||||
| Horizon | 16 | 29 % / 80 % | – | 0 |
|
||||
| Vanilla | 16 | 41 % / 91 % | – | 0 |
|
||||
| Vanilla + mods | 16 | 51 % / 90 % | – | 0 |
|
||||
| Horizon | 32 | 36 % / 100 % | 5 % / 15 % | 0 |
|
||||
| Vanilla | 32 | 39 % / 73 % | 6 % / 18 % | 0 |
|
||||
| Vanilla + mods | 32 | 42 % / 88 % | 6 % / 21 % | 0 |
|
||||
|
||||
"Other processes" is the machine's load minus the benchmark's own processes (the game and `perf_bench.py`), in
|
||||
2-second samples. Most of it is the system's share of the game's own drawing (the graphics driver and the
|
||||
compositor), which no process of the benchmark is charged for; the desktop apps left open (a browser, chat apps)
|
||||
briefly reached 5 % at most. RD 16's load was logged from outside every 5 seconds, so it has no such split, and its mean includes the time between
|
||||
games. Compared with the first pass, the idle RD 8 runs give Horizon about 10 % higher averages and about 50 % higher
|
||||
1% lows, and vanilla up to 10 % higher averages.
|
||||
|
||||
## Results
|
||||
|
||||
Mean of 3 games ± 95% confidence interval. FPS over the 12-second flight at 16 blocks/s (average / 1% low /
|
||||
0.1% low):
|
||||
|
||||
| RD | scene | Horizon v0.2.97 | Vanilla 26.3 | Vanilla + mods |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 8 | forest | 3495 ±276 / 1295 ±397 / 859 ±298 | 2457 ±836 / 625 ±224 / 252 ±52 | 2425 ±65 / 733 ±30 / 250 ±22 |
|
||||
| 8 | mountain | 3376 ±67 / 1384 ±168 / 850 ±256 | 2605 ±811 / 700 ±218 / 264 ±71 | 2538 ±167 / 760 ±34 / 241 ±44 |
|
||||
| 8 | ocean | 3346 ±147 / 1358 ±237 / 912 ±235 | 2470 ±828 / 689 ±241 / 286 ±53 | 2432 ±82 / 764 ±37 / 262 ±37 |
|
||||
| 8 | village | 2942 ±197 / 1271 ±146 / 839 ±94 | 2217 ±112 / 545 ±37 / 215 ±14 | 2104 ±101 / 577 ±84 / 196 ±47 |
|
||||
| 16 | forest | 2424 ±138 / 709 ±165 / 401 ±100 | 1912 ±324 / 402 ±89 / 149 ±25 | 1746 ±32 / 461 ±27 / 179 ±9 |
|
||||
| 16 | mountain | 1961 ±99 / 537 ±96 / 297 ±75 | 1653 ±153 / 332 ±27 / 113 ±8 | 1500 ±55 / 408 ±40 / 140 ±10 |
|
||||
| 16 | ocean | 2201 ±183 / 554 ±251 / 325 ±326 | 1861 ±289 / 446 ±142 / 165 ±29 | 1815 ±119 / 514 ±142 / 192 ±58 |
|
||||
| 16 | village | 1712 ±192 / 593 ±80 / 358 ±69 | 1218 ±65 / 318 ±16 / 127 ±5 | 1282 ±162 / 343 ±25 / 132 ±12 |
|
||||
| 32 | forest | 1389 ±70 / 504 ±33 / 330 ±118 | 1203 ±51 / 228 ±13 / 108 ±12 | 929 ±8 / 313 ±41 / 129 ±32 |
|
||||
| 32 | mountain | 1086 ±34 / 258 ±78 / 108 ±37 | 815 ±61 / 55 ±13 / 8 ±3 | 645 ±10 / 166 ±12 / 86 ±21 |
|
||||
| 32 | ocean | 1189 ±27 / 155 ±9 / 68 ±8 | 1223 ±33 / 236 ±17 / 99 ±21 | 1017 ±14 / 325 ±38 / 117 ±28 |
|
||||
| 32 | village | 1164 ±9 / 416 ±39 / 216 ±86 | 882 ±48 / 184 ±12 / 83 ±17 | 926 ±17 / 250 ±5 / 93 ±11 |
|
||||
|
||||
Startup, loading and memory (means of 3 games):
|
||||
|
||||
| RD | | Horizon v0.2.97 | Vanilla 26.3 | Vanilla + mods |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 8 | start to title / in-world | 3.0 s / 6.1 s | 7.7 s / 13.9 s | 11.1 s / 17.6 s |
|
||||
| 8 | mean scene load | 1.3 s | 4.0 s | 3.6 s |
|
||||
| 8 | working set, steady / peak | 1082 / 1172 MB | 1913 / 2120 MB | 1904 / 2053 MB |
|
||||
| 16 | start to title / in-world | 3.2 s / 7.2 s | 7.7 s / 19.9 s | 10.8 s / 23.5 s |
|
||||
| 16 | mean scene load | 2.4 s | 9.2 s | 9.0 s |
|
||||
| 16 | working set, steady / peak | 1236 / 1459 MB | 2300 / 2540 MB | 2327 / 2496 MB |
|
||||
| 32 | start to title / in-world | 2.9 s / 10.5 s | 7.5 s / 35.4 s | 11.0 s / 39.2 s |
|
||||
| 32 | mean scene load | 5.7 s | 21.1 s | 19.9 s |
|
||||
| 32 | working set, steady / peak | 1730 / 2045 MB | 2799 / 2892 MB | 2895 / 2965 MB |
|
||||
|
||||
## What it shows
|
||||
|
||||
- At RD 8 and 16, Horizon's average FPS is 18–42 % above vanilla's in every scene. Its 1% lows are 1.2–2.3 times
|
||||
vanilla's and its 0.1% lows 2.0–3.9 times. On this CPU and GPU the performance mods leave vanilla's averages
|
||||
level or slightly lower, and raise its 1% lows by 6–23 %.
|
||||
- At RD 32, Horizon's average is 15–33 % above vanilla's over the forest, the mountain and the village, with 1% lows
|
||||
2.2–4.7 times vanilla's. Over the ocean its average is level with vanilla's, but its lows are lower than vanilla's
|
||||
and the modded client's (155 vs 236 and 325 FPS 1% low, 68 vs 99 and 117 FPS 0.1% low), on an idle machine too.
|
||||
That scene is worth a closer look.
|
||||
- Horizon reaches the title screen 2.4–2.6 times faster than vanilla (3.4–3.8 times faster than the modded client)
|
||||
and loads a scene's chunks 3–4 times faster. Its steady working set is 38–46 % smaller.
|
||||
- Each game uses its own graphics API: Horizon Vulkan, vanilla OpenGL. That difference is part of what's compared.
|
||||
@@ -4,10 +4,10 @@ version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.86"
|
||||
description = "cargo horizon-mod new/build/run/check: make Horizon mods in Rust (docs/mods.md)"
|
||||
description = "cargo horizon-mod new/build/run/check: make Horizon content mods (docs/mods.md)"
|
||||
|
||||
[dependencies]
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["pack", "runtime", "index"] }
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
|
||||
serde_json = "1.0.151"
|
||||
|
||||
[lints]
|
||||
|
||||
@@ -1,35 +1,19 @@
|
||||
//! `cargo horizon-mod check <file>`: what the registry checks of a submission,
|
||||
//! run anywhere: its kind's own checks (docs/api/mod-index.md), its signature
|
||||
//! when given a key, and for a mod a trial run in the sandbox: it must start,
|
||||
//! survive ticks, frames, a chat line and each of its commands (a server mod:
|
||||
//! a player joining, chatting, typing them and leaving), and import no more
|
||||
//! than its capabilities.
|
||||
//! when given a key, and for a `.hmod` that it is content only.
|
||||
use horizon_mods::{
|
||||
Clock, GameMode, Mods, Output, Package, Player, ServerPlayer, Snapshot, Status,
|
||||
Package,
|
||||
index::{self, Facts, Kind},
|
||||
manifest::component_imports,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::Path;
|
||||
|
||||
/// How long the trial run plays: two seconds of game.
|
||||
const TICKS: u64 = 40;
|
||||
const FRAMES_PER_TICK: u64 = 3;
|
||||
|
||||
pub struct Report {
|
||||
pub kind: Kind,
|
||||
pub facts: Facts,
|
||||
pub sha256: String,
|
||||
pub size: u64,
|
||||
pub signed: bool,
|
||||
/// A mod has code to run (a content mod may have none).
|
||||
pub code: bool,
|
||||
/// A mod's component imports.
|
||||
pub imports: Vec<String>,
|
||||
/// A mod's commands, each run once.
|
||||
pub commands: Vec<String>,
|
||||
/// What the mod showed during the trial run.
|
||||
pub output: Vec<String>,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
@@ -53,10 +37,6 @@ impl Report {
|
||||
"sha256": self.sha256,
|
||||
"size": self.size,
|
||||
"signed": self.signed,
|
||||
"code": self.code,
|
||||
"imports": self.imports,
|
||||
"commands": self.commands,
|
||||
"output": self.output,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -100,18 +80,8 @@ impl Report {
|
||||
if self.kind == Kind::NativeMod {
|
||||
out += " native source: unsandboxed; not executed by the registry check. Compile and test against the exact pinned Horizon and Mixins revisions before review.\n";
|
||||
}
|
||||
if self.kind == Kind::Mod && !self.code {
|
||||
if self.kind == Kind::Mod {
|
||||
out += " no code: content only, nothing to run\n";
|
||||
} else if self.kind == Kind::Mod {
|
||||
let commands = if self.commands.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(", /{}", self.commands.join(", /"))
|
||||
};
|
||||
out += &format!(
|
||||
" trial run: started, {TICKS} ticks, {} frames, a chat line{commands}: ok\n",
|
||||
TICKS * FRAMES_PER_TICK
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -153,145 +123,26 @@ pub fn check(
|
||||
(None, None) => false,
|
||||
_ => return Err("--key and --signature go together".into()),
|
||||
};
|
||||
let mut report = Report {
|
||||
if kind == Kind::Mod {
|
||||
content_only(path)?;
|
||||
}
|
||||
Ok(Report {
|
||||
kind,
|
||||
facts,
|
||||
sha256: index::sha256_file(path)?,
|
||||
size,
|
||||
signed,
|
||||
code: false,
|
||||
imports: Vec::new(),
|
||||
commands: Vec::new(),
|
||||
output: Vec::new(),
|
||||
};
|
||||
if report.kind == Kind::Mod {
|
||||
trial_run(path, &mut report)?;
|
||||
}
|
||||
Ok(report)
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs a mod in the sandbox, as the game does, from a scratch copy so its
|
||||
/// storage lands in a folder removed afterwards. A content mod has no code to
|
||||
/// run: `inspect` already read its content as the game does.
|
||||
fn trial_run(path: &Path, report: &mut Report) -> Result<(), String> {
|
||||
let package = Package::read(path)?;
|
||||
let Some(wasm) = package.wasm.as_deref() else {
|
||||
return Ok(());
|
||||
};
|
||||
report.code = true;
|
||||
report.imports = component_imports(wasm)?;
|
||||
package
|
||||
.manifest
|
||||
.check_imports(report.imports.iter().map(String::as_str))?;
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mod-check-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
let copy = dir.join("mod.hmod");
|
||||
let result = std::fs::copy(path, ©)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|_| play(©, report));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
result
|
||||
}
|
||||
|
||||
fn play(path: &Path, report: &mut Report) -> Result<(), String> {
|
||||
let mut mods = Mods::load(&[path.to_path_buf()], |_| true, |_| false);
|
||||
let failed = |mods: &Mods| match &mods.entries()[0].status {
|
||||
Status::Failed(reason) => Some(reason.clone()),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(reason) = failed(&mods) {
|
||||
return Err(format!("it didn't start: {reason}"));
|
||||
}
|
||||
let client: Vec<String> = mods.commands().map(str::to_owned).collect();
|
||||
let server: Vec<String> = mods.server_commands().map(|c| c.name.clone()).collect();
|
||||
report.commands = [client.clone(), server.clone()].concat();
|
||||
// A server mod plays with one player on, who joins, chats and leaves.
|
||||
let steve = ServerPlayer {
|
||||
id: "00000000-0000-4000-8000-000000000001".into(),
|
||||
name: "Steve".into(),
|
||||
x: 0.5,
|
||||
y: 64.0,
|
||||
z: 0.5,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
game_mode: GameMode::Survival,
|
||||
health: 20.0,
|
||||
};
|
||||
let snapshot = |t: u64| Snapshot {
|
||||
player: Some(Player {
|
||||
x: 0.5 + t as f64 * 0.1,
|
||||
y: 64.0,
|
||||
z: 0.5,
|
||||
yaw: 90.0,
|
||||
pitch: 0.0,
|
||||
health: 20.0,
|
||||
food: 20,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
}),
|
||||
clock: Some(Clock {
|
||||
game_time: t,
|
||||
day_time: 1000 + t,
|
||||
}),
|
||||
players: vec![steve.clone()],
|
||||
// The GUI of a 1280 x 720 window at scale 3.
|
||||
gui_size: (427.0, 240.0),
|
||||
..Snapshot::default()
|
||||
};
|
||||
mods.set_snapshot(snapshot(0));
|
||||
mods.player_joined(&steve);
|
||||
for t in 0..TICKS {
|
||||
mods.set_snapshot(snapshot(t));
|
||||
mods.tick(t);
|
||||
mods.server_tick(t);
|
||||
for _ in 0..FRAMES_PER_TICK {
|
||||
mods.frame(1.0 / 60.0);
|
||||
}
|
||||
}
|
||||
mods.chat("<Steve> hello");
|
||||
mods.player_chat(&steve, "hello");
|
||||
for name in &client {
|
||||
mods.command(name);
|
||||
}
|
||||
for name in &server {
|
||||
mods.server_command(&steve, name);
|
||||
}
|
||||
mods.set_snapshot(Snapshot::default());
|
||||
mods.player_left(&steve);
|
||||
for out in mods.drain() {
|
||||
report.output.push(match out {
|
||||
Output::Chat { text, .. } => format!("chat: {text}"),
|
||||
Output::Tell { text, .. } => format!("tell: {text}"),
|
||||
Output::Broadcast { text, .. } => format!("broadcast: {text}"),
|
||||
Output::Feedback { text, error } | Output::Reply { text, error, .. } => {
|
||||
format!("{}: {text}", if error { "error" } else { "feedback" })
|
||||
}
|
||||
Output::Command { line } => format!("command: /{line}"),
|
||||
Output::QuietCommands { lines, .. } => {
|
||||
format!("{} quiet command(s)", lines.len())
|
||||
}
|
||||
Output::Stopped { reason, .. } => return Err(format!("it stopped: {reason}")),
|
||||
Output::SetBlocks {
|
||||
dimension, blocks, ..
|
||||
} => {
|
||||
format!("set {} block(s) in {dimension}", blocks.len())
|
||||
}
|
||||
Output::OpenScreen { .. } => "opened a screen".to_string(),
|
||||
Output::CloseScreen { .. } => "closed its screen".to_string(),
|
||||
Output::PlaceRecipe { book_id, all } => {
|
||||
format!("placed recipe {book_id}{}", if all { " (all)" } else { "" })
|
||||
}
|
||||
Output::Give { item, count, .. } => format!("gave {count} {item}"),
|
||||
Output::Shake { strength, seconds } => {
|
||||
format!("shook the camera: {strength:.2} for {seconds:.1} s")
|
||||
}
|
||||
});
|
||||
}
|
||||
let shapes = mods.hud_shapes().count();
|
||||
if shapes > 0 {
|
||||
report.output.push(format!("drew {shapes} HUD shape(s)"));
|
||||
}
|
||||
match failed(&mods) {
|
||||
Some(reason) => Err(format!("it stopped: {reason}")),
|
||||
/// A mod is content only: WebAssembly code mods don't run any more, code mods are native
|
||||
/// (`.hnmod`, docs/native-mods.md). `inspect` already read its content as the game does.
|
||||
fn content_only(path: &Path) -> Result<(), String> {
|
||||
match Package::read(path)?.wasm {
|
||||
Some(_) => Err(
|
||||
"it has WebAssembly code, which Horizon doesn't run any more: make it a native mod (docs/native-mods.md)"
|
||||
.into(),
|
||||
),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
@@ -316,7 +167,7 @@ mod tests {
|
||||
.text()
|
||||
.contains("cozy 1.0.0 (modpack, format 1, for 26.3)")
|
||||
);
|
||||
// The fixture mod's code is a stand-in: its manifest reads, its run fails.
|
||||
// A mod with WebAssembly code is refused: code mods are native.
|
||||
let hmod = fixture("coords_hud-0.1.0.hmod");
|
||||
let key = std::fs::read_to_string(fixture("test.pub")).unwrap();
|
||||
let sig = std::fs::read_to_string(fixture("coords_hud-0.1.0.hmod.minisig")).unwrap();
|
||||
@@ -324,7 +175,7 @@ mod tests {
|
||||
let e = check(&hmod, Kind::Mod, Some(key), Some(&sig))
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(e.contains("didn't start"), "{e}");
|
||||
assert!(e.contains("native mod"), "{e}");
|
||||
let e = check(
|
||||
&hmod,
|
||||
Kind::Mod,
|
||||
|
||||
@@ -1,32 +1,23 @@
|
||||
//! `cargo horizon-mod`: make Horizon mods in Rust (`docs/mods.md`).
|
||||
//! `cargo horizon-mod`: make Horizon content mods (`docs/modpacks.md`): blocks,
|
||||
//! items, data and assets, no code. Code mods are native (docs/native-mods.md).
|
||||
//!
|
||||
//! - `new <path> [--template code|content] [--name "Name"] [--sdk-path <dir>]`:
|
||||
//! a mod from a template: a Rust crate, or a content mod (no code).
|
||||
//! - `build [--debug]`: the `.hmod` in `target/horizon-mod/`, with the mod's
|
||||
//! `assets/` and `data/`; a folder without `Cargo.toml` is content only.
|
||||
//! - `new <path> [--name "Name"]`: a content mod from the template.
|
||||
//! - `build`: the `.hmod` in `target/horizon-mod/`, with the mod's `assets/`
|
||||
//! and `data/`.
|
||||
//! - `run [--game <horizon-client>] [-- <game args>]`: build, then play with
|
||||
//! the mod; run `build` again and the game reloads it.
|
||||
//! - `test [--debug] [--hmod <file>] [<name>]`: build, then run the mod's
|
||||
//! `tests/*.toml` scripts in the sandbox, with no game.
|
||||
//! the mod.
|
||||
//! - `check <file> [--json]`: what the registry checks of a submission
|
||||
//! (`.hmod`, `.hpack`, resource pack `.zip`), with a trial run for a mod.
|
||||
//! (`.hmod`, `.hnmod`, `.hpack`, resource pack `.zip`, `.hshader`).
|
||||
//! - `registry build|review|check|list`: the registry repository's CI.
|
||||
use horizon_mods::{
|
||||
Capability, Content, Manifest, Package,
|
||||
manifest::{MANIFEST, component_imports, componentize},
|
||||
testing::{Script, TestGame},
|
||||
};
|
||||
use horizon_mods::{Capability, Content, Manifest, Package, manifest::MANIFEST};
|
||||
mod check;
|
||||
mod registry;
|
||||
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, ExitCode, Stdio},
|
||||
process::{Command, ExitCode},
|
||||
};
|
||||
|
||||
const TARGET: &str = "wasm32-unknown-unknown";
|
||||
const SDK_GIT: &str = "{ git = \"https://github.com/horizon-rust/horizon\" }";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
// Cargo runs `cargo-horizon-mod horizon-mod <args>`.
|
||||
let mut args: Vec<String> = std::env::args().skip(1).collect();
|
||||
@@ -35,14 +26,13 @@ fn main() -> ExitCode {
|
||||
}
|
||||
let result = match args.first().map(String::as_str) {
|
||||
Some("new") => new(&args[1..]),
|
||||
Some("build") => build(!args.iter().any(|a| a == "--debug")).map(|p| {
|
||||
Some("build") => build().map(|p| {
|
||||
println!("Built {}", p.display());
|
||||
}),
|
||||
Some("run") => run(&args[1..]),
|
||||
Some("test") => test(&args[1..]),
|
||||
Some("check") => check(&args[1..]),
|
||||
Some("registry") => registry::main(&args[1..]),
|
||||
_ => Err("usage: cargo horizon-mod new <path> [--template code|content] | build [--debug] | run [--game <horizon-client>] [-- <game args>] | test [--debug] [--hmod <file>] [<name>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
|
||||
_ => Err("usage: cargo horizon-mod new <path> [--name \"Name\"] | build | run [--game <horizon-client>] [-- <game args>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
@@ -83,31 +73,9 @@ fn new(args: &[String]) -> Result<(), String> {
|
||||
})
|
||||
.collect();
|
||||
let name = flag(args, "--name").map_or_else(|| base.replace(['-', '_'], " "), str::to_string);
|
||||
let sdk = match flag(args, "--sdk-path") {
|
||||
Some(p) => {
|
||||
let p = std::fs::canonicalize(p).map_err(|e| format!("--sdk-path {p}: {e}"))?;
|
||||
format!("{{ path = {:?} }}", p.display().to_string())
|
||||
}
|
||||
None => SDK_GIT.to_string(),
|
||||
};
|
||||
let fill = |t: &str| {
|
||||
t.replace("{crate}", &id.replace('_', "-"))
|
||||
.replace("{id}", &id)
|
||||
.replace("{name}", &name)
|
||||
.replace("{sdk}", &sdk)
|
||||
};
|
||||
let code = [
|
||||
("Cargo.toml", include_str!("../template/Cargo.toml.in")),
|
||||
(MANIFEST, include_str!("../template/horizon-mod.toml")),
|
||||
("src/lib.rs", include_str!("../template/src/lib.rs")),
|
||||
(".gitignore", include_str!("../template/gitignore")),
|
||||
(
|
||||
"tests/hello.toml",
|
||||
include_str!("../template/tests/hello.toml"),
|
||||
),
|
||||
];
|
||||
let fill = |t: &str| t.replace("{id}", &id).replace("{name}", &name);
|
||||
// A block copying a vanilla one, with its model, name, loot, recipe and tag.
|
||||
let content = [
|
||||
let files = [
|
||||
(
|
||||
MANIFEST,
|
||||
include_str!("../template/content/horizon-mod.toml"),
|
||||
@@ -146,12 +114,12 @@ fn new(args: &[String]) -> Result<(), String> {
|
||||
),
|
||||
(".gitignore", include_str!("../template/gitignore")),
|
||||
];
|
||||
let files: &[(&str, &str)] = match flag(args, "--template").unwrap_or("code") {
|
||||
"code" => &code,
|
||||
"content" => &content,
|
||||
other => return Err(format!("no template {other}: code or content")),
|
||||
};
|
||||
for &(file, text) in files {
|
||||
if flag(args, "--template").is_some_and(|t| t != "content") {
|
||||
return Err(
|
||||
"only content mods are made here: code mods are native (docs/native-mods.md)".into(),
|
||||
);
|
||||
}
|
||||
for (file, text) in files {
|
||||
let file = fill(file);
|
||||
let file = file.as_str();
|
||||
let to = dir.join(file);
|
||||
@@ -169,29 +137,24 @@ fn new(args: &[String]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Builds the mod in the current folder and packs `target/horizon-mod/<id>.hmod`:
|
||||
/// the manifest, the crate's component (none without a `Cargo.toml`: a
|
||||
/// content mod) and the files in `assets/` and `data/`.
|
||||
fn build(release: bool) -> Result<PathBuf, String> {
|
||||
build_in(Path::new("."), release)
|
||||
/// Packs the content mod in the current folder into `target/horizon-mod/<id>.hmod`: the
|
||||
/// manifest and the files in `assets/` and `data/`.
|
||||
fn build() -> Result<PathBuf, String> {
|
||||
build_in(Path::new("."))
|
||||
}
|
||||
|
||||
fn build_in(root: &Path, release: bool) -> Result<PathBuf, String> {
|
||||
fn build_in(root: &Path) -> Result<PathBuf, String> {
|
||||
let manifest_text = std::fs::read_to_string(root.join(MANIFEST))
|
||||
.map_err(|e| format!("{MANIFEST}: {e} (run this in a mod's folder)"))?;
|
||||
let manifest = Manifest::parse(&manifest_text)?;
|
||||
let (wasm, target_dir) = if root.join("Cargo.toml").exists() {
|
||||
let (wasm, target) = compile(root, release)?;
|
||||
// The game refuses a component importing capabilities the manifest lacks.
|
||||
manifest.check_imports(component_imports(&wasm)?.iter().map(String::as_str))?;
|
||||
(Some(wasm), target)
|
||||
} else if manifest.capabilities.contains(&Capability::Registries) {
|
||||
(None, root.join("target"))
|
||||
} else {
|
||||
if root.join("Cargo.toml").exists() {
|
||||
return Err("a Cargo.toml: code mods are native now (docs/native-mods.md)".into());
|
||||
}
|
||||
if !manifest.capabilities.contains(&Capability::Registries) {
|
||||
return Err(format!(
|
||||
"no Cargo.toml: only a content mod (capability registries in {MANIFEST}) has no code"
|
||||
"a content mod has capability registries in {MANIFEST}"
|
||||
));
|
||||
};
|
||||
}
|
||||
// The same checks as the game's, and the files in the order it lists them.
|
||||
let content = Content::read(root, &manifest)?;
|
||||
let mut reader = content.reader()?;
|
||||
@@ -200,53 +163,17 @@ fn build_in(root: &Path, release: bool) -> Result<PathBuf, String> {
|
||||
files.push((name.as_str(), reader.read(name)?));
|
||||
}
|
||||
let files: Vec<(&str, &[u8])> = files.iter().map(|(n, b)| (*n, b.as_slice())).collect();
|
||||
let dir = target_dir.join("horizon-mod");
|
||||
let dir = root.join("target/horizon-mod");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
let path = dir.join(format!("{}.hmod", manifest.id));
|
||||
// Write then rename, so a running game never reads half a file.
|
||||
let partial = path.with_extension("hmod.partial");
|
||||
let bytes = Package::write_files(&manifest_text, wasm.as_deref(), &files)?;
|
||||
let bytes = Package::write_files(&manifest_text, None, &files)?;
|
||||
std::fs::write(&partial, bytes).map_err(|e| e.to_string())?;
|
||||
std::fs::rename(&partial, &path).map_err(|e| e.to_string())?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Builds the crate for WebAssembly: its component, and Cargo's target folder.
|
||||
fn compile(root: &Path, release: bool) -> Result<(Vec<u8>, PathBuf), String> {
|
||||
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
|
||||
let mut cmd = Command::new(cargo);
|
||||
cmd.current_dir(root).args([
|
||||
"build",
|
||||
"--lib",
|
||||
"--target",
|
||||
TARGET,
|
||||
"--message-format=json-render-diagnostics",
|
||||
]);
|
||||
if release {
|
||||
cmd.arg("--release");
|
||||
}
|
||||
let out = cmd
|
||||
.stderr(Stdio::inherit())
|
||||
.output()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !out.status.success() {
|
||||
return Err(format!(
|
||||
"cargo build failed (is the target installed? rustup target add {TARGET})"
|
||||
));
|
||||
}
|
||||
let core = String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
|
||||
.filter(|m| m["reason"] == "compiler-artifact")
|
||||
.flat_map(|m| m["filenames"].as_array().cloned().unwrap_or_default())
|
||||
.filter_map(|f| f.as_str().map(PathBuf::from))
|
||||
.rfind(|f| f.extension().is_some_and(|e| e == "wasm"))
|
||||
.ok_or("the build made no .wasm: is the crate a cdylib?")?;
|
||||
let wasm = componentize(&std::fs::read(&core).map_err(|e| e.to_string())?)?;
|
||||
let target = core.ancestors().nth(3).ok_or("unexpected target folder")?;
|
||||
Ok((wasm, target.to_path_buf()))
|
||||
}
|
||||
|
||||
/// `check <file>`: the registry's checks of a submission, printed; fails
|
||||
/// when one does.
|
||||
fn check(args: &[String]) -> Result<(), String> {
|
||||
@@ -278,74 +205,14 @@ fn check(args: &[String]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `test`: every `tests/*.toml` script (those whose name holds `<name>`)
|
||||
/// against the built mod, or the `.hmod` given with `--hmod`.
|
||||
fn test(args: &[String]) -> Result<(), String> {
|
||||
let hmod = match flag(args, "--hmod") {
|
||||
Some(p) => PathBuf::from(p),
|
||||
None => build(!args.iter().any(|a| a == "--debug"))?,
|
||||
};
|
||||
let filter = args
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(i, a)| !a.starts_with("--") && (*i == 0 || args[i - 1] != "--hmod"))
|
||||
.map(|(_, a)| a.as_str());
|
||||
let mut scripts: Vec<PathBuf> = std::fs::read_dir("tests")
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"tests/: {e} (a mod's tests are tests/<name>.toml: docs/mods.md, \"Testing a mod\")"
|
||||
)
|
||||
})?
|
||||
.filter_map(|e| Some(e.ok()?.path()))
|
||||
.filter(|p| p.extension().is_some_and(|e| e == "toml"))
|
||||
.filter(|p| filter.is_none_or(|f| p.to_string_lossy().contains(f)))
|
||||
.collect();
|
||||
scripts.sort();
|
||||
if scripts.is_empty() {
|
||||
return Err("no tests/*.toml to run".into());
|
||||
}
|
||||
println!(
|
||||
"running {} scripts against {}",
|
||||
scripts.len(),
|
||||
hmod.display()
|
||||
);
|
||||
let mut failed = 0;
|
||||
for path in &scripts {
|
||||
let result = std::fs::read_to_string(path)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|text| Script::parse(&text))
|
||||
.and_then(|script| {
|
||||
let mut game = TestGame::load(&hmod)?;
|
||||
script.run(&mut game)?;
|
||||
Ok(script.steps.len())
|
||||
});
|
||||
match result {
|
||||
Ok(steps) => println!("test {} ... ok ({steps} steps)", path.display()),
|
||||
Err(e) => {
|
||||
failed += 1;
|
||||
println!("test {} ... FAILED\n {e}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
let passed = scripts.len() - failed;
|
||||
if failed > 0 {
|
||||
return Err(format!("{failed} failed, {passed} passed"));
|
||||
}
|
||||
println!("test result: ok. {passed} passed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> Result<(), String> {
|
||||
let path = build(true)?;
|
||||
let path = build()?;
|
||||
let game = flag(args, "--game")
|
||||
.map(str::to_string)
|
||||
.or_else(|| std::env::var("HORIZON_CLIENT").ok())
|
||||
.unwrap_or_else(|| "horizon-client".into());
|
||||
let extra = args.iter().skip_while(|a| *a != "--").skip(1);
|
||||
println!(
|
||||
"Starting {game} with {} (rebuild with `cargo horizon-mod build` to reload it)",
|
||||
path.display()
|
||||
);
|
||||
println!("Starting {game} with {}", path.display());
|
||||
let status = Command::new(&game)
|
||||
.arg("--dev-mod")
|
||||
.arg(&path)
|
||||
@@ -364,28 +231,6 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_template_is_a_valid_mod() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mod-new-{}", std::process::id()));
|
||||
let path = dir.join("My-Mod");
|
||||
new(&[path.display().to_string(), "--name".into(), "My Mod".into()]).unwrap();
|
||||
let manifest =
|
||||
Manifest::parse(&std::fs::read_to_string(path.join(MANIFEST)).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
(manifest.id.as_str(), manifest.name.as_str()),
|
||||
("my_mod", "My Mod")
|
||||
);
|
||||
let cargo = std::fs::read_to_string(path.join("Cargo.toml")).unwrap();
|
||||
assert!(cargo.contains("name = \"my-mod\"") && cargo.contains(SDK_GIT));
|
||||
let script = std::fs::read_to_string(path.join("tests/hello.toml")).unwrap();
|
||||
assert_eq!(Script::parse(&script).unwrap().steps.len(), 3);
|
||||
assert!(
|
||||
new(&[path.display().to_string()]).is_err(),
|
||||
"never overwrites"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_content_template_builds_and_checks_with_no_code() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mod-content-{}", std::process::id()));
|
||||
@@ -397,7 +242,7 @@ mod tests {
|
||||
];
|
||||
new(&new_args).unwrap();
|
||||
assert!(!path.join("Cargo.toml").exists());
|
||||
let hmod = build_in(&path, true).unwrap();
|
||||
let hmod = build_in(&path).unwrap();
|
||||
assert_eq!(hmod, path.join("target/horizon-mod/gems.hmod"));
|
||||
let package = Package::read(&hmod).unwrap();
|
||||
assert!(package.wasm.is_none());
|
||||
@@ -409,17 +254,14 @@ mod tests {
|
||||
);
|
||||
assert_eq!(content.files.len(), 8, "{:?}", content.files);
|
||||
let report = check::check(&hmod, check::kind_of(&hmod).unwrap(), None, None).unwrap();
|
||||
assert!(!report.code);
|
||||
assert!(
|
||||
report.text().contains("no code: content only"),
|
||||
"{}",
|
||||
report.text()
|
||||
);
|
||||
// Without registries, a mod needs code.
|
||||
let manifest = std::fs::read_to_string(path.join(MANIFEST)).unwrap();
|
||||
let manifest = manifest.replace("[\"registries\"]", "[]");
|
||||
std::fs::write(path.join(MANIFEST), manifest).unwrap();
|
||||
assert!(build_in(&path, true).unwrap_err().contains("no Cargo.toml"));
|
||||
// Code is native now.
|
||||
std::fs::write(path.join("Cargo.toml"), "").unwrap();
|
||||
assert!(build_in(&path).unwrap_err().contains("native"));
|
||||
assert!(
|
||||
new(&[
|
||||
dir.join("x").display().to_string(),
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
[package]
|
||||
name = "{crate}"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
horizon-mod-sdk = {sdk}
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
@@ -1,12 +0,0 @@
|
||||
id = "{id}"
|
||||
name = "{name}"
|
||||
version = "0.1.0"
|
||||
description = ""
|
||||
authors = []
|
||||
# The Horizon mod API the SDK targets.
|
||||
api = "0.1"
|
||||
# client: runs in the player's game.
|
||||
side = "client"
|
||||
# What the mod may do, shown to players before they turn it on:
|
||||
# game-read (position, health, time), chat (show messages), hud (draw text).
|
||||
capabilities = ["chat"]
|
||||
@@ -1,19 +0,0 @@
|
||||
use horizon_mod_sdk::{Mod, Registration, chat};
|
||||
|
||||
struct MyMod {
|
||||
greeted: u32,
|
||||
}
|
||||
|
||||
impl Mod for MyMod {
|
||||
fn init() -> Result<(Self, Registration), String> {
|
||||
Ok((MyMod { greeted: 0 }, Registration::new().command("hello")))
|
||||
}
|
||||
|
||||
fn command(&mut self, _name: &str, args: &str) -> Result<Option<String>, String> {
|
||||
self.greeted += 1;
|
||||
chat::show(&format!("Hello {args}! ({} so far)", self.greeted));
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
horizon_mod_sdk::export_mod!(MyMod);
|
||||
@@ -1,14 +0,0 @@
|
||||
# `cargo horizon-mod test` runs every tests/*.toml against the built mod, with no game:
|
||||
# steps happen in order, and `expect` checks what the mod showed during the step.
|
||||
# Every key: docs/mods.md, "Testing a mod".
|
||||
|
||||
[[step]]
|
||||
expect.commands = ["hello"]
|
||||
|
||||
[[step]]
|
||||
command = "hello world"
|
||||
expect.chat = ["Hello world! (1 so far)"]
|
||||
|
||||
[[step]]
|
||||
command = "hello again"
|
||||
expect.chat = ["Hello again! (2 so far)"]
|
||||
@@ -494,7 +494,11 @@ pub fn canonical(tag: &Tag) -> Option<Tag> {
|
||||
_ => string("minecraft:air"),
|
||||
},
|
||||
);
|
||||
f.put("facing", Tag::Int(int(tag.get("facing")).unwrap_or(0)));
|
||||
// Direction.LEGACY_ID_CODEC: the 3D data value as a byte.
|
||||
f.put(
|
||||
"facing",
|
||||
Tag::Byte(int(tag.get("facing")).unwrap_or(0) as i8),
|
||||
);
|
||||
f.float("progress", 0.0);
|
||||
f.flag("extending", false);
|
||||
f.flag("source", false);
|
||||
@@ -873,6 +877,23 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `PistonMovingBlockEntity.saveAdditional`: `facing` through
|
||||
/// `Direction.LEGACY_ID_CODEC` (a byte), `progress` a float.
|
||||
#[test]
|
||||
fn moving_piston_fields_match_vanilla() {
|
||||
let out = canon(
|
||||
"{id:\"minecraft:piston\",x:1,y:2,z:3,blockState:{Name:\"minecraft:stone\"},\
|
||||
facing:5,progress:0.5f,extending:1b,source:0b}",
|
||||
);
|
||||
assert_eq!(out.get("facing"), Some(&Tag::Byte(5)));
|
||||
assert_eq!(out.get("progress"), Some(&f32_tag(0.5)));
|
||||
assert_eq!(out.get("extending"), Some(&Tag::Byte(1)));
|
||||
assert_eq!(out.get("source"), Some(&Tag::Byte(0)));
|
||||
let fresh = canon("{id:\"minecraft:piston\",x:1,y:2,z:3}");
|
||||
assert_eq!(fresh.get("facing"), Some(&Tag::Byte(0)));
|
||||
assert_eq!(fresh.get("blockState"), Some(&string("minecraft:air")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loot_table_replaces_items_and_zero_seed_is_omitted() {
|
||||
let out = canon(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Text components from commands: `ComponentArgument` reads an SNBT (or JSON)
|
||||
//! value, `ComponentSerialization` turns it into styled text.
|
||||
use crate::text::{Style, Text, named_color};
|
||||
use crate::text::{InlineObject, Style, Text, named_color};
|
||||
use horizon_world::nbt::Tag;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
@@ -195,6 +195,7 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
|
||||
.and_then(|h| u32::from_str_radix(h, 16).ok())
|
||||
});
|
||||
}
|
||||
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");
|
||||
@@ -256,6 +257,28 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
|
||||
.map(str::to_owned);
|
||||
style
|
||||
}
|
||||
|
||||
/// `Style.shadow_color`: a packed ARGB int or normalized RGBA array.
|
||||
pub(crate) fn shadow_color(value: &Value) -> Option<u32> {
|
||||
if let Some(packed) = value.as_i64() {
|
||||
return i32::try_from(packed).ok().map(|n| n as u32);
|
||||
}
|
||||
let rgba = value.as_array()?;
|
||||
if rgba.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
let channels = rgba
|
||||
.iter()
|
||||
.map(|channel| {
|
||||
// ExtraCodecs.ARGB_COLOR_CODEC reads floats; ARGB.as8BitChannel
|
||||
// floors each normalized channel multiplied by 255.
|
||||
let value = channel.as_f64()? as f32;
|
||||
(value.is_finite() && (0.0..=1.0).contains(&value))
|
||||
.then_some((value * 255.0_f32).floor() as u32)
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
Some(channels[3] << 24 | channels[0] << 16 | channels[1] << 8 | channels[2])
|
||||
}
|
||||
/// `ComponentSerialization.CODEC` decoding into text. `selector` components
|
||||
/// are resolved by `resolve` (ComponentUtils.updateForEntity).
|
||||
pub fn to_text(v: &Value, resolve: &mut dyn FnMut(&str) -> Text) -> Text {
|
||||
@@ -296,6 +319,10 @@ pub fn to_text_with(
|
||||
let style = object_style(o);
|
||||
let content = if let Some(t) = contents(o) {
|
||||
t
|
||||
} else if let Some(object) = inline_object(o, resolve, contents) {
|
||||
let mut text = Text::literal("\u{fffc}");
|
||||
text.spans[0].style.inline_object = Some(object);
|
||||
text
|
||||
} else if let Some(t) = o.get("text") {
|
||||
match t {
|
||||
Value::String(s) => Text::literal(s.clone()),
|
||||
@@ -332,12 +359,74 @@ pub fn to_text_with(
|
||||
{
|
||||
out.push(to_text_with(extra, resolve, contents).with_style(&style));
|
||||
}
|
||||
out
|
||||
if o.contains_key("object") || o.contains_key("player") || o.contains_key("sprite") {
|
||||
out.with_component(Value::Object(o.clone()))
|
||||
} else {
|
||||
out
|
||||
}
|
||||
}
|
||||
Value::Null => Text::empty(),
|
||||
}
|
||||
}
|
||||
|
||||
fn inline_object(
|
||||
object: &serde_json::Map<String, Value>,
|
||||
resolve: &mut dyn FnMut(&str) -> Text,
|
||||
contents: &mut dyn FnMut(&serde_json::Map<String, Value>) -> Option<Text>,
|
||||
) -> Option<InlineObject> {
|
||||
let kind = object.get("object").and_then(Value::as_str);
|
||||
let fallback = object
|
||||
.get("fallback")
|
||||
.map(|value| to_text_with(value, resolve, contents).plain());
|
||||
match kind {
|
||||
Some("player") | None if object.contains_key("player") => {
|
||||
let profile = object.get("player")?.clone();
|
||||
// PlayerSprite.defaultFallback.
|
||||
let fallback = fallback.unwrap_or_else(|| {
|
||||
match profile
|
||||
.as_str()
|
||||
.or_else(|| profile.get("name").and_then(Value::as_str))
|
||||
{
|
||||
Some(name) => format!("[{name} head]"),
|
||||
None => "[unknown player head]".to_owned(),
|
||||
}
|
||||
});
|
||||
Some(InlineObject::Player {
|
||||
profile,
|
||||
hat: object.get("hat").and_then(Value::as_bool).unwrap_or(true),
|
||||
fallback,
|
||||
})
|
||||
}
|
||||
Some("atlas") | None if object.contains_key("sprite") => {
|
||||
let atlas = object
|
||||
.get("atlas")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("minecraft:blocks")
|
||||
.to_owned();
|
||||
let sprite = object.get("sprite")?.as_str()?.to_owned();
|
||||
// AtlasSprite.defaultFallback: short names, the atlas unless blocks.
|
||||
let fallback = fallback.unwrap_or_else(|| {
|
||||
let short = |id: &str| match id.split_once(':') {
|
||||
Some(("minecraft", path)) => path.to_owned(),
|
||||
Some(_) => id.to_owned(),
|
||||
None => id.to_owned(),
|
||||
};
|
||||
if short(&atlas) == "blocks" {
|
||||
format!("[{}]", short(&sprite))
|
||||
} else {
|
||||
format!("[{}@{}]", short(&sprite), short(&atlas))
|
||||
}
|
||||
});
|
||||
Some(InlineObject::Atlas {
|
||||
atlas,
|
||||
sprite,
|
||||
fallback,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `CustomName` as text: a string keeps its legacy formatting codes, anything
|
||||
/// else is a component (`ComponentSerialization`).
|
||||
pub fn name_text(tag: &Tag) -> Text {
|
||||
@@ -351,6 +440,61 @@ pub fn name_text(tag: &Tag) -> Text {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shadow_color_accepts_packed_and_rgba_styles() {
|
||||
let packed = serde_json::json!({"text": "A", "shadow_color": -16711936});
|
||||
let text = to_text(&packed, &mut |_| Text::empty());
|
||||
assert_eq!(text.spans[0].style.shadow_color, Some(0xff00ff00));
|
||||
assert_eq!(text.component()["shadow_color"], -16711936);
|
||||
let rgba = serde_json::json!({"text": "A", "shadow_color": [1, 0, 0.5, 1]});
|
||||
let text = to_text(&rgba, &mut |_| Text::empty());
|
||||
assert_eq!(text.spans[0].style.shadow_color, Some(0xffff007f));
|
||||
let translucent = serde_json::json!({"text": "A", "shadow_color": [1, 0, 0.5, 0.5]});
|
||||
let text = to_text(&translucent, &mut |_| Text::empty());
|
||||
assert_eq!(text.spans[0].style.shadow_color, Some(0x7fff007f));
|
||||
let no_shadow = serde_json::json!({"text": "A", "shadow_color": 0});
|
||||
let text = to_text(&no_shadow, &mut |_| Text::empty());
|
||||
assert_eq!(text.spans[0].style.shadow_color, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_object_survives_text_parsing_and_keeps_its_siblings() {
|
||||
let component = serde_json::json!({
|
||||
"text": "<",
|
||||
"extra": [
|
||||
{"object": "player", "player": {"name": "elyapluggin"}, "hat": false,
|
||||
"fallback": "[head]"},
|
||||
{"text": " >", "color": "gold"}
|
||||
]
|
||||
});
|
||||
let text = to_text(&component, &mut |_| Text::empty());
|
||||
assert_eq!(text.plain(), "<[head] >");
|
||||
assert!(matches!(
|
||||
&text.spans[1].style.inline_object,
|
||||
Some(InlineObject::Player { hat: false, .. })
|
||||
));
|
||||
assert!(text.spans[2].style.inline_object.is_none());
|
||||
assert_eq!(text.component(), component);
|
||||
let sprite = serde_json::json!({"object": "atlas", "sprite": "minecraft:item/porkchop"});
|
||||
let text = to_text(&sprite, &mut |_| Text::empty());
|
||||
assert!(matches!(
|
||||
&text.spans[0].style.inline_object,
|
||||
Some(InlineObject::Atlas { sprite, .. }) if sprite == "minecraft:item/porkchop"
|
||||
));
|
||||
// ObjectContents.visit without a fallback: the sprite's defaultFallback.
|
||||
assert_eq!(text.plain(), "[item/porkchop]");
|
||||
let head = serde_json::json!({"object": "player", "player": {"name": "Steve"}});
|
||||
assert_eq!(
|
||||
to_text(&head, &mut |_| Text::empty()).plain(),
|
||||
"[Steve head]"
|
||||
);
|
||||
let gui = serde_json::json!({"atlas": "gui", "sprite": "hud/heart/full"});
|
||||
assert_eq!(
|
||||
to_text(&gui, &mut |_| Text::empty()).plain(),
|
||||
"[hud/heart/full@gui]"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snbt_and_json_components() {
|
||||
let input = "{text:'Hi ',color:red,extra:[{text:\"there\",bold:1b}]} rest";
|
||||
|
||||
@@ -60,6 +60,12 @@ impl ResourceSource for Bundled {
|
||||
"assets/horizon/lang/ru_ru.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/ru_ru.json")
|
||||
}
|
||||
"assets/horizon/lang/pl_pl.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/pl_pl.json")
|
||||
}
|
||||
"assets/horizon/lang/uk_ua.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/uk_ua.json")
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(body.as_bytes().to_vec())
|
||||
@@ -266,6 +272,21 @@ mod tests {
|
||||
assert_eq!(en.get("horizon.options.controller"), Some("Controller"));
|
||||
let ru = Language::load(&[&empty], "ru_ru");
|
||||
assert_eq!(ru.get("horizon.options.controller"), Some("Геймпад"));
|
||||
// Every bundled string carries both languages: a key added to one file
|
||||
// and forgotten in the other shows as a key name on screen.
|
||||
for key in en.map().keys() {
|
||||
assert!(ru.get(key).is_some(), "{key} has no ru_ru value");
|
||||
}
|
||||
assert_eq!(en.get("horizon.pages.mods"), Some("Mods"));
|
||||
assert_eq!(ru.get("horizon.pages.mods"), Some("Моды"));
|
||||
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"));
|
||||
let uk = Language::load(&[&empty], "uk_ua");
|
||||
assert_eq!(
|
||||
uk.get("horizon.options.on_screen_keyboard"),
|
||||
Some("Екранна клавіатура")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -91,6 +91,7 @@ pub fn effect(id: &str) -> Option<&'static EffectDef> {
|
||||
/// Namespaced effect ids for mob effect storage.
|
||||
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);
|
||||
IDS.get_or_init(|| {
|
||||
EFFECTS
|
||||
.iter()
|
||||
@@ -102,7 +103,7 @@ pub fn namespaced(id: &'static str) -> &'static str {
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.get(id)
|
||||
.get(key)
|
||||
.copied()
|
||||
.unwrap_or(id)
|
||||
}
|
||||
|
||||
@@ -54,6 +54,12 @@ pub struct Registries {
|
||||
pub function_tags: Vec<String>,
|
||||
/// The block the source is looking at (relevant coordinates).
|
||||
pub target_block: Option<[i32; 3]>,
|
||||
/// `ClientSuggestionProvider.getSelectedEntities`: the UUID of the
|
||||
/// entity under the crosshair (`Entity.getStringUUID`), if any.
|
||||
pub selected_entities: Vec<String>,
|
||||
/// `ClientSuggestionProvider.getAbsoluteCoordinates`: the exact point of
|
||||
/// that block hit, each coordinate as `%.2f` (`Locale.ROOT`).
|
||||
pub target_point: Option<[String; 3]>,
|
||||
/// Every registry's element and tag ids (`minecraft:item` -> ...), as
|
||||
/// the server's registry access and reloadable registries hold them.
|
||||
pub by_registry: BTreeMap<String, RegistryIds>,
|
||||
|
||||
@@ -112,6 +112,7 @@ pub fn style_from_json(v: &Value) -> Style {
|
||||
.and_then(|h| u32::from_str_radix(h, 16).ok())
|
||||
});
|
||||
}
|
||||
s.shadow_color = o.get("shadow_color").and_then(component::shadow_color);
|
||||
let flag = |k: &str| o.get(k).and_then(Value::as_bool);
|
||||
s.bold = flag("bold");
|
||||
s.italic = flag("italic");
|
||||
@@ -662,10 +663,22 @@ pub fn name_shown(own: Option<&Team>, viewer: Option<&Team>, v: NameView) -> boo
|
||||
}
|
||||
!v.hud_hidden && !v.is_camera && visible && !v.is_vehicle
|
||||
}
|
||||
/// `ServerPlayer.die`: whether a player's death message reaches the player
|
||||
/// themselves (the team broadcasts leave out the dying player).
|
||||
pub fn own_death_message_shown(team: Option<&Team>) -> bool {
|
||||
team.is_none_or(|t| t.death_message_visibility == Visibility::Always)
|
||||
/// `ServerPlayer.die`: whether `other` hears the death message of `dying`
|
||||
/// (`broadcastSystemMessage`, `broadcastSystemToTeam` or
|
||||
/// `broadcastSystemToAllExceptTeam`, by the team's death message visibility).
|
||||
pub fn death_message_reaches(board: &Scoreboard, dying: &str, other: &str) -> bool {
|
||||
let Some(team) = board.players_team(dying) else {
|
||||
return true;
|
||||
};
|
||||
let same_team = board
|
||||
.players_team(other)
|
||||
.is_some_and(|t| t.name == team.name);
|
||||
match team.death_message_visibility {
|
||||
Visibility::Always => true,
|
||||
Visibility::Never => false,
|
||||
Visibility::HideForOtherTeams => same_team && other != dying,
|
||||
Visibility::HideForOwnTeam => !same_team,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- objectives and scores ---------------------------------------------
|
||||
@@ -1551,3 +1564,49 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `DamageSource.getLocalizedDeathMessage`: the cause's item with a custom
|
||||
/// name gets the `<id>.item` message, with the item's name as a third
|
||||
/// argument.
|
||||
pub fn death_message_args(
|
||||
key: &str,
|
||||
victim: &Text,
|
||||
cause: &Text,
|
||||
item: Option<&Text>,
|
||||
) -> (String, Vec<Text>) {
|
||||
match item {
|
||||
Some(item) => (
|
||||
format!("{key}.item"),
|
||||
vec![victim.clone(), cause.clone(), item.clone()],
|
||||
),
|
||||
None => (key.to_owned(), vec![victim.clone(), cause.clone()]),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod death_message_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_death_message_names_the_causes_item_when_it_has_a_custom_name() {
|
||||
let victim = Text::literal("Steve");
|
||||
let cause = Text::literal("Zombie");
|
||||
// DamageSource.getLocalizedDeathMessage: the item variant, with the
|
||||
// item's name as the third argument.
|
||||
let (key, args) = death_message_args(
|
||||
"death.attack.arrow",
|
||||
&victim,
|
||||
&cause,
|
||||
Some(&Text::literal("My Sword")),
|
||||
);
|
||||
assert_eq!(key, "death.attack.arrow.item");
|
||||
assert_eq!(args.len(), 3);
|
||||
assert_eq!(args[0].plain(), "Steve");
|
||||
assert_eq!(args[1].plain(), "Zombie");
|
||||
assert_eq!(args[2].plain(), "My Sword");
|
||||
// Without a custom name the message keeps its two arguments.
|
||||
let (key, args) = death_message_args("death.attack.arrow", &victim, &cause, None);
|
||||
assert_eq!(key, "death.attack.arrow");
|
||||
assert_eq!(args.len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -58,10 +58,34 @@ pub enum ClickEvent {
|
||||
OpenFile(String),
|
||||
}
|
||||
|
||||
/// A non-character `minecraft:object` embedded in a text component.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum InlineObject {
|
||||
Player {
|
||||
profile: Value,
|
||||
hat: bool,
|
||||
fallback: String,
|
||||
},
|
||||
Atlas {
|
||||
atlas: String,
|
||||
sprite: String,
|
||||
fallback: String,
|
||||
},
|
||||
}
|
||||
impl InlineObject {
|
||||
pub fn fallback(&self) -> &str {
|
||||
match self {
|
||||
Self::Player { fallback, .. } | Self::Atlas { fallback, .. } => fallback,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Text style; `None` fields inherit from the enclosing style.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Style {
|
||||
pub color: Option<u32>,
|
||||
/// Text component `shadow_color` as packed ARGB. Zero suppresses shadows.
|
||||
pub shadow_color: Option<u32>,
|
||||
pub bold: Option<bool>,
|
||||
pub italic: Option<bool>,
|
||||
pub underlined: Option<bool>,
|
||||
@@ -77,6 +101,8 @@ pub struct Style {
|
||||
/// The font (`FontDescription.Resource`), a full id like
|
||||
/// `minecraft:alt`; the default font when unset.
|
||||
pub font: Option<String>,
|
||||
/// Content metadata for a single U+FFFC object span; never inherited.
|
||||
pub inline_object: Option<InlineObject>,
|
||||
}
|
||||
/// A style's `font` field: an identifier (`alt` is `minecraft:alt`).
|
||||
pub fn font_id(v: &Value) -> Option<String> {
|
||||
@@ -92,6 +118,7 @@ impl Style {
|
||||
/// Fill unset fields from `base` (Style.applyTo).
|
||||
pub fn inherit(&mut self, base: &Style) {
|
||||
self.color = self.color.or(base.color);
|
||||
self.shadow_color = self.shadow_color.or(base.shadow_color);
|
||||
self.bold = self.bold.or(base.bold);
|
||||
self.italic = self.italic.or(base.italic);
|
||||
self.underlined = self.underlined.or(base.underlined);
|
||||
@@ -337,7 +364,13 @@ impl Text {
|
||||
}
|
||||
/// Component.getString: the plain characters.
|
||||
pub fn plain(&self) -> String {
|
||||
self.spans.iter().map(|s| s.text.as_str()).collect()
|
||||
self.spans
|
||||
.iter()
|
||||
.map(|s| match &s.style.inline_object {
|
||||
Some(object) if s.text == "\u{fffc}" => object.fallback(),
|
||||
_ => s.text.as_str(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.spans.iter().all(|s| s.text.is_empty())
|
||||
@@ -490,8 +523,9 @@ impl Text {
|
||||
}
|
||||
|
||||
/// The style fields of a component's JSON.
|
||||
const STYLE_KEYS: [&str; 10] = [
|
||||
const STYLE_KEYS: [&str; 11] = [
|
||||
"color",
|
||||
"shadow_color",
|
||||
"bold",
|
||||
"italic",
|
||||
"underlined",
|
||||
@@ -512,6 +546,9 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
|
||||
.map_or_else(|| format!("#{c:06X}"), |n| n.1.to_owned());
|
||||
out.push(("color".to_owned(), name.into()));
|
||||
}
|
||||
if let Some(shadow) = style.shadow_color {
|
||||
out.push(("shadow_color".to_owned(), Value::from(shadow as i32)));
|
||||
}
|
||||
for (key, flag) in [
|
||||
("bold", style.bold),
|
||||
("italic", style.italic),
|
||||
|
||||
@@ -60,6 +60,19 @@ pub fn edit_shortcut(
|
||||
})
|
||||
}
|
||||
|
||||
/// Windows reports AltGr as Ctrl plus Alt: the system presses a left Control
|
||||
/// in front of the right Alt.
|
||||
pub const ALTGR_IS_CTRL_ALT: bool = cfg!(windows);
|
||||
|
||||
/// Whether a key's text reaches the screen (`KeyboardHandler.textInput`, then
|
||||
/// `charTyped` per code point). The edit modifier's shortcuts type nothing,
|
||||
/// but `textInput` takes no modifiers: with AltGr held (Ctrl plus Alt where
|
||||
/// `altgr_is_ctrl_alt`) the layout's third-level characters are text, as `/`
|
||||
/// on ABNT2, `@`, `{` and `[` on German or the Polish letters.
|
||||
pub fn types_text(edit_modifier: bool, alt: bool, altgr_is_ctrl_alt: bool) -> bool {
|
||||
!edit_modifier || (altgr_is_ctrl_alt && alt)
|
||||
}
|
||||
|
||||
/// `StringUtil.isAllowedChatCharacter`.
|
||||
pub fn is_allowed_chat_character(c: char) -> bool {
|
||||
c != '§' && c >= ' ' && c != '\u{7f}'
|
||||
@@ -303,6 +316,22 @@ pub fn append_limited(value: &mut String, text: &str, max_chars: usize) -> bool
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn altgr_types_text_where_it_is_ctrl_alt() {
|
||||
// Plain keys and Shift type.
|
||||
assert!(types_text(false, false, true));
|
||||
assert!(types_text(false, false, false));
|
||||
// Ctrl (Cmd) shortcuts do not.
|
||||
assert!(!types_text(true, false, true));
|
||||
assert!(!types_text(true, false, false));
|
||||
// AltGr on Windows is Ctrl+Alt: ABNT2 `/`, German `@` type.
|
||||
assert!(types_text(true, true, true));
|
||||
// Where AltGr is its own modifier, Ctrl+Alt is a shortcut.
|
||||
assert!(!types_text(true, true, false));
|
||||
// Alt alone (macOS Option, Linux AltGr) types.
|
||||
assert!(types_text(false, true, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcuts_need_the_edit_modifier_alone() {
|
||||
let c = Some('c');
|
||||
|
||||
@@ -300,9 +300,8 @@ impl Default for KeyBinds {
|
||||
}
|
||||
}
|
||||
/// Each default key once, in order: two mappings that share a default key
|
||||
/// (Advancements and the debug profiler both on L) are one press of it, not
|
||||
/// two (the second would close the screen the first opened). Vanilla clicks
|
||||
/// both mappings, but the profiler one only acts with F3 held.
|
||||
/// (Pick Block and the spectator hotbar both on the middle button) are one
|
||||
/// press of it, not two.
|
||||
fn distinct<T: PartialEq>(keys: Vec<T>) -> Vec<T> {
|
||||
let mut out = Vec::with_capacity(keys.len());
|
||||
for k in keys {
|
||||
@@ -368,6 +367,25 @@ impl KeyBinds {
|
||||
.map(|m| m.0)
|
||||
.filter(move |n| self.get(n) == key)
|
||||
}
|
||||
/// The mappings bound to `key` that [`KeyBinds::translate_all`] follows:
|
||||
/// all but the debug keys. Those act only with F3 held
|
||||
/// (`KeyboardHandler.handleDebugKeys`, `debug_keys.rs` reads the physical
|
||||
/// key and the binding itself); their defaults (A, S, D, T, L, 1 to 4...)
|
||||
/// would otherwise turn the key back into its old gameplay meaning.
|
||||
fn translated_mappings<'a>(&'a self, key: &'a str) -> impl Iterator<Item = &'static str> + 'a {
|
||||
MAPPINGS
|
||||
.iter()
|
||||
.filter(|m| m.2 != "debug")
|
||||
.map(|m| m.0)
|
||||
.filter(move |n| self.get(n) == key)
|
||||
}
|
||||
/// Whether a mapping (other than a debug key) whose default is `key` was
|
||||
/// bound elsewhere.
|
||||
fn default_moved(&self, key: &str) -> bool {
|
||||
MAPPINGS
|
||||
.iter()
|
||||
.any(|(n, d, c)| *d == key && *c != "debug" && !self.is_default(n))
|
||||
}
|
||||
/// Translate a physical key into the default key of the mapping it is
|
||||
/// bound to, so game code written against vanilla defaults follows the
|
||||
/// player's bindings. `None` means the key now does nothing; mouse
|
||||
@@ -384,7 +402,7 @@ impl KeyBinds {
|
||||
return vec![Ok(key)];
|
||||
};
|
||||
let bound: Vec<_> = self
|
||||
.mappings_for(name)
|
||||
.translated_mappings(name)
|
||||
.filter_map(|mapping| {
|
||||
let default = Self::default_of(mapping);
|
||||
match key_code(default) {
|
||||
@@ -397,10 +415,7 @@ impl KeyBinds {
|
||||
return distinct(bound);
|
||||
}
|
||||
// An unbound key whose default meaning was moved elsewhere is inert.
|
||||
if MAPPINGS
|
||||
.iter()
|
||||
.any(|(n, d, _)| *d == name && !self.is_default(n))
|
||||
{
|
||||
if self.default_moved(name) {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![Ok(key)]
|
||||
@@ -413,7 +428,7 @@ impl KeyBinds {
|
||||
pub fn translate_mouse_all(&self, button: MouseButton) -> Vec<Result<KeyCode, MouseButton>> {
|
||||
let name = mouse_name(button);
|
||||
let bound: Vec<_> = self
|
||||
.mappings_for(&name)
|
||||
.translated_mappings(&name)
|
||||
.filter_map(|mapping| {
|
||||
let default = Self::default_of(mapping);
|
||||
match mouse_button(default) {
|
||||
@@ -425,10 +440,7 @@ impl KeyBinds {
|
||||
if !bound.is_empty() {
|
||||
return distinct(bound);
|
||||
}
|
||||
if MAPPINGS
|
||||
.iter()
|
||||
.any(|(n, d, _)| *d == name && !self.is_default(n))
|
||||
{
|
||||
if self.default_moved(&name) {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![Err(button)]
|
||||
@@ -508,6 +520,29 @@ mod tests {
|
||||
assert_eq!(k.conflicts("key.forward"), ["key.jump"]);
|
||||
}
|
||||
#[test]
|
||||
fn a_moved_key_keeps_no_default_action() {
|
||||
// #3158: the debug keys (F3+A, F3+1...) share these keys' defaults,
|
||||
// and must not hand them back their gameplay meaning.
|
||||
let mut k = KeyBinds::default();
|
||||
k.set("key.hotbar.1", "key.keyboard.q");
|
||||
assert_eq!(k.translate_all(KeyCode::Digit1), []);
|
||||
assert_eq!(
|
||||
k.translate_all(KeyCode::KeyQ),
|
||||
[Ok(KeyCode::KeyQ), Ok(KeyCode::Digit1)]
|
||||
);
|
||||
k.set("key.inventory", "key.keyboard.a");
|
||||
k.set("key.left", "key.keyboard.e");
|
||||
assert_eq!(k.translate_all(KeyCode::KeyA), [Ok(KeyCode::KeyE)]);
|
||||
assert_eq!(k.translate_all(KeyCode::KeyE), [Ok(KeyCode::KeyA)]);
|
||||
k.set("key.back", "key.keyboard.down");
|
||||
assert_eq!(k.translate_all(KeyCode::KeyS), []);
|
||||
// Keys left alone still act, and a moved debug key changes nothing.
|
||||
k.set("key.debug.fpsCharts", "key.keyboard.9");
|
||||
assert_eq!(k.translate_all(KeyCode::Digit2), [Ok(KeyCode::Digit2)]);
|
||||
assert_eq!(k.translate_all(KeyCode::Digit9), [Ok(KeyCode::Digit9)]);
|
||||
assert_eq!(k.translate_all(KeyCode::F3), [Ok(KeyCode::F3)]);
|
||||
}
|
||||
#[test]
|
||||
fn display_names() {
|
||||
assert_eq!(display_name("key.keyboard.w").plain(), "W");
|
||||
assert_eq!(display_name("key.keyboard.slash").plain(), "/");
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Two offscreen frames presented in one window, without game resources or player data.
|
||||
//! `cargo run --profile fast -p horizon-client-render --example compositor -- --smoke`
|
||||
use horizon_client_render::gfx::compositor::Compositor;
|
||||
use std::sync::Arc;
|
||||
use winit::{
|
||||
application::ApplicationHandler,
|
||||
event::WindowEvent,
|
||||
event_loop::{ActiveEventLoop, EventLoop},
|
||||
window::{Window, WindowId},
|
||||
};
|
||||
|
||||
struct Frames {
|
||||
window: Arc<Window>,
|
||||
compositor: Compositor,
|
||||
textures: [wgpu::Texture; 2],
|
||||
}
|
||||
|
||||
impl Frames {
|
||||
fn new(event_loop: &ActiveEventLoop) -> horizon_client_render::Result<Self> {
|
||||
let window = Arc::new(
|
||||
event_loop.create_window(
|
||||
Window::default_attributes()
|
||||
.with_title("Compositor: red and blue views")
|
||||
.with_inner_size(winit::dpi::PhysicalSize::new(640, 360)),
|
||||
)?,
|
||||
);
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_with_display_handle(
|
||||
Box::new(window.clone()),
|
||||
));
|
||||
let surface = instance.create_surface(window.clone())?;
|
||||
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
|
||||
compatible_surface: Some(&surface),
|
||||
..Default::default()
|
||||
}))?;
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default()))?;
|
||||
let textures = [[0, 0, 255, 255], [255, 0, 0, 255]].map(|pixel| {
|
||||
let size = wgpu::Extent3d {
|
||||
width: 320,
|
||||
height: 360,
|
||||
depth_or_array_layers: 1,
|
||||
};
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Offscreen view"),
|
||||
size,
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Bgra8Unorm,
|
||||
usage: wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
queue.write_texture(
|
||||
texture.as_image_copy(),
|
||||
&pixel.repeat(320 * 360),
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(320 * 4),
|
||||
rows_per_image: Some(360),
|
||||
},
|
||||
size,
|
||||
);
|
||||
texture
|
||||
});
|
||||
let compositor = Compositor::new(
|
||||
surface,
|
||||
&adapter,
|
||||
&device,
|
||||
&queue,
|
||||
window.inner_size().into(),
|
||||
)?;
|
||||
Ok(Self {
|
||||
window,
|
||||
compositor,
|
||||
textures,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Demo {
|
||||
frames: Option<Frames>,
|
||||
error: Option<String>,
|
||||
presented: usize,
|
||||
smoke: bool,
|
||||
}
|
||||
|
||||
impl ApplicationHandler for Demo {
|
||||
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||
if self.frames.is_none() {
|
||||
match Frames::new(event_loop) {
|
||||
Ok(frames) => self.frames = Some(frames),
|
||||
Err(error) => {
|
||||
self.error = Some(error.to_string());
|
||||
event_loop.exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn window_event(&mut self, event_loop: &ActiveEventLoop, _: WindowId, event: WindowEvent) {
|
||||
let Some(f) = &mut self.frames else { return };
|
||||
match event {
|
||||
WindowEvent::CloseRequested => event_loop.exit(),
|
||||
WindowEvent::Resized(size) => f.compositor.resize(size.width, size.height),
|
||||
// Uncovered pixels are black; resize smaller to exercise clipping.
|
||||
WindowEvent::RedrawRequested
|
||||
if f.compositor
|
||||
.present(&[(&f.textures[0], [0, 0]), (&f.textures[1], [320, 0])]) =>
|
||||
{
|
||||
self.presented += 1;
|
||||
if self.smoke && self.presented == 10 {
|
||||
f.compositor.set_vsync(false);
|
||||
f.compositor.resize(0, 0);
|
||||
assert!(!f.compositor.present(&[]));
|
||||
f.compositor.resize(u32::MAX, u32::MAX);
|
||||
assert!(!f.compositor.present(&[]));
|
||||
f.compositor.resize(400, 240);
|
||||
let _ = f
|
||||
.window
|
||||
.request_inner_size(winit::dpi::PhysicalSize::new(400, 240));
|
||||
}
|
||||
if self.smoke && self.presented == 20 {
|
||||
println!("compositor: 20 frames, resize, suspension and clipping passed");
|
||||
event_loop.exit();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn about_to_wait(&mut self, _: &ActiveEventLoop) {
|
||||
if let Some(f) = &self.frames {
|
||||
f.window.request_redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> horizon_client_render::Result<()> {
|
||||
let mut demo = Demo {
|
||||
smoke: std::env::args().any(|a| a == "--smoke"),
|
||||
..Default::default()
|
||||
};
|
||||
EventLoop::new()?.run_app(&mut demo)?;
|
||||
if let Some(error) = demo.error {
|
||||
return Err(error.into());
|
||||
}
|
||||
if demo.smoke && demo.presented < 20 {
|
||||
return Err("smoke test closed early".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -144,6 +144,7 @@ type Stream = cpal::Stream;
|
||||
type Stream = std::convert::Infallible;
|
||||
pub struct Audio {
|
||||
_stream: Option<Stream>,
|
||||
device_name: String,
|
||||
pub engine: SoundEngine,
|
||||
backend: MixerBackend,
|
||||
/// Record `Heard` sounds (the subtitle overlay is listening).
|
||||
@@ -543,15 +544,48 @@ fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
|
||||
)?;
|
||||
Ok(stream)
|
||||
}
|
||||
/// Available audio output device names.
|
||||
#[cfg(feature = "audio")]
|
||||
pub fn output_devices() -> Vec<String> {
|
||||
if output_disabled(|name| std::env::var_os(name)).is_some() {
|
||||
return Vec::new();
|
||||
}
|
||||
let host = cpal::default_host();
|
||||
let mut names = Vec::new();
|
||||
if let Ok(devices) = host.output_devices() {
|
||||
for device in devices {
|
||||
if let Ok(name) = device.name()
|
||||
&& !names.contains(&name)
|
||||
{
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "audio"))]
|
||||
pub fn output_devices() -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
impl Audio {
|
||||
pub fn new(root: &Path) -> Self {
|
||||
Self::with_output(root, true)
|
||||
Self::with_device(root, "")
|
||||
}
|
||||
pub fn with_device(root: &Path, device_name: &str) -> Self {
|
||||
Self::with_output(root, true, device_name)
|
||||
}
|
||||
/// No output device: the dedicated server.
|
||||
pub fn silent(root: &Path) -> Self {
|
||||
Self::with_output(root, false)
|
||||
Self::with_output(root, false, "")
|
||||
}
|
||||
fn with_output(root: &Path, output: bool) -> Self {
|
||||
/// The output device asked for (`soundDevice`), empty for the system's default; the
|
||||
/// default plays when that one can't be opened, as in vanilla.
|
||||
pub fn device(&self) -> &str {
|
||||
&self.device_name
|
||||
}
|
||||
fn with_output(root: &Path, output: bool, target_device: &str) -> Self {
|
||||
let registry = SoundRegistry::load(root);
|
||||
if output && registry.is_empty() {
|
||||
// The game then plays nothing on a working output (#1035, #1076).
|
||||
@@ -564,7 +598,7 @@ impl Audio {
|
||||
}
|
||||
let mixer = Arc::new(Mutex::new(Mixer::default()));
|
||||
let opened = if output {
|
||||
Self::open(mixer.clone())
|
||||
Self::open_named(mixer.clone(), target_device)
|
||||
} else {
|
||||
Err("no output on a dedicated server".into())
|
||||
};
|
||||
@@ -581,6 +615,7 @@ impl Audio {
|
||||
engine.loaded = live;
|
||||
Self {
|
||||
_stream: stream,
|
||||
device_name: target_device.to_owned(),
|
||||
engine,
|
||||
backend: MixerBackend {
|
||||
mixer,
|
||||
@@ -602,10 +637,61 @@ impl Audio {
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "audio"))]
|
||||
fn open(_: Arc<Mutex<Mixer>>) -> Result<Stream> {
|
||||
fn open_named(_: Arc<Mutex<Mixer>>, _: &str) -> Result<Stream> {
|
||||
Err("this build has no sound output (built without the `audio` feature)".into())
|
||||
}
|
||||
#[cfg(feature = "audio")]
|
||||
fn open_named(mixer: Arc<Mutex<Mixer>>, target: &str) -> Result<Stream> {
|
||||
if let Some(reason) = output_disabled(|name| std::env::var_os(name)) {
|
||||
return Err(reason.into());
|
||||
}
|
||||
let host = cpal::default_host();
|
||||
if !target.is_empty()
|
||||
&& let Ok(devices) = host.output_devices()
|
||||
{
|
||||
for device in devices {
|
||||
let name = device.name().unwrap_or_else(|_| "unnamed device".into());
|
||||
if name == target {
|
||||
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) => {
|
||||
log::warn!(
|
||||
"Horizon: could not open audio device '{target}': {e}; falling back to default"
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::open(mixer)
|
||||
}
|
||||
#[cfg(feature = "audio")]
|
||||
pub fn set_device(&mut self, name: &str) -> Result<()> {
|
||||
if !self.backend.live && self._stream.is_none() && !self.engine.loaded {
|
||||
self.device_name = name.to_owned();
|
||||
return Ok(());
|
||||
}
|
||||
self._stream = Some(Self::open_named(self.backend.mixer.clone(), name)?);
|
||||
self.device_name = name.to_owned();
|
||||
self.backend.live = true;
|
||||
self.engine.loaded = true;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(feature = "audio"))]
|
||||
pub fn set_device(&mut self, name: &str) -> Result<()> {
|
||||
self.device_name = name.to_owned();
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(feature = "audio")]
|
||||
fn open(mixer: Arc<Mutex<Mixer>>) -> Result<Stream> {
|
||||
if let Some(reason) = output_disabled(|name| std::env::var_os(name)) {
|
||||
return Err(reason.into());
|
||||
|
||||
@@ -256,6 +256,10 @@ impl Biomes {
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
/// Whether the game has the biome `name`.
|
||||
pub fn contains(&self, name: &str) -> bool {
|
||||
self.index.contains_key(name)
|
||||
}
|
||||
pub fn get(&self, id: u16) -> &BiomeInfo {
|
||||
&self.list[usize::from(id).min(self.list.len() - 1)]
|
||||
}
|
||||
|
||||
@@ -64,6 +64,10 @@ pub struct BlockEntities {
|
||||
/// Positions whose block entity `take` took and `put` has not brought
|
||||
/// back.
|
||||
out: FxHashSet<Pos>,
|
||||
/// Positions a loaded chunk gave a new block entity over an old one: at
|
||||
/// the next tick their slot stays only if their block state ticks
|
||||
/// (`updateBlockEntityTicker`: rebind, or remove without a ticker).
|
||||
replaced: FxHashSet<Pos>,
|
||||
/// `Ticking` by block state id; empty until the game builds it.
|
||||
states: Vec<Ticking>,
|
||||
}
|
||||
@@ -239,15 +243,20 @@ impl BlockEntities {
|
||||
self.added.clear();
|
||||
self.pending.clear();
|
||||
self.out.clear();
|
||||
self.replaced.clear();
|
||||
self.freed = false;
|
||||
}
|
||||
/// A loaded or generated chunk's block entities, in the order the chunk
|
||||
/// lists them; they tick in its block entity map's order
|
||||
/// (`registerAllBlockEntitiesAfterLevelLoad`).
|
||||
/// (`registerAllBlockEntitiesAfterLevelLoad`). One replacing an entity
|
||||
/// already at its position (`LevelChunk.setBlockEntity`) drops that one;
|
||||
/// its slot is decided by the block state at the next tick.
|
||||
pub fn load_chunk(&mut self, entities: Vec<(Pos, BlockEntity)>) {
|
||||
let order = chunk_map_order(entities.iter().map(|(p, _)| *p));
|
||||
for (p, e) in entities {
|
||||
self.map.insert(p, e);
|
||||
if self.map.insert(p, e).is_some() {
|
||||
self.replaced.insert(p);
|
||||
}
|
||||
}
|
||||
for p in order {
|
||||
self.add(p);
|
||||
@@ -291,6 +300,11 @@ impl BlockEntities {
|
||||
self.live.insert(pos, slot);
|
||||
Arc::make_mut(&mut self.tickers).push((pos, slot));
|
||||
}
|
||||
for pos in std::mem::take(&mut self.replaced) {
|
||||
if self.live.contains_key(&pos) && self.ticking(state(pos)) != Ticking::Yes {
|
||||
self.cancel(&pos);
|
||||
}
|
||||
}
|
||||
if !std::mem::take(&mut self.freed) && self.out.is_empty() {
|
||||
return Arc::clone(&self.tickers);
|
||||
}
|
||||
@@ -390,4 +404,33 @@ mod tests {
|
||||
let order = chunk_map_order(many.iter().copied().chain(many.iter().copied()));
|
||||
assert_eq!(order.len(), 100);
|
||||
}
|
||||
|
||||
/// A chunk loaded over a block entity already there: the new one replaces
|
||||
/// it, and keeps the old slot only while its block state ticks.
|
||||
#[test]
|
||||
fn loaded_chunk_replaces_block_entity_by_state() {
|
||||
let mut b = level();
|
||||
let mut s = HashMap::new();
|
||||
let (p, q) = ((0, 0, 0), (1, 0, 0));
|
||||
for pos in [p, q] {
|
||||
s.insert(pos, 1);
|
||||
}
|
||||
// Two chunks, so the order is the order they load in.
|
||||
b.load_chunk(vec![(p, hopper())]);
|
||||
b.load_chunk(vec![(q, hopper())]);
|
||||
assert_eq!(order(&mut b, &s), [p, q]);
|
||||
// Replaced where the state ticks: the slot stays in its place.
|
||||
b.load_chunk(vec![(p, hopper())]);
|
||||
assert_eq!(order(&mut b, &s), [p, q]);
|
||||
// Replaced where the state has no ticker: the slot is freed, and the
|
||||
// new block entity is the one in the map.
|
||||
s.insert(p, 2);
|
||||
b.load_chunk(vec![(p, BlockEntity::Chest(vec![None; 27]))]);
|
||||
assert_eq!(order(&mut b, &s), [q]);
|
||||
assert!(matches!(b.get(&p), Some(BlockEntity::Chest(items)) if items.len() == 27));
|
||||
// Ticking again later appends it, as any new ticker does.
|
||||
s.insert(p, 1);
|
||||
b.state_changed(p, 2, 1);
|
||||
assert_eq!(order(&mut b, &s), [q, p]);
|
||||
}
|
||||
}
|
||||
@@ -654,10 +654,20 @@ fn sign_text_calls(
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// How far the text of an outlined call sits in front of its outline, in
|
||||
/// text units (`Font.DisplayMode.POLYGON_OFFSET`, see `draw_text`).
|
||||
const TEXT_OVER_OUTLINE: f32 = 0.05;
|
||||
|
||||
/// Draws a text call with the font atlas: 8x8 glyph cells, the outline as
|
||||
/// eight one-pixel offsets under the text.
|
||||
///
|
||||
/// `TextFeatureRenderer.renderText` draws the outline glyphs NORMAL and the
|
||||
/// text over them POLYGON_OFFSET, so the coplanar text always wins the depth
|
||||
/// test. Here both share one mesh, so the text is lifted a hair towards the
|
||||
/// reader instead (a twentieth of a font pixel, invisible) rather than
|
||||
/// z-fighting with the outline under it.
|
||||
fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
|
||||
let mut emit = |dx: f32, dy: f32, argb: u32| {
|
||||
let mut emit = |dx: f32, dy: f32, z: f32, argb: u32| {
|
||||
let mut x = call.x + dx;
|
||||
let y0 = call.y + dy;
|
||||
for ch in call.text.chars() {
|
||||
@@ -672,7 +682,7 @@ fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
|
||||
normal: [0; 4],
|
||||
pos: call
|
||||
.matrix
|
||||
.transform_point3(Vec3::new(cx, cy, 0.0))
|
||||
.transform_point3(Vec3::new(cx, cy, z))
|
||||
.to_array(),
|
||||
uv,
|
||||
layer: 0,
|
||||
@@ -697,10 +707,15 @@ fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
|
||||
(0.0, 1.0),
|
||||
(1.0, 1.0),
|
||||
] {
|
||||
emit(dx, dy, call.outline);
|
||||
emit(dx, dy, 0.0, call.outline);
|
||||
}
|
||||
}
|
||||
emit(0.0, 0.0, call.color);
|
||||
let z = if call.outline != 0 {
|
||||
TEXT_OVER_OUTLINE
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
emit(0.0, 0.0, z, call.color);
|
||||
}
|
||||
|
||||
/// Block-entity fields the level ticks, at one position (vanilla's own field
|
||||
@@ -2258,6 +2273,69 @@ mod tests {
|
||||
assert_eq!(out[2].uv, [0.5, 0.5]);
|
||||
}
|
||||
|
||||
/// TextFeatureRenderer.renderText: an outlined call's text is drawn over
|
||||
/// its outline (Font.DisplayMode.POLYGON_OFFSET), never level with it,
|
||||
/// and plain text stays on the sign's plane.
|
||||
#[test]
|
||||
fn outlined_sign_text_sits_in_front_of_its_outline() {
|
||||
let world = World::new(BlockStateId(0), 1);
|
||||
let sprites = HashMap::new();
|
||||
let glyph = |_: char| {
|
||||
let rect = Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 0.0625,
|
||||
v1: 0.0625,
|
||||
w: 8,
|
||||
h: 8,
|
||||
};
|
||||
(Some(rect), 6.0)
|
||||
};
|
||||
let scene = Scene {
|
||||
camera: DVec3::ZERO,
|
||||
orientation: Quat::IDENTITY,
|
||||
anim: &|_| BlockAnim::default(),
|
||||
texts: None,
|
||||
partial: 0.0,
|
||||
game_time: 0,
|
||||
max_y: 320,
|
||||
world: &world,
|
||||
sprites: &sprites,
|
||||
nether: false,
|
||||
lid: &|_| 0.0,
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &glyph,
|
||||
glyphs: 0,
|
||||
};
|
||||
let mut call = TextCall {
|
||||
matrix: Mat4::IDENTITY,
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
text: "Hi".into(),
|
||||
color: 0xFFFF_FF00,
|
||||
outline: 0xFF33_3300,
|
||||
light: 240 | 240 << 8,
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
draw_text(&scene, &call, &mut out);
|
||||
// Eight outline copies, then the text, four corners a glyph.
|
||||
assert_eq!(out.len(), 9 * 2 * 4);
|
||||
let (outline, text) = out.split_at(8 * 2 * 4);
|
||||
let front = outline.iter().map(|v| v.pos[2]).fold(f32::MIN, f32::max);
|
||||
assert!(
|
||||
text.iter().all(|v| v.pos[2] > front),
|
||||
"text z {:?} not in front of the outline at {front}",
|
||||
text.iter().map(|v| v.pos[2]).collect::<Vec<_>>()
|
||||
);
|
||||
call.outline = 0;
|
||||
out.clear();
|
||||
draw_text(&scene, &call, &mut out);
|
||||
assert_eq!(out.len(), 2 * 4);
|
||||
assert!(out.iter().all(|v| v.pos[2] == 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_sign_text_matches_and_follows_changes() {
|
||||
let renderer = BlockEntityRenderer {
|
||||
|
||||
@@ -50,20 +50,30 @@ pub fn tooltip(
|
||||
Category::Neutral,
|
||||
)];
|
||||
}
|
||||
def.effects
|
||||
.iter()
|
||||
effect_lines(def.effects.iter().copied(), scale, translate)
|
||||
}
|
||||
/// `PotionContents.addPotionTooltip`'s line per effect: its name, potency and
|
||||
/// scaled duration, for (effect id, duration, amplifier) entries.
|
||||
pub fn effect_lines<'a>(
|
||||
effects: impl IntoIterator<Item = (&'a str, i32, i32)>,
|
||||
scale: f32,
|
||||
translate: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Vec<(String, Category)> {
|
||||
effects
|
||||
.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());
|
||||
if *amp > 0 {
|
||||
translate(&format!("effect.minecraft.{e}")).unwrap_or_else(|| e.to_owned());
|
||||
if amp > 0 {
|
||||
line.push(' ');
|
||||
line.push_str(
|
||||
&translate(&format!("potion.potency.{amp}"))
|
||||
.unwrap_or_else(|| crate::enchant::level_name(*amp as u32 + 1)),
|
||||
.unwrap_or_else(|| crate::enchant::level_name(amp as u32 + 1)),
|
||||
);
|
||||
}
|
||||
let instant = effect(e).is_some_and(|d| d.instant);
|
||||
let scaled = (*duration as f32 * scale) as i32;
|
||||
let scaled = (duration as f32 * scale) as i32;
|
||||
if !instant && scaled > 20 {
|
||||
line = format!("{line} ({})", format_duration(scaled));
|
||||
}
|
||||
@@ -386,4 +396,23 @@ mod tests {
|
||||
stand.tick(&brewing, &items, &providers);
|
||||
assert_eq!(stand.brew_time, 0);
|
||||
}
|
||||
|
||||
/// #3312: `PotionContents.getPotionDescription` names the effects with
|
||||
/// translatable components, so a Russian game reads them from ru_ru.json
|
||||
/// (the prepared one; skipped without it), as the tooltip's other lines.
|
||||
#[test]
|
||||
fn effect_lines_read_the_selected_language() {
|
||||
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("assets/minecraft/lang/ru_ru.json").exists() {
|
||||
return;
|
||||
}
|
||||
let ru = crate::lang::Language::load(&[&root], "ru_ru");
|
||||
let translate = |key: &str| ru.get(key).map(str::to_owned);
|
||||
let lines = tooltip(Some("minecraft:turtle_master"), 0.125, &translate);
|
||||
assert_eq!(lines.len(), 2);
|
||||
assert!(lines[0].0.starts_with("Замедление IV ("), "{lines:?}");
|
||||
assert!(lines[1].0.starts_with("Сопротивление III ("), "{lines:?}");
|
||||
}
|
||||
}
|
||||
@@ -653,6 +653,42 @@ pub struct Commands<'a> {
|
||||
pub registries: &'a Registries,
|
||||
}
|
||||
|
||||
/// `CommandSuggestions`' constructor arguments: how a screen's suggestions
|
||||
/// parse and where they are drawn.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct SuggestLayout {
|
||||
/// `commandsOnly`: the input is a command without a leading `/`.
|
||||
pub commands_only: bool,
|
||||
/// `onlyShowIfCursorPastError`: completions from the command's start,
|
||||
/// not only past the `/`.
|
||||
pub only_past_error: bool,
|
||||
/// `suggestionLineLimit`.
|
||||
pub line_limit: usize,
|
||||
/// `anchorToBottom`: the list and usage over a box at the bottom, else
|
||||
/// under one at the top (y 72).
|
||||
pub anchor_to_bottom: bool,
|
||||
/// `fillColor`: behind the list and the usage lines.
|
||||
pub fill: i32,
|
||||
}
|
||||
impl SuggestLayout {
|
||||
/// ChatScreen.init's `CommandSuggestions`.
|
||||
pub const CHAT: Self = Self {
|
||||
commands_only: false,
|
||||
only_past_error: false,
|
||||
line_limit: 10,
|
||||
anchor_to_bottom: true,
|
||||
fill: FILL,
|
||||
};
|
||||
/// AbstractCommandBlockEditScreen.init's `CommandSuggestions`.
|
||||
pub const COMMAND_BLOCK: Self = Self {
|
||||
commands_only: true,
|
||||
only_past_error: true,
|
||||
line_limit: 7,
|
||||
anchor_to_bottom: false,
|
||||
fill: i32::MIN,
|
||||
};
|
||||
}
|
||||
|
||||
/// The open chat screen: vanilla's `ChatScreen` with its `EditBox` and
|
||||
/// `CommandSuggestions` (argument colouring, usage lines, parse errors,
|
||||
/// the suggestion list, Tab completion, history).
|
||||
@@ -680,6 +716,7 @@ pub struct ChatScreen {
|
||||
pub auto_suggestions: bool,
|
||||
/// `CommandSuggestions.setRestrictions`: what the Chat option allows.
|
||||
pub abilities: ChatAbilities,
|
||||
pub layout: SuggestLayout,
|
||||
}
|
||||
/// Local completions waiting for the server's reply to request `id`.
|
||||
struct Awaiting {
|
||||
@@ -750,6 +787,43 @@ impl ChatScreen {
|
||||
keep_suggestions: false,
|
||||
auto_suggestions: true,
|
||||
abilities,
|
||||
layout: SuggestLayout::CHAT,
|
||||
};
|
||||
s.update_command_info(m, c);
|
||||
s
|
||||
}
|
||||
/// `AbstractCommandBlockEditScreen.init`: the command box at the top,
|
||||
/// focused, holding `command`, with suggestions from the start.
|
||||
pub fn command_block(
|
||||
m: &Measure,
|
||||
c: &Commands,
|
||||
command: &str,
|
||||
width: i32,
|
||||
height: i32,
|
||||
) -> Self {
|
||||
let mut input = TextField::new(width / 2 - 150, 50, 300, 20);
|
||||
input.max_length = 32500;
|
||||
input.focused = true;
|
||||
input.set_value(m, command);
|
||||
let mut s = Self {
|
||||
input,
|
||||
width,
|
||||
height,
|
||||
history_buffer: String::new(),
|
||||
history_pos: 0,
|
||||
parse: None,
|
||||
pending: None,
|
||||
awaiting: None,
|
||||
request: None,
|
||||
suggestions: None,
|
||||
usage: Vec::new(),
|
||||
usage_position: 0,
|
||||
usage_width: 0,
|
||||
allow_suggestions: true,
|
||||
keep_suggestions: false,
|
||||
auto_suggestions: true,
|
||||
abilities: ChatAbilities::new(ChatVisibility::Full),
|
||||
layout: SuggestLayout::COMMAND_BLOCK,
|
||||
};
|
||||
s.update_command_info(m, c);
|
||||
s
|
||||
@@ -768,7 +842,11 @@ impl ChatScreen {
|
||||
let value = self.input.value.clone();
|
||||
let auto = self.auto_suggestions;
|
||||
let abilities = self.abilities;
|
||||
*self = Self::new(m, c, &value, self.history_pos, width, height, abilities);
|
||||
*self = if self.layout == SuggestLayout::CHAT {
|
||||
Self::new(m, c, &value, self.history_pos, width, height, abilities)
|
||||
} else {
|
||||
Self::command_block(m, c, &value, width, height)
|
||||
};
|
||||
self.auto_suggestions = auto;
|
||||
}
|
||||
}
|
||||
@@ -814,11 +892,18 @@ impl ChatScreen {
|
||||
self.usage.clear();
|
||||
let cursor = self.input.cursor;
|
||||
let cursor_b = byte_at(&command, cursor);
|
||||
if command.starts_with('/') {
|
||||
let slash = command.starts_with('/');
|
||||
if self.layout.commands_only || slash {
|
||||
let start = usize::from(slash);
|
||||
if self.parse.is_none() {
|
||||
self.parse = Some(c.dispatcher.parse_at(&command, 1, c.registries));
|
||||
self.parse = Some(c.dispatcher.parse_at(&command, start, c.registries));
|
||||
}
|
||||
if cursor >= 1 && (self.suggestions.is_none() || !self.keep_suggestions) {
|
||||
let from = if self.layout.only_past_error {
|
||||
start
|
||||
} else {
|
||||
1
|
||||
};
|
||||
if cursor >= from && (self.suggestions.is_none() || !self.keep_suggestions) {
|
||||
let parse = self.parse.as_ref().expect("parsed above");
|
||||
let (range, list, ask) =
|
||||
c.dispatcher
|
||||
@@ -1002,7 +1087,11 @@ impl ChatScreen {
|
||||
0,
|
||||
self.input.screen_x(m, 0) + self.input.inner_width() - width,
|
||||
);
|
||||
let y = self.height - 12;
|
||||
let y = if self.layout.anchor_to_bottom {
|
||||
self.height - 12
|
||||
} else {
|
||||
72
|
||||
};
|
||||
// sortSuggestions: those starting with the word at the cursor first.
|
||||
let partial = &value[..byte_at(&value, self.input.cursor)];
|
||||
let word = partial[last_word_index(partial)..].to_lowercase();
|
||||
@@ -1011,9 +1100,16 @@ impl ChatScreen {
|
||||
s.text.starts_with(&word) || s.text.starts_with(&format!("minecraft:{word}"))
|
||||
});
|
||||
first.extend(rest);
|
||||
let n = first.len().min(10) as i32;
|
||||
// SuggestionsList: beside a bordered box, or over an unbordered one.
|
||||
let n = first.len().min(self.layout.line_limit) as i32;
|
||||
let bordered = i32::from(self.input.bordered);
|
||||
let top = if self.layout.anchor_to_bottom {
|
||||
y - 3 - n * 12
|
||||
} else {
|
||||
y - bordered
|
||||
};
|
||||
let mut l = SuggestionList {
|
||||
rect: [x - 1, y - 3 - n * 12, width + 1, n * 12],
|
||||
rect: [x - (1 - bordered), top, width + 1, n * 12],
|
||||
original: value,
|
||||
list: first,
|
||||
offset: 0,
|
||||
@@ -1042,11 +1138,12 @@ impl ChatScreen {
|
||||
}
|
||||
fn cycle(&mut self, l: &mut SuggestionList, dir: isize) {
|
||||
self.select(l, l.current as isize + dir);
|
||||
let max = l.list.len().saturating_sub(10);
|
||||
let limit = self.layout.line_limit;
|
||||
let max = l.list.len().saturating_sub(limit);
|
||||
if l.current < l.offset {
|
||||
l.offset = l.current.min(max);
|
||||
} else if l.current > l.offset + 9 {
|
||||
l.offset = (l.current as isize + 1 - 10).clamp(0, max as isize) as usize;
|
||||
} else if l.current >= l.offset + limit {
|
||||
l.offset = (l.current as isize + 1 - limit as isize).clamp(0, max as isize) as usize;
|
||||
}
|
||||
}
|
||||
/// `SuggestionsList.useSuggestion`.
|
||||
@@ -1074,7 +1171,57 @@ impl ChatScreen {
|
||||
ctrl: bool,
|
||||
) -> ChatAction {
|
||||
use winit::keyboard::KeyCode as K;
|
||||
// CommandSuggestions.keyPressed.
|
||||
if self.suggestions_key(m, c, key, shift) {
|
||||
return ChatAction::None;
|
||||
}
|
||||
match key {
|
||||
K::Escape => return ChatAction::Close,
|
||||
K::Enter | K::NumpadEnter => {
|
||||
// ChatScreen.keyPressed: without `hasAllowedInput` the input
|
||||
// stays, unsent, and the screen stays open.
|
||||
if !self.abilities.allows(&self.input.value) {
|
||||
return ChatAction::None;
|
||||
}
|
||||
return ChatAction::Submit(self.normalized());
|
||||
}
|
||||
K::ArrowUp => self.move_in_history(m, c, history, -1),
|
||||
K::ArrowDown => self.move_in_history(m, c, history, 1),
|
||||
_ => {
|
||||
self.field_key(m, c, key, shift, ctrl);
|
||||
}
|
||||
}
|
||||
ChatAction::None
|
||||
}
|
||||
/// The input box's own keys (`EditBox.keyPressed`), the suggestions
|
||||
/// following an edit: whether the box took the key.
|
||||
pub fn field_key(
|
||||
&mut self,
|
||||
m: &Measure,
|
||||
c: &Commands,
|
||||
key: winit::keyboard::KeyCode,
|
||||
shift: bool,
|
||||
ctrl: bool,
|
||||
) -> bool {
|
||||
match self.input.key(m, key, shift, ctrl) {
|
||||
Some(changed) => {
|
||||
if changed {
|
||||
self.edited(m, c);
|
||||
}
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
/// `CommandSuggestions.keyPressed`: the list's arrows, Tab and Escape,
|
||||
/// and Tab to show it. True when the suggestions took the key.
|
||||
pub fn suggestions_key(
|
||||
&mut self,
|
||||
m: &Measure,
|
||||
c: &Commands,
|
||||
key: winit::keyboard::KeyCode,
|
||||
shift: bool,
|
||||
) -> bool {
|
||||
use winit::keyboard::KeyCode as K;
|
||||
if let Some(mut l) = self.suggestions.take() {
|
||||
let handled = match key {
|
||||
K::ArrowUp | K::ArrowDown => {
|
||||
@@ -1091,37 +1238,18 @@ impl ChatScreen {
|
||||
}
|
||||
K::Escape => {
|
||||
self.input.suggestion = None;
|
||||
return ChatAction::None;
|
||||
return true;
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
self.suggestions = Some(l);
|
||||
if handled {
|
||||
return ChatAction::None;
|
||||
}
|
||||
handled
|
||||
} else if key == K::Tab {
|
||||
self.show_suggestions(m);
|
||||
return ChatAction::None;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
match key {
|
||||
K::Escape => return ChatAction::Close,
|
||||
K::Enter | K::NumpadEnter => {
|
||||
// ChatScreen.keyPressed: without `hasAllowedInput` the input
|
||||
// stays, unsent, and the screen stays open.
|
||||
if !self.abilities.allows(&self.input.value) {
|
||||
return ChatAction::None;
|
||||
}
|
||||
return ChatAction::Submit(self.normalized());
|
||||
}
|
||||
K::ArrowUp => self.move_in_history(m, c, history, -1),
|
||||
K::ArrowDown => self.move_in_history(m, c, history, 1),
|
||||
_ => {
|
||||
if self.input.key(m, key, shift, ctrl) == Some(true) {
|
||||
self.edited(m, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
ChatAction::None
|
||||
}
|
||||
/// `ChatScreen.moveInHistory`.
|
||||
fn move_in_history(&mut self, m: &Measure, c: &Commands, history: &[String], dir: isize) {
|
||||
@@ -1175,7 +1303,7 @@ impl ChatScreen {
|
||||
if !l.contains(mouse.0, mouse.1) {
|
||||
return false;
|
||||
}
|
||||
let max = l.list.len().saturating_sub(10) as i32;
|
||||
let max = l.list.len().saturating_sub(self.layout.line_limit) as i32;
|
||||
l.offset = ((l.offset as f64 - amount.clamp(-1.0, 1.0)) as i32).clamp(0, max) as usize;
|
||||
true
|
||||
}
|
||||
@@ -1246,7 +1374,9 @@ impl ChatScreen {
|
||||
) -> Vec<Draw> {
|
||||
let (w, h) = (self.width, self.height);
|
||||
let mut out = Vec::new();
|
||||
d::fill(&mut out, 2, h - 14, w - 2, h - 2, background);
|
||||
if self.layout.anchor_to_bottom {
|
||||
d::fill(&mut out, 2, h - 14, w - 2, h - 2, background);
|
||||
}
|
||||
let format = |s: &str, o: usize| self.format(s, o);
|
||||
self.input.draw(&mut out, m, blink_ms, Some(&format));
|
||||
if let Some(mut l) = self.suggestions.take() {
|
||||
@@ -1254,14 +1384,18 @@ impl ChatScreen {
|
||||
self.suggestions = Some(l);
|
||||
} else {
|
||||
for (i, line) in self.usage.iter().enumerate() {
|
||||
let y = h - 27 - 12 * i as i32;
|
||||
let y = if self.layout.anchor_to_bottom {
|
||||
h - 27 - 12 * i as i32
|
||||
} else {
|
||||
72 + 12 * i as i32
|
||||
};
|
||||
d::fill(
|
||||
&mut out,
|
||||
self.usage_position - 1,
|
||||
y,
|
||||
self.usage_position + self.usage_width + 1,
|
||||
y + 12,
|
||||
FILL,
|
||||
self.layout.fill,
|
||||
);
|
||||
d::text(&mut out, line.clone(), self.usage_position, y + 2, -1, true);
|
||||
}
|
||||
@@ -1275,7 +1409,7 @@ impl ChatScreen {
|
||||
l: &mut SuggestionList,
|
||||
mouse: (i32, i32),
|
||||
) {
|
||||
let limit = l.list.len().min(10);
|
||||
let limit = l.list.len().min(self.layout.line_limit);
|
||||
let [rx, ry, rw, rh] = l.rect;
|
||||
let has_prev = l.offset > 0;
|
||||
let has_next = l.list.len() > l.offset + limit;
|
||||
@@ -1284,8 +1418,8 @@ impl ChatScreen {
|
||||
l.last_mouse = mouse;
|
||||
}
|
||||
if has_prev || has_next {
|
||||
d::fill(out, rx, ry - 1, rx + rw, ry, FILL);
|
||||
d::fill(out, rx, ry + rh, rx + rw, ry + rh + 1, FILL);
|
||||
d::fill(out, rx, ry - 1, rx + rw, ry, self.layout.fill);
|
||||
d::fill(out, rx, ry + rh, rx + rw, ry + rh + 1, self.layout.fill);
|
||||
if has_prev {
|
||||
for x in (0..rw).step_by(2) {
|
||||
d::fill(out, rx + x, ry - 1, rx + x + 1, ry, -1);
|
||||
@@ -1300,7 +1434,7 @@ impl ChatScreen {
|
||||
let mut hovered = false;
|
||||
for i in 0..limit {
|
||||
let y = ry + 12 * i as i32;
|
||||
d::fill(out, rx, y, rx + rw, y + 12, FILL);
|
||||
d::fill(out, rx, y, rx + rw, y + 12, self.layout.fill);
|
||||
if mouse.0 > rx && mouse.0 < rx + rw && mouse.1 > y && mouse.1 < y + 12 {
|
||||
if moved {
|
||||
self.select(l, (i + l.offset) as isize);
|
||||
|
||||
@@ -74,9 +74,28 @@ pub enum BlockEntity {
|
||||
/// `DaylightDetectorBlockEntity`: no data, only the ticker
|
||||
/// (`DaylightDetectorBlock.tickEntity`).
|
||||
DaylightDetector,
|
||||
/// Suspicious sand and gravel: saved loot and the transient brushed face.
|
||||
Brushable(Box<Brushable>),
|
||||
/// A shelf holds three displayed item stacks.
|
||||
Shelf(Box<Shelf>),
|
||||
/// `ChiseledBookShelfBlockEntity`: a `Container` of six one-book slots.
|
||||
ChiseledBookshelf(Box<ChiseledBookshelf>),
|
||||
}
|
||||
/// `BrushableBlockEntity`: the loot table is resolved on the first brush.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Brushable {
|
||||
pub loot_table: Option<horizon_world::nbt::Tag>,
|
||||
pub loot_seed: Option<horizon_world::nbt::Tag>,
|
||||
pub item: Option<ItemStack>,
|
||||
/// Direction's legacy network id; not saved to disk by vanilla.
|
||||
pub hit_direction: Option<i32>,
|
||||
}
|
||||
/// `ShelfBlockEntity`'s displayed slots and alignment flag.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Shelf {
|
||||
pub items: [Option<ItemStack>; 3],
|
||||
pub align_to_bottom: bool,
|
||||
}
|
||||
/// `ChiseledBookShelfBlockEntity`: six slots and `lastInteractedSlot`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ChiseledBookshelf {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
//! kinds, ticking as `AbstractFurnaceBlockEntity.serverTick` with fuel times
|
||||
//! from the data-driven `context_int_provider`/`context_float_provider` files.
|
||||
pub use crate::chunk::block_entity::{
|
||||
Banner, Beacon, BlockEntity, Campfire, ChiseledBookshelf, Crafter, CreakingHeart, EndGateway,
|
||||
Furnace, FurnaceKind, Jukebox, Lectern, Skull, SkullSkin,
|
||||
Banner, Beacon, BlockEntity, Brushable, Campfire, ChiseledBookshelf, Crafter, CreakingHeart,
|
||||
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, Shelf, Skull, SkullSkin,
|
||||
};
|
||||
use crate::items::ItemStack;
|
||||
use std::collections::HashMap;
|
||||
@@ -230,6 +230,14 @@ impl BlockEntity {
|
||||
recipes_used: HashMap::new(),
|
||||
}));
|
||||
}
|
||||
if matches!(
|
||||
name,
|
||||
"minecraft:suspicious_sand"
|
||||
| "minecraft:suspicious_gravel"
|
||||
| "minecraft:brushable_block"
|
||||
) {
|
||||
return Some(Self::Brushable(Box::default()));
|
||||
}
|
||||
if name == "minecraft:creaking_heart" {
|
||||
return Some(Self::CreakingHeart(Box::default()));
|
||||
}
|
||||
@@ -292,6 +300,9 @@ impl BlockEntity {
|
||||
if name == "minecraft:campfire" || name == "minecraft:soul_campfire" {
|
||||
return Some(Self::Campfire(Box::default()));
|
||||
}
|
||||
if name == "minecraft:shelf" || name.ends_with("_shelf") {
|
||||
return Some(Self::Shelf(Box::default()));
|
||||
}
|
||||
if name == "minecraft:chiseled_bookshelf" {
|
||||
return Some(Self::ChiseledBookshelf(Box::default()));
|
||||
}
|
||||
@@ -334,8 +345,10 @@ impl BlockEntity {
|
||||
Self::Skull(_) => "minecraft:skull",
|
||||
Self::EndGateway(_) => "minecraft:end_gateway",
|
||||
Self::DaylightDetector => "minecraft:daylight_detector",
|
||||
Self::Brushable(_) => "minecraft:brushable_block",
|
||||
Self::Campfire(_) => "minecraft:campfire",
|
||||
Self::ChiseledBookshelf(_) => "minecraft:chiseled_bookshelf",
|
||||
Self::Shelf(_) => "minecraft:shelf",
|
||||
}
|
||||
}
|
||||
pub fn contents(&self) -> Vec<ItemStack> {
|
||||
@@ -354,13 +367,15 @@ impl BlockEntity {
|
||||
| Self::CommandBlock(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::DaylightDetector => Vec::new(),
|
||||
| Self::DaylightDetector
|
||||
| Self::Brushable(_) => Vec::new(),
|
||||
Self::Lectern(l) => l.book.iter().cloned().collect(),
|
||||
Self::Sculk(_) => Vec::new(),
|
||||
Self::Banner(_) => Vec::new(),
|
||||
Self::Crafter(c) => c.items.iter().flatten().cloned().collect(),
|
||||
Self::Campfire(c) => c.items.iter().flatten().cloned().collect(),
|
||||
Self::ChiseledBookshelf(b) => b.items.iter().flatten().cloned().collect(),
|
||||
Self::Shelf(b) => b.items.iter().flatten().cloned().collect(),
|
||||
}
|
||||
}
|
||||
pub fn slots(&self) -> &[Option<ItemStack>] {
|
||||
@@ -376,9 +391,11 @@ impl BlockEntity {
|
||||
| Self::Banner(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::DaylightDetector => &[],
|
||||
| Self::DaylightDetector
|
||||
| Self::Brushable(_) => &[],
|
||||
Self::Crafter(c) => &c.items,
|
||||
Self::ChiseledBookshelf(b) => &b.items,
|
||||
Self::Shelf(b) => &b.items,
|
||||
Self::CreakingHeart(_)
|
||||
| Self::Spawner(_)
|
||||
| Self::TrialSpawner(_)
|
||||
@@ -402,9 +419,11 @@ impl BlockEntity {
|
||||
| Self::Banner(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::DaylightDetector => &mut [],
|
||||
| Self::DaylightDetector
|
||||
| Self::Brushable(_) => &mut [],
|
||||
Self::Crafter(c) => &mut c.items,
|
||||
Self::ChiseledBookshelf(b) => &mut b.items,
|
||||
Self::Shelf(b) => &mut b.items,
|
||||
Self::CreakingHeart(_)
|
||||
| Self::Spawner(_)
|
||||
| Self::TrialSpawner(_)
|
||||
@@ -486,6 +505,24 @@ pub fn create_experience(
|
||||
xp
|
||||
}
|
||||
|
||||
/// The `MenuType` the block entity of a one-block storage container opens
|
||||
/// (`createMenu`): `ChestMenu.threeRows` for chests, trapped and copper chests
|
||||
/// and barrels, `ShulkerBoxMenu` for shulker boxes of any colour (its
|
||||
/// `ShulkerBoxSlot`s refuse shulker boxes), `DispenserMenu` for dispensers
|
||||
/// and droppers, `HopperMenu` for hoppers. None for other blocks.
|
||||
pub fn block_menu_type(block: &str) -> Option<&'static str> {
|
||||
Some(match block {
|
||||
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => {
|
||||
"minecraft:generic_9x3"
|
||||
}
|
||||
n if n.ends_with("copper_chest") => "minecraft:generic_9x3",
|
||||
n if n.ends_with("shulker_box") => "minecraft:shulker_box",
|
||||
"minecraft:dispenser" | "minecraft:dropper" => "minecraft:generic_3x3",
|
||||
"minecraft:hopper" => "minecraft:hopper",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! Written to `crash-reports/crash-<yyyy-MM-dd_HH.mm.ss>-client.txt` in the game folder when
|
||||
//! the main thread panics or `main` gets an error back. It leaves the machine only scrubbed, and
|
||||
//! only if the player allows crash reports (`telemetry`).
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::fmt::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
@@ -14,6 +15,51 @@ use chrono::{DateTime, Local};
|
||||
static DETAILS: Mutex<Vec<(&'static str, String)>> = Mutex::new(Vec::new());
|
||||
static REPORTED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
thread_local! {
|
||||
/// How many `contain` calls this thread is inside.
|
||||
static CONTAINED: Cell<u32> = const { Cell::new(0) };
|
||||
/// The message of the last panic `contain` caught on this thread, from the hook.
|
||||
static CAUGHT: RefCell<Option<String>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Runs `f`, and returns a panic in it as an error with the panic's message instead of letting
|
||||
/// it end the game. For work that is not the game's own and whose failure the caller recovers
|
||||
/// from, such as handing a shader pack's programs to the GPU driver's compiler: the panic goes
|
||||
/// to the log with its stack, and no crash report is made for it.
|
||||
pub fn contain<T>(f: impl FnOnce() -> T) -> Result<T, String> {
|
||||
CONTAINED.with(|c| c.set(c.get() + 1));
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
|
||||
CONTAINED.with(|c| c.set(c.get() - 1));
|
||||
result.map_err(|payload| {
|
||||
CAUGHT
|
||||
.with(|c| c.borrow_mut().take())
|
||||
.unwrap_or_else(|| payload_text(&*payload))
|
||||
})
|
||||
}
|
||||
|
||||
/// For the hook: a panic inside `contain` is logged with its stack and kept for `contain` to
|
||||
/// return. False for any other panic.
|
||||
fn contained_panic(info: &std::panic::PanicHookInfo) -> bool {
|
||||
if CONTAINED.with(Cell::get) == 0 {
|
||||
return false;
|
||||
}
|
||||
let message = panic_message(info);
|
||||
let stack = stack_lines(
|
||||
&std::backtrace::Backtrace::force_capture().to_string(),
|
||||
true,
|
||||
);
|
||||
log::error!(
|
||||
"Recovered from an error: {message}\n{}",
|
||||
stack
|
||||
.lines()
|
||||
.map(|l| format!("\tat {l}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
);
|
||||
CAUGHT.with(|c| *c.borrow_mut() = Some(message));
|
||||
true
|
||||
}
|
||||
|
||||
/// Sets one line of the report's details (replacing the last value under `key`).
|
||||
pub fn set_detail(key: &'static str, value: impl Into<String>) {
|
||||
let mut details = DETAILS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
@@ -308,13 +354,35 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
fn panic_message(info: &std::panic::PanicHookInfo) -> String {
|
||||
let payload = info.payload();
|
||||
let text = payload
|
||||
/// The report for a hung server tick, written by the watchdog thread
|
||||
/// (`ServerWatchdog`): the stuck thread's stack is not available to Rust, so
|
||||
/// the report carries none.
|
||||
pub fn report_watchdog(home: &Path, side: &'static str, message: String) -> Option<PathBuf> {
|
||||
deliver(
|
||||
home,
|
||||
Report {
|
||||
time: Local::now(),
|
||||
description: "Watching Server".into(),
|
||||
error: format!("{message} (Watching Server)"),
|
||||
stack: String::new(),
|
||||
thread: thread_name(),
|
||||
side,
|
||||
details: system_details(),
|
||||
log: crate::logging::recent_lines(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn payload_text(payload: &(dyn std::any::Any + Send)) -> String {
|
||||
payload
|
||||
.downcast_ref::<&str>()
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| payload.downcast_ref::<String>().cloned())
|
||||
.unwrap_or_else(|| "Box<dyn Any>".into());
|
||||
.unwrap_or_else(|| "Box<dyn Any>".into())
|
||||
}
|
||||
|
||||
fn panic_message(info: &std::panic::PanicHookInfo) -> String {
|
||||
let text = payload_text(info.payload());
|
||||
match info.location() {
|
||||
Some(at) => format!("panicked at {at}:\n{text}"),
|
||||
None => format!("panicked:\n{text}"),
|
||||
@@ -417,10 +485,13 @@ pub fn panic_report(info: &std::panic::PanicHookInfo, side: &'static str) -> Rep
|
||||
|
||||
/// Reports panics: on the main thread a crash report (the panic then ends the game, exit code
|
||||
/// 101); on other threads the error goes to the log, as vanilla's uncaught exception handler
|
||||
/// does. The default hook still prints the panic.
|
||||
/// does. The default hook still prints the panic. A panic inside `contain` is only logged.
|
||||
pub fn install(home: PathBuf, side: &'static str) {
|
||||
let default = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
if contained_panic(info) {
|
||||
return;
|
||||
}
|
||||
default(info);
|
||||
let main = std::thread::current().name() == Some("main");
|
||||
if main {
|
||||
@@ -609,6 +680,17 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contained_panics_come_back_as_errors() {
|
||||
assert_eq!(contain(|| 7), Ok(7));
|
||||
let error = contain(|| -> u8 { panic!("a pack's program") }).unwrap_err();
|
||||
assert!(error.contains("a pack's program"), "{error}");
|
||||
// Nested, and the thread is no longer inside one after.
|
||||
let outer = contain(|| contain(|| -> u8 { panic!("inner") }));
|
||||
assert!(outer.unwrap().unwrap_err().contains("inner"));
|
||||
assert_eq!(CONTAINED.with(Cell::get), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_debug_crash_is_reported_as_vanillas() {
|
||||
let error: Box<dyn std::error::Error> = DebugCrash::new().into();
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
//! the parts of them commands use: `.mcfunction` functions (with macros and
|
||||
//! line continuation), function tags and predicates.
|
||||
use crate::zip::Zip;
|
||||
use horizon_world::nbt::Tag;
|
||||
use horizon_world::{
|
||||
nbt::Tag,
|
||||
worldgen::overrides::{self, PackFile, WorldgenOverrides},
|
||||
};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
path::{Path, PathBuf},
|
||||
@@ -125,6 +128,114 @@ pub fn discover(world: &Path) -> Vec<DataPack> {
|
||||
packs
|
||||
}
|
||||
|
||||
// ---- Create World ------------------------------------------------------------
|
||||
|
||||
/// Create World's Data Packs screen (`PackSelectionScreen` on vanilla's
|
||||
/// `mcworld-` folder): the folder's packs (`PackSource.WORLD`) and the
|
||||
/// built-in one (`resources` holds its `pack.png`), which can be unselected
|
||||
/// and stays at the bottom. Horizon has no experiments, so their feature
|
||||
/// packs are not offered.
|
||||
pub fn screen_packs(dir: &Path, resources: &Path) -> Vec<crate::pack_screen::PackInfo> {
|
||||
use crate::{
|
||||
pack_screen::{Compat, PackInfo, Source, load_icon},
|
||||
packs::Position,
|
||||
text::{Style, Text},
|
||||
};
|
||||
let format = horizon_core::version::DATA_PACK_FORMAT;
|
||||
let mut infos: Vec<PackInfo> = crate::packs::folder(dir, format)
|
||||
.into_iter()
|
||||
.map(|p| PackInfo {
|
||||
icon: p.icon_png().as_deref().and_then(load_icon),
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
description: p.raw_description,
|
||||
description_style: p.description_style,
|
||||
source: Source::World,
|
||||
compat: p.compatibility,
|
||||
required: false,
|
||||
position: Position::Top,
|
||||
fixed: false,
|
||||
})
|
||||
.collect();
|
||||
infos.push(PackInfo {
|
||||
id: "vanilla".into(),
|
||||
title: Text::translate("dataPack.vanilla.name", &[]),
|
||||
description: Text::translate("dataPack.vanilla.description", &[]),
|
||||
description_style: Style::default(),
|
||||
source: Source::BuiltIn,
|
||||
compat: Compat::Compatible,
|
||||
icon: std::fs::read(resources.join("pack.png"))
|
||||
.ok()
|
||||
.as_deref()
|
||||
.and_then(load_icon),
|
||||
required: false,
|
||||
position: Position::Bottom,
|
||||
fixed: false,
|
||||
});
|
||||
infos
|
||||
}
|
||||
/// A new empty folder for Create World's packs
|
||||
/// (`Files.createTempDirectory("mcworld-")`).
|
||||
pub fn temp_folder() -> std::io::Result<PathBuf> {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("mcworld-{}-{nanos}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
Ok(dir)
|
||||
}
|
||||
/// What's in a folder of packs, to notice packs added or removed while the
|
||||
/// screen shows it (`PackSelectionScreen.Watcher`).
|
||||
pub fn folder_stamp(dir: &Path) -> Vec<(String, Option<std::time::SystemTime>)> {
|
||||
let mut stamp: Vec<_> = std::fs::read_dir(dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| {
|
||||
let modified = e.metadata().and_then(|m| m.modified()).ok();
|
||||
(e.file_name().to_string_lossy().into_owned(), modified)
|
||||
})
|
||||
.collect();
|
||||
stamp.sort();
|
||||
stamp
|
||||
}
|
||||
/// Create World's packs into the new world before it opens
|
||||
/// (`copyTempDataPackDirToNewWorld`): every pack of `dir` goes to
|
||||
/// `<world>/datapacks`, and `datapacks.json` enables those `selected`
|
||||
/// (lowest priority first) and disables the rest.
|
||||
pub fn install(dir: &Path, world: &Path, selected: &[String]) -> Result<(), String> {
|
||||
let err = |p: &Path, e: std::io::Error| format!("{}: {e}", p.display());
|
||||
let target = world.join("datapacks");
|
||||
std::fs::create_dir_all(&target).map_err(|e| err(&target, e))?;
|
||||
let mut disabled = Vec::new();
|
||||
for pack in crate::packs::folder(dir, horizon_core::version::DATA_PACK_FORMAT) {
|
||||
let (crate::packs::Source::Dir(from) | crate::packs::Source::Zip(from)) = &pack.source;
|
||||
copy(from, &target.join(pack.id.trim_start_matches("file/")))?;
|
||||
if !selected.contains(&pack.id) {
|
||||
disabled.push(pack.id);
|
||||
}
|
||||
}
|
||||
if !selected.iter().any(|id| id == "vanilla") {
|
||||
disabled.push("vanilla".into());
|
||||
}
|
||||
let json = serde_json::json!({ "enabled": selected, "disabled": disabled });
|
||||
let file = world.join("datapacks.json");
|
||||
std::fs::write(&file, json.to_string()).map_err(|e| err(&file, e))
|
||||
}
|
||||
/// 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());
|
||||
if !from.is_dir() {
|
||||
return std::fs::copy(from, to).map(drop).map_err(err);
|
||||
}
|
||||
std::fs::create_dir_all(to).map_err(err)?;
|
||||
for e in std::fs::read_dir(from).map_err(err)?.flatten() {
|
||||
copy(&e.path(), &to.join(e.file_name()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- functions ---------------------------------------------------------------
|
||||
|
||||
/// A `.mcfunction`: plain command lines and macro lines.
|
||||
@@ -368,11 +479,21 @@ impl DataStore {
|
||||
/// included, each function once).
|
||||
pub fn tag_functions(&self, id: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
self.expand(id, &mut out, 0);
|
||||
let mut seen_fns = std::collections::HashSet::new();
|
||||
let mut seen_tags = std::collections::HashSet::new();
|
||||
self.expand(id, &mut out, &mut seen_fns, &mut seen_tags);
|
||||
out
|
||||
}
|
||||
fn expand(&self, id: &str, out: &mut Vec<String>, depth: usize) {
|
||||
if depth > 16 {
|
||||
/// Depth-first walk keeping first-seen order. Each tag is expanded at most
|
||||
/// once, so reference cycles end and nesting depth is not capped.
|
||||
fn expand<'a>(
|
||||
&'a self,
|
||||
id: &'a str,
|
||||
out: &mut Vec<String>,
|
||||
seen_fns: &mut std::collections::HashSet<&'a str>,
|
||||
seen_tags: &mut std::collections::HashSet<&'a str>,
|
||||
) {
|
||||
if !seen_tags.insert(id) {
|
||||
return;
|
||||
}
|
||||
let Some(entries) = self.function_tags.get(id) else {
|
||||
@@ -380,9 +501,9 @@ impl DataStore {
|
||||
};
|
||||
for (e, required) in entries {
|
||||
match e.strip_prefix('#') {
|
||||
Some(inner) => self.expand(inner, out, depth + 1),
|
||||
Some(inner) => self.expand(inner, out, seen_fns, seen_tags),
|
||||
None => {
|
||||
if (self.functions.contains_key(e) || *required) && !out.contains(e) {
|
||||
if (self.functions.contains_key(e) || *required) && seen_fns.insert(e) {
|
||||
out.push(e.clone());
|
||||
}
|
||||
}
|
||||
@@ -421,9 +542,138 @@ fn hash_normalize(s: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The ids of `packs` (a world's, from `discover`) that are enabled, lowest
|
||||
/// priority first: those `<world>/datapacks.json` lists as enabled, in its
|
||||
/// order, then every pack it lists neither way, in name order.
|
||||
pub fn enabled_ids(world: &Path, packs: &[DataPack]) -> Vec<String> {
|
||||
let saved: Option<serde_json::Value> = std::fs::read(world.join("datapacks.json"))
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice(&b).ok());
|
||||
let list = |key: &str| -> Vec<String> {
|
||||
saved
|
||||
.as_ref()
|
||||
.and_then(|v| v[key].as_array())
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|s| s.as_str().map(str::to_owned))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let disabled = list("disabled");
|
||||
let mut enabled: Vec<String> = list("enabled")
|
||||
.into_iter()
|
||||
.filter(|id| packs.iter().any(|p| &p.id == id))
|
||||
.collect();
|
||||
for p in packs {
|
||||
if !enabled.contains(&p.id) && !disabled.contains(&p.id) {
|
||||
enabled.push(p.id.clone());
|
||||
}
|
||||
}
|
||||
enabled
|
||||
}
|
||||
|
||||
// ---- worldgen ----------------------------------------------------------------
|
||||
|
||||
/// The worldgen entries of `packs` (lowest priority first, the built-in pack
|
||||
/// left out: the prepared bundles are it) for the chunk generator, and what
|
||||
/// it leaves out, for the log. `biomes` says which biomes the game has: a
|
||||
/// pack's new biome is left out (the game has no colours, sounds or id for it).
|
||||
pub fn worldgen_overrides(
|
||||
packs: &[&DataPack],
|
||||
biomes: impl Fn(&str) -> bool,
|
||||
) -> (WorldgenOverrides, Vec<String>) {
|
||||
let mut out = WorldgenOverrides::default();
|
||||
let mut not_read: BTreeMap<String, usize> = BTreeMap::new();
|
||||
let mut warnings = Vec::new();
|
||||
for pack in packs {
|
||||
for file in pack.list_kinds(&overrides::PACK_KINDS) {
|
||||
let read = || String::from_utf8(pack.read(&file)?).ok();
|
||||
match out.add_file(&pack.id, &file, read) {
|
||||
PackFile::Read | PackFile::Other => {}
|
||||
PackFile::DimensionType { id, min_y, height } => warnings.push(format!(
|
||||
"{}: dimension type {id} (min_y {min_y}, height {height}) is not used: \
|
||||
the dimension keeps its vanilla build height",
|
||||
pack.id
|
||||
)),
|
||||
PackFile::NotRead(kind) => {
|
||||
*not_read.entry(format!("{}: {kind}", pack.id)).or_default() += 1
|
||||
}
|
||||
PackFile::Unreadable => warnings.push(format!("{}: {file}: unreadable", pack.id)),
|
||||
}
|
||||
}
|
||||
}
|
||||
warnings.extend(out.finish(biomes));
|
||||
for (kind, files) in not_read {
|
||||
warnings.push(format!("{kind}: {files} files the generator does not read"));
|
||||
}
|
||||
(out, warnings)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn enabled_packs_override_worldgen_in_order() {
|
||||
let dir = std::env::temp_dir().join(format!("hz-datapack-worldgen-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let write = |pack: &str, p: &str, t: &str| {
|
||||
let path = dir.join("datapacks").join(pack).join(p);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, t).unwrap();
|
||||
};
|
||||
for pack in ["a", "b", "off"] {
|
||||
write(pack, "pack.mcmeta", "{}");
|
||||
write(pack, "data/minecraft/worldgen/noise/cave.json", pack);
|
||||
write(
|
||||
pack,
|
||||
"data/minecraft/tags/worldgen/biome/is_hill.json",
|
||||
"{\"values\":[]}",
|
||||
);
|
||||
}
|
||||
write("a", "data/minecraft/worldgen/biome/new_peaks.json", "{}");
|
||||
write("a", "data/minecraft/worldgen/biome/plains.json", "{}");
|
||||
write(
|
||||
"a",
|
||||
"data/minecraft/worldgen/world_preset/normal.json",
|
||||
"{}",
|
||||
);
|
||||
write(
|
||||
"b",
|
||||
"data/minecraft/dimension_type/overworld.json",
|
||||
"{\"min_y\":-64,\"height\":2096}",
|
||||
);
|
||||
// `b` is enabled first, so `a` (enabled after it) wins.
|
||||
std::fs::write(
|
||||
dir.join("datapacks.json"),
|
||||
"{\"enabled\":[\"file/b\",\"file/a\"],\"disabled\":[\"file/off\"]}",
|
||||
)
|
||||
.unwrap();
|
||||
let packs = discover(&dir);
|
||||
let enabled = enabled_ids(&dir, &packs);
|
||||
assert_eq!(enabled, ["file/b", "file/a"]);
|
||||
let enabled: Vec<&DataPack> = enabled
|
||||
.iter()
|
||||
.map(|id| packs.iter().find(|p| &p.id == id).unwrap())
|
||||
.collect();
|
||||
let (o, warnings) = worldgen_overrides(&enabled, |b| b == "minecraft:plains");
|
||||
assert_eq!(
|
||||
o.entries("noises").collect::<Vec<_>>(),
|
||||
[("minecraft:cave", "a")]
|
||||
);
|
||||
assert_eq!(
|
||||
o.entries("biomes").collect::<Vec<_>>(),
|
||||
[("minecraft:plains", "{}")]
|
||||
);
|
||||
let tag_packs: Vec<&str> = o.tags("biome_tags").map(|(_, p, _)| p).collect();
|
||||
assert_eq!(tag_packs, ["file/b", "file/a"]);
|
||||
assert_eq!(warnings.len(), 3, "{warnings:?}");
|
||||
assert!(warnings[0].contains("height 2096"));
|
||||
assert!(warnings[1].contains("minecraft:new_peaks"));
|
||||
assert!(warnings[2].contains("worldgen/world_preset"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
use horizon_world::nbt::parse_snbt;
|
||||
|
||||
#[test]
|
||||
@@ -527,6 +777,40 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_resolution_dedupes_fast_ends_cycles_and_nests_deeply() {
|
||||
let mut store = DataStore::default();
|
||||
let big: Vec<(String, bool)> = (0..50_000)
|
||||
.map(|i| (format!("t:f{}", i % 25_000), true))
|
||||
.collect();
|
||||
store.function_tags.insert("t:big".into(), big);
|
||||
let start = std::time::Instant::now();
|
||||
let got = store.tag_functions("t:big");
|
||||
assert_eq!(got.len(), 25_000);
|
||||
assert_eq!(got[0], "t:f0");
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(5));
|
||||
// A cycle terminates and keeps first-seen order.
|
||||
store.function_tags.insert(
|
||||
"t:a".into(),
|
||||
vec![("#t:b".into(), true), ("t:x".into(), true)],
|
||||
);
|
||||
store.function_tags.insert(
|
||||
"t:b".into(),
|
||||
vec![("#t:a".into(), true), ("t:y".into(), true)],
|
||||
);
|
||||
assert_eq!(store.tag_functions("t:a"), ["t:y", "t:x"]);
|
||||
// Nesting deeper than the old cap of 16 still resolves.
|
||||
for i in 0..40 {
|
||||
store
|
||||
.function_tags
|
||||
.insert(format!("d:{i}"), vec![(format!("#d:{}", i + 1), true)]);
|
||||
}
|
||||
store
|
||||
.function_tags
|
||||
.insert("d:40".into(), vec![("t:deep".into(), true)]);
|
||||
assert_eq!(store.tag_functions("d:0"), ["t:deep"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_packs_list_the_needed_kinds_in_walk_order() {
|
||||
let dir = std::env::temp_dir().join(format!("hz-datapack-kinds-{}", std::process::id()));
|
||||
@@ -566,4 +850,51 @@ mod tests {
|
||||
assert_eq!(p.list_kinds(&kinds), wanted);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_world_installs_its_packs() {
|
||||
let root = std::env::temp_dir().join(format!("hz-datapack-install-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let (dir, world) = (root.join("mcworld"), root.join("world"));
|
||||
let write = |pack: &str, p: &str, t: &str| {
|
||||
let path = dir.join(pack).join(p);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, t).unwrap();
|
||||
};
|
||||
write("fns", "pack.mcmeta", r#"{"pack":{"pack_format":121}}"#);
|
||||
write("fns", "data/x/function/f.mcfunction", "say hi");
|
||||
write("off", "pack.mcmeta", r#"{"pack":{"pack_format":121}}"#);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(dir.join("old.zip")).unwrap());
|
||||
zip.start_file("pack.mcmeta", zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
std::io::Write::write_all(&mut zip, br#"{"pack":{"pack_format":48}}"#).unwrap();
|
||||
zip.finish().unwrap();
|
||||
let infos = screen_packs(&dir, &root);
|
||||
let ids: Vec<&str> = infos.iter().map(|p| p.id.as_str()).collect();
|
||||
assert_eq!(ids, ["file/fns", "file/off", "file/old.zip", "vanilla"]);
|
||||
let compat: Vec<_> = infos.iter().map(|p| p.compat).collect();
|
||||
use crate::pack_screen::Compat;
|
||||
assert_eq!(
|
||||
compat,
|
||||
[
|
||||
Compat::Compatible,
|
||||
Compat::Compatible,
|
||||
Compat::TooOld,
|
||||
Compat::Compatible
|
||||
]
|
||||
);
|
||||
let selected = ["vanilla", "file/old.zip", "file/fns"].map(String::from);
|
||||
install(&dir, &world, &selected).unwrap();
|
||||
let packs = discover(&world);
|
||||
let ids: Vec<&str> = packs.iter().map(|p| p.id.as_str()).collect();
|
||||
assert_eq!(ids, ["file/fns", "file/off", "file/old.zip"]);
|
||||
let saved: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(world.join("datapacks.json")).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
saved,
|
||||
serde_json::json!({"enabled": selected, "disabled": ["file/off"]})
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,9 @@ struct Out {
|
||||
@location(11) glint_uv: vec2<f32>,
|
||||
// 1 drops the quad's back-facing pixels (Vertex::normal w = 1), like a culling render type.
|
||||
@location(12) @interpolate(flat) cull: u32,
|
||||
// HDR tier of a light-giving particle (mesher::hdr_tier), from the overlay's
|
||||
// high bits (particles.rs): fs_hdr_mark marks the frame with it.
|
||||
@location(13) @interpolate(flat) tier: u32,
|
||||
}
|
||||
// Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed.
|
||||
fn entity_light(normal: vec3<f32>, nether: bool) -> f32 {
|
||||
@@ -126,8 +129,9 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
|
||||
@vertex fn vs_entity(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
|
||||
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>,
|
||||
@location(5) normal: vec4<f32>, @location(6) glint_uv: vec2<f32>) -> Out {
|
||||
var out = entity_vertex(position, uv, overlay, color, light, normal);
|
||||
var out = entity_vertex(position, uv, overlay & 0xffffu, color, light, normal);
|
||||
out.glint_uv = glint_uv;
|
||||
out.tier = overlay >> 16u;
|
||||
return out;
|
||||
}
|
||||
// Retained bolt-local geometry, translated using the same f64 camera
|
||||
@@ -279,6 +283,14 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
|
||||
return vec4(in.color.rgb, 1.0);
|
||||
}
|
||||
|
||||
// HDR output: a light-giving particle's pixels marked in the frame's alpha the
|
||||
// way terrain.wgsl marks a light source's, drawn again after the entities with
|
||||
// only alpha written, so its colour stays as blended.
|
||||
@fragment fn fs_hdr_mark(in: Out) -> @location(0) vec4<f32> {
|
||||
if in.tier == 0u || textureSample(atlas, atlas_sampler, in.uv).a < 0.1 { discard; }
|
||||
return vec4(0.0, 0.0, 0.0, 0.7 * f32(4u - min(in.tier, 4u)) / 4.0);
|
||||
}
|
||||
|
||||
fn celestial_color(in: Out) -> vec4<f32> {
|
||||
return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
|
||||
}
|
||||
|
||||
@@ -389,3 +389,36 @@ pub fn apply_weather(sky: &mut Sky, rain: f32, thunder: f32) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sky_darken(environment: &Environment, day_time: i64, rain: f32, thunder: f32) -> i32 {
|
||||
let mut sky = environment.sample(day_time, None, None);
|
||||
apply_weather(&mut sky, rain, thunder);
|
||||
// Level.updateSkyBrightness: darkening from the sky light level.
|
||||
(15.0 - 15.0 * sky.sky_light_level).round() as i32
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn overworld() -> Option<Environment> {
|
||||
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3");
|
||||
root.join("data/minecraft/timeline/day.json")
|
||||
.exists()
|
||||
.then(|| Environment::load(&root).unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dark_outside_follows_time_and_weather_levels() {
|
||||
let Some(env) = overworld() else {
|
||||
return;
|
||||
};
|
||||
let dark = |time, rain, thunder| sky_darken(&env, time, rain, thunder) >= 4;
|
||||
assert!(!dark(6000, 0.0, 0.0), "clear noon");
|
||||
assert!(!dark(6000, 1.0, 0.0), "rainy noon");
|
||||
assert!(dark(6000, 1.0, 1.0), "thunderstorm at noon");
|
||||
assert!(dark(18000, 0.0, 0.0), "clear midnight");
|
||||
assert!(!dark(1000, 0.0, 0.0), "clear morning");
|
||||
}
|
||||
}
|
||||
@@ -72,9 +72,8 @@ pub struct Extras {
|
||||
pub zoom_smooth: bool,
|
||||
/// Divide mouse turning by the zoom.
|
||||
pub zoom_sensitivity: bool,
|
||||
/// The FPS counter and keystrokes extras are official mods now
|
||||
/// (`moved_extras`): their old settings stay here until the game has
|
||||
/// installed the mod for a player who had one on.
|
||||
/// The FPS counter and keystrokes extras are official mods now: their
|
||||
/// old settings stay here, kept as they were.
|
||||
pub fps: bool,
|
||||
pub fps_corner: Corner,
|
||||
/// Also show the minimum and 1% low.
|
||||
@@ -83,8 +82,8 @@ pub struct Extras {
|
||||
pub keystrokes_corner: Corner,
|
||||
/// Clicks per second under the mouse buttons.
|
||||
pub keystrokes_cps: bool,
|
||||
/// The official mods installed for the extras they replace, by mod id
|
||||
/// (`movedToMods`): each is done once.
|
||||
/// The official mods once installed for the extras they replace, by mod
|
||||
/// id (`movedToMods`).
|
||||
pub moved: Vec<String>,
|
||||
pub minimap: bool,
|
||||
pub minimap_corner: Corner,
|
||||
@@ -97,6 +96,8 @@ pub struct Extras {
|
||||
pub minimap_rotate: bool,
|
||||
/// Retry lost multiplayer connections (`reconnect`).
|
||||
pub auto_reconnect: bool,
|
||||
/// The player's own name tag over its model in third person.
|
||||
pub own_name_tag: bool,
|
||||
/// Mouse Tweaks-style drags in the inventory and storage containers.
|
||||
pub mouse_tweaks: bool,
|
||||
/// With them, the wheel over a slot moves its items one at a time.
|
||||
@@ -137,6 +138,7 @@ impl Default for Extras {
|
||||
minimap_zoom: 1.0,
|
||||
minimap_rotate: false,
|
||||
auto_reconnect: false,
|
||||
own_name_tag: false,
|
||||
mouse_tweaks: false,
|
||||
mouse_tweaks_wheel: true,
|
||||
sort: false,
|
||||
@@ -211,6 +213,7 @@ impl Extras {
|
||||
}
|
||||
"minimapRotate" => e.minimap_rotate = flag(e.minimap_rotate),
|
||||
"autoReconnect" => e.auto_reconnect = flag(e.auto_reconnect),
|
||||
"ownNameTag" => e.own_name_tag = flag(e.own_name_tag),
|
||||
"mouseTweaks" => e.mouse_tweaks = flag(e.mouse_tweaks),
|
||||
"mouseTweaksWheel" => e.mouse_tweaks_wheel = flag(e.mouse_tweaks_wheel),
|
||||
"sort" => e.sort = flag(e.sort),
|
||||
@@ -225,7 +228,7 @@ impl Extras {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\n",
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nownNameTag:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\n",
|
||||
bool_text(self.fullbright),
|
||||
bool_text(self.dynamic_lights),
|
||||
bool_text(self.zoom),
|
||||
@@ -251,6 +254,7 @@ impl Extras {
|
||||
self.minimap_zoom,
|
||||
bool_text(self.minimap_rotate),
|
||||
bool_text(self.auto_reconnect),
|
||||
bool_text(self.own_name_tag),
|
||||
bool_text(self.mouse_tweaks),
|
||||
bool_text(self.mouse_tweaks_wheel),
|
||||
bool_text(self.sort),
|
||||
@@ -282,7 +286,7 @@ impl Extras {
|
||||
}
|
||||
|
||||
/// The field-of-view modifier target `Camera.tickFov` eases toward: vanilla's
|
||||
/// (`getFieldOfViewModifier`) divided by a mod's zoom (`render.set-zoom`). A
|
||||
/// (`getFieldOfViewModifier`) divided by a mod's zoom (`native_zoom`). A
|
||||
/// zoom of 1 leaves it as is.
|
||||
pub fn fov_target(vanilla: f32, zoom: f32) -> f32 {
|
||||
if zoom == 1.0 { vanilla } else { vanilla / zoom }
|
||||
@@ -378,6 +382,7 @@ impl Cps {
|
||||
pub enum Item {
|
||||
Fullbright,
|
||||
DynamicLights,
|
||||
OwnNameTag,
|
||||
AutoReconnect,
|
||||
MouseTweaks,
|
||||
MouseTweaksWheel,
|
||||
@@ -396,6 +401,8 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
Err("Lighting"),
|
||||
Ok(Fullbright),
|
||||
Ok(DynamicLights),
|
||||
Err("Third person"),
|
||||
Ok(OwnNameTag),
|
||||
Err("Inventory"),
|
||||
Ok(MouseTweaks),
|
||||
Ok(MouseTweaksWheel),
|
||||
@@ -410,6 +417,7 @@ impl Item {
|
||||
match self {
|
||||
Item::Fullbright => "Fullbright",
|
||||
Item::DynamicLights => "Dynamic lights",
|
||||
Item::OwnNameTag => "Own name tag",
|
||||
Item::AutoReconnect => "Auto-reconnect",
|
||||
Item::MouseTweaks => "Mouse Tweaks controls",
|
||||
Item::MouseTweaksWheel => "Scroll wheel moves items",
|
||||
@@ -426,7 +434,7 @@ impl Item {
|
||||
Some(match self {
|
||||
MouseTweaksWheel => MouseTweaks,
|
||||
SortKey => Sort,
|
||||
Fullbright | DynamicLights | MouseTweaks | Sort | AutoReconnect | NoAi
|
||||
Fullbright | DynamicLights | OwnNameTag | MouseTweaks | Sort | AutoReconnect | NoAi
|
||||
| AgentControl | DiscordPresence => {
|
||||
return None;
|
||||
}
|
||||
@@ -439,6 +447,9 @@ impl Item {
|
||||
Item::DynamicLights => {
|
||||
"Torches and other light sources held, dropped or on fire light the blocks around them."
|
||||
}
|
||||
Item::OwnNameTag => {
|
||||
"Shows your name above your head in third person, as other players see it. Hidden when the server hides your team's names or you're invisible."
|
||||
}
|
||||
Item::AutoReconnect => {
|
||||
"Rejoins a server after a lost connection or a restart, never after a kick or ban."
|
||||
}
|
||||
@@ -471,7 +482,9 @@ impl Item {
|
||||
match self {
|
||||
Item::Fullbright => Some(Cost::None),
|
||||
Item::DynamicLights => Some(Cost::Low),
|
||||
Item::AutoReconnect | Item::MouseTweaks | Item::Sort => Some(Cost::None),
|
||||
Item::OwnNameTag | Item::AutoReconnect | Item::MouseTweaks | Item::Sort => {
|
||||
Some(Cost::None)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -480,6 +493,7 @@ impl Item {
|
||||
Some(match self {
|
||||
Item::Fullbright => e.fullbright,
|
||||
Item::DynamicLights => e.dynamic_lights,
|
||||
Item::OwnNameTag => e.own_name_tag,
|
||||
Item::AutoReconnect => e.auto_reconnect,
|
||||
Item::MouseTweaks => e.mouse_tweaks,
|
||||
Item::MouseTweaksWheel => e.mouse_tweaks_wheel,
|
||||
@@ -506,6 +520,7 @@ impl Item {
|
||||
match self {
|
||||
Item::Fullbright => e.fullbright = !e.fullbright,
|
||||
Item::DynamicLights => e.dynamic_lights = !e.dynamic_lights,
|
||||
Item::OwnNameTag => e.own_name_tag = !e.own_name_tag,
|
||||
Item::AutoReconnect => e.auto_reconnect = !e.auto_reconnect,
|
||||
Item::MouseTweaks => e.mouse_tweaks = !e.mouse_tweaks,
|
||||
Item::MouseTweaksWheel => e.mouse_tweaks_wheel = !e.mouse_tweaks_wheel,
|
||||
@@ -534,7 +549,7 @@ mod tests {
|
||||
let d = Extras::default();
|
||||
assert!(!d.fullbright && !d.dynamic_lights);
|
||||
assert!(!d.zoom && !d.fps && !d.keystrokes && !d.minimap && !d.auto_reconnect);
|
||||
assert!(!d.mouse_tweaks && !d.sort);
|
||||
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag);
|
||||
assert!(d.discord_presence);
|
||||
assert_eq!(Extras::parse(""), d);
|
||||
assert_eq!(Extras::parse(&d.serialize()), d);
|
||||
@@ -558,6 +573,8 @@ mod tests {
|
||||
e.minimap_rotate = true;
|
||||
e.minimap_corner = Corner::BottomLeft;
|
||||
e.auto_reconnect = true;
|
||||
Item::OwnNameTag.cycle(&mut e, false);
|
||||
assert_eq!(Item::OwnNameTag.switch(&e), Some(true));
|
||||
e.mouse_tweaks = true;
|
||||
e.mouse_tweaks_wheel = false;
|
||||
e.sort = true;
|
||||
|
||||
@@ -379,6 +379,22 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
|
||||
}
|
||||
fog
|
||||
}
|
||||
/// Horizon's Fog option turned off (`horizon-graphics.txt` `fog:false`):
|
||||
/// no distance fog, in any dimension: the dimension's and biome's own fog
|
||||
/// (`visual/fog_start_distance`, `fog_end_distance`: the Nether's ends at 96
|
||||
/// blocks) and the render-distance fog. The fog of the fluid or powder snow
|
||||
/// the camera is in, blindness, darkness and a boss's world fog keep their
|
||||
/// limits. Vanilla has no such option.
|
||||
pub fn clear_distance_fog(fog: &mut FogData, input: &FogInputs) {
|
||||
if input.fog_type == FogType::Atmospheric
|
||||
&& input.darkness.is_none()
|
||||
&& !input.boss_world_fog
|
||||
&& input.blindness.is_none()
|
||||
{
|
||||
(fog.render_distance_start, fog.render_distance_end) = (1e9, 2e9);
|
||||
(fog.environmental_start, fog.environmental_end) = (1e9, 2e9);
|
||||
}
|
||||
}
|
||||
/// `AtmosphericFogEnvironment.updateRainFogState`.
|
||||
pub fn update_rain_fog(
|
||||
state: &mut FogState,
|
||||
@@ -452,6 +468,56 @@ mod tests {
|
||||
assert_eq!(water.environmental_start, -8.0);
|
||||
}
|
||||
#[test]
|
||||
fn fog_off_clears_the_nether_fog_too() {
|
||||
// The Nether's visual/fog_start_distance and fog_end_distance (10, 96)
|
||||
// end before the render distance; the Fog option still clears them.
|
||||
let nether = Sky {
|
||||
fog_start: 10.0,
|
||||
fog_end: 96.0,
|
||||
..Sky::default()
|
||||
};
|
||||
let tables = FogTables {
|
||||
water_fog_end: vec![1.0],
|
||||
};
|
||||
let mut input = inputs(&nether);
|
||||
input.skybox = false;
|
||||
let mut fog = setup_fog(&tables, &FogState::default(), &input);
|
||||
assert_eq!(fog.environmental_end, 96.0);
|
||||
clear_distance_fog(&mut fog, &input);
|
||||
assert!(fog.environmental_start >= 1e9 && fog.render_distance_start >= 1e9);
|
||||
}
|
||||
#[test]
|
||||
fn fog_off_keeps_fluid_and_effect_fog() {
|
||||
let sky = Sky {
|
||||
fog_start: 10.0,
|
||||
fog_end: 96.0,
|
||||
..Sky::default()
|
||||
};
|
||||
let tables = FogTables {
|
||||
water_fog_end: vec![1.0],
|
||||
};
|
||||
let kept = |input: &FogInputs| {
|
||||
let mut fog = setup_fog(&tables, &FogState::default(), input);
|
||||
let before = fog;
|
||||
clear_distance_fog(&mut fog, input);
|
||||
fog == before
|
||||
};
|
||||
for fog_type in [FogType::Lava, FogType::PowderSnow, FogType::Water] {
|
||||
let mut input = inputs(&sky);
|
||||
input.fog_type = fog_type;
|
||||
assert!(kept(&input), "{fog_type:?}");
|
||||
}
|
||||
let mut input = inputs(&sky);
|
||||
input.blindness = Some((100, false));
|
||||
assert!(kept(&input));
|
||||
let mut input = inputs(&sky);
|
||||
input.darkness = Some(0.5);
|
||||
assert!(kept(&input));
|
||||
let mut input = inputs(&sky);
|
||||
input.boss_world_fog = true;
|
||||
assert!(kept(&input));
|
||||
}
|
||||
#[test]
|
||||
fn boss_darkening_dims_fog_color() {
|
||||
// FogRenderer.computeFogColor: lerp toward 70/60/60% of each channel.
|
||||
let sky = Sky {
|
||||
|
||||
@@ -896,6 +896,9 @@ impl Font {
|
||||
std::mem::take(&mut self.cache.borrow_mut().region.pending)
|
||||
}
|
||||
pub fn advance(&self, c: char, style: &Style) -> f32 {
|
||||
if c == '\u{fffc}' && style.inline_object.is_some() {
|
||||
return 8.0;
|
||||
}
|
||||
let g = self.glyph_styled(c, style);
|
||||
g.advance + if style.is_bold() { g.offset } else { 0.0 }
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ pub use entity_batch::{
|
||||
TRANSLUCENT_GLINT_ITEM_OVERLAY, TRANSLUCENT_ITEM_OVERLAY, TRANSLUCENT_PARTICLE_OVERLAY,
|
||||
WATER_MASK_OVERLAY,
|
||||
};
|
||||
pub use view::{View, ViewSet};
|
||||
|
||||
/// Window.getAppropriateLineWidth, in framebuffer pixels.
|
||||
pub fn outline_width(width: u32) -> f32 {
|
||||
@@ -69,6 +70,7 @@ pub fn outline_vertices(edges: &[[f64; 6]], camera: glam::DVec3) -> Vec<[f32; 6]
|
||||
|
||||
mod animate;
|
||||
mod arena;
|
||||
pub mod compositor;
|
||||
mod cull;
|
||||
mod end_sky;
|
||||
mod flat;
|
||||
@@ -92,12 +94,14 @@ pub mod pack_uniforms;
|
||||
mod pack_weather;
|
||||
mod page_runs;
|
||||
pub mod panorama;
|
||||
mod readback;
|
||||
mod resident;
|
||||
mod stars;
|
||||
mod sunrise_fan;
|
||||
mod timing;
|
||||
pub mod translucency;
|
||||
pub(crate) mod upload;
|
||||
mod view;
|
||||
mod wgsl_prefix;
|
||||
mod world_post;
|
||||
|
||||
@@ -353,6 +357,14 @@ struct BorderUniform {
|
||||
color: [f32; 4],
|
||||
offset: [f32; 4],
|
||||
}
|
||||
/// A frame to save as a PNG: F2's screenshot, an agent's `game_screenshot`,
|
||||
/// or the icon the Select World list shows.
|
||||
pub struct ScreenshotRequest {
|
||||
pub path: PathBuf,
|
||||
/// `GameRenderer.takeAutoScreenshot`: centre-crop the frame to a square
|
||||
/// and average it down to this size. `None` saves the frame as drawn.
|
||||
pub size: Option<[u32; 2]>,
|
||||
}
|
||||
pub struct Renderer {
|
||||
instance: wgpu::Instance,
|
||||
adapter: wgpu::Adapter,
|
||||
@@ -380,6 +392,11 @@ pub struct Renderer {
|
||||
pub last_wait: std::time::Duration,
|
||||
/// CPU time of the last `render`, waits excluded.
|
||||
pub last_encode: std::time::Duration,
|
||||
/// What the system says about the window's display, watched only while HDR output
|
||||
/// is on; before `window`, so it's dropped first.
|
||||
display: DisplayWatch,
|
||||
/// The watch was just made: the settings' Auto values take what it reported.
|
||||
display_fresh: bool,
|
||||
window: Arc<Window>,
|
||||
pub device: wgpu::Device,
|
||||
pub queue: wgpu::Queue,
|
||||
@@ -411,6 +428,7 @@ pub struct Renderer {
|
||||
crosshair_group: wgpu::BindGroup,
|
||||
crosshair_globals: wgpu::Buffer,
|
||||
entity_pipeline: wgpu::RenderPipeline,
|
||||
hdr_mark_pipeline: wgpu::RenderPipeline,
|
||||
weather: weather::Weather,
|
||||
crumbling_pipeline: wgpu::RenderPipeline,
|
||||
shadow_blob_pipeline: wgpu::RenderPipeline,
|
||||
@@ -566,6 +584,8 @@ pub struct Renderer {
|
||||
pub hud: HudRenderer,
|
||||
/// Vanilla's menu background blur, made on first use.
|
||||
menu_blur: Option<crate::menu_blur::MenuBlur>,
|
||||
/// Where a screenshot's frame is drawn when the swapchain cannot be read back.
|
||||
readback: readback::Readback,
|
||||
pub info: wgpu::AdapterInfo,
|
||||
pub suspended: bool,
|
||||
pub drawn_sections: usize,
|
||||
@@ -575,8 +595,8 @@ pub struct Renderer {
|
||||
pub section_draw_calls: usize,
|
||||
pub uploaded_bytes: u64,
|
||||
started: Instant,
|
||||
/// Write the next presented frame to this PNG path (control socket).
|
||||
pub screenshot: Option<PathBuf>,
|
||||
/// The next frame to write to a PNG path (control socket, F2, world icon).
|
||||
pub screenshot: Option<ScreenshotRequest>,
|
||||
/// Outcome of the last screenshot: frame size, or the error.
|
||||
pub screenshot_result: Option<std::result::Result<[u32; 2], String>>,
|
||||
/// Brightness option (lightmap gamma), 0..1.
|
||||
@@ -879,6 +899,14 @@ fn check_layers(limits: &wgpu::Limits, layers: u32) -> Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Whether the swapchain can take a window of `width` x `height`: each side from 1 to the
|
||||
/// device's `max_texture_dimension_2d` (`max`), else `Surface::configure` panics. 0 is a
|
||||
/// minimized window. Past `max`: Windows lets a program size a window up to 65535 pixels
|
||||
/// (`SetWindowPos`), a 984x65496 client area, when an RTX 5080 takes 32768. Vanilla's
|
||||
/// `Window.onFramebufferResize` likewise keeps the last size for one that is not positive.
|
||||
fn drawable_size(width: u32, height: u32, max: u32) -> bool {
|
||||
(1..=max).contains(&width) && (1..=max).contains(&height)
|
||||
}
|
||||
/// The swapchain format: a non-sRGB target, because vanilla blends and tints in gamma
|
||||
/// space, and 8-bit, because wgpu gives `Rgba16Float` the linear `ExtendedSrgb` color
|
||||
/// space: the compositor would read these gamma-encoded values as linear light and wash
|
||||
@@ -1126,9 +1154,14 @@ impl Renderer {
|
||||
let info = adapter.get_info();
|
||||
crate::graphics::suggest_reduced_far_detail(crate::graphics::lod_capped(&info));
|
||||
let size = window.inner_size();
|
||||
let max_side = device.limits().max_texture_dimension_2d;
|
||||
let (config, present_modes, hdr_output) = if let Some(surface) = &surface {
|
||||
let mut config = surface
|
||||
.get_default_config(&adapter, size.width.max(1), size.height.max(1))
|
||||
.get_default_config(
|
||||
&adapter,
|
||||
size.width.clamp(1, max_side),
|
||||
size.height.clamp(1, max_side),
|
||||
)
|
||||
.ok_or("surface has no supported configuration")?;
|
||||
let caps = surface.get_capabilities(&adapter);
|
||||
config.format = surface_format(&caps.formats, config.format);
|
||||
@@ -1155,6 +1188,7 @@ impl Renderer {
|
||||
}
|
||||
surface.configure(&device, &config);
|
||||
let hdr_output = hdr::output(&caps);
|
||||
crate::graphics::offer_hdr(hdr_output.is_some());
|
||||
if previous.is_none() {
|
||||
log::info!(
|
||||
"Horizon renderer: HDR output {}",
|
||||
@@ -1167,8 +1201,8 @@ impl Renderer {
|
||||
let config = wgpu::SurfaceConfiguration {
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
format,
|
||||
width: size.width.max(1),
|
||||
height: size.height.max(1),
|
||||
width: size.width.clamp(1, max_side),
|
||||
height: size.height.clamp(1, max_side),
|
||||
present_mode: wgpu::PresentMode::Immediate,
|
||||
desired_maximum_frame_latency: 2,
|
||||
alpha_mode: wgpu::CompositeAlphaMode::Opaque,
|
||||
@@ -2094,6 +2128,18 @@ impl Renderer {
|
||||
"fs_energy_swirl",
|
||||
wgpu::ColorWrites::ALL,
|
||||
);
|
||||
// HDR output's mark on light-giving particles (entity.wgsl fs_hdr_mark).
|
||||
let hdr_mark_pipeline = entity_pipeline_with(
|
||||
"HDR particle mark",
|
||||
None,
|
||||
false,
|
||||
wgpu::CompareFunction::GreaterEqual,
|
||||
Default::default(),
|
||||
wgpu::BlendState::REPLACE,
|
||||
false,
|
||||
"fs_hdr_mark",
|
||||
wgpu::ColorWrites::ALPHA,
|
||||
);
|
||||
// Attribute 7: 6 is the entity vertex buffer's glint UV.
|
||||
let lightning_attributes = wgpu::vertex_attr_array![7=>Float32x3];
|
||||
let lightning_vertex = [
|
||||
@@ -2567,6 +2613,8 @@ impl Renderer {
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
display: DisplayWatch::default(),
|
||||
display_fresh: false,
|
||||
instance,
|
||||
adapter,
|
||||
surface,
|
||||
@@ -2605,6 +2653,7 @@ impl Renderer {
|
||||
crosshair_group,
|
||||
crosshair_globals,
|
||||
entity_pipeline,
|
||||
hdr_mark_pipeline,
|
||||
weather,
|
||||
crumbling_pipeline,
|
||||
shadow_blob_pipeline,
|
||||
@@ -2709,8 +2758,9 @@ impl Renderer {
|
||||
pack_terrain_flat: false,
|
||||
hud,
|
||||
menu_blur: None,
|
||||
readback: readback::Readback::default(),
|
||||
info,
|
||||
suspended: size.width == 0 || size.height == 0,
|
||||
suspended: !drawable_size(size.width, size.height, max_side),
|
||||
drawn_sections: 0,
|
||||
section_draws: 0,
|
||||
section_draw_calls: 0,
|
||||
@@ -2870,8 +2920,11 @@ impl Renderer {
|
||||
);
|
||||
}
|
||||
}
|
||||
/// Follows the window to `width` x `height`. A size the swapchain can't take
|
||||
/// ([`drawable_size`]) keeps the last one and draws nothing until the next.
|
||||
pub fn resize(&mut self, width: u32, height: u32) {
|
||||
self.suspended = width == 0 || height == 0;
|
||||
let max = self.device.limits().max_texture_dimension_2d;
|
||||
self.suspended = !drawable_size(width, height, max);
|
||||
if self.suspended {
|
||||
return;
|
||||
}
|
||||
@@ -3225,7 +3278,7 @@ impl Renderer {
|
||||
) {
|
||||
surface.configure(device, config);
|
||||
if let Some(hdr) = hdr {
|
||||
horizon_hdr::set_metadata(device, surface, hdr.paper_white, hdr.peak);
|
||||
horizon_hdr::set_metadata(device, surface, hdr.paper_white, hdr.peak, hdr.black);
|
||||
}
|
||||
}
|
||||
fn configure_surface(&self) {
|
||||
@@ -3240,7 +3293,7 @@ impl Renderer {
|
||||
}
|
||||
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);
|
||||
horizon_hdr::set_metadata(&self.device, surface, hdr.paper_white, hdr.peak, hdr.black);
|
||||
}
|
||||
}
|
||||
/// The swapchain's configuration: `config` (what every pipeline draws in), or
|
||||
@@ -3270,7 +3323,15 @@ impl Renderer {
|
||||
}
|
||||
/// HDR output (Horizon setting): HDR10 or scRGB at `paper_white` nits, the world's
|
||||
/// highlights expanded up to `peak` nits. Off where the surface can't present it.
|
||||
pub fn set_hdr(&mut self, on: bool, paper_white: f32, peak: f32, marker: &std::path::Path) {
|
||||
pub fn set_hdr(
|
||||
&mut self,
|
||||
on: bool,
|
||||
paper_white: f32,
|
||||
peak: f32,
|
||||
black: f32,
|
||||
ui: f32,
|
||||
marker: &std::path::Path,
|
||||
) {
|
||||
let output = self.hdr_output.filter(|_| on && self.surface.is_some());
|
||||
if output.is_some() != self.hdr.is_some() {
|
||||
// If the game dies before HDR output shows a frame, the marker is still there at
|
||||
@@ -3281,6 +3342,16 @@ impl Renderer {
|
||||
} else if let Some(marker) = self.hdr_marker.take() {
|
||||
let _ = std::fs::remove_file(marker);
|
||||
}
|
||||
// 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);
|
||||
crate::graphics::report(now);
|
||||
self.display_fresh = true;
|
||||
watch
|
||||
} else {
|
||||
DisplayWatch::default()
|
||||
};
|
||||
let format = self.config.format.remove_srgb_suffix();
|
||||
self.hdr = output.map(|output| hdr::Hdr::new(&self.device, format, output));
|
||||
self.configure_surface();
|
||||
@@ -3293,16 +3364,35 @@ impl Renderer {
|
||||
None => log::info!("Horizon renderer: HDR off"),
|
||||
}
|
||||
}
|
||||
if let Some(hdr) = &mut self.hdr {
|
||||
hdr.ui = ui;
|
||||
}
|
||||
if let Some(hdr) = &mut self.hdr
|
||||
&& (hdr.paper_white, hdr.peak) != (paper_white, peak)
|
||||
&& (hdr.paper_white, hdr.peak, hdr.black) != (paper_white, peak, black)
|
||||
{
|
||||
hdr.paper_white = paper_white;
|
||||
hdr.peak = peak;
|
||||
hdr.black = black;
|
||||
self.send_hdr_metadata();
|
||||
}
|
||||
}
|
||||
/// Whether what the system reports for the display changed (another output, the
|
||||
/// player recalibrating the desktop): the settings' Auto values follow it then, so
|
||||
/// apply them again.
|
||||
pub fn display_changed(&mut self) -> bool {
|
||||
flush_display_log();
|
||||
if std::mem::take(&mut self.display_fresh) {
|
||||
return true;
|
||||
}
|
||||
let Some(now) = self.display.changed() else {
|
||||
return false;
|
||||
};
|
||||
crate::graphics::report(now);
|
||||
true
|
||||
}
|
||||
/// `upscale`: output pixels per rendered pixel, for TAA to rebuild (1: native).
|
||||
pub fn set_world_post(&mut self, scale: f32, antialias: bool, taa: bool, upscale: f32) {
|
||||
let taa = taa && self.world_post.supports_taa();
|
||||
self.world_post.scale = scale.clamp(0.25, 2.0);
|
||||
self.world_post.antialias = antialias && !taa;
|
||||
self.world_post.taa = taa;
|
||||
@@ -3502,14 +3592,14 @@ impl Renderer {
|
||||
&self.lightmap_sampler,
|
||||
);
|
||||
}
|
||||
pub fn view_projection(&self, frame: &Frame) -> Mat4 {
|
||||
self.projection(
|
||||
/// The views `render` draws `frame` from.
|
||||
pub fn views(&self, frame: &Frame) -> ViewSet {
|
||||
ViewSet::single(self.screen_view(
|
||||
frame.fov_degrees,
|
||||
frame.yaw,
|
||||
frame.pitch,
|
||||
frame.bob * frame.spin,
|
||||
frame.depth_far,
|
||||
)
|
||||
))
|
||||
}
|
||||
/// The view frustum `render` will cull with, for culling geometry built
|
||||
/// before the frame (entities, block entities) the same way.
|
||||
@@ -3521,13 +3611,14 @@ impl Renderer {
|
||||
bob: Mat4,
|
||||
depth_far: f32,
|
||||
) -> Frustum {
|
||||
let views = ViewSet::single(self.screen_view(fov_degrees, yaw, pitch, depth_far));
|
||||
Frustum {
|
||||
planes: frustum(self.projection(fov_degrees, yaw, pitch, bob, depth_far)),
|
||||
planes: frustum(views.cull().view_proj(bob)),
|
||||
}
|
||||
}
|
||||
fn projection(&self, fov_degrees: f32, yaw: f32, pitch: f32, bob: Mat4, far: f32) -> Mat4 {
|
||||
fn screen_view(&self, fov_degrees: f32, yaw: f32, pitch: f32, far: f32) -> View {
|
||||
let aspect = self.config.width as f32 / self.config.height.max(1) as f32;
|
||||
world_projection(fov_degrees, aspect, far) * bob * crate::camera::view_matrix(yaw, pitch)
|
||||
View::screen(yaw, pitch, fov_degrees, aspect, far)
|
||||
}
|
||||
fn update_lightmap(
|
||||
&mut self,
|
||||
@@ -3659,7 +3750,12 @@ impl Renderer {
|
||||
let size = [self.config.width, self.config.height];
|
||||
let target_texture = match (&surface_frame, &mut self.hdr) {
|
||||
(Some(_), Some(hdr)) => hdr.frame(&self.device, size),
|
||||
(Some(f), None) => f.texture.clone(),
|
||||
// A screenshot of a swapchain without COPY_SRC (OpenGL) draws
|
||||
// into a texture that has it, then onto the swapchain.
|
||||
(Some(f), None) => self
|
||||
.readback
|
||||
.target(&self.device, &f.texture, self.screenshot.is_some())
|
||||
.unwrap_or_else(|| f.texture.clone()),
|
||||
(None, _) => self.offscreen.clone().ok_or("no offscreen target")?,
|
||||
};
|
||||
// One command buffer for the frame, after the section copies staged
|
||||
@@ -3687,7 +3783,9 @@ impl Renderer {
|
||||
frame.block_flicker,
|
||||
frame.boss_darken,
|
||||
);
|
||||
let view_proj = self.view_projection(frame);
|
||||
let views = self.views(frame);
|
||||
let pose = frame.bob * frame.spin;
|
||||
let view_proj = views.primary().view_proj(pose);
|
||||
// TAA runs where the world post pass does, not under a shader pack's own.
|
||||
let taa = !frame.title
|
||||
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
|
||||
@@ -3707,8 +3805,7 @@ impl Renderer {
|
||||
frame.pitch,
|
||||
);
|
||||
}
|
||||
let aspect = self.config.width as f32 / self.config.height.max(1) as f32;
|
||||
let view = crate::camera::view_matrix(frame.yaw, frame.pitch);
|
||||
let view = views.primary().view;
|
||||
let sun_angle = frame.sky.sun_angle.to_radians();
|
||||
// Celestial bodies rotate around the X axis; angle 0 = noon.
|
||||
let sun = Vec3::new(0.0, sun_angle.cos(), -sun_angle.sin());
|
||||
@@ -3761,8 +3858,8 @@ impl Renderer {
|
||||
misc: [
|
||||
1.0 - frame.sky.sky_light_factor,
|
||||
frame.game_time,
|
||||
aspect,
|
||||
(frame.fov_degrees.to_radians() / 2.0).tan(),
|
||||
views.primary().aspect,
|
||||
views.primary().tan_half_fov,
|
||||
],
|
||||
inverse_view: view.inverse().to_cols_array_2d(),
|
||||
sunrise: frame.sky.sunrise_color,
|
||||
@@ -3825,14 +3922,15 @@ impl Renderer {
|
||||
bytemuck::bytes_of(&crosshair_globals),
|
||||
);
|
||||
// Frustum culling and camera-relative origins.
|
||||
let cull_proj = views.cull().view_proj(pose);
|
||||
let view = Frustum {
|
||||
planes: frustum(view_proj),
|
||||
planes: frustum(cull_proj),
|
||||
};
|
||||
let camera = frame.camera;
|
||||
let camera_section = camera_section(camera);
|
||||
// Solid quads facing away from the eye are left out: the bob moves
|
||||
// the eye off `camera`, so it is found from the projection.
|
||||
let eye = projection_center(view_proj).filter(|_| self.sides_on);
|
||||
let eye = projection_center(cull_proj).filter(|_| self.sides_on);
|
||||
let culling = !frame.title && frame.cave_culling;
|
||||
if culling {
|
||||
crate::spans::time("occlusion", || {
|
||||
@@ -4842,6 +4940,10 @@ impl Renderer {
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
|
||||
if self.hdr.is_some() {
|
||||
pass.set_pipeline(&self.hdr_mark_pipeline);
|
||||
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
|
||||
}
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if text_quads > 0 {
|
||||
@@ -5179,6 +5281,10 @@ impl Renderer {
|
||||
((world_quads + additive_quads) * 4) as i32,
|
||||
0..1,
|
||||
);
|
||||
drop(pass);
|
||||
if let Some(hdr) = &self.hdr {
|
||||
hdr.mark_hand(&mut encoder);
|
||||
}
|
||||
}
|
||||
// GameRenderer.render: LevelRenderer.blitEntityOutline after the level and the hand.
|
||||
self.entity_outline.draw(
|
||||
@@ -5187,6 +5293,13 @@ impl Renderer {
|
||||
&self.entity_group,
|
||||
&self.indices,
|
||||
);
|
||||
// The world icon grabs here: after the level and the hand, before
|
||||
// the effects and the HUD draw over them (`tryTakeScreenshotIfNeeded`
|
||||
// runs right after `renderLevel`).
|
||||
let readback = self
|
||||
.screenshot
|
||||
.take_if(|request| request.size.is_some())
|
||||
.and_then(|request| self.take_readback(request, &mut encoder, &target_texture));
|
||||
if !frame.screen.is_empty() {
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view_target,
|
||||
@@ -5290,24 +5403,20 @@ impl Renderer {
|
||||
(&view_target, self.config.format.remove_srgb_suffix()),
|
||||
);
|
||||
}
|
||||
let readback = self.screenshot.take().and_then(|path| {
|
||||
// The copy and PNG writer both assume 8-bit pixels; a float surface would make
|
||||
// the row stride too small for the copy and abort in validation.
|
||||
if self.config.format.block_copy_size(None) == Some(4) {
|
||||
Some((path, self.copy_frame(&mut encoder, &target_texture)))
|
||||
} else {
|
||||
self.screenshot_result = Some(Err(format!(
|
||||
"screenshots need a 32-bit surface format, not {:?}",
|
||||
self.config.format
|
||||
)));
|
||||
None
|
||||
}
|
||||
let readback = readback.or_else(|| {
|
||||
self.screenshot
|
||||
.take()
|
||||
.and_then(|request| self.take_readback(request, &mut encoder, &target_texture))
|
||||
});
|
||||
self.uploads.finish(&encoder);
|
||||
if has_section_copies {
|
||||
self.section_copy_bytes = 0;
|
||||
self.section_uploads.finish(&encoder);
|
||||
}
|
||||
if let Some(f) = &surface_frame {
|
||||
self.readback
|
||||
.present(&self.device, &mut encoder, &f.texture);
|
||||
}
|
||||
if let (Some(hdr), Some(f)) = (&self.hdr, &surface_frame) {
|
||||
hdr.present(&self.queue, &mut encoder, &f.texture, !frame.title);
|
||||
}
|
||||
@@ -5328,9 +5437,9 @@ impl Renderer {
|
||||
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
|
||||
timer.submitted(&self.queue);
|
||||
}
|
||||
if let Some((path, buffer)) = readback {
|
||||
if let Some((request, buffer)) = readback {
|
||||
self.screenshot_result = Some(
|
||||
self.write_screenshot(&path, &buffer)
|
||||
self.write_screenshot(&request, &buffer)
|
||||
.map_err(|e| e.to_string()),
|
||||
);
|
||||
}
|
||||
@@ -5380,43 +5489,68 @@ impl Renderer {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
/// Bytes per row of a `width`-pixel frame read back at 4 bytes a pixel.
|
||||
fn padded_row(width: u32) -> u32 {
|
||||
(width * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT)
|
||||
}
|
||||
/// Encodes the copy of `texture`'s `size` pixels into a mappable buffer.
|
||||
fn copy_frame(
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
texture: &wgpu::Texture,
|
||||
[width, height]: [u32; 2],
|
||||
) -> wgpu::Buffer {
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon screenshot"),
|
||||
size: u64::from(padded_row(width)) * u64::from(height),
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_texture_to_buffer(
|
||||
texture.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &buffer,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(padded_row(width)),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
buffer
|
||||
}
|
||||
impl Renderer {
|
||||
fn padded_row(&self) -> u32 {
|
||||
(self.config.width * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT)
|
||||
}
|
||||
fn copy_frame(
|
||||
&self,
|
||||
/// Take a screenshot request: queue the frame's copy, or report why the
|
||||
/// frame cannot be read back.
|
||||
fn take_readback(
|
||||
&mut self,
|
||||
request: ScreenshotRequest,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
texture: &wgpu::Texture,
|
||||
) -> wgpu::Buffer {
|
||||
let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon screenshot"),
|
||||
size: u64::from(self.padded_row()) * u64::from(self.config.height),
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_texture_to_buffer(
|
||||
texture.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &buffer,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(self.padded_row()),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: self.config.width,
|
||||
height: self.config.height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
buffer
|
||||
}
|
||||
fn write_screenshot(&self, path: &std::path::Path, buffer: &wgpu::Buffer) -> Result<[u32; 2]> {
|
||||
if !self.config.usage.contains(wgpu::TextureUsages::COPY_SRC) {
|
||||
return Err("this surface does not support frame readback".into());
|
||||
) -> Option<(ScreenshotRequest, wgpu::Buffer)> {
|
||||
// The copy and PNG writer both assume 8-bit pixels; a float surface would make
|
||||
// the row stride too small for the copy and abort in validation.
|
||||
if self.config.format.block_copy_size(None) == Some(4) {
|
||||
let size = [self.config.width, self.config.height];
|
||||
Some((request, copy_frame(&self.device, encoder, texture, size)))
|
||||
} else {
|
||||
self.screenshot_result = Some(Err(format!(
|
||||
"screenshots need a 32-bit surface format, not {:?}",
|
||||
self.config.format
|
||||
)));
|
||||
None
|
||||
}
|
||||
}
|
||||
fn write_screenshot(
|
||||
&self,
|
||||
request: &ScreenshotRequest,
|
||||
buffer: &wgpu::Buffer,
|
||||
) -> Result<[u32; 2]> {
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
buffer.map_async(wgpu::MapMode::Read, .., move |r| {
|
||||
let _ = sender.send(r);
|
||||
@@ -5431,7 +5565,7 @@ impl Renderer {
|
||||
let (width, height, row) = (
|
||||
self.config.width,
|
||||
self.config.height,
|
||||
self.padded_row() as usize,
|
||||
padded_row(self.config.width) as usize,
|
||||
);
|
||||
let bgra = matches!(
|
||||
self.config.format.remove_srgb_suffix(),
|
||||
@@ -5451,8 +5585,18 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
buffer.unmap();
|
||||
// NativeImage.resizeSubRectTo on takeAutoScreenshot's grab: the icon
|
||||
// is the frame's centre square averaged down to its size.
|
||||
let (width, height, pixels) = match request.size {
|
||||
Some(size) => (
|
||||
size[0],
|
||||
size[1],
|
||||
square_downscale(&pixels, self.config.width, self.config.height, size),
|
||||
),
|
||||
None => (self.config.width, self.config.height, pixels),
|
||||
};
|
||||
let mut encoder = png::Encoder::new(
|
||||
std::io::BufWriter::new(std::fs::File::create(path)?),
|
||||
std::io::BufWriter::new(std::fs::File::create(&request.path)?),
|
||||
width,
|
||||
height,
|
||||
);
|
||||
@@ -5462,6 +5606,42 @@ impl Renderer {
|
||||
Ok([width, height])
|
||||
}
|
||||
}
|
||||
/// Centre-crop the frame to a square and average it down to `size`.
|
||||
pub(crate) fn square_downscale(pixels: &[u8], width: u32, height: u32, size: [u32; 2]) -> Vec<u8> {
|
||||
let (side, cx, cy) = if width > height {
|
||||
(height, (width - height) / 2, 0)
|
||||
} else {
|
||||
(width, 0, (height - width) / 2)
|
||||
};
|
||||
let [tw, th] = size;
|
||||
let mut out = vec![0u8; (tw * th * 4) as usize];
|
||||
for ty in 0..th {
|
||||
let y0 = cy + ty * side / th;
|
||||
let y1 = cy + (ty + 1) * side / th;
|
||||
for tx in 0..tw {
|
||||
let x0 = cx + tx * side / tw;
|
||||
let x1 = cx + (tx + 1) * side / tw;
|
||||
let (mut r, mut g, mut b) = (0u32, 0u32, 0u32);
|
||||
for y in y0..y1 {
|
||||
for x in x0..x1 {
|
||||
let p = ((y * width + x) * 4) as usize;
|
||||
r += u32::from(pixels[p]);
|
||||
g += u32::from(pixels[p + 1]);
|
||||
b += u32::from(pixels[p + 2]);
|
||||
}
|
||||
}
|
||||
let count = (x1 - x0) * (y1 - y0);
|
||||
let o = ((ty * tw + tx) * 4) as usize;
|
||||
out[o..o + 4].copy_from_slice(&[
|
||||
(r / count) as u8,
|
||||
(g / count) as u8,
|
||||
(b / count) as u8,
|
||||
255,
|
||||
]);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Camera-relative clip planes (`Frustum`).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Frustum {
|
||||
@@ -5553,6 +5733,23 @@ fn frustum(m: Mat4) -> [Vec4; 6] {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn drawable_size_skips_minimized_and_oversized_windows() {
|
||||
use super::drawable_size;
|
||||
assert!(drawable_size(1280, 720, 8192));
|
||||
assert!(drawable_size(1, 1, 8192));
|
||||
assert!(drawable_size(8192, 8192, 8192));
|
||||
// Minimized.
|
||||
assert!(!drawable_size(0, 720, 8192));
|
||||
assert!(!drawable_size(1280, 0, 8192));
|
||||
assert!(!drawable_size(0, 0, 8192));
|
||||
// One past the device's largest texture.
|
||||
assert!(!drawable_size(8193, 720, 8192));
|
||||
assert!(!drawable_size(1280, 8193, 8192));
|
||||
// The crash: a window `SetWindowPos` made 65535 pixels tall, on an RTX 5080 (32768).
|
||||
assert!(!drawable_size(984, 65496, 32768));
|
||||
assert!(!drawable_size(984, u32::MAX, 32768));
|
||||
}
|
||||
#[test]
|
||||
fn surface_is_8_bit_and_not_srgb() {
|
||||
use super::surface_format;
|
||||
@@ -5975,3 +6172,103 @@ 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.
|
||||
#[derive(Default)]
|
||||
struct DisplayWatch {
|
||||
#[cfg(target_os = "linux")]
|
||||
wayland: Option<horizon_hdr::wayland::Watch>,
|
||||
}
|
||||
impl DisplayWatch {
|
||||
/// Starts watching, with what the system reports now.
|
||||
fn new(window: &Window) -> (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),
|
||||
};
|
||||
return (watch, now.map(reported));
|
||||
}
|
||||
let _ = window;
|
||||
(Self::default(), None)
|
||||
}
|
||||
/// What the system reports, if that changed since the last call.
|
||||
fn changed(&mut self) -> 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
|
||||
}
|
||||
}
|
||||
|
||||
/// The log of what the system reports about the display: one line per change. The
|
||||
/// renderer is made anew a few times while the game starts and each asks again, and a
|
||||
/// slider dragged in the desktop's settings changes the values many times a second, so
|
||||
/// a change within [`DISPLAY_LOG_QUIET`] of the last line waits until the values have
|
||||
/// held that long, and only the one they settled on is logged.
|
||||
struct DisplayLog {
|
||||
last: String,
|
||||
at: Option<std::time::Instant>,
|
||||
pending: Option<(String, std::time::Instant)>,
|
||||
}
|
||||
static DISPLAY_LOG: std::sync::Mutex<DisplayLog> = std::sync::Mutex::new(DisplayLog {
|
||||
last: String::new(),
|
||||
at: None,
|
||||
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
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if line == log.last {
|
||||
log.pending = None;
|
||||
} else if log.at.is_none_or(|at| now - at >= DISPLAY_LOG_QUIET) {
|
||||
log::info!("{line}");
|
||||
(log.last, log.at, log.pending) = (line, Some(now), None);
|
||||
} else {
|
||||
log.pending = Some((line, now));
|
||||
}
|
||||
}
|
||||
/// Logs a change [`log_display`] held back, once the values have held for
|
||||
/// [`DISPLAY_LOG_QUIET`].
|
||||
fn flush_display_log() {
|
||||
let now = std::time::Instant::now();
|
||||
let mut log = DISPLAY_LOG
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some((_, since)) = &log.pending
|
||||
&& now - *since >= DISPLAY_LOG_QUIET
|
||||
&& let Some((line, _)) = log.pending.take()
|
||||
{
|
||||
log::info!("{line}");
|
||||
(log.last, log.at) = (line, Some(now));
|
||||
}
|
||||
}
|
||||
|
||||
/// What a compositor's luminances mean in HDR output's scale.
|
||||
///
|
||||
/// KWin maps content's reference white (203 nits in PQ, BT.2408) onto its own, so the
|
||||
/// desktop's white is 203 there and the peak is the target's maximum over that ratio.
|
||||
/// Checked on KWin. Hyprland (0.57) reports the standard 203, and shows HDR windows below
|
||||
/// the SDR white of its desktop (which it doesn't report), so Auto can't match its desktop
|
||||
/// there. Other compositors are unchecked. The peak is kept within the settings' lowest and
|
||||
/// PQ's top, so the metadata never ends below the black level.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn reported(l: horizon_hdr::wayland::Luminances) -> crate::graphics::Reported {
|
||||
let peak = (l.target_max * 203.0 / l.reference.max(1.0)).round() as u32;
|
||||
log_display(format!(
|
||||
"Horizon renderer: the compositor shows this window with reference white {} nits, \
|
||||
{}..{} nits: peak {peak} nits in HDR output's scale",
|
||||
l.reference, l.target_min, l.target_max,
|
||||
));
|
||||
crate::graphics::Reported {
|
||||
paper_white: 203,
|
||||
peak: peak.clamp(200, 10_000),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
//! Splitscreen: several players' renderers draw offscreen on one device, and the
|
||||
//! compositor copies each frame into its place on a window's surface. A window holds views but
|
||||
//! never owns them, so a view can later move to another window (another monitor, detached).
|
||||
use super::Renderer;
|
||||
use crate::Result;
|
||||
use std::sync::Arc;
|
||||
use winit::window::Window;
|
||||
|
||||
pub struct Compositor {
|
||||
surface: wgpu::Surface<'static>,
|
||||
device: wgpu::Device,
|
||||
queue: wgpu::Queue,
|
||||
config: wgpu::SurfaceConfiguration,
|
||||
present_modes: Vec<wgpu::PresentMode>,
|
||||
suspended: bool,
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
/// A compositor for `window` on this renderer's device, presenting on the surface it gave up.
|
||||
pub fn compositor(&self, window: Arc<Window>) -> Result<Compositor> {
|
||||
// The surface this renderer set aside for its successor (`release_surface`), else a new
|
||||
// one: a window can't keep two.
|
||||
let spare = self
|
||||
.spare_surface
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.take();
|
||||
let surface = match spare {
|
||||
Some(surface) => surface,
|
||||
None => self.instance.create_surface(window.clone())?,
|
||||
};
|
||||
Compositor::new(
|
||||
surface,
|
||||
&self.adapter,
|
||||
&self.device,
|
||||
&self.queue,
|
||||
window.inner_size().into(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The last offscreen frame (`--offscreen`), for the compositor.
|
||||
pub fn offscreen_frame(&self) -> Option<&wgpu::Texture> {
|
||||
self.offscreen.as_ref()
|
||||
}
|
||||
|
||||
/// Splitscreen turned off: a compositor's surface, set aside for the renderer that draws on
|
||||
/// the window again (it takes it, as after `release_surface`).
|
||||
pub fn take_back(&self, compositor: Compositor) {
|
||||
*self
|
||||
.spare_surface
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(compositor.surface);
|
||||
}
|
||||
}
|
||||
|
||||
impl Compositor {
|
||||
/// Present offscreen BGRA frames on a compatible surface using their existing device.
|
||||
/// The surface must support COPY_DST and Bgra8Unorm; unsupported surfaces return an error.
|
||||
pub fn new(
|
||||
surface: wgpu::Surface<'static>,
|
||||
adapter: &wgpu::Adapter,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
size: [u32; 2],
|
||||
) -> Result<Self> {
|
||||
let caps = surface.get_capabilities(adapter);
|
||||
// ponytail: a plain texture copy needs the offscreen frames' format; a blit pass would
|
||||
// take any surface format (Rgba8Unorm on some Linux drivers).
|
||||
if !caps.usages.contains(wgpu::TextureUsages::COPY_DST) {
|
||||
return Err("surface does not support copying offscreen frames".into());
|
||||
}
|
||||
let format = wgpu::TextureFormat::Bgra8Unorm;
|
||||
if !caps.formats.contains(&format) {
|
||||
return Err(format!(
|
||||
"splitscreen needs a {format:?} surface; this one offers {:?}",
|
||||
caps.formats
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let max = device.limits().max_texture_dimension_2d;
|
||||
let config = wgpu::SurfaceConfiguration {
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_DST,
|
||||
format,
|
||||
width: size[0].clamp(1, max),
|
||||
height: size[1].clamp(1, max),
|
||||
present_mode: wgpu::PresentMode::Fifo,
|
||||
desired_maximum_frame_latency: 2,
|
||||
alpha_mode: wgpu::CompositeAlphaMode::Opaque,
|
||||
view_formats: vec![],
|
||||
color_space: wgpu::SurfaceColorSpace::default(),
|
||||
};
|
||||
surface.configure(device, &config);
|
||||
Ok(Compositor {
|
||||
surface,
|
||||
device: device.clone(),
|
||||
queue: queue.clone(),
|
||||
config,
|
||||
present_modes: caps.present_modes,
|
||||
suspended: !super::drawable_size(size[0], size[1], max),
|
||||
})
|
||||
}
|
||||
|
||||
/// Vsync on: presents wait for the display. Off: they don't (Mailbox, else Immediate).
|
||||
pub fn set_vsync(&mut self, vsync: bool) {
|
||||
let mode = [wgpu::PresentMode::Mailbox, wgpu::PresentMode::Immediate]
|
||||
.into_iter()
|
||||
.find(|m| !vsync && self.present_modes.contains(m))
|
||||
.unwrap_or(wgpu::PresentMode::Fifo);
|
||||
if mode != self.config.present_mode {
|
||||
self.config.present_mode = mode;
|
||||
self.surface.configure(&self.device, &self.config);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resize(&mut self, width: u32, height: u32) {
|
||||
self.suspended =
|
||||
!super::drawable_size(width, height, self.device.limits().max_texture_dimension_2d);
|
||||
if !self.suspended {
|
||||
self.config.width = width;
|
||||
self.config.height = height;
|
||||
self.surface.configure(&self.device, &self.config);
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy each view's frame to its top-left corner on the window and present. Frames that
|
||||
/// don't fit are clipped; parts of the window no view covers (a view before its first frame,
|
||||
/// one still at its old size after a resize) are black. Nothing when the window has no frame
|
||||
/// to give (occluded, resized, timed out). Returns true when a frame was submitted.
|
||||
pub fn present(&mut self, views: &[(&wgpu::Texture, [u32; 2])]) -> bool {
|
||||
if self.suspended {
|
||||
return false;
|
||||
}
|
||||
let frame = match self.surface.get_current_texture() {
|
||||
wgpu::CurrentSurfaceTexture::Success(f)
|
||||
| wgpu::CurrentSurfaceTexture::Suboptimal(f) => f,
|
||||
wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => {
|
||||
self.surface.configure(&self.device, &self.config);
|
||||
return false;
|
||||
}
|
||||
_ => return false,
|
||||
};
|
||||
let mut encoder = self
|
||||
.device
|
||||
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||
label: Some("Splitscreen compositor"),
|
||||
});
|
||||
let target = frame.texture.create_view(&Default::default());
|
||||
drop(encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Splitscreen compositor clear"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &target,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
}));
|
||||
for &(texture, [x, y]) in views {
|
||||
let [width, height] = clipped_size(
|
||||
[texture.width(), texture.height()],
|
||||
[x, y],
|
||||
[self.config.width, self.config.height],
|
||||
);
|
||||
if width == 0 || height == 0 {
|
||||
continue;
|
||||
}
|
||||
encoder.copy_texture_to_texture(
|
||||
texture.as_image_copy(),
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
origin: wgpu::Origin3d { x, y, z: 0 },
|
||||
..frame.texture.as_image_copy()
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
self.queue.submit([encoder.finish()]);
|
||||
self.queue.present(frame);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Copies never reach past the surface, including while a resized view still has its old frame.
|
||||
fn clipped_size(view: [u32; 2], at: [u32; 2], surface: [u32; 2]) -> [u32; 2] {
|
||||
[
|
||||
view[0].min(surface[0].saturating_sub(at[0])),
|
||||
view[1].min(surface[1].saturating_sub(at[1])),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn frames_are_clipped_to_the_window_during_resize() {
|
||||
assert_eq!(clipped_size([640, 480], [0, 0], [1280, 720]), [640, 480]);
|
||||
assert_eq!(
|
||||
clipped_size([640, 480], [1000, 600], [1280, 720]),
|
||||
[280, 120]
|
||||
);
|
||||
assert_eq!(clipped_size([640, 480], [1280, 720], [1280, 720]), [0, 0]);
|
||||
assert_eq!(
|
||||
clipped_size([640, 480], [u32::MAX, 0], [1280, 720]),
|
||||
[0, 480]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -60,19 +60,62 @@ struct Targets {
|
||||
group: wgpu::BindGroup,
|
||||
}
|
||||
|
||||
/// How one tier of light sources (`mesher::hdr_tier`) shines: `share` of the headroom for
|
||||
/// its brightest pixels, lifted from brightness `start` (none) to `full` (all of it).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Tier {
|
||||
pub share: f32,
|
||||
pub start: f32,
|
||||
pub full: f32,
|
||||
}
|
||||
/// Low, incandescent, bright and very bright.
|
||||
pub const TIERS: [Tier; 4] = [
|
||||
Tier {
|
||||
share: 0.2,
|
||||
start: 0.08,
|
||||
full: 0.09,
|
||||
},
|
||||
Tier {
|
||||
share: 0.5,
|
||||
start: 0.14,
|
||||
full: 0.38,
|
||||
},
|
||||
Tier {
|
||||
share: 0.6,
|
||||
start: 0.14,
|
||||
full: 0.38,
|
||||
},
|
||||
Tier {
|
||||
share: 1.0,
|
||||
start: 0.14,
|
||||
full: 0.38,
|
||||
},
|
||||
];
|
||||
|
||||
pub struct Hdr {
|
||||
layout: wgpu::BindGroupLayout,
|
||||
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.
|
||||
clear_alpha: wgpu::RenderPipeline,
|
||||
/// Scales the frame's alpha down to 1/255 where the hand drew (`mark_hand`), so HDR
|
||||
/// output keeps the hand at the world's brightness.
|
||||
hand_mark: wgpu::RenderPipeline,
|
||||
params: wgpu::Buffer,
|
||||
pub output: Output,
|
||||
/// The frame's format: what every other pipeline draws in.
|
||||
format: wgpu::TextureFormat,
|
||||
targets: Option<Targets>,
|
||||
/// `HORIZON_HDR_DEBUG=1`: paint expanded pixels magenta.
|
||||
/// `HORIZON_HDR_DEBUG=1`: grey what isn't expanded, tint what is magenta.
|
||||
debug: bool,
|
||||
/// Paper white and peak brightness, in nits.
|
||||
pub paper_white: f32,
|
||||
pub peak: f32,
|
||||
/// The display's black level, in nits: shadows are lifted onto it (`hdr.wgsl`).
|
||||
pub black: f32,
|
||||
pub tiers: [Tier; 4],
|
||||
/// The HUD and menus' brightness, as a share of paper white (0.25..=1).
|
||||
pub ui: f32,
|
||||
}
|
||||
|
||||
impl Hdr {
|
||||
@@ -138,15 +181,59 @@ impl Hdr {
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let alpha_pass = |label, blend| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout: Some(&device.create_pipeline_layout(&Default::default())),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_hdr"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_clear_alpha"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend,
|
||||
write_mask: wgpu::ColorWrites::ALPHA,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let clear_alpha = alpha_pass("Horizon HDR UI mask", None);
|
||||
// Alpha times the blend constant (1/255): the hand's 1 becomes 1/255, the world's 0
|
||||
// stays 0.
|
||||
let scale_alpha = wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::Zero,
|
||||
dst_factor: wgpu::BlendFactor::Constant,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
};
|
||||
let hand_mark = alpha_pass(
|
||||
"Horizon HDR hand mark",
|
||||
Some(wgpu::BlendState {
|
||||
color: scale_alpha,
|
||||
alpha: scale_alpha,
|
||||
}),
|
||||
);
|
||||
let params = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon HDR parameters"),
|
||||
size: 32,
|
||||
size: 80,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
Self {
|
||||
layout,
|
||||
pipeline,
|
||||
clear_alpha,
|
||||
hand_mark,
|
||||
params,
|
||||
output,
|
||||
format,
|
||||
@@ -154,6 +241,9 @@ impl Hdr {
|
||||
debug: std::env::var_os("HORIZON_HDR_DEBUG").is_some_and(|v| v == "1"),
|
||||
paper_white: 200.0,
|
||||
peak: 1000.0,
|
||||
black: 0.0,
|
||||
tiers: TIERS,
|
||||
ui: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,7 +300,9 @@ impl Hdr {
|
||||
self.targets.as_ref().unwrap().frame.clone()
|
||||
}
|
||||
|
||||
/// Keeps the world as drawn so far, before the hand and HUD cover it.
|
||||
/// 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.
|
||||
pub fn keep_world(&self, encoder: &mut wgpu::CommandEncoder) {
|
||||
let Some(t) = &self.targets else {
|
||||
return;
|
||||
@@ -220,6 +312,56 @@ impl Hdr {
|
||||
t.world.as_image_copy(),
|
||||
t.frame.size(),
|
||||
);
|
||||
let view = t.frame.create_view(&Default::default());
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon HDR UI mask"),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.clear_alpha);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
/// After the hand: its pixels' alpha goes down to 1/255, below anything the HUD and
|
||||
/// menus leave, so HDR output tells the hand (the world's brightness, never lifted)
|
||||
/// from the UI (UI Brightness).
|
||||
pub fn mark_hand(&self, encoder: &mut wgpu::CommandEncoder) {
|
||||
let Some(t) = &self.targets else {
|
||||
return;
|
||||
};
|
||||
let view = t.frame.create_view(&Default::default());
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon HDR hand mark"),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.hand_mark);
|
||||
let a = 1.0 / 255.0;
|
||||
pass.set_blend_constant(wgpu::Color {
|
||||
r: a,
|
||||
g: a,
|
||||
b: a,
|
||||
a,
|
||||
});
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
/// Writes the finished frame to the swapchain image `target`.
|
||||
@@ -234,16 +376,21 @@ impl Hdr {
|
||||
return;
|
||||
};
|
||||
let paper = self.paper_white.max(1.0);
|
||||
let params = [
|
||||
let mut params = [0.0; 20];
|
||||
params[..5].copy_from_slice(&[
|
||||
paper / 80.0,
|
||||
(self.peak / paper).max(1.0),
|
||||
f32::from(u8::from(world)),
|
||||
f32::from(u8::from(self.debug)),
|
||||
f32::from(u8::from(self.output == Output::Pq)),
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
];
|
||||
]);
|
||||
params[5] = self.black.clamp(0.0, paper);
|
||||
params[6] = self.ui.clamp(0.25, 1.0);
|
||||
for (i, t) in self.tiers.iter().enumerate() {
|
||||
params[8 + i] = t.share;
|
||||
params[12 + i] = t.start;
|
||||
params[16 + i] = t.full;
|
||||
}
|
||||
queue.write_buffer(&self.params, 0, bytemuck::cast_slice(¶ms));
|
||||
let view = target.create_view(&Default::default());
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
@@ -271,6 +418,24 @@ mod tests {
|
||||
use super::{Output, pick};
|
||||
use wgpu::{SurfaceColorSpaces as S, TextureFormat as F};
|
||||
|
||||
#[test]
|
||||
fn hdr_shaders_validate() {
|
||||
let entity = crate::overlay::wgsl_constants() + include_str!("../entity.wgsl");
|
||||
for source in [
|
||||
include_str!("../hdr.wgsl"),
|
||||
include_str!("../terrain.wgsl"),
|
||||
&entity,
|
||||
] {
|
||||
let module = naga::front::wgsl::parse_str(source).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn caps(offered: &[(F, S)]) -> wgpu::SurfaceCapabilities {
|
||||
wgpu::SurfaceCapabilities {
|
||||
format_capabilities: offered
|
||||
|
||||
@@ -15,10 +15,19 @@ pub struct Bolt {
|
||||
#[derive(Default)]
|
||||
pub struct Input<'a> {
|
||||
pub bolts: &'a [Bolt],
|
||||
/// Current atlas white texel; a changed rect invalidates retained vertices.
|
||||
/// Current atlas white texel ([`white_uv`]); a changed rect invalidates
|
||||
/// retained vertices.
|
||||
pub uv: [f32; 2],
|
||||
}
|
||||
|
||||
/// The atlas point an untextured quad (`RenderTypes.lightning` has no
|
||||
/// texture) samples: the middle of the white sprite. Its corner (`u0`, `v0`)
|
||||
/// is on the sprite's border, where the atlas's nearest sampling picks the
|
||||
/// neighbouring, transparent texels for most fragments.
|
||||
pub fn white_uv(white: &crate::hud::Rect) -> [f32; 2] {
|
||||
[(white.u0 + white.u1) / 2.0, (white.v0 + white.v1) / 2.0]
|
||||
}
|
||||
|
||||
// Four layers, fourteen segments (eight trunk, two branches of three), four quads.
|
||||
pub(super) const QUADS: u32 = 4 * 14 * 4;
|
||||
|
||||
|
||||
@@ -496,3 +496,253 @@ fn retained_gpu_draw_matches_transient_additive_draw() {
|
||||
assert_eq!(pixels[0], pixels[1], "frame {frame}: GPU output differs");
|
||||
}
|
||||
}
|
||||
|
||||
/// `horizon/white` (4x4 texels) at (40, 12) of a 64x32 atlas.
|
||||
const ATLAS: (u32, u32) = (64, 32);
|
||||
fn white_sprite() -> crate::hud::Rect {
|
||||
let (w, h) = (ATLAS.0 as f32, ATLAS.1 as f32);
|
||||
crate::hud::Rect {
|
||||
u0: 40.0 / w,
|
||||
v0: 12.0 / h,
|
||||
u1: 44.0 / w,
|
||||
v1: 16.0 / h,
|
||||
w: 4,
|
||||
h: 4,
|
||||
}
|
||||
}
|
||||
|
||||
/// The white sprite's corner is on its border with the next atlas texels,
|
||||
/// which nearest sampling picks for most of a bolt's fragments (#3171): the
|
||||
/// point sampled is at least a texel inside the sprite.
|
||||
#[test]
|
||||
fn white_uv_is_inside_the_white_sprite() {
|
||||
let [u, v] = white_uv(&white_sprite());
|
||||
let (x, y) = (u * ATLAS.0 as f32, v * ATLAS.1 as f32);
|
||||
assert!((41.0..=43.0).contains(&x), "u at texel {x}");
|
||||
assert!((13.0..=15.0).contains(&y), "v at texel {y}");
|
||||
}
|
||||
|
||||
/// A bolt seen from 14 blocks through the atlas as the game binds it (nearest
|
||||
/// sampling, only the white sprite opaque) draws the same pixels as through a
|
||||
/// texture that is white everywhere (#3171: the sprite's corner left a few
|
||||
/// scattered pixels). Run explicitly, like the test above.
|
||||
#[test]
|
||||
#[ignore = "requires GPU; run separately with --ignored --exact"]
|
||||
fn bolt_through_the_atlas_white_sprite_is_fully_drawn() {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
};
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Wgsl(crate::gfx::entity_shader().into()),
|
||||
});
|
||||
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
|
||||
let offsets = wgpu::vertex_attr_array![7=>Float32x3];
|
||||
let buffers = [
|
||||
Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &attributes,
|
||||
}),
|
||||
Some(wgpu::VertexBufferLayout {
|
||||
array_stride: 12,
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: &offsets,
|
||||
}),
|
||||
];
|
||||
// The lightning pipeline (gfx.rs): additive, into RGBA8.
|
||||
let additive = wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::SrcAlpha,
|
||||
dst_factor: wgpu::BlendFactor::One,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
};
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_lightning"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &buffers,
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: None,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_celestial"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
blend: Some(wgpu::BlendState {
|
||||
color: additive,
|
||||
alpha: additive,
|
||||
}),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
// The game's camera: 70 degrees, reversed depth, the bolt 14 blocks ahead.
|
||||
let globals = crate::gfx::Globals {
|
||||
view_proj: (glam::Mat4::perspective_rh(70f32.to_radians(), 1.0, 400.0, 0.05)
|
||||
* glam::Mat4::look_at_rh(glam::Vec3::ZERO, glam::Vec3::Z, glam::Vec3::Y))
|
||||
.to_cols_array_2d(),
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
let globals = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: bytemuck::bytes_of(&globals),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let texture = |(width, height): (u32, u32), data: &[u8]| {
|
||||
device
|
||||
.create_texture_with_data(
|
||||
&queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
data,
|
||||
)
|
||||
.create_view(&Default::default())
|
||||
};
|
||||
let white = texture((1, 1), &[255; 4]);
|
||||
// Transparent but for the white sprite.
|
||||
let mut texels = vec![0u8; (ATLAS.0 * ATLAS.1 * 4) as usize];
|
||||
for y in 12..16 {
|
||||
for x in 40..44 {
|
||||
let i = ((y * ATLAS.0 + x) * 4) as usize;
|
||||
texels[i..i + 4].copy_from_slice(&[255; 4]);
|
||||
}
|
||||
}
|
||||
let atlas = texture(ATLAS, &texels);
|
||||
// The atlas sampler (gfx.rs): wgpu's default, nearest.
|
||||
let sampler = device.create_sampler(&Default::default());
|
||||
let indices = crate::gfx::Renderer::quad_indices(&device, QUADS);
|
||||
let size = 256;
|
||||
let extent = wgpu::Extent3d {
|
||||
width: size,
|
||||
height: size,
|
||||
depth_or_array_layers: 1,
|
||||
};
|
||||
let draw = |view: &wgpu::TextureView, uv: [f32; 2]| -> Vec<u8> {
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &pipeline.get_bind_group_layout(0),
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: globals.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::Sampler(&sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::TextureView(&white),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: wgpu::BindingResource::Sampler(&sampler),
|
||||
},
|
||||
],
|
||||
});
|
||||
let bolts = [Bolt {
|
||||
seed: 123,
|
||||
at: [0.0, -1.62, 14.0],
|
||||
}];
|
||||
let input = Input { bolts: &bolts, uv };
|
||||
let mut renderer = Renderer::new();
|
||||
let mut uploads = Uploads::new(&device);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
renderer.prepare(&device, &mut uploads, &mut encoder, &input, DVec3::ZERO);
|
||||
let color = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: extent,
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
{
|
||||
let target = color.create_view(&Default::default());
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: None,
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &target,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.set_index_buffer(indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
renderer.draw(&mut pass, &input);
|
||||
}
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: u64::from(size * size * 4),
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_texture_to_buffer(
|
||||
color.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &read,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(size * 4),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
extent,
|
||||
);
|
||||
uploads.finish(&encoder);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
read.slice(..).get_mapped_range().unwrap().to_vec()
|
||||
};
|
||||
let lit = |pixels: &[u8]| pixels.chunks(4).filter(|p| p[3] != 0).count();
|
||||
let expected = draw(&white, [0.5; 2]);
|
||||
let drawn = draw(&atlas, white_uv(&white_sprite()));
|
||||
assert!(lit(&expected) > 1000, "{} pixels", lit(&expected));
|
||||
assert_eq!(
|
||||
lit(&drawn),
|
||||
lit(&expected),
|
||||
"pixels drawn through the atlas"
|
||||
);
|
||||
assert!(drawn == expected, "the bolt's colours through the atlas");
|
||||
}
|
||||
@@ -476,10 +476,12 @@ fn sampler(
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs `create` and returns its validation error, if any.
|
||||
/// Runs `create` and returns its validation error, if any. A panic while it runs is an error
|
||||
/// too: naga's backends turn a module they cannot write into a panic as the driver gets the
|
||||
/// pipeline (#3525), which a pack's program must not turn into a crash.
|
||||
fn checked<T>(device: &wgpu::Device, create: impl FnOnce() -> T) -> Result<T, String> {
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let value = create();
|
||||
let value = crate::crash::contain(create);
|
||||
match pollster::block_on(scope.pop()) {
|
||||
Some(e) => {
|
||||
// The details are in the error's description or its sources.
|
||||
@@ -494,7 +496,7 @@ fn checked<T>(device: &wgpu::Device, create: impl FnOnce() -> T) -> Result<T, St
|
||||
}
|
||||
Err(message)
|
||||
}
|
||||
None => Ok(value),
|
||||
None => value.map_err(|panic| format!("the shader compiler failed: {panic}")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2629,6 +2631,38 @@ mod tests {
|
||||
assert_eq!(used, [1, 2, 3]);
|
||||
}
|
||||
|
||||
/// #3525: naga's SPIR-V writer panicked on Photon's gbuffers programs as the pipeline was
|
||||
/// made. Such a panic skips the pass, as a validation error does, and the device goes on.
|
||||
#[test]
|
||||
fn a_shader_compiler_panic_skips_the_pass() {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&Default::default()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let failed = checked(&device, || -> wgpu::Buffer {
|
||||
panic!("internal error: entered unreachable code: Expression [89] is not cached!")
|
||||
});
|
||||
let error = failed.expect_err("the panic comes back as an error");
|
||||
assert!(error.contains("Expression [89] is not cached!"), "{error}");
|
||||
let buffer = |usage| {
|
||||
checked(&device, || {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 16,
|
||||
usage,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
})
|
||||
};
|
||||
// The device still makes things, and its errors still come back.
|
||||
assert!(buffer(wgpu::BufferUsages::UNIFORM).is_ok());
|
||||
assert!(buffer(wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::MAP_WRITE).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_formats_map_to_renderable_ones() {
|
||||
let f = |name| texture_format(Format::parse(name).unwrap(), wgpu::Features::empty());
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
//! Screenshots of a swapchain that cannot be copied from. OpenGL's surfaces
|
||||
//! offer `RENDER_ATTACHMENT` only, so a frame whose pixels are read back
|
||||
//! (the screenshot key, a world's icon) is drawn into a texture that can be,
|
||||
//! then drawn onto the swapchain image as a fullscreen triangle.
|
||||
|
||||
/// The texture a frame to read back is drawn into, and the pass that puts it
|
||||
/// on the swapchain afterwards.
|
||||
#[derive(Default)]
|
||||
pub struct Readback {
|
||||
texture: Option<wgpu::Texture>,
|
||||
blitter: Option<(wgpu::TextureFormat, wgpu::util::TextureBlitter)>,
|
||||
/// A frame drew into `texture` and still has to reach the swapchain.
|
||||
drawn: bool,
|
||||
}
|
||||
|
||||
impl Readback {
|
||||
/// The texture this frame draws into instead of the swapchain image
|
||||
/// `surface`: `None` unless the frame is read back (`screenshot`) and the
|
||||
/// image cannot be copied from.
|
||||
pub fn target(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
surface: &wgpu::Texture,
|
||||
screenshot: bool,
|
||||
) -> Option<wgpu::Texture> {
|
||||
if !screenshot || surface.usage().contains(wgpu::TextureUsages::COPY_SRC) {
|
||||
return None;
|
||||
}
|
||||
let format = surface.format().remove_srgb_suffix();
|
||||
if self
|
||||
.texture
|
||||
.as_ref()
|
||||
.is_none_or(|t| t.size() != surface.size() || t.format() != format)
|
||||
{
|
||||
self.texture = Some(device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Horizon screenshot frame"),
|
||||
size: surface.size(),
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::COPY_SRC
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
}));
|
||||
}
|
||||
self.drawn = true;
|
||||
self.texture.clone()
|
||||
}
|
||||
|
||||
/// Draws the frame `target` gave onto the swapchain image `surface`; nothing
|
||||
/// when this frame drew straight into it.
|
||||
pub fn present(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
surface: &wgpu::Texture,
|
||||
) {
|
||||
if !std::mem::take(&mut self.drawn) {
|
||||
return;
|
||||
}
|
||||
let Some(texture) = &self.texture else {
|
||||
return;
|
||||
};
|
||||
// The frame's passes write the swapchain through its non-sRGB view
|
||||
// (`view_formats`), so the copy goes there unconverted too.
|
||||
let format = surface.format().remove_srgb_suffix();
|
||||
if self.blitter.as_ref().is_none_or(|b| b.0 != format) {
|
||||
self.blitter = Some((format, wgpu::util::TextureBlitter::new(device, format)));
|
||||
}
|
||||
let target = surface.create_view(&wgpu::TextureViewDescriptor {
|
||||
format: Some(format),
|
||||
..Default::default()
|
||||
});
|
||||
let source = texture.create_view(&Default::default());
|
||||
if let Some((_, blitter)) = &self.blitter {
|
||||
blitter.copy(device, encoder, &source, &target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
}
|
||||
|
||||
fn texture(device: &wgpu::Device, usage: wgpu::TextureUsages) -> wgpu::Texture {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 70,
|
||||
height: 3,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Bgra8Unorm,
|
||||
usage,
|
||||
view_formats: &[],
|
||||
})
|
||||
}
|
||||
|
||||
fn clear(encoder: &mut wgpu::CommandEncoder, texture: &wgpu::Texture, color: wgpu::Color) {
|
||||
let view = texture.create_view(&Default::default());
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: None,
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(color),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
fn read(device: &wgpu::Device, buffer: &wgpu::Buffer) -> Vec<u8> {
|
||||
buffer.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
buffer.get_mapped_range(..).unwrap().to_vec()
|
||||
}
|
||||
|
||||
/// The last pixel of the last row of a 70x3 frame read back by `copy_frame`.
|
||||
fn last_pixel(device: &wgpu::Device, buffer: &wgpu::Buffer) -> Vec<u8> {
|
||||
let pixels = read(device, buffer);
|
||||
let row = 70usize * 4;
|
||||
let last = row.next_multiple_of(256) * 2 + row - 4;
|
||||
pixels[last..last + 4].to_vec()
|
||||
}
|
||||
|
||||
/// The renderer's screenshot path on a surface like OpenGL's (#3510): the
|
||||
/// frame is drawn into `target`, read back with `copy_frame` and drawn on
|
||||
/// the swapchain, with no validation error (the copy used to need the
|
||||
/// swapchain's missing `COPY_SRC`), and the swapchain shows the frame.
|
||||
#[test]
|
||||
fn screenshot_of_a_surface_without_copy_src() {
|
||||
let Some((device, queue)) = device() else {
|
||||
return;
|
||||
};
|
||||
// Sampled only so the test can look at what reached it.
|
||||
let surface = texture(
|
||||
&device,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
);
|
||||
let check = texture(
|
||||
&device,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
let blitter = wgpu::util::TextureBlitter::new(&device, wgpu::TextureFormat::Bgra8Unorm);
|
||||
let mut readback = Readback::default();
|
||||
let colors = [
|
||||
(1.0, [0, 0, 255, 255]),
|
||||
// A second screenshot reuses the texture.
|
||||
(0.0, [255, 0, 0, 255]),
|
||||
];
|
||||
for (r, bgra) in colors {
|
||||
let color = wgpu::Color {
|
||||
r,
|
||||
g: 0.0,
|
||||
b: 1.0 - r,
|
||||
a: 1.0,
|
||||
};
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let frame = readback
|
||||
.target(&device, &surface, true)
|
||||
.unwrap_or_else(|| surface.clone());
|
||||
clear(&mut encoder, &frame, color);
|
||||
let buffer = crate::gfx::copy_frame(&device, &mut encoder, &frame, [70, 3]);
|
||||
readback.present(&device, &mut encoder, &surface);
|
||||
queue.submit([encoder.finish()]);
|
||||
if let Some(error) = pollster::block_on(scope.pop()) {
|
||||
panic!("{error}");
|
||||
}
|
||||
assert_eq!(last_pixel(&device, &buffer), bgra);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
|
||||
blitter.copy(&device, &mut encoder, &view(&surface), &view(&check));
|
||||
let buffer = crate::gfx::copy_frame(&device, &mut encoder, &check, [70, 3]);
|
||||
queue.submit([encoder.finish()]);
|
||||
assert_eq!(last_pixel(&device, &buffer), bgra);
|
||||
}
|
||||
assert!(!readback.drawn);
|
||||
}
|
||||
|
||||
/// A surface that can be copied from, or a frame nobody reads back,
|
||||
/// draws straight into the swapchain image.
|
||||
#[test]
|
||||
fn frames_draw_straight_to_a_readable_surface() {
|
||||
let Some((device, _)) = device() else {
|
||||
return;
|
||||
};
|
||||
let mut readback = Readback::default();
|
||||
let readable = texture(
|
||||
&device,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
assert!(readback.target(&device, &readable, true).is_none());
|
||||
let surface = texture(&device, wgpu::TextureUsages::RENDER_ATTACHMENT);
|
||||
assert!(readback.target(&device, &surface, false).is_none());
|
||||
assert!(readback.texture.is_none() && !readback.drawn);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//! The views a frame is drawn from. A flat screen has one; a headset has
|
||||
//! one per eye. Every view shares the frame's camera position, so the
|
||||
//! camera-relative world (section origins, entity offsets) is built once and
|
||||
//! only each view's rotation and lens differ.
|
||||
|
||||
use glam::Mat4;
|
||||
|
||||
/// The most views one frame draws.
|
||||
const MAX_VIEWS: usize = 2;
|
||||
|
||||
/// One eye's view of the camera-relative world.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct View {
|
||||
/// Camera-relative world to eye: a rotation, plus the eye's offset from
|
||||
/// the camera on a headset.
|
||||
pub view: Mat4,
|
||||
/// Reversed-Z perspective: the near plane at depth 1, far at 0.
|
||||
pub projection: Mat4,
|
||||
/// Width over height of the image this view fills.
|
||||
pub aspect: f32,
|
||||
/// `tan(fov_y / 2)`, for shaders rebuilding view rays from NDC.
|
||||
pub tan_half_fov: f32,
|
||||
}
|
||||
|
||||
impl View {
|
||||
/// The flat-screen view: vanilla's camera rotation and a symmetric
|
||||
/// vertical field of view.
|
||||
pub fn screen(yaw: f32, pitch: f32, fov_degrees: f32, aspect: f32, far: f32) -> Self {
|
||||
Self {
|
||||
view: crate::camera::view_matrix(yaw, pitch),
|
||||
projection: super::world_projection(fov_degrees, aspect, far),
|
||||
aspect,
|
||||
tan_half_fov: (fov_degrees.to_radians() / 2.0).tan(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Clip space from the camera-relative world, with `pose` (view bobbing,
|
||||
/// nausea spin) between the lens and the rotation.
|
||||
pub fn view_proj(&self, pose: Mat4) -> Mat4 {
|
||||
self.projection * pose * self.view
|
||||
}
|
||||
}
|
||||
|
||||
/// Every view of a frame, and the one geometry is culled with.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ViewSet {
|
||||
views: [View; MAX_VIEWS],
|
||||
len: usize,
|
||||
cull: View,
|
||||
}
|
||||
|
||||
impl ViewSet {
|
||||
/// A frame drawn from `view` alone, culled with it.
|
||||
pub fn single(view: View) -> Self {
|
||||
Self {
|
||||
views: [view; MAX_VIEWS],
|
||||
len: 1,
|
||||
cull: view,
|
||||
}
|
||||
}
|
||||
|
||||
/// The view the HUD, hand and screen-space effects follow.
|
||||
pub fn primary(&self) -> &View {
|
||||
&self.views[0]
|
||||
}
|
||||
|
||||
pub fn views(&self) -> &[View] {
|
||||
&self.views[..self.len]
|
||||
}
|
||||
|
||||
/// The view whose frustum covers every other view's.
|
||||
pub fn cull(&self) -> &View {
|
||||
&self.cull
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The formulas `Renderer` used before views existed, kept to pin the
|
||||
/// screen view to them bit for bit.
|
||||
fn legacy_view_proj(fov: f32, yaw: f32, pitch: f32, pose: Mat4, aspect: f32, far: f32) -> Mat4 {
|
||||
super::super::world_projection(fov, aspect, far)
|
||||
* pose
|
||||
* crate::camera::view_matrix(yaw, pitch)
|
||||
}
|
||||
|
||||
fn poses() -> [Mat4; 3] {
|
||||
[
|
||||
Mat4::IDENTITY,
|
||||
Mat4::from_translation(glam::Vec3::new(0.013, -0.027, 0.0))
|
||||
* Mat4::from_rotation_z(0.021),
|
||||
Mat4::from_rotation_y(0.4) * Mat4::from_scale(glam::Vec3::new(1.1, 0.9, 1.0)),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_screen_view_matches_the_legacy_projection_bit_for_bit() {
|
||||
for (fov, yaw, pitch, aspect, far) in [
|
||||
(70.0, 0.0, 0.0, 16.0 / 9.0, 512.0),
|
||||
(110.0, -137.25, 61.5, 21.0 / 9.0, 8192.0),
|
||||
(30.0, 359.9, -90.0, 4.0 / 3.0, 96.0),
|
||||
(82.333, 12.5, 7.75, 1.0, 1024.0),
|
||||
] {
|
||||
let view = View::screen(yaw, pitch, fov, aspect, far);
|
||||
for pose in poses() {
|
||||
let old = legacy_view_proj(fov, yaw, pitch, pose, aspect, far);
|
||||
let new = view.view_proj(pose);
|
||||
assert_eq!(
|
||||
old.to_cols_array().map(f32::to_bits),
|
||||
new.to_cols_array().map(f32::to_bits),
|
||||
"fov {fov} yaw {yaw} pitch {pitch}"
|
||||
);
|
||||
}
|
||||
assert_eq!(view.view, crate::camera::view_matrix(yaw, pitch));
|
||||
assert_eq!(
|
||||
view.tan_half_fov.to_bits(),
|
||||
(fov.to_radians() / 2.0).tan().to_bits()
|
||||
);
|
||||
assert_eq!(view.aspect.to_bits(), aspect.to_bits());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_view_set_draws_and_culls_with_its_view() {
|
||||
let view = View::screen(45.0, 10.0, 70.0, 16.0 / 9.0, 512.0);
|
||||
let set = ViewSet::single(view);
|
||||
assert_eq!(set.views(), &[view]);
|
||||
assert_eq!(*set.primary(), view);
|
||||
assert_eq!(*set.cull(), view);
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,15 @@ struct TaaUniform {
|
||||
jitter: [f32; 4],
|
||||
}
|
||||
|
||||
/// TAA's GPU resources, absent when the device cannot compile the shader
|
||||
/// (the OpenGL backend's naga GLSL emitter does not support `textureLoad`
|
||||
/// from depth textures).
|
||||
struct TaaResources {
|
||||
layout: wgpu::BindGroupLayout,
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
uniform: wgpu::Buffer,
|
||||
}
|
||||
|
||||
pub struct Post {
|
||||
layout: wgpu::BindGroupLayout,
|
||||
sampler: wgpu::Sampler,
|
||||
@@ -57,9 +66,7 @@ pub struct Post {
|
||||
/// TAA runs this frame (`jitter`): not on the title screen or under a shader pack.
|
||||
taa_on: bool,
|
||||
target: Option<WorldTarget>,
|
||||
taa_layout: wgpu::BindGroupLayout,
|
||||
taa_pipeline: wgpu::RenderPipeline,
|
||||
taa_uniform: wgpu::Buffer,
|
||||
taa_resources: Option<TaaResources>,
|
||||
/// Frames jittered so far (the Halton index), and which history the next resolve writes.
|
||||
taa_frame: u32,
|
||||
/// The last TAA frame's unjittered `view_proj` and camera, `None` once a frame ran without it.
|
||||
@@ -105,40 +112,11 @@ impl Post {
|
||||
count: None,
|
||||
};
|
||||
let float = wgpu::TextureSampleType::Float { filterable: true };
|
||||
let taa_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Horizon TAA bindings"),
|
||||
entries: &[
|
||||
texture(0, float),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
texture(2, float),
|
||||
texture(3, wgpu::TextureSampleType::Depth),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 4,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Horizon post"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let taa_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Horizon TAA"),
|
||||
bind_group_layouts: &[Some(&taa_layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let pipeline = |entry: &str, layout: &wgpu::PipelineLayout, format| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Horizon post"),
|
||||
@@ -166,6 +144,88 @@ impl Post {
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
// The OpenGL backend's GLSL translator does not support textureLoad
|
||||
// from depth textures: on devices running through OpenGL (such as older
|
||||
// Intel GPUs on Linux), TAA cannot compile its pipeline.
|
||||
let taa_resources = {
|
||||
let taa_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Horizon TAA bindings"),
|
||||
entries: &[
|
||||
texture(0, float),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
texture(2, float),
|
||||
texture(3, wgpu::TextureSampleType::Depth),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 4,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let taa_pipeline_layout =
|
||||
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Horizon TAA"),
|
||||
bind_group_layouts: &[Some(&taa_layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
|
||||
let taa_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Horizon TAA"),
|
||||
layout: Some(&taa_pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_post"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_taa"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: HISTORY_FORMAT,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let internal = pollster::block_on(internal.pop());
|
||||
match internal.or(pollster::block_on(validation.pop())) {
|
||||
None => {
|
||||
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon TAA"),
|
||||
size: std::mem::size_of::<TaaUniform>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
Some(TaaResources {
|
||||
layout: taa_layout,
|
||||
pipeline: taa_pipeline,
|
||||
uniform,
|
||||
})
|
||||
}
|
||||
Some(e) => {
|
||||
log::warn!("Horizon renderer: temporal anti-aliasing unavailable ({e})");
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Horizon post"),
|
||||
mag_filter: wgpu::FilterMode::Linear,
|
||||
@@ -175,15 +235,7 @@ impl Post {
|
||||
Self {
|
||||
copy: pipeline("fs_copy", &pipeline_layout, format),
|
||||
fxaa: pipeline("fs_fxaa", &pipeline_layout, format),
|
||||
taa_pipeline: pipeline("fs_taa", &taa_pipeline_layout, HISTORY_FORMAT),
|
||||
taa_uniform: device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon TAA"),
|
||||
size: std::mem::size_of::<TaaUniform>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
layout,
|
||||
taa_layout,
|
||||
sampler,
|
||||
format,
|
||||
scale: 1.0,
|
||||
@@ -192,12 +244,18 @@ impl Post {
|
||||
upscale: 1.0,
|
||||
taa_on: false,
|
||||
target: None,
|
||||
taa_resources,
|
||||
taa_frame: 0,
|
||||
taa_previous: None,
|
||||
taa_this_frame: bytemuck::Zeroable::zeroed(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether TAA is supported on this GPU/backend.
|
||||
pub fn supports_taa(&self) -> bool {
|
||||
self.taa_resources.is_some()
|
||||
}
|
||||
|
||||
/// The world's rendered size and the size it is shown at (TAA's history).
|
||||
fn sizes(&self, window: [u32; 2]) -> ([u32; 2], [u32; 2]) {
|
||||
let output = window.map(|v| ((v as f32 * self.scale).round() as u32).clamp(1, 8192));
|
||||
@@ -212,7 +270,7 @@ impl Post {
|
||||
/// world's projection (identity while `on` is false); `view_proj` is the unjittered
|
||||
/// projection, relative to `camera`. Call it before `prepare`, once per frame.
|
||||
pub fn jitter(&mut self, window: [u32; 2], on: bool, view_proj: Mat4, camera: DVec3) -> Mat4 {
|
||||
self.taa_on = on && self.taa;
|
||||
self.taa_on = on && self.taa && self.taa_resources.is_some();
|
||||
if !self.taa_on {
|
||||
self.taa_previous = None;
|
||||
return Mat4::IDENTITY;
|
||||
@@ -290,7 +348,8 @@ impl Post {
|
||||
)
|
||||
.create_view(&Default::default());
|
||||
let group = self.bind(device, &color);
|
||||
let taa = history_size.map(|output| {
|
||||
let taa = history_size.and_then(|output| {
|
||||
let res = self.taa_resources.as_ref()?;
|
||||
let history = [0, 1].map(|_| {
|
||||
make_tex("TAA history", output, HISTORY_FORMAT, sampled)
|
||||
.create_view(&Default::default())
|
||||
@@ -298,7 +357,7 @@ impl Post {
|
||||
let resolve = [0, 1].map(|i| {
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Horizon TAA"),
|
||||
layout: &self.taa_layout,
|
||||
layout: &res.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
@@ -318,18 +377,18 @@ impl Post {
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: self.taa_uniform.as_entire_binding(),
|
||||
resource: res.uniform.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
})
|
||||
});
|
||||
let show = [0, 1].map(|i| self.bind(device, &history[i]));
|
||||
History {
|
||||
Some(History {
|
||||
size: output,
|
||||
history,
|
||||
resolve,
|
||||
show,
|
||||
}
|
||||
})
|
||||
});
|
||||
// New textures: no history yet.
|
||||
self.taa_this_frame.jitter[2] = 1.0;
|
||||
@@ -381,12 +440,12 @@ impl Post {
|
||||
self.finish_with(encoder, screen, &target.group);
|
||||
return;
|
||||
};
|
||||
let Some(res) = &self.taa_resources else {
|
||||
self.finish_with(encoder, screen, &target.group);
|
||||
return;
|
||||
};
|
||||
let i = (self.taa_frame % 2) as usize;
|
||||
queue.write_buffer(
|
||||
&self.taa_uniform,
|
||||
0,
|
||||
bytemuck::bytes_of(&self.taa_this_frame),
|
||||
);
|
||||
queue.write_buffer(&res.uniform, 0, bytemuck::bytes_of(&self.taa_this_frame));
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &taa.history[i],
|
||||
resolve_target: None,
|
||||
@@ -401,7 +460,7 @@ impl Post {
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.taa_pipeline);
|
||||
pass.set_pipeline(&res.pipeline);
|
||||
pass.set_bind_group(0, &taa.resolve[i], &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
drop(pass);
|
||||
@@ -485,4 +544,21 @@ mod tests {
|
||||
[0.5, 0.25, 0.75]
|
||||
);
|
||||
}
|
||||
|
||||
fn test_device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance =
|
||||
wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle().with_env());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_post_pipelines_compile_or_fall_back() {
|
||||
let Some((device, _)) = test_device() else {
|
||||
return;
|
||||
};
|
||||
let post = Post::new(&device, wgpu::TextureFormat::Rgba8Unorm);
|
||||
// Copy and FXAA pipelines always compile; TAA compiles if supported by backend.
|
||||
let _ = post.supports_taa();
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,39 @@
|
||||
//! `horizon-graphics.txt` next to `options.txt` so that file stays exactly
|
||||
//! vanilla's. Every default reproduces vanilla's rendering; only choices the
|
||||
//! player makes here depart from it.
|
||||
use std::{fs, path::Path};
|
||||
use std::{
|
||||
fs,
|
||||
path::Path,
|
||||
sync::atomic::{AtomicU32, Ordering},
|
||||
};
|
||||
|
||||
pub const FILE: &str = "horizon-graphics.txt";
|
||||
/// Written beside it before HDR output is switched on and removed once the first HDR frame is
|
||||
/// presented. Found at the next start, it means HDR output took the game down, so HDR starts off.
|
||||
pub const HDR_MARKER: &str = "horizon-hdr-pending";
|
||||
|
||||
/// What the system reports for the display HDR output goes to, in nits in HDR output's
|
||||
/// scale: where its SDR white sits (the desktop's white) and the peak.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Reported {
|
||||
pub paper_white: u32,
|
||||
pub peak: u32,
|
||||
}
|
||||
|
||||
static REPORTED: [AtomicU32; 2] = [AtomicU32::new(0), AtomicU32::new(0)];
|
||||
|
||||
/// Sets what the system reports (the renderer, when it opens its surface).
|
||||
pub fn report(reported: Option<Reported>) {
|
||||
let (paper_white, peak) = reported.map_or((0, 0), |r| (r.paper_white, r.peak));
|
||||
REPORTED[0].store(paper_white, Ordering::Relaxed);
|
||||
REPORTED[1].store(peak, Ordering::Relaxed);
|
||||
}
|
||||
/// What the system reports, if it does.
|
||||
pub fn reported() -> Option<Reported> {
|
||||
let [paper_white, peak] = REPORTED.each_ref().map(|n| n.load(Ordering::Relaxed));
|
||||
(peak > 0).then_some(Reported { paper_white, peak })
|
||||
}
|
||||
|
||||
/// Simple-level presets. `Custom` leaves every option as the player set it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum Preset {
|
||||
@@ -285,6 +311,16 @@ pub fn reduced_far_detail_suggested() -> bool {
|
||||
REDUCED_SUGGESTED.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
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.
|
||||
pub fn offer_hdr(on: bool) {
|
||||
HDR_OFFERED.store(on, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
pub fn hdr_offered() -> bool {
|
||||
HDR_OFFERED.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The farthest LOD Distance, in chunks, on integrated GPUs other than
|
||||
/// Apple's: they share the CPU's memory bandwidth, and past it far terrain
|
||||
/// costs them more frame time than it is worth.
|
||||
@@ -343,10 +379,18 @@ pub struct Graphics {
|
||||
pub upscaling: Upscaling,
|
||||
/// HDR output (HDR10, or scRGB), where the display path offers it.
|
||||
pub hdr: bool,
|
||||
/// HDR paper white (where SDR white sits), in nits.
|
||||
/// HDR paper white (where SDR white sits), in nits; 0 is Auto: the desktop's SDR
|
||||
/// white, as the system reports it ([`Graphics::paper_white_nits`]).
|
||||
pub hdr_paper_white: u32,
|
||||
/// HDR peak brightness the world's highlights reach, in nits.
|
||||
/// HDR peak brightness the world's highlights reach, in nits; 0 is Auto:
|
||||
/// what the system reports for the display ([`Graphics::peak_nits`]).
|
||||
pub hdr_peak: u32,
|
||||
/// HDR black level: the display's darkest black, in hundredths of a nit (0 for
|
||||
/// an OLED's true black). Shadows are lifted onto it, and the HDR metadata says it.
|
||||
pub hdr_black_level: u32,
|
||||
/// HDR brightness of the HUD and menus, in percent of paper white (25..=100); the
|
||||
/// first-person hand stays at the world's.
|
||||
pub hdr_ui_brightness: u32,
|
||||
/// 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.
|
||||
@@ -381,8 +425,10 @@ impl Default for Graphics {
|
||||
taa: false,
|
||||
upscaling: Upscaling::Native,
|
||||
hdr: false,
|
||||
hdr_paper_white: 200,
|
||||
hdr_peak: 1000,
|
||||
hdr_paper_white: 0,
|
||||
hdr_peak: 0,
|
||||
hdr_black_level: 0,
|
||||
hdr_ui_brightness: 100,
|
||||
battery: false,
|
||||
api: GraphicsApi::Auto,
|
||||
shader_pack: String::new(),
|
||||
@@ -445,8 +491,22 @@ impl Graphics {
|
||||
"taa" => g.taa = flag(g.taa),
|
||||
"upscaling" => g.upscaling = Upscaling::parse(value),
|
||||
"hdr" => g.hdr = flag(g.hdr),
|
||||
"hdrPaperWhite" => g.hdr_paper_white = number(g.hdr_paper_white).clamp(80, 480),
|
||||
"hdrPeak" => g.hdr_peak = number(g.hdr_peak).clamp(200, 2000),
|
||||
"hdrPaperWhite" => {
|
||||
g.hdr_paper_white = match number(g.hdr_paper_white) {
|
||||
0 => 0,
|
||||
n => n.clamp(80, 480),
|
||||
}
|
||||
}
|
||||
"hdrPeak" => {
|
||||
g.hdr_peak = match number(g.hdr_peak) {
|
||||
0 => 0,
|
||||
n => n.clamp(200, 2000),
|
||||
}
|
||||
}
|
||||
"hdrBlackLevel" => g.hdr_black_level = number(g.hdr_black_level).min(100),
|
||||
"hdrUiBrightness" => {
|
||||
g.hdr_ui_brightness = number(g.hdr_ui_brightness).clamp(25, 100)
|
||||
}
|
||||
"batteryMode" => g.battery = flag(g.battery),
|
||||
"graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(),
|
||||
"pickedShaderPack" => g.shader_pack = value.to_owned(),
|
||||
@@ -464,7 +524,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:{}\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:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
@@ -494,6 +554,8 @@ impl Graphics {
|
||||
bool_text(self.hdr),
|
||||
self.hdr_paper_white,
|
||||
self.hdr_peak,
|
||||
self.hdr_black_level,
|
||||
self.hdr_ui_brightness,
|
||||
bool_text(self.battery),
|
||||
self.api.name(),
|
||||
self.shader_pack,
|
||||
@@ -502,6 +564,22 @@ impl Graphics {
|
||||
pub fn save(&self, dir: &Path) {
|
||||
let _ = fs::write(dir.join(FILE), self.serialize());
|
||||
}
|
||||
/// Paper white, in nits: the player's, or on Auto the desktop's SDR white as the
|
||||
/// system reports it, else 203 (BT.2408's reference white).
|
||||
pub fn paper_white_nits(&self) -> u32 {
|
||||
match self.hdr_paper_white {
|
||||
0 => reported().map_or(203, |r| r.paper_white),
|
||||
n => n,
|
||||
}
|
||||
}
|
||||
/// The peak the world's highlights reach, in nits: the player's, or on Auto
|
||||
/// what the system reports, else 1000.
|
||||
pub fn peak_nits(&self) -> u32 {
|
||||
match self.hdr_peak {
|
||||
0 => reported().map_or(1000, |r| r.peak),
|
||||
n => n,
|
||||
}
|
||||
}
|
||||
/// Worker threads for chunk generation and meshing.
|
||||
pub fn worker_threads(&self) -> usize {
|
||||
if self.workers > 0 {
|
||||
@@ -611,12 +689,19 @@ mod tests {
|
||||
hdr: true,
|
||||
hdr_paper_white: 240,
|
||||
hdr_peak: 1400,
|
||||
hdr_black_level: 12,
|
||||
hdr_ui_brightness: 60,
|
||||
battery: true,
|
||||
api: GraphicsApi::Dx12,
|
||||
shader_pack: "Golden Hour".into(),
|
||||
};
|
||||
assert_eq!(Graphics::parse(&g.serialize()), g);
|
||||
assert_eq!(Graphics::parse(""), Graphics::default());
|
||||
// 0 is Auto; anything else is a peak the slider reaches.
|
||||
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);
|
||||
// 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());
|
||||
|
||||
@@ -130,8 +130,9 @@ impl Style {
|
||||
label: &Text,
|
||||
value: f32,
|
||||
hover: bool,
|
||||
active: bool,
|
||||
) {
|
||||
ui::slider(batch, atlas, x, y, w, label, value, hover, true);
|
||||
ui::slider(batch, atlas, x, y, w, label, value, hover && active, active);
|
||||
}
|
||||
/// The cost tag and the description beside a control, wrapped to two lines.
|
||||
pub fn description(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Horizon's graphics settings screens, Simple and Advanced: which settings
|
||||
//! Horizon's graphics settings screens, Simple, Advanced and HDR: which settings
|
||||
//! they show, in what groups, with what description and cost, and their
|
||||
//! input. How they look comes from `graphics_style`.
|
||||
use crate::{
|
||||
@@ -14,6 +14,8 @@ use crate::{
|
||||
pub enum Level {
|
||||
Simple,
|
||||
Advanced,
|
||||
/// HDR output's own settings, opened from Advanced while HDR is on.
|
||||
Hdr,
|
||||
}
|
||||
|
||||
/// Horizon's own settings (`graphics::Graphics`).
|
||||
@@ -38,8 +40,12 @@ pub enum H {
|
||||
Antialiasing,
|
||||
Upscaling,
|
||||
Hdr,
|
||||
HdrPaperWhite,
|
||||
/// Opens `Level::Hdr`.
|
||||
HdrSettings,
|
||||
HdrBlackLevel,
|
||||
HdrPeak,
|
||||
HdrPaperWhite,
|
||||
HdrUiBrightness,
|
||||
BatteryMode,
|
||||
GraphicsApi,
|
||||
}
|
||||
@@ -120,6 +126,34 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
);
|
||||
let mut rows = match level {
|
||||
Level::Simple => vec![preset, render, lod, far, fov, fps, vsync, gui],
|
||||
Level::Hdr => vec![
|
||||
Row::Header("HDR"),
|
||||
h(
|
||||
H::Hdr,
|
||||
None,
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.",
|
||||
),
|
||||
h(
|
||||
H::HdrBlackLevel,
|
||||
None,
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.",
|
||||
),
|
||||
h(
|
||||
H::HdrPeak,
|
||||
None,
|
||||
"How bright highlights get. Auto: the peak your system reports.",
|
||||
),
|
||||
h(
|
||||
H::HdrPaperWhite,
|
||||
None,
|
||||
"How bright white paper and menus look. Auto: your desktop's white.",
|
||||
),
|
||||
h(
|
||||
H::HdrUiBrightness,
|
||||
None,
|
||||
"How bright the HUD and menus are, against paper white.",
|
||||
),
|
||||
],
|
||||
Level::Advanced => vec![
|
||||
Row::Header("Rendering"),
|
||||
render,
|
||||
@@ -230,20 +264,10 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
"Renders the world smaller and rebuilds it with TAA. Faster, softer.",
|
||||
),
|
||||
h(
|
||||
H::Hdr,
|
||||
H::HdrSettings,
|
||||
None,
|
||||
"HDR output on an HDR display: lava, fire and sunlight shine brighter.",
|
||||
),
|
||||
h(
|
||||
H::HdrPaperWhite,
|
||||
None,
|
||||
"HDR: how bright white paper and menus look. Match your desktop.",
|
||||
),
|
||||
h(
|
||||
H::HdrPeak,
|
||||
None,
|
||||
"HDR: how bright the brightest highlights get. Your display's peak.",
|
||||
),
|
||||
h(
|
||||
H::PresentMode,
|
||||
None,
|
||||
@@ -351,8 +375,18 @@ impl H {
|
||||
}
|
||||
H::Workers => Some(g.workers as f32 / max_workers() as f32),
|
||||
H::ResolutionScale => Some((g.resolution_scale as f32 - 50.0) / 150.0),
|
||||
H::HdrPaperWhite => Some((g.hdr_paper_white as f32 - 80.0) / 400.0),
|
||||
H::HdrPeak => Some((g.hdr_peak as f32 - 200.0) / 1800.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 200..2000 by 50.
|
||||
H::HdrPeak => Some(match g.hdr_peak {
|
||||
0 => 0.0,
|
||||
n => (n as f32 - 150.0) / 1850.0,
|
||||
}),
|
||||
H::HdrBlackLevel => Some(g.hdr_black_level as f32 / 100.0),
|
||||
H::HdrUiBrightness => Some((g.hdr_ui_brightness as f32 - 25.0) / 75.0),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -403,8 +437,23 @@ impl H {
|
||||
),
|
||||
H::Upscaling => labelled("Upscaling", g.upscaling.label()),
|
||||
H::Hdr => labelled("HDR", on_off(g.hdr)),
|
||||
H::HdrPaperWhite => labelled("Paper White", format!("{} nits", g.hdr_paper_white)),
|
||||
H::HdrPeak => labelled("Peak Brightness", format!("{} nits", g.hdr_peak)),
|
||||
H::HdrSettings => Text::literal("HDR Settings..."),
|
||||
H::HdrPaperWhite => match g.hdr_paper_white {
|
||||
0 => labelled(
|
||||
"Paper White",
|
||||
format!("Auto ({} nits)", g.paper_white_nits()),
|
||||
),
|
||||
n => labelled("Paper White", format!("{n} nits")),
|
||||
},
|
||||
H::HdrPeak => match g.hdr_peak {
|
||||
0 => labelled("Peak Brightness", format!("Auto ({} nits)", g.peak_nits())),
|
||||
n => labelled("Peak Brightness", format!("{n} nits")),
|
||||
},
|
||||
H::HdrBlackLevel => labelled(
|
||||
"Black Level",
|
||||
format!("{:.2} nits", g.hdr_black_level as f32 / 100.0),
|
||||
),
|
||||
H::HdrUiBrightness => labelled("UI Brightness", format!("{}%", g.hdr_ui_brightness)),
|
||||
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
|
||||
H::GraphicsApi => labelled("Graphics API", g.api.label()),
|
||||
H::Overlay => labelled(
|
||||
@@ -427,8 +476,20 @@ impl H {
|
||||
}
|
||||
H::Workers => g.workers = (v * max_workers() as f32).round() as u32,
|
||||
H::ResolutionScale => g.resolution_scale = (50.0 + (v * 30.0).round() * 5.0) as u32,
|
||||
H::HdrPaperWhite => g.hdr_paper_white = (80.0 + (v * 40.0).round() * 10.0) as u32,
|
||||
H::HdrPeak => g.hdr_peak = (200.0 + (v * 36.0).round() * 50.0) as u32,
|
||||
H::HdrPaperWhite => {
|
||||
g.hdr_paper_white = match (v * 41.0).round() as u32 {
|
||||
0 => 0,
|
||||
step => 70 + step * 10,
|
||||
}
|
||||
}
|
||||
H::HdrPeak => {
|
||||
g.hdr_peak = match (v * 37.0).round() as u32 {
|
||||
0 => 0,
|
||||
step => 150 + step * 50,
|
||||
}
|
||||
}
|
||||
H::HdrBlackLevel => g.hdr_black_level = (v * 100.0).round() as u32,
|
||||
H::HdrUiBrightness => g.hdr_ui_brightness = (25.0 + (v * 15.0).round() * 5.0) as u32,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -504,6 +565,25 @@ impl Item {
|
||||
fn available(self) -> bool {
|
||||
!matches!(self, Item::Vanilla(o) if !o.available())
|
||||
}
|
||||
/// Why a control can't be used now, when it can't: HDR's screen opens only where
|
||||
/// the display offers HDR output, whatever `horizon-graphics.txt` says (a copied game
|
||||
/// folder, a display swapped for an SDR one: the renderer leaves HDR off there, and
|
||||
/// the saved values wait for an HDR display), and its sliders do something only
|
||||
/// while it is on.
|
||||
fn blocked(self, s: &Settings) -> Option<&'static str> {
|
||||
if !self.available() {
|
||||
return Some("This feature is not yet available in Horizon.");
|
||||
}
|
||||
match self {
|
||||
Item::Horizon(H::HdrSettings | H::Hdr) if !crate::graphics::hdr_offered() => {
|
||||
Some("Your display or system doesn't offer HDR output.")
|
||||
}
|
||||
Item::Horizon(
|
||||
H::HdrBlackLevel | H::HdrPeak | H::HdrPaperWhite | H::HdrUiBrightness,
|
||||
) if !s.horizon.hdr => Some("Turn HDR on to use it."),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// On/off settings and their value.
|
||||
pub fn switch(self, s: &Settings) -> Option<bool> {
|
||||
let g = &s.horizon;
|
||||
@@ -605,6 +685,7 @@ pub enum Action {
|
||||
SliderDrag,
|
||||
OpenAdvanced,
|
||||
OpenSimple,
|
||||
OpenHdr,
|
||||
Done,
|
||||
}
|
||||
|
||||
@@ -624,15 +705,20 @@ struct Widget {
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct GraphicsUi {
|
||||
scroll: f32,
|
||||
/// The list's scroll amount and its held scrollbar (`AbstractScrollArea`).
|
||||
scroll: ui::Scroll,
|
||||
dragging: Option<Item>,
|
||||
}
|
||||
|
||||
impl GraphicsUi {
|
||||
pub fn reset(&mut self) {
|
||||
self.scroll = 0.0;
|
||||
self.scroll = ui::Scroll::default();
|
||||
self.dragging = None;
|
||||
}
|
||||
/// The scrollbar's column: right of the descriptions.
|
||||
fn scrollbar_x(w: f32) -> f32 {
|
||||
STYLE.row_x(w) + STYLE.control_width + STYLE.gap + STYLE.text_width(w) + 6.0
|
||||
}
|
||||
fn bounds(h: f32) -> (f32, f32) {
|
||||
(STYLE.band, h - STYLE.band)
|
||||
}
|
||||
@@ -657,7 +743,7 @@ impl GraphicsUi {
|
||||
self.clamp_scroll(h, content);
|
||||
let (top, _) = Self::bounds(h);
|
||||
let x = STYLE.row_x(w);
|
||||
let mut y = top + 4.0 - self.scroll;
|
||||
let mut y = top + 4.0 - self.scroll.amount;
|
||||
let mut widgets = Vec::new();
|
||||
let mut headers = Vec::new();
|
||||
for row in extra_rows(level, s) {
|
||||
@@ -683,6 +769,17 @@ impl GraphicsUi {
|
||||
let other = match level {
|
||||
Level::Simple => Action::OpenAdvanced,
|
||||
Level::Advanced => Action::OpenSimple,
|
||||
// One Done, back to Advanced.
|
||||
Level::Hdr => {
|
||||
widgets.push(Widget {
|
||||
x: cx - 100.0,
|
||||
y: by,
|
||||
w: 200.0,
|
||||
target: Target::Button(Action::Done),
|
||||
list: false,
|
||||
});
|
||||
return (widgets, headers, content);
|
||||
}
|
||||
};
|
||||
widgets.push(Widget {
|
||||
x: cx - 155.0,
|
||||
@@ -702,7 +799,7 @@ impl GraphicsUi {
|
||||
}
|
||||
fn clamp_scroll(&mut self, h: f32, content: f32) {
|
||||
let (top, bottom) = Self::bounds(h);
|
||||
self.scroll = self.scroll.clamp(0.0, (content - (bottom - top)).max(0.0));
|
||||
self.scroll.clamp(top, bottom, content);
|
||||
}
|
||||
pub fn draw(
|
||||
&mut self,
|
||||
@@ -744,12 +841,10 @@ impl GraphicsUi {
|
||||
continue;
|
||||
}
|
||||
let label = item.label(s);
|
||||
let live = item.available();
|
||||
if hover && !live {
|
||||
tooltip = Some(atlas.font.split(
|
||||
&Text::literal("This feature is not yet available in Horizon."),
|
||||
170.0,
|
||||
));
|
||||
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));
|
||||
}
|
||||
match item.slider(s) {
|
||||
Some(value) => STYLE.slider(
|
||||
@@ -759,6 +854,7 @@ impl GraphicsUi {
|
||||
&label,
|
||||
value,
|
||||
hover || self.dragging == Some(item),
|
||||
live,
|
||||
),
|
||||
None => STYLE.button(
|
||||
batch,
|
||||
@@ -799,15 +895,16 @@ impl GraphicsUi {
|
||||
ui::scrollbar(
|
||||
batch,
|
||||
atlas,
|
||||
x + STYLE.control_width + STYLE.gap + STYLE.text_width(w) + 6.0,
|
||||
Self::scrollbar_x(w),
|
||||
top,
|
||||
bottom,
|
||||
content,
|
||||
self.scroll,
|
||||
self.scroll.amount,
|
||||
);
|
||||
let title = match level {
|
||||
Level::Simple => "Graphics",
|
||||
Level::Advanced => "Graphics: Advanced",
|
||||
Level::Hdr => "Graphics: HDR",
|
||||
};
|
||||
STYLE.title(batch, atlas, &Text::literal(title));
|
||||
if let Some(lines) = tooltip {
|
||||
@@ -825,9 +922,14 @@ impl GraphicsUi {
|
||||
drag: bool,
|
||||
right: bool,
|
||||
) -> Vec<Action> {
|
||||
let (widgets, _, _) = self.layout(level, s, w, h);
|
||||
let (widgets, _, content) = self.layout(level, s, w, h);
|
||||
let (top, bottom) = Self::bounds(h);
|
||||
if drag {
|
||||
// AbstractScrollArea.mouseDragged: the held scrollbar follows the pointer.
|
||||
if self.scroll.held() {
|
||||
self.scroll.drag(y, top, bottom, content);
|
||||
return Vec::new();
|
||||
}
|
||||
if let Some(item) = self.dragging
|
||||
&& let Some(wd) = widgets
|
||||
.iter()
|
||||
@@ -841,6 +943,13 @@ impl GraphicsUi {
|
||||
if right {
|
||||
return Vec::new();
|
||||
}
|
||||
// AbstractScrollArea.updateScrolling: a press on the scrollbar holds it, silently.
|
||||
if self
|
||||
.scroll
|
||||
.press((x, y), Self::scrollbar_x(w), top, bottom, content)
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
for wd in widgets {
|
||||
if !ui::hovered((x, y), wd.x, wd.y, wd.w, 20.0) {
|
||||
continue;
|
||||
@@ -851,7 +960,10 @@ impl GraphicsUi {
|
||||
return match wd.target {
|
||||
Target::Button(action) => vec![Action::ButtonClick, action],
|
||||
// A disabled control does nothing: no sound, no preset change.
|
||||
Target::Item(item) if !item.available() => Vec::new(),
|
||||
Target::Item(item) if item.blocked(s).is_some() => Vec::new(),
|
||||
Target::Item(Item::Horizon(H::HdrSettings)) => {
|
||||
vec![Action::ButtonClick, Action::OpenHdr]
|
||||
}
|
||||
Target::Item(item) => {
|
||||
if item.slider(s).is_some() {
|
||||
self.dragging = Some(item);
|
||||
@@ -866,14 +978,16 @@ impl GraphicsUi {
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
/// Lets go of a slider (true: it plays its click) or the scrollbar.
|
||||
pub fn release(&mut self) -> bool {
|
||||
self.scroll.release();
|
||||
self.dragging.take().is_some()
|
||||
}
|
||||
pub fn scroll(&mut self, level: Level, s: &Settings, h: f32, amount: f32) {
|
||||
let content = Self::content_height(level, s);
|
||||
self.clamp_scroll(h, content);
|
||||
self.scroll -= amount * 18.0;
|
||||
self.clamp_scroll(h, content);
|
||||
let (top, bottom) = Self::bounds(h);
|
||||
self.scroll.clamp(top, bottom, content);
|
||||
self.scroll.wheel(amount, 18.0, top, bottom, content);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -881,6 +995,53 @@ impl GraphicsUi {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hdr_settings_open_where_hdr_is_offered() {
|
||||
let mut s = Settings::default();
|
||||
let mut ui = GraphicsUi::default();
|
||||
let size = (480.0, 2000.0);
|
||||
let at = |ui: &mut GraphicsUi, s: &Settings, level, item| {
|
||||
let (widgets, _, _) = ui.layout(level, s, size.0, size.1);
|
||||
let w = widgets
|
||||
.iter()
|
||||
.find(|w| matches!(w.target, Target::Item(i) if i == item))
|
||||
.unwrap();
|
||||
(w.x + 1.0, w.y + 1.0)
|
||||
};
|
||||
let button = at(&mut ui, &s, Level::Advanced, Item::Horizon(H::HdrSettings));
|
||||
let click = |ui: &mut GraphicsUi, s: &mut Settings, level, point| {
|
||||
ui.click(level, s, point, size, [480, 2000], false, false)
|
||||
};
|
||||
crate::graphics::offer_hdr(false);
|
||||
assert!(click(&mut ui, &mut s, Level::Advanced, button).is_empty());
|
||||
// Not even with HDR saved on, from another computer.
|
||||
s.horizon.hdr = true;
|
||||
assert!(click(&mut ui, &mut s, Level::Advanced, button).is_empty());
|
||||
s.horizon.hdr = false;
|
||||
crate::graphics::offer_hdr(true);
|
||||
assert_eq!(
|
||||
click(&mut ui, &mut s, Level::Advanced, button),
|
||||
[Action::ButtonClick, Action::OpenHdr]
|
||||
);
|
||||
// On HDR's screen, the sliders wait for the switch above them.
|
||||
let peak = at(&mut ui, &s, Level::Hdr, Item::Horizon(H::HdrPeak));
|
||||
assert!(click(&mut ui, &mut s, Level::Hdr, peak).is_empty());
|
||||
s.horizon.hdr = true;
|
||||
assert_eq!(
|
||||
click(&mut ui, &mut s, Level::Hdr, peak),
|
||||
[Action::SliderDrag]
|
||||
);
|
||||
let on = |level, item| {
|
||||
extra_rows(level, &s)
|
||||
.iter()
|
||||
.any(|r| matches!(r, Row::Setting(x) if x.item == Item::Horizon(item)))
|
||||
};
|
||||
assert!(on(Level::Hdr, H::Hdr) && !on(Level::Advanced, H::Hdr));
|
||||
assert!(on(Level::Hdr, H::HdrPeak) && !on(Level::Advanced, H::HdrPeak));
|
||||
for item in [H::HdrBlackLevel, H::HdrPaperWhite, H::HdrUiBrightness] {
|
||||
assert!(on(Level::Hdr, item) && !on(Level::Advanced, item));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn graphics_buttons_sound_on_press_and_sliders_on_release() {
|
||||
let mut s = Settings::default();
|
||||
@@ -960,29 +1121,68 @@ mod tests {
|
||||
assert!(max > 4.5);
|
||||
for _ in 0..3 {
|
||||
ui.scroll(level, &s, h, 1000.0);
|
||||
assert_eq!(ui.scroll, 0.0);
|
||||
assert_eq!(ui.scroll.amount, 0.0);
|
||||
}
|
||||
for _ in 0..3 {
|
||||
ui.scroll(level, &s, h, -1000.0);
|
||||
assert_eq!(ui.scroll, max);
|
||||
assert_eq!(ui.scroll.amount, max);
|
||||
}
|
||||
ui.scroll(level, &s, h, 0.25);
|
||||
assert_eq!(ui.scroll, max - 4.5);
|
||||
assert_eq!(ui.scroll.amount, max - 4.5);
|
||||
ui.scroll(level, &s, h, 1000.0);
|
||||
ui.scroll(level, &s, h, -0.25);
|
||||
assert_eq!(ui.scroll, 4.5);
|
||||
assert_eq!(ui.scroll.amount, 4.5);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scrollbar_can_be_held_and_dragged() {
|
||||
// #3552: AbstractScrollArea.updateScrolling holds the scrollbar on a press over its
|
||||
// column, mouseDragged moves the list with the pointer, onRelease lets go.
|
||||
let mut s = Settings::default();
|
||||
let size = (480.0, 2.0 * STYLE.band + 60.0);
|
||||
let (top, bottom) = GraphicsUi::bounds(size.1);
|
||||
// On the scrollbar column, as `draw` paints it.
|
||||
let x =
|
||||
STYLE.row_x(size.0) + STYLE.control_width + STYLE.gap + STYLE.text_width(size.0) + 8.0;
|
||||
let first_row = |ui: &mut GraphicsUi| {
|
||||
ui.layout(Level::Advanced, &Settings::default(), size.0, size.1)
|
||||
.0[0]
|
||||
.y
|
||||
};
|
||||
let mut ui = GraphicsUi::default();
|
||||
let start = first_row(&mut ui);
|
||||
let at = |y| (x, y);
|
||||
let press = |ui: &mut GraphicsUi, s: &mut Settings, y, drag| {
|
||||
ui.click(Level::Advanced, s, at(y), size, [854, 480], drag, false)
|
||||
};
|
||||
// The press itself makes no sound and changes nothing.
|
||||
assert!(press(&mut ui, &mut s, top + 5.0, false).is_empty());
|
||||
assert!(press(&mut ui, &mut s, top + 15.0, true).is_empty());
|
||||
let moved = first_row(&mut ui);
|
||||
assert!(moved < start, "dragging down scrolls the list down");
|
||||
// Past the bottom of the list: the last row.
|
||||
press(&mut ui, &mut s, bottom + 50.0, true);
|
||||
let content = GraphicsUi::content_height(Level::Advanced, &s);
|
||||
assert_eq!(first_row(&mut ui), start - (content - (bottom - top)));
|
||||
// Above the list: the first row again.
|
||||
press(&mut ui, &mut s, top - 50.0, true);
|
||||
assert_eq!(first_row(&mut ui), start);
|
||||
// Released: drags no longer scroll, and no slider click plays.
|
||||
assert!(!ui.release());
|
||||
press(&mut ui, &mut s, bottom + 50.0, true);
|
||||
assert_eq!(first_row(&mut ui), start);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resizing_clamps_before_positioning_graphics_rows() {
|
||||
let s = Settings::default();
|
||||
for level in [Level::Simple, Level::Advanced] {
|
||||
let mut ui = GraphicsUi::default();
|
||||
ui.scroll(level, &s, 2.0 * STYLE.band + 10.0, -1000.0);
|
||||
assert!(ui.scroll > 0.0);
|
||||
assert!(ui.scroll.amount > 0.0);
|
||||
let (widgets, headers, _) = ui.layout(level, &s, 480.0, 4000.0);
|
||||
assert_eq!(ui.scroll, 0.0);
|
||||
assert_eq!(ui.scroll.amount, 0.0);
|
||||
if let Some((y, _)) = headers.first() {
|
||||
assert_eq!(*y, STYLE.band + 4.0);
|
||||
}
|
||||
@@ -992,7 +1192,7 @@ mod tests {
|
||||
expected.iter().map(|w| w.y).collect::<Vec<_>>()
|
||||
);
|
||||
ui.scroll(level, &s, 4000.0, -1000.0);
|
||||
assert_eq!(ui.scroll, 0.0);
|
||||
assert_eq!(ui.scroll.amount, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,23 +12,31 @@ struct Params {
|
||||
peak: f32,
|
||||
// 1 when this frame drew a world.
|
||||
world: f32,
|
||||
// 1 paints expanded pixels magenta (HORIZON_HDR_DEBUG=1).
|
||||
// 1 greys what isn't expanded and tints what is (HORIZON_HDR_DEBUG=1).
|
||||
debug: f32,
|
||||
// 1 for an HDR10 swapchain, 0 for scRGB.
|
||||
pq: f32,
|
||||
// The display's black level in nits; 0 leaves the picture as it is.
|
||||
black: f32,
|
||||
// The HUD and menus' brightness as a share of paper white.
|
||||
ui: f32,
|
||||
// Per tier, low to very bright (hdr.rs `Tier`): the share of the headroom
|
||||
// the brightest pixels get, and the brightness where the lift starts and
|
||||
// where it is full.
|
||||
share: vec4<f32>,
|
||||
start: vec4<f32>,
|
||||
full: vec4<f32>,
|
||||
};
|
||||
@group(0) @binding(2) var<uniform> params: Params;
|
||||
|
||||
// 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
|
||||
// near-white spots (glowstone) and leaves the rest (lava, a jack o'lantern's
|
||||
// face) nearly as it was. Pixels brighter than LIFT_FULL get STRENGTH of the
|
||||
// headroom, dimmer ones less down to none at LIFT_START (lava's crust, a
|
||||
// lantern's metal), and no channel goes past the peak. Brightness is the mean
|
||||
// of luminance and the brightest channel, so saturated light counts as bright.
|
||||
const LIFT_START: f32 = 0.18;
|
||||
const LIFT_FULL: f32 = 0.42;
|
||||
const STRENGTH: f32 = 0.7;
|
||||
// face) nearly as it was. Each tier's pixels brighter than its `full` get its
|
||||
// `share` of the headroom, dimmer ones less down to none at its `start`
|
||||
// (lava's crust, a lantern's metal), and no channel goes past the peak.
|
||||
// Brightness is the mean of luminance and the brightest channel, so saturated
|
||||
// light counts as bright.
|
||||
|
||||
// Whether `c` still shows the world pixel `w`: the same, or darkened evenly
|
||||
// across its channels, as the vignette and other screen-wide tints darken it.
|
||||
@@ -56,37 +64,84 @@ fn pq(nits: vec3<f32>) -> vec3<f32> {
|
||||
return pow((0.8359375 + 18.8515625 * y) / (1.0 + 18.6875 * y), vec3(78.84375));
|
||||
}
|
||||
|
||||
// ST 2084's inverse: the PQ signal to nits.
|
||||
fn pq_nits(e: vec3<f32>) -> vec3<f32> {
|
||||
let p = pow(clamp(e, vec3(0.0), vec3(1.0)), vec3(1.0 / 78.84375));
|
||||
let y = max(p - 0.8359375, vec3(0.0)) / (18.8515625 - 18.6875 * p);
|
||||
return 10000.0 * pow(y, vec3(1.0 / 0.1593017578125));
|
||||
}
|
||||
|
||||
// ITU-R BT.2390's black level lift (its EETF without the knee) on the PQ signal
|
||||
// `e`: black moves up to the display's black level, the peak stays, and the lift
|
||||
// fades out by the fourth power on the way up, so only the shadows move. A display
|
||||
// that can't go darker than its black level would crush everything below it.
|
||||
fn lift_black(e: vec3<f32>) -> vec3<f32> {
|
||||
let top = pq(vec3(params.peak * params.paper * 80.0)).x;
|
||||
let n = min(e / top, vec3(1.0));
|
||||
return e + pq(vec3(params.black)).x * pow(vec3(1.0) - n, vec3(4.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);
|
||||
}
|
||||
|
||||
// Hdr::keep_world: once the world is kept, the frame's alpha starts at 0 again.
|
||||
@fragment fn fs_clear_alpha() -> @location(0) vec4<f32> {
|
||||
return vec4(0.0);
|
||||
}
|
||||
|
||||
@fragment fn fs_hdr(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let at = vec2<i32>(pos.xy);
|
||||
let c = textureLoad(frame, at, 0).rgb;
|
||||
let f = textureLoad(frame, at, 0);
|
||||
let c = f.rgb;
|
||||
// What covers the world here (Hdr::keep_world): nothing at 0, the hand at
|
||||
// 1/255 (Hdr::mark_hand: drawn at the world's brightness), else the HUD or a
|
||||
// menu, by how much.
|
||||
let covered = f.a > 0.0;
|
||||
let ui = select(f.a, 0.0, f.a < 1.5 / 255.0);
|
||||
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
|
||||
var light = pow(max(c, vec3(0.0)), vec3(2.2));
|
||||
let w = textureLoad(world, at, 0);
|
||||
// The world's alpha is 0 on light-emitting blocks, the sun and the moon
|
||||
// (terrain.wgsl, the celestial pipeline): only those shine.
|
||||
if params.world > 0.5 && params.peak > 1.0 && w.a < 0.75 && shows_world(c, w.rgb) {
|
||||
// The world's alpha is below 0.75 on light sources, the sun and the moon
|
||||
// (terrain.wgsl, the celestial pipeline): only those shine, by the tier it
|
||||
// holds, the lower the brighter, and only where nothing was drawn over them
|
||||
// since: grey text over the white sun still looks like the sun darkened, but
|
||||
// it raised the frame's alpha.
|
||||
var lifted = false;
|
||||
if params.world > 0.5 && params.peak > 1.0 && w.a < 0.75 && !covered
|
||||
&& shows_world(c, w.rgb) {
|
||||
// Brightness of the world pixel, before the vignette darkens the frame
|
||||
// toward its edges: a light source shines the same anywhere on screen.
|
||||
let wl = pow(max(w.rgb, vec3(0.0)), vec3(2.2));
|
||||
let y = 0.5 * (dot(wl, vec3(0.2126, 0.7152, 0.0722)) + max(wl.r, max(wl.g, wl.b)));
|
||||
let lift = smoothstep(LIFT_START, LIFT_FULL, y);
|
||||
let tier = u32(clamp(round(4.0 - 4.0 * w.a / 0.7), 1.0, 4.0)) - 1u;
|
||||
let lift = smoothstep(params.start[tier], params.full[tier], y);
|
||||
if lift > 0.0 {
|
||||
let top = max(light.r, max(light.g, light.b));
|
||||
let gain = min(1.0 + (params.peak - 1.0) * STRENGTH * lift, params.peak / max(top, 1e-4));
|
||||
let gain = min(1.0 + (params.peak - 1.0) * params.share[tier] * lift, params.peak / max(top, 1e-4));
|
||||
light *= max(gain, 1.0);
|
||||
if params.debug > 0.5 {
|
||||
light = mix(light, vec3(gain, 0.0, gain), 0.6);
|
||||
}
|
||||
lifted = true;
|
||||
}
|
||||
}
|
||||
// HORIZON_HDR_DEBUG=1: what isn't lifted turns grey, what is keeps its
|
||||
// colour with a magenta tint, so a lift shows on any colour.
|
||||
if params.debug > 0.5 && params.world > 0.5 {
|
||||
if lifted {
|
||||
light *= vec3(1.0, 0.6, 1.0);
|
||||
} else {
|
||||
light = vec3(dot(light, vec3(0.2126, 0.7152, 0.0722)));
|
||||
}
|
||||
}
|
||||
// The HUD and menus at their own brightness, by how much they cover.
|
||||
light *= mix(1.0, params.ui, ui);
|
||||
let scrgb = light * params.paper;
|
||||
if params.pq > 0.5 {
|
||||
return vec4(pq(BT709_TO_BT2020 * scrgb * 80.0), 1.0);
|
||||
let e = pq(BT709_TO_BT2020 * scrgb * 80.0);
|
||||
return vec4(select(e, lift_black(e), params.black > 0.0), 1.0);
|
||||
}
|
||||
if params.black > 0.0 {
|
||||
return vec4(pq_nits(lift_black(pq(scrgb * 80.0))) / 80.0, 1.0);
|
||||
}
|
||||
return vec4(scrgb, 1.0);
|
||||
}
|
||||
@@ -59,6 +59,8 @@ pub struct HudAtlas {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub sprites: HashMap<String, Rect>,
|
||||
/// Resolved player-profile names and UUIDs for inline head components.
|
||||
pub object_skins: HashMap<String, String>,
|
||||
/// Sprites with a 0 < alpha < 255 texel (`SpriteContents.transparency().hasTranslucent`),
|
||||
/// which puts their particles on vanilla's translucent layer.
|
||||
pub translucent: HashSet<String>,
|
||||
@@ -90,9 +92,9 @@ pub const MAP_SLOTS: usize = 256;
|
||||
/// The atlas slot the minimap extra draws into.
|
||||
pub const MINIMAP_SPRITE: &str = "horizon/minimap";
|
||||
/// Atlas slots for downloaded player skins (`skins/<sha1>`), 64x64 each.
|
||||
/// Vanilla registers a texture per skin. Here a skin evicted from its slot
|
||||
/// is not stored again, and its player falls back to the default skin, so
|
||||
/// the slots must cover a busy server's player list.
|
||||
/// Vanilla registers a texture per skin. Here the slots are shared
|
||||
/// (`skins::SkinSlots`): skins drawn in the last frame keep theirs, and a
|
||||
/// skin past them shows the default skin until a slot frees up.
|
||||
pub const SKIN_SLOTS: usize = 128;
|
||||
/// Reused by the visible rows of the classic world selector.
|
||||
pub const WORLD_ICON_SLOTS: usize = 32;
|
||||
@@ -252,6 +254,24 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> Vec<PackedImage> {
|
||||
out.into_iter().map(|(_, image)| image).collect()
|
||||
}
|
||||
impl HudAtlas {
|
||||
/// Keep a ready profile skin when another source only has its default.
|
||||
pub fn set_object_skin(&mut self, key: String, skin: String) {
|
||||
use std::collections::hash_map::Entry;
|
||||
if self.object_skins.len() >= 1024 && !self.object_skins.contains_key(&key) {
|
||||
self.object_skins.clear();
|
||||
}
|
||||
match self.object_skins.entry(key) {
|
||||
Entry::Occupied(entry)
|
||||
if skin.starts_with("entity/player/") && self.sprites.contains_key(entry.get()) => {
|
||||
}
|
||||
Entry::Occupied(mut entry) => {
|
||||
entry.insert(skin);
|
||||
}
|
||||
Entry::Vacant(entry) => {
|
||||
entry.insert(skin);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A font glyph's atlas cell (8x8 GUI pixels) and advance.
|
||||
pub fn glyph(&self, ch: char) -> Option<(Rect, f32)> {
|
||||
let g = self.font.glyph(ch);
|
||||
@@ -592,6 +612,7 @@ impl HudAtlas {
|
||||
width,
|
||||
height,
|
||||
sprites,
|
||||
object_skins: HashMap::new(),
|
||||
translucent,
|
||||
flags,
|
||||
sprite_animations,
|
||||
@@ -641,6 +662,8 @@ pub struct HudBatch {
|
||||
pub glint_alpha: 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.
|
||||
pub inline_profiles: Vec<serde_json::Value>,
|
||||
}
|
||||
/// How a run of HUD vertices blends and tests depth.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
@@ -692,6 +715,7 @@ impl HudBatch {
|
||||
end_portal: EndPortalFill::default(),
|
||||
glint_alpha: 0.75,
|
||||
blur_at: 0,
|
||||
inline_profiles: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// Draw the vertices added since `start` with the inverting blend.
|
||||
@@ -1042,40 +1066,67 @@ impl HudBatch {
|
||||
let mut cursor = x;
|
||||
let mut previous = None;
|
||||
for (i, (ch, style)) in text.chars().enumerate() {
|
||||
let object = (ch == '\u{fffc}')
|
||||
.then_some(style.inline_object.as_ref())
|
||||
.flatten();
|
||||
if let Some(crate::text::InlineObject::Player { profile, .. }) = object
|
||||
&& self.inline_profiles.len() < 256
|
||||
&& !self.inline_profiles.contains(profile)
|
||||
{
|
||||
self.inline_profiles.push(profile.clone());
|
||||
}
|
||||
// Font.PreparedTextBuilder.accept: lines start 1 left of a
|
||||
// sequence's first character. Language.getVisualOrder makes a
|
||||
// sequence of each run of one style, which world text has.
|
||||
let first = i == 0 || world && previous != Some(style);
|
||||
previous = Some(style);
|
||||
// The style's font (`Style.getFont`), the default one unless set.
|
||||
let mut g = font.glyph_styled(ch, style);
|
||||
let mut g = if object.is_some() {
|
||||
crate::font::Glyph {
|
||||
rect: None,
|
||||
left: 0.0,
|
||||
top: 0.0,
|
||||
right: 0.0,
|
||||
bottom: 8.0,
|
||||
advance: 8.0,
|
||||
offset: 0.0,
|
||||
}
|
||||
} else {
|
||||
font.glyph_styled(ch, style)
|
||||
};
|
||||
// Font.StringRenderOutput.accept: obfuscated characters draw a
|
||||
// random glyph of the same width each frame.
|
||||
if style.obfuscated == Some(true) && ch != ' ' {
|
||||
if object.is_none() && style.obfuscated == Some(true) && ch != ' ' {
|
||||
g = font.random_glyph(style, g.advance);
|
||||
}
|
||||
let c = style.color.map_or(color, |rgb| {
|
||||
rgba((rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8, alpha)
|
||||
});
|
||||
let bold = style.is_bold();
|
||||
let bold = object.is_none() && style.is_bold();
|
||||
let advance = g.advance + if bold { g.offset } else { 0.0 };
|
||||
glyphs.push((
|
||||
cursor,
|
||||
g,
|
||||
c,
|
||||
style.shadow_color,
|
||||
bold,
|
||||
style.is_italic(),
|
||||
style.underlined == Some(true),
|
||||
style.strikethrough == Some(true),
|
||||
first,
|
||||
advance,
|
||||
object.cloned(),
|
||||
));
|
||||
cursor += advance;
|
||||
}
|
||||
self.patches.extend(font.take_patches());
|
||||
let white = font.white();
|
||||
let passes: &[bool] = if shadow { &[true, false] } else { &[false] };
|
||||
// ARGB.scaleRGB(color, 0.25): the shadow's colour.
|
||||
let has_shadow = shadow
|
||||
|| glyphs
|
||||
.iter()
|
||||
.any(|glyph| glyph.3.is_some_and(|color| color != 0));
|
||||
let passes: &[bool] = if has_shadow { &[true, false] } else { &[false] };
|
||||
// ARGB.scaleRGB(color, 0.25): the default shadow colour.
|
||||
let shade = |c: u32| {
|
||||
let b = c.to_le_bytes();
|
||||
rgba(
|
||||
@@ -1086,20 +1137,48 @@ impl HudBatch {
|
||||
)
|
||||
};
|
||||
for &is_shadow in passes {
|
||||
for &(gx, g, c, bold, italic, underline, strike, first, advance) in &glyphs {
|
||||
// In the world a glyph whose shadow is 0 has none (hasShadow).
|
||||
let has_shadow = shadow && !(world && shade(c) == 0);
|
||||
for (
|
||||
gx,
|
||||
g,
|
||||
c,
|
||||
custom_shadow,
|
||||
bold,
|
||||
italic,
|
||||
underline,
|
||||
strike,
|
||||
first,
|
||||
advance,
|
||||
object,
|
||||
) in &glyphs
|
||||
{
|
||||
if let Some(object) = object {
|
||||
if !is_shadow {
|
||||
self.draw_inline_object(atlas, object, *gx, y, alpha);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let shadow_color = if let Some(argb) = custom_shadow {
|
||||
let text_alpha = (c >> 24) as u8 as u16;
|
||||
let shadow_alpha = (argb >> 24) as u8 as u16;
|
||||
let alpha = (shadow_alpha * text_alpha / 255) as u8;
|
||||
rgba((argb >> 16) as u8, (argb >> 8) as u8, *argb as u8, alpha)
|
||||
} else {
|
||||
shade(*c)
|
||||
};
|
||||
let has_shadow = (custom_shadow.is_some_and(|argb| argb != 0)
|
||||
|| custom_shadow.is_none() && shadow)
|
||||
&& !(world && shadow_color == 0);
|
||||
if is_shadow && !has_shadow {
|
||||
continue;
|
||||
}
|
||||
let (dx, col) = if is_shadow {
|
||||
(g.offset, shade(c))
|
||||
(g.offset, shadow_color)
|
||||
} else {
|
||||
(0.0, c)
|
||||
(0.0, *c)
|
||||
};
|
||||
let (px, py) = (gx + dx, y + dx);
|
||||
let shear = |v: f32| if italic { 1.0 - 0.25 * v } else { 0.0 };
|
||||
let thick = if bold { 0.1 } else { 0.0 };
|
||||
let (px, py) = (*gx + dx, y + dx);
|
||||
let shear = |v: f32| if *italic { 1.0 - 0.25 * v } else { 0.0 };
|
||||
let thick = if *bold { 0.1 } else { 0.0 };
|
||||
let draw = |batch: &mut Self, ox: f32| match g.rect {
|
||||
Some(r) => {
|
||||
let (x0, x1, y0, y1) = (
|
||||
@@ -1144,25 +1223,121 @@ impl HudBatch {
|
||||
let start = self.vertices.len();
|
||||
draw(self, 0.0);
|
||||
lift(self, start, base);
|
||||
if bold {
|
||||
if *bold {
|
||||
let start = self.vertices.len();
|
||||
draw(self, g.offset);
|
||||
lift(self, start, base + 0.001);
|
||||
}
|
||||
let x0 = if first { px - 1.0 } else { px };
|
||||
let x0 = if *first { px - 1.0 } else { px };
|
||||
let start = self.vertices.len();
|
||||
if strike {
|
||||
self.rect_uv(x0, py + 3.5, px + advance - x0, 1.0, white, col);
|
||||
if *strike {
|
||||
self.rect_uv(x0, py + 3.5, px + *advance - x0, 1.0, white, col);
|
||||
}
|
||||
if underline {
|
||||
self.rect_uv(x0, py + 8.0, px + advance - x0, 1.0, white, col);
|
||||
if *underline {
|
||||
self.rect_uv(x0, py + 8.0, px + *advance - x0, 1.0, white, col);
|
||||
}
|
||||
lift(self, start, base + 0.01);
|
||||
}
|
||||
}
|
||||
cursor - x
|
||||
}
|
||||
|
||||
fn draw_inline_object(
|
||||
&mut self,
|
||||
atlas: &HudAtlas,
|
||||
object: &crate::text::InlineObject,
|
||||
x: f32,
|
||||
y: f32,
|
||||
alpha: u8,
|
||||
) {
|
||||
use crate::text::InlineObject;
|
||||
let tint = rgba(255, 255, 255, alpha);
|
||||
match object {
|
||||
InlineObject::Player { profile, hat, .. } => {
|
||||
let id = crate::skins::parse_profile(profile).id;
|
||||
let name = inline_profile_name(profile);
|
||||
let by_id = atlas.object_skins.get(&format!("id:{id:032x}"));
|
||||
let by_name = name.and_then(|name| {
|
||||
atlas
|
||||
.object_skins
|
||||
.get(&format!("name:{}", name.to_ascii_lowercase()))
|
||||
});
|
||||
let resolved = if profile.get("id").is_some() {
|
||||
by_id.or(by_name)
|
||||
} else {
|
||||
by_name.or(by_id)
|
||||
};
|
||||
let default = crate::tab_list::default_skin(id);
|
||||
let skin = resolved
|
||||
.filter(|path| atlas.sprites.contains_key(*path))
|
||||
.map_or(default, String::as_str);
|
||||
let Some(rect) = atlas.sprites.get(skin).copied() else {
|
||||
return;
|
||||
};
|
||||
for u in [8.0, 40.0].into_iter().take(if *hat { 2 } else { 1 }) {
|
||||
let du = (rect.u1 - rect.u0) / 64.0;
|
||||
let dv = (rect.v1 - rect.v0) / 64.0;
|
||||
self.rect_uv(
|
||||
x,
|
||||
y,
|
||||
8.0,
|
||||
8.0,
|
||||
Rect {
|
||||
u0: rect.u0 + du * u,
|
||||
v0: rect.v0 + dv * 8.0,
|
||||
u1: rect.u0 + du * (u + 8.0),
|
||||
v1: rect.v0 + dv * 16.0,
|
||||
w: 8,
|
||||
h: 8,
|
||||
},
|
||||
tint,
|
||||
);
|
||||
}
|
||||
}
|
||||
InlineObject::Atlas { sprite, .. } => {
|
||||
let key = sprite.strip_prefix("minecraft:").unwrap_or(sprite);
|
||||
if let Some(rect) = atlas.sprites.get(key).copied() {
|
||||
self.rect_uv(x, y, 8.0, 8.0, rect, tint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SubmitNodeCollection.submitNameTag's text in its own space (x right,
|
||||
/// y down, in GUI pixels): centred at `-Font.width / 2`, drawn as
|
||||
/// Font.prepareText draws any text (styles, fonts and glyph boxes; no
|
||||
/// shadow), over `background` (RGBA) unless its alpha is 0. Returns how
|
||||
/// many vertices at the start of the batch are the background.
|
||||
pub fn name_tag(
|
||||
&mut self,
|
||||
atlas: &HudAtlas,
|
||||
text: &crate::text::Text,
|
||||
y: f32,
|
||||
color: u32,
|
||||
background: u32,
|
||||
) -> usize {
|
||||
let x = -atlas.font.width_text(text) / 2.0;
|
||||
let start = self.vertices.len();
|
||||
let advance = self.text_styled(atlas, text, x, y, color, false);
|
||||
if background >> 24 == 0 {
|
||||
return 0;
|
||||
}
|
||||
// PreparedTextBuilder.markBackground: one pixel round the advances,
|
||||
// from a pixel above the line to 9 below its top.
|
||||
let glyphs = self.vertices.split_off(start);
|
||||
self.fill(atlas, x - 1.0, y - 1.0, advance + 1.0, 10.0, background);
|
||||
let behind = self.vertices.len() - start;
|
||||
self.vertices.extend(glyphs);
|
||||
behind
|
||||
}
|
||||
}
|
||||
|
||||
fn inline_profile_name(profile: &serde_json::Value) -> Option<&str> {
|
||||
profile
|
||||
.as_str()
|
||||
.or_else(|| profile.get("name").and_then(serde_json::Value::as_str))
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct HudUniform {
|
||||
@@ -1877,6 +2052,161 @@ mod tests {
|
||||
assert!(missing.rect.is_none() && missing.right == 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_shadow_color_overrides_text_call_shadow() {
|
||||
let root = two_glyph_font("component-shadow");
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let mut batch = HudBatch::new([320, 240]);
|
||||
let text = crate::text::Text::styled(
|
||||
"a",
|
||||
crate::text::Style {
|
||||
shadow_color: Some(0x8040_2010),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
batch.text_styled(&atlas, &text, 10.0, 10.0, u32::MAX, false);
|
||||
assert_eq!(batch.vertices.len(), 12);
|
||||
assert_eq!(batch.vertices[0].color, rgba(0x40, 0x20, 0x10, 0x80));
|
||||
batch.vertices.clear();
|
||||
batch.text_styled_world(&atlas, &text, 10.0, 10.0, u32::MAX, false);
|
||||
assert_eq!(batch.vertices.len(), 12);
|
||||
assert_eq!(batch.vertices[0].depth, 0.0);
|
||||
assert_eq!(batch.vertices[6].depth, 0.03);
|
||||
batch.vertices.clear();
|
||||
let no_shadow = crate::text::Text::styled(
|
||||
"a",
|
||||
crate::text::Style {
|
||||
shadow_color: Some(0),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
batch.text_styled(&atlas, &no_shadow, 10.0, 10.0, u32::MAX, true);
|
||||
assert_eq!(batch.vertices.len(), 6);
|
||||
batch.vertices.clear();
|
||||
let gradient = crate::component::to_text(
|
||||
&serde_json::json!({
|
||||
"text": "",
|
||||
"extra": [
|
||||
{"text": "a", "color": "#ff0000"},
|
||||
{"text": "b", "color": "#0000ff"}
|
||||
]
|
||||
}),
|
||||
&mut |_| crate::text::Text::empty(),
|
||||
);
|
||||
batch.text_styled(&atlas, &gradient, 10.0, 10.0, u32::MAX, false);
|
||||
assert_eq!(batch.vertices[0].color, rgba(255, 0, 0, 255));
|
||||
assert_eq!(batch.vertices[6].color, rgba(0, 0, 255, 255));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_object_draws_an_inline_head() {
|
||||
let root = two_glyph_font("inline-head");
|
||||
write_png(&root, "entity/player/slim/alex", 64, 64);
|
||||
write_png(&root, "entity/player/wide/steve", 64, 64);
|
||||
let mut atlas = HudAtlas::build(&root).unwrap();
|
||||
let text = crate::component::to_text(
|
||||
&serde_json::json!({"object":"player","player":{"id":"00000000-0000-0000-0000-000000000000"}}),
|
||||
&mut |_| crate::text::Text::empty(),
|
||||
);
|
||||
let mut batch = HudBatch::new([320, 240]);
|
||||
assert_eq!(
|
||||
batch.text_styled(&atlas, &text, 10.0, 10.0, u32::MAX, false),
|
||||
8.0
|
||||
);
|
||||
assert_eq!(batch.vertices.len(), 12); // face and hat, six vertices each
|
||||
assert_eq!(batch.inline_profiles.len(), 1);
|
||||
atlas.set_object_skin("name:elyapluggin".into(), "entity/player/wide/steve".into());
|
||||
let named = crate::component::to_text(
|
||||
&serde_json::json!({"object":"player","player":{"name":"elyapluggin"}}),
|
||||
&mut |_| crate::text::Text::empty(),
|
||||
);
|
||||
batch.vertices.clear();
|
||||
batch.text_styled(&atlas, &named, 10.0, 10.0, u32::MAX, false);
|
||||
let skin = atlas.sprites["entity/player/wide/steve"];
|
||||
assert_eq!(batch.vertices[0].uv[0], skin.u0 + (skin.u1 - skin.u0) / 8.0);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atlas_object_draws_an_item_sprite_inline() {
|
||||
let root = two_glyph_font("inline-item");
|
||||
write_png(&root, "item/porkchop", 16, 16);
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let text = crate::component::to_text(
|
||||
&serde_json::json!({
|
||||
"text": "a",
|
||||
"extra": [{"object": "atlas", "sprite": "minecraft:item/porkchop"}]
|
||||
}),
|
||||
&mut |_| crate::text::Text::empty(),
|
||||
);
|
||||
let mut batch = HudBatch::new([320, 240]);
|
||||
let width = batch.text_styled(&atlas, &text, 10.0, 10.0, u32::MAX, false);
|
||||
assert_eq!(width, atlas.font.glyph('a').advance + 8.0);
|
||||
assert_eq!(batch.vertices.len(), 12); // glyph and one sprite
|
||||
let sprite = atlas.sprites["item/porkchop"];
|
||||
assert_eq!(batch.vertices[6].uv, [sprite.u0, sprite.v0]);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_tags_draw_bold_text_and_tall_glyphs_like_any_text() {
|
||||
// `ascii.png`'s 8x8 'a' (4 pixels wide) and `accented.png`'s 9x12
|
||||
// 'й' (height 12, ascent 10: the breve above the line), 7 wide.
|
||||
let root = std::env::temp_dir().join(format!("horizon-name-tag-{}", std::process::id()));
|
||||
let textures = root.join("assets/minecraft/textures/font");
|
||||
fs::create_dir_all(root.join("assets/minecraft/font")).unwrap();
|
||||
fs::create_dir_all(&textures).unwrap();
|
||||
fs::write(
|
||||
root.join("assets/minecraft/font/default.json"),
|
||||
r#"{"providers":[
|
||||
{"type":"bitmap","file":"minecraft:font/a.png","ascent":7,"chars":["a"]},
|
||||
{"type":"bitmap","file":"minecraft:font/tall.png","height":12,"ascent":10,"chars":["й"]}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
for (name, (w, h), used) in [("a", (8, 8), 4), ("tall", (9, 12), 7)] {
|
||||
let pixels: Vec<u8> = (0..w * h)
|
||||
.flat_map(|i| [255, 255, 255, if i % w < used { 255 } else { 0 }])
|
||||
.collect();
|
||||
let file = fs::File::create(textures.join(format!("{name}.png"))).unwrap();
|
||||
let mut encoder = png::Encoder::new(file, w, h);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header().unwrap();
|
||||
writer.write_image_data(&pixels).unwrap();
|
||||
}
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let text = crate::text::Text::literal("aй").bold();
|
||||
let mut batch = HudBatch::new([1, 1]);
|
||||
let behind = batch.name_tag(&atlas, &text, 0.0, u32::MAX, 0x3F00_0000);
|
||||
// Each quad's box: x0, y0, x1, y1.
|
||||
let boxes: Vec<[f32; 4]> = batch
|
||||
.vertices
|
||||
.as_chunks::<6>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|q| [q[0].pos[0], q[0].pos[1], q[2].pos[0], q[2].pos[1]])
|
||||
.collect();
|
||||
// Advances 5 and 8, plus 1 each for bold: Font.width 15, centred.
|
||||
let x = -7.5;
|
||||
// Each glyph twice (bold), the copy a pixel right; 'й' in its 9x12
|
||||
// box from 3 pixels above the line, each bold quad 0.1 larger.
|
||||
let near = |a: [f32; 4], b: [f32; 4]| a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-4);
|
||||
let expected = [
|
||||
[x - 0.1, -0.1, x + 8.1, 8.1],
|
||||
[x + 0.9, -0.1, x + 9.1, 8.1],
|
||||
[x + 5.9, -3.1, x + 15.1, 9.1],
|
||||
[x + 6.9, -3.1, x + 16.1, 9.1],
|
||||
];
|
||||
for (got, want) in boxes[1..].iter().zip(expected) {
|
||||
assert!(near(*got, want), "{got:?} != {want:?}");
|
||||
}
|
||||
assert_eq!(boxes.len(), 1 + expected.len());
|
||||
// The background first: a pixel round the advances, 10 pixels tall.
|
||||
assert_eq!(behind, 6);
|
||||
assert_eq!(boxes[0], [x - 1.0, -1.0, x + 15.0, 9.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_icons_are_painted_from_the_mob_effect_directory() {
|
||||
use crate::{effects::EffectInstance, hud_layout, hud_overlays, inventory_effects};
|
||||
@@ -2063,6 +2393,7 @@ mod tests {
|
||||
end_portal: EndPortalFill::default(),
|
||||
glint_alpha: 0.75,
|
||||
blur_at: 0,
|
||||
inline_profiles: Vec::new(),
|
||||
};
|
||||
for (image_width, image_height) in [(195, 136), (176, 166), (176, 222)] {
|
||||
assert_eq!(
|
||||
|
||||
@@ -70,6 +70,7 @@ pub mod skins;
|
||||
pub mod sound_engine;
|
||||
pub mod sound_events;
|
||||
pub mod spans;
|
||||
pub mod split_layout;
|
||||
mod sprite_cache;
|
||||
pub mod sprites;
|
||||
pub mod streaming;
|
||||
|
||||
@@ -883,6 +883,24 @@ fn relight_layer(
|
||||
let Some(old) = level(world, sky, p) else {
|
||||
continue;
|
||||
};
|
||||
// `BlockLightEngine.checkNode`: a block emitting at least the light
|
||||
// already there keeps it and pulls its neighbours' light in. Clearing
|
||||
// it would darken and relight everything it lit (flowing lava next to
|
||||
// lava: thousands of cells per block).
|
||||
if !sky && !force && old > 0 {
|
||||
let emission = world
|
||||
.state(p.0, p.1, p.2)
|
||||
.map_or(0, |s| tables.cells[s as usize].emission);
|
||||
if emission >= old {
|
||||
if emission > old {
|
||||
set_level(world, sky, p, emission);
|
||||
changed.push(p);
|
||||
}
|
||||
decrease.push_back((p, 0));
|
||||
increase.push_back(p);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if old > 0 {
|
||||
set_level(world, sky, p, 0);
|
||||
changed.push(p);
|
||||
@@ -1089,6 +1107,49 @@ mod tests {
|
||||
assert!(same(&w, (1, 0)));
|
||||
assert_eq!(block(&w, 14, 0, 8), 13);
|
||||
}
|
||||
/// Emitters placed in light at most as bright as theirs (flowing lava
|
||||
/// next to lava) only brighten: nothing they lit is darkened and lit
|
||||
/// again, and the result is the full relight's.
|
||||
#[test]
|
||||
fn emitters_placed_in_their_own_light_only_brighten() {
|
||||
let mut w = world(|c| {
|
||||
c.set(8, 0, 8, TORCH);
|
||||
});
|
||||
let block = |w: &HashMap<(i32, i32), Arc<Chunk>>, p: Pos| {
|
||||
w[&(p.0 >> 4, p.2 >> 4)].light(p.0 & 15, p.1, p.2 & 15) & 15
|
||||
};
|
||||
for (x, y, z) in [(9, 0, 8), (10, 0, 8), (10, 1, 8), (11, 1, 9), (8, 0, 9)] {
|
||||
let around: Vec<Pos> = (-15..=15)
|
||||
.flat_map(|dx| {
|
||||
(-3..=3).flat_map(move |dy| (-15..=15).map(move |dz| (x + dx, y + dy, z + dz)))
|
||||
})
|
||||
.collect();
|
||||
let before: Vec<u8> = around.iter().map(|&p| block(&w, p)).collect();
|
||||
Arc::make_mut(w.get_mut(&(0, 0)).unwrap()).set(x, y, z, TORCH);
|
||||
let mut changed = LightChanges::default();
|
||||
relight(&mut w, &tables(), true, &[(x, y, z)], false, &mut changed);
|
||||
for (&p, &was) in around.iter().zip(&before) {
|
||||
assert!(block(&w, p) >= was, "{p:?} darkened from {was}");
|
||||
}
|
||||
}
|
||||
// An emitter checked again with its block light unchanged changes
|
||||
// nothing, so no section is remeshed.
|
||||
let mut changed = LightChanges::default();
|
||||
relight(&mut w, &tables(), false, &[(8, 0, 8)], false, &mut changed);
|
||||
assert!(changed.is_empty());
|
||||
let region: [[Option<&Chunk>; 3]; 3] = std::array::from_fn(|dz| {
|
||||
std::array::from_fn(|dx| w.get(&(dx as i32 - 1, dz as i32 - 1)).map(|c| &**c))
|
||||
});
|
||||
for (si, s) in light_chunk(®ion, &tables(), true).iter().enumerate() {
|
||||
for i in 0..4096 {
|
||||
assert_eq!(
|
||||
s.get(i),
|
||||
w[&(0, 0)].sections[si].light(i),
|
||||
"section {si} index {i}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn random_border_relights_match_full_relight() {
|
||||
// Edits around every border of chunk (0, 0), at the top and bottom of
|
||||
|
||||
@@ -25,6 +25,16 @@ const MAX_SAVED: usize = 1024;
|
||||
/// (`Lod::observing`).
|
||||
pub const OBSERVE_AHEAD: usize = 8;
|
||||
const SAMPLE_JOBS: usize = 4;
|
||||
/// Threads that sample far tiles beside the mesher. Machines with 12 or more
|
||||
/// threads use up to 6 (measured: a cold 1024-chunk LoD loads about a quarter
|
||||
/// sooner than with 3); the rest keep a quarter of their threads, at most 3.
|
||||
fn sampling_threads(cores: usize) -> usize {
|
||||
if cores >= 12 {
|
||||
(cores / 2).min(6)
|
||||
} else {
|
||||
(cores / 4).clamp(1, 3)
|
||||
}
|
||||
}
|
||||
/// While the near terrain loads on a machine with four threads or fewer, far
|
||||
/// 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.
|
||||
@@ -425,6 +435,28 @@ impl Plan {
|
||||
false
|
||||
}
|
||||
}
|
||||
/// Whether chunk column (x, z) is drawn by the tile planned there (ready;
|
||||
/// at level 0, also `filled`) rather than by a coarser ancestor standing
|
||||
/// in for it, or is past the far terrain.
|
||||
fn replaces(
|
||||
&self,
|
||||
(x, z): (i32, i32),
|
||||
ready: &FxHashMap<TileKey, impl Sized>,
|
||||
filled: impl Fn(TileKey) -> bool,
|
||||
) -> bool {
|
||||
let Some(key) = (0..=MAX_LEVEL)
|
||||
.map(|level| TileKey {
|
||||
x: (x * 16).div_euclid(16 << level),
|
||||
z: (z * 16).div_euclid(16 << level),
|
||||
level,
|
||||
})
|
||||
.find(|key| self.nodes.contains(key))
|
||||
else {
|
||||
return true;
|
||||
};
|
||||
// A split node's child there is out of range: nothing is drawn.
|
||||
self.split.contains(&key) || (ready.contains_key(&key) && (key.level > 0 || filled(key)))
|
||||
}
|
||||
}
|
||||
/// Visited chunks' summaries (`Lod::saved`) and their reductions.
|
||||
type Saved = FxHashMap<(i32, i32), Arc<[Column; 256]>>;
|
||||
@@ -524,6 +556,8 @@ struct Shared {
|
||||
kinds: std::sync::OnceLock<Vec<u8>>,
|
||||
wake: Condvar,
|
||||
stop: AtomicBool,
|
||||
/// Reduced far detail: samplers approximate wide cells.
|
||||
approximate: AtomicBool,
|
||||
}
|
||||
/// One background worker and at most two outstanding sample jobs. Only the
|
||||
/// current view's quadtree and a bounded set of visited summaries are retained.
|
||||
@@ -550,6 +584,8 @@ pub struct Lod {
|
||||
detail_request_at: std::time::Instant,
|
||||
/// Four threads or fewer: far work waits for the near terrain (`hold`).
|
||||
small: bool,
|
||||
/// Sample jobs that may be queued or running at once.
|
||||
sample_jobs: usize,
|
||||
/// The near terrain is loading (`set_near_loading`).
|
||||
near_loading: bool,
|
||||
/// When `request_samples` last started a job while held.
|
||||
@@ -559,8 +595,17 @@ pub struct Lod {
|
||||
active: FxHashSet<TileKey>,
|
||||
dirty: FxHashSet<TileKey>,
|
||||
removed: Vec<TileKey>,
|
||||
/// The columns left out: `full` and `retiring`.
|
||||
covered: FxHashSet<(i32, i32)>,
|
||||
covered_bounds: Option<[i32; 4]>,
|
||||
/// The full-detail columns `update` was last given.
|
||||
full: FxHashSet<(i32, i32)>,
|
||||
/// Columns that left the full-detail area, still left out (their
|
||||
/// full-detail meshes still drawn) until the tile planned there is ready
|
||||
/// (`Plan::replaces`). Tiles inside that area are never sampled: when the
|
||||
/// render distance shrinks, their coarsest ancestors would otherwise
|
||||
/// stand in for it for minutes.
|
||||
retiring: FxHashSet<(i32, i32)>,
|
||||
/// The covered columns the active meshes leave out, when a mesh job
|
||||
/// has finished since the last `take_cutout`.
|
||||
cutout: Option<FxHashSet<(i32, i32)>>,
|
||||
@@ -570,12 +615,14 @@ pub struct Lod {
|
||||
}
|
||||
impl Lod {
|
||||
pub fn new(source: Arc<dyn LodSource>, context: Arc<WorkerContext>) -> Self {
|
||||
let cores = thread::available_parallelism().map_or(1, |n| n.get());
|
||||
let shared = Arc::new(Shared {
|
||||
jobs: Mutex::new(VecDeque::new()),
|
||||
observed: Mutex::new(FxHashMap::default()),
|
||||
kinds: std::sync::OnceLock::new(),
|
||||
wake: Condvar::new(),
|
||||
stop: AtomicBool::new(false),
|
||||
approximate: AtomicBool::new(false),
|
||||
});
|
||||
let worker = shared.clone();
|
||||
let mesh_context = context.clone();
|
||||
@@ -583,10 +630,10 @@ impl Lod {
|
||||
let handle = thread::Builder::new()
|
||||
.name("Far terrain".into())
|
||||
.spawn(move || {
|
||||
// Ticks and frames come first (horizon_threads).
|
||||
horizon_threads::behind_server();
|
||||
let mut sampler = source.sampler();
|
||||
let count = thread::available_parallelism()
|
||||
.map_or(1, |n| n.get() / 4)
|
||||
.clamp(1, 3);
|
||||
let count = sampling_threads(cores);
|
||||
let mut sampling = Vec::new();
|
||||
for _ in 0..count {
|
||||
let Some(mut sample) = sampler.fork() else {
|
||||
@@ -598,6 +645,7 @@ impl Lod {
|
||||
thread::Builder::new()
|
||||
.name("Far sampling".into())
|
||||
.spawn(move || {
|
||||
horizon_threads::behind_server();
|
||||
loop {
|
||||
let key = {
|
||||
let mut jobs = shared.jobs.lock().unwrap();
|
||||
@@ -619,6 +667,9 @@ impl Lod {
|
||||
}
|
||||
};
|
||||
let [x, _, z] = key.origin();
|
||||
sample.set_approximate(
|
||||
shared.approximate.load(Ordering::Relaxed),
|
||||
);
|
||||
let samples =
|
||||
sample.grid(x, z, key.size() / CELLS as i32, CELLS);
|
||||
if shared.stop.load(Ordering::Relaxed) {
|
||||
@@ -681,6 +732,7 @@ impl Lod {
|
||||
}
|
||||
Job::Sample(key) => {
|
||||
let [x, _, z] = key.origin();
|
||||
sampler.set_approximate(worker.approximate.load(Ordering::Relaxed));
|
||||
let samples = sampler.grid(x, z, key.size() / CELLS as i32, CELLS);
|
||||
Done::Sample { key, samples }
|
||||
}
|
||||
@@ -746,7 +798,8 @@ impl Lod {
|
||||
detail_masks: FxHashMap::default(),
|
||||
detail_request_pending: false,
|
||||
detail_request_at: std::time::Instant::now(),
|
||||
small: thread::available_parallelism().map_or(1, |n| n.get()) <= 4,
|
||||
small: cores <= 4,
|
||||
sample_jobs: SAMPLE_JOBS.max(sampling_threads(cores) + 1),
|
||||
near_loading: false,
|
||||
held_sample_at: std::time::Instant::now(),
|
||||
ready: FxHashMap::default(),
|
||||
@@ -755,6 +808,8 @@ impl Lod {
|
||||
removed: Vec::new(),
|
||||
covered: FxHashSet::default(),
|
||||
covered_bounds: None,
|
||||
full: FxHashSet::default(),
|
||||
retiring: FxHashSet::default(),
|
||||
cutout: None,
|
||||
saved: FxHashMap::default(),
|
||||
reduced: FxHashMap::default(),
|
||||
@@ -976,9 +1031,11 @@ impl Lod {
|
||||
self.near_loading = loading;
|
||||
}
|
||||
/// Reduced far detail (`graphics::FarDetail::Reduced`): the next update
|
||||
/// replans when it changes.
|
||||
/// replans when it changes, and tiles sampled from then on approximate
|
||||
/// wide cells (`GeneratorWorker::lod_grid`).
|
||||
pub fn set_reduced_detail(&mut self, reduced: bool) {
|
||||
self.reduced_detail = reduced;
|
||||
self.shared.approximate.store(reduced, Ordering::Relaxed);
|
||||
}
|
||||
fn held(&self) -> bool {
|
||||
self.small && self.near_loading
|
||||
@@ -1014,7 +1071,8 @@ impl Lod {
|
||||
(ox + (index as i32 % 16) * stride).div_euclid(16),
|
||||
(oz + (index as i32 / 16) * stride).div_euclid(16),
|
||||
);
|
||||
if self.covered.contains(&chunk) {
|
||||
// A retiring column's tile is drawn soon: it needs its detail.
|
||||
if self.full.contains(&chunk) {
|
||||
self.detail_masks.entry(key).or_default()[index / 64] |= 1 << (index % 64);
|
||||
} else {
|
||||
chunks.insert(chunk);
|
||||
@@ -1136,31 +1194,13 @@ impl Lod {
|
||||
}
|
||||
/// Takes the new full-detail columns, when they changed (`None`: not).
|
||||
fn cover(&mut self, covered: Option<FxHashSet<(i32, i32)>>) {
|
||||
if let Some(covered) = covered
|
||||
&& self.covered != covered
|
||||
if let Some(full) = covered
|
||||
&& self.full != full
|
||||
{
|
||||
let changes: Vec<_> = self
|
||||
.covered
|
||||
.symmetric_difference(&covered)
|
||||
.copied()
|
||||
.collect();
|
||||
for (x, z) in changes {
|
||||
self.invalidate_chunk(x, z);
|
||||
}
|
||||
self.covered_bounds = covered.iter().fold(None, |bounds, &(x, z)| {
|
||||
Some(match bounds {
|
||||
None => [x * 16, z * 16, (x + 1) * 16, (z + 1) * 16],
|
||||
Some([x0, z0, x1, z1]) => [
|
||||
x0.min(x * 16),
|
||||
z0.min(z * 16),
|
||||
x1.max((x + 1) * 16),
|
||||
z1.max((z + 1) * 16),
|
||||
],
|
||||
})
|
||||
});
|
||||
self.covered = covered;
|
||||
self.replanned = true;
|
||||
self.reselect = true;
|
||||
self.retiring.extend(self.full.difference(&full).copied());
|
||||
self.retiring.retain(|column| !full.contains(column));
|
||||
self.full = full;
|
||||
self.leave_out();
|
||||
}
|
||||
// Complete resident columns need no duplicate noise work. Their real
|
||||
// summaries are retained when the full-detail chunks leave the view.
|
||||
@@ -1174,12 +1214,55 @@ impl Lod {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Leaves out the full-detail and retiring columns: the tiles touching
|
||||
/// one that changed are meshed again.
|
||||
fn leave_out(&mut self) {
|
||||
let covered: FxHashSet<_> = self.full.union(&self.retiring).copied().collect();
|
||||
if self.covered == covered {
|
||||
return;
|
||||
}
|
||||
let changes: Vec<_> = self
|
||||
.covered
|
||||
.symmetric_difference(&covered)
|
||||
.copied()
|
||||
.collect();
|
||||
for (x, z) in changes {
|
||||
self.invalidate_chunk(x, z);
|
||||
}
|
||||
self.covered_bounds = covered.iter().fold(None, |bounds, &(x, z)| {
|
||||
Some(match bounds {
|
||||
None => [x * 16, z * 16, (x + 1) * 16, (z + 1) * 16],
|
||||
Some([x0, z0, x1, z1]) => [
|
||||
x0.min(x * 16),
|
||||
z0.min(z * 16),
|
||||
x1.max((x + 1) * 16),
|
||||
z1.max((z + 1) * 16),
|
||||
],
|
||||
})
|
||||
});
|
||||
// Only new covered columns need stand-ins.
|
||||
self.replanned |= !covered.is_subset(&self.covered);
|
||||
self.covered = covered;
|
||||
self.reselect = true;
|
||||
}
|
||||
/// Chooses the tiles to draw and meshes those that changed, when the
|
||||
/// plan, the samples, the covered columns or the active tiles changed.
|
||||
fn select(&mut self) {
|
||||
if !std::mem::take(&mut self.reselect) && self.dirty.is_empty() {
|
||||
return;
|
||||
}
|
||||
if !self.retiring.is_empty() {
|
||||
let (plan, ready, saved, masks) =
|
||||
(&self.plan, &self.ready, &self.saved, &self.detail_masks);
|
||||
// A level-0 tile there is an empty stand-in (`cover`) until the
|
||||
// chunk's summary or generated detail fills it (`apply_fine`).
|
||||
self.retiring.retain(|&column| {
|
||||
!plan.replaces(column, ready, |key| {
|
||||
saved.contains_key(&column) || masks.get(&key) == Some(&[u64::MAX; 4])
|
||||
})
|
||||
});
|
||||
self.leave_out();
|
||||
}
|
||||
{
|
||||
let mut selected = Vec::new();
|
||||
for &key in &self.plan.roots {
|
||||
@@ -1243,7 +1326,13 @@ impl Lod {
|
||||
self.pending.is_empty() && self.held_sample_at.elapsed() >= HELD_SAMPLE_STEP,
|
||||
)
|
||||
} else {
|
||||
SAMPLE_JOBS.saturating_sub(self.pending.len())
|
||||
// The extra sampling threads wait until the near terrain is in.
|
||||
let slots = if self.near_loading {
|
||||
SAMPLE_JOBS
|
||||
} else {
|
||||
self.sample_jobs
|
||||
};
|
||||
slots.saturating_sub(self.pending.len())
|
||||
};
|
||||
if free == 0 {
|
||||
return;
|
||||
@@ -1291,8 +1380,8 @@ impl Lod {
|
||||
}
|
||||
// Only the first `free` keys not already pending are taken. A key is
|
||||
// listed at most twice (as a root and as a node), so the best
|
||||
// `2 * (SAMPLE_JOBS + pending)` entries hold them, in order.
|
||||
let best = 2 * (SAMPLE_JOBS + self.pending.len());
|
||||
// `2 * (sample_jobs + pending)` entries hold them, in order.
|
||||
let best = 2 * (self.sample_jobs + self.pending.len());
|
||||
if wanted.len() > best {
|
||||
wanted.select_nth_unstable(best);
|
||||
wanted.truncate(best);
|
||||
@@ -1590,6 +1679,48 @@ mod tests {
|
||||
assert_eq!(selected, parent.children());
|
||||
}
|
||||
#[test]
|
||||
fn a_shrunk_render_distance_keeps_columns_until_their_planned_tiles_are_ready() {
|
||||
// Render distance 64 under LOD 64: every tile inside it is a level-0
|
||||
// stand-in for a covered column, and only the roots are sampled.
|
||||
let camera = [8, 8];
|
||||
let before = Plan::new(camera, 64, 64);
|
||||
let after = Plan::with_previous(camera, 8, 64, &before, 0, false);
|
||||
let mut ready: FxHashMap<TileKey, ()> = before
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|key| key.level == 0 || before.roots.contains(key))
|
||||
.map(|&key| (key, ()))
|
||||
.collect();
|
||||
ready.retain(|key, _| after.nodes.contains(key));
|
||||
let contains = |key: &TileKey, (x, z): (i32, i32)| {
|
||||
let [ox, _, oz] = key.origin();
|
||||
(ox..ox + key.size()).contains(&(x * 16)) && (oz..oz + key.size()).contains(&(z * 16))
|
||||
};
|
||||
// 24 chunks out, now past the render distance: its planned tile was
|
||||
// split before, so never sampled, and a root stands in for it.
|
||||
let column = (24, 0);
|
||||
let mut selected = Vec::new();
|
||||
for &root in &after.roots {
|
||||
after.select(root, &ready, &mut selected);
|
||||
}
|
||||
let drawn = selected.iter().filter(|key| contains(key, column));
|
||||
assert_eq!(drawn.map(|key| key.level).min(), Some(6));
|
||||
assert!(!after.replaces(column, &ready, |_| true));
|
||||
let planned = TileKey {
|
||||
x: 12,
|
||||
z: 0,
|
||||
level: 1,
|
||||
};
|
||||
assert!(after.nodes.contains(&planned) && before.split.contains(&planned));
|
||||
ready.insert(planned, ());
|
||||
assert!(after.replaces(column, &ready, |_| true));
|
||||
// 12 chunks out, its level-0 tile is the stand-in until filled.
|
||||
assert!(!after.replaces((12, 0), &ready, |_| false));
|
||||
assert!(after.replaces((12, 0), &ready, |_| true));
|
||||
// Past the far terrain nothing waits.
|
||||
assert!(after.replaces((100, 0), &ready, |_| false));
|
||||
}
|
||||
#[test]
|
||||
fn detail_survives_movement_across_a_split_threshold() {
|
||||
let before = Plan::new([0, 0], 2, 256);
|
||||
let after = Plan::with_previous([16, 0], 2, 256, &before, 0, false);
|
||||
@@ -2000,6 +2131,7 @@ mod tests {
|
||||
lod.ready
|
||||
.insert(parent, Arc::new(vec![Column::default(); 256]));
|
||||
lod.active = [parent].into_iter().collect();
|
||||
lod.sample_jobs = SAMPLE_JOBS;
|
||||
lod.pending = (0..SAMPLE_JOBS - 1)
|
||||
.map(|i| TileKey {
|
||||
x: 999 + i as i32,
|
||||
@@ -2058,6 +2190,7 @@ mod tests {
|
||||
}
|
||||
lod.active = [near, far].into_iter().collect();
|
||||
lod.shared.jobs.lock().unwrap().clear();
|
||||
lod.sample_jobs = SAMPLE_JOBS;
|
||||
lod.pending = (0..SAMPLE_JOBS - 1)
|
||||
.map(|i| TileKey {
|
||||
x: 999 + i as i32,
|
||||
@@ -2091,6 +2224,34 @@ mod tests {
|
||||
(1..=32).map(|x| (x, 0)).collect::<Vec<_>>(),
|
||||
"one unfinished fine tile must not starve neighbouring requests"
|
||||
);
|
||||
|
||||
// The extra sampling threads' slots open once the near terrain is in.
|
||||
lod.small = false;
|
||||
lod.sample_jobs = SAMPLE_JOBS + 3;
|
||||
lod.plan = Plan::new([-256, -256], 2, 64);
|
||||
lod.ready.clear();
|
||||
lod.active.clear();
|
||||
lod.pending.clear();
|
||||
lod.meshing = true;
|
||||
lod.shared.jobs.lock().unwrap().clear();
|
||||
let queued = |lod: &mut Lod, near_loading: bool| {
|
||||
lod.set_near_loading(near_loading);
|
||||
lod.shared.jobs.lock().unwrap().clear();
|
||||
lod.pending.clear();
|
||||
lod.update([-256., 64., -256.], 0., 2, 64, Some(FxHashSet::default()));
|
||||
let jobs = lod.shared.jobs.lock().unwrap();
|
||||
jobs.iter()
|
||||
.filter(|job| matches!(job, Job::Sample(_)))
|
||||
.count()
|
||||
};
|
||||
assert_eq!(queued(&mut lod, true), SAMPLE_JOBS);
|
||||
assert_eq!(queued(&mut lod, false), SAMPLE_JOBS + 3);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_sampling_threads_only_on_large_machines() {
|
||||
assert_eq!([1, 2, 4, 8, 11].map(sampling_threads), [1, 1, 1, 2, 2]);
|
||||
assert_eq!([12, 16, 64].map(sampling_threads), [6, 6, 6]);
|
||||
}
|
||||
}
|
||||
@@ -123,9 +123,25 @@ mod vertex_tests {
|
||||
fn emission_keeps_the_colour_and_the_free_bits() {
|
||||
let rgb = super::pack_rgb([0x12, 0x34, 0x56]);
|
||||
assert_eq!(super::emitting(rgb, 0), rgb);
|
||||
assert_eq!(super::emitting(rgb, 9), 0xF656_3412);
|
||||
assert_eq!(super::emitting(rgb, 15), 0xF056_3412);
|
||||
assert_eq!(super::emitting(rgb, 15) | super::EMISSION_BITS, rgb);
|
||||
assert_eq!(super::emitting(rgb, super::INCANDESCENT), 0xFD56_3412);
|
||||
assert_eq!(super::emitting(rgb, super::VERY_BRIGHT), 0xFB56_3412);
|
||||
assert_eq!(
|
||||
super::emitting(rgb, super::VERY_BRIGHT) | super::EMISSION_BITS,
|
||||
rgb
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn light_sources_shine_by_kind_not_light_level() {
|
||||
use super::{BRIGHT, INCANDESCENT, LOW, hdr_tier};
|
||||
assert_eq!(hdr_tier("glowstone", 15), BRIGHT);
|
||||
// A candle's flame is a particle: the block keeps only its wick.
|
||||
assert_eq!(hdr_tier("torch", 14), INCANDESCENT);
|
||||
assert_eq!(hdr_tier("red_candle", 3), LOW);
|
||||
assert_eq!(hdr_tier("glow_lichen", 7), 0);
|
||||
assert_eq!(hdr_tier("stone", 0), 0);
|
||||
// Unlisted ones by their light level.
|
||||
assert_eq!(hdr_tier("ochre_froglight", 15), INCANDESCENT);
|
||||
assert_eq!(hdr_tier("sculk_sensor", 1), LOW);
|
||||
}
|
||||
}
|
||||
pub struct SectionMesh {
|
||||
@@ -263,6 +279,8 @@ enum Tint {
|
||||
/// `BlockTintSources.grassBlock`: grass in the world, none on its particles.
|
||||
GrassBlock,
|
||||
TallGrassUpper,
|
||||
SugarCane,
|
||||
LilyPad,
|
||||
Foliage,
|
||||
DryFoliage,
|
||||
Water,
|
||||
@@ -282,6 +300,8 @@ struct Info {
|
||||
/// `getShadeBrightness` (ambient occlusion contribution).
|
||||
shade: f32,
|
||||
emission: u8,
|
||||
/// `hdr_tier`.
|
||||
tier: u8,
|
||||
/// `emissiveRendering`: drawn and sampled at full brightness.
|
||||
emissive: bool,
|
||||
fluid: FluidState,
|
||||
@@ -329,6 +349,10 @@ pub struct MeshTables {
|
||||
lava_still: u16,
|
||||
lava_flow: u16,
|
||||
}
|
||||
fn stem_color(age: u8) -> u32 {
|
||||
let a = u32::from(age);
|
||||
(a * 32) << 16 | (255 - a * 8) << 8 | (a * 4)
|
||||
}
|
||||
fn redstone_color(power: u8) -> u32 {
|
||||
// RedstoneWireBlock.COLORS
|
||||
let f = f32::from(power) / 15.0;
|
||||
@@ -377,10 +401,9 @@ impl MeshTables {
|
||||
([Tint::Grass; 2], 1)
|
||||
}
|
||||
}
|
||||
"fern" | "short_grass" | "potted_fern" | "bush" | "sugar_cane" => {
|
||||
([Tint::Grass; 2], 1)
|
||||
}
|
||||
"fern" | "short_grass" | "potted_fern" | "bush" => ([Tint::Grass; 2], 1),
|
||||
"grass_block" => ([Tint::GrassBlock; 2], 1),
|
||||
"sugar_cane" => ([Tint::SugarCane; 2], 1),
|
||||
"pink_petals" | "wildflowers" => ([Tint::None, Tint::Grass], 2),
|
||||
"spruce_leaves" => ([Tint::Constant(0x61_9961); 2], 1),
|
||||
"birch_leaves" => ([Tint::Constant(0x80_A755); 2], 1),
|
||||
@@ -395,7 +418,7 @@ impl MeshTables {
|
||||
([Tint::Constant(0xE0_C71C); 2], 1)
|
||||
}
|
||||
"melon_stem" | "pumpkin_stem" => ([Tint::Stem(prop("age")); 2], 1),
|
||||
"lily_pad" => ([Tint::Constant(0x20_8030); 2], 1),
|
||||
"lily_pad" => ([Tint::LilyPad; 2], 1),
|
||||
_ => ([Tint::None; 2], 0),
|
||||
};
|
||||
// getShadeBrightness overrides; the default is 0.2 for full collision shapes.
|
||||
@@ -450,6 +473,7 @@ impl MeshTables {
|
||||
permeable: !s.solid_render || s.dampening == 0,
|
||||
shade,
|
||||
emission: s.emission,
|
||||
tier: hdr_tier(name, s.emission),
|
||||
emissive,
|
||||
fluid: FluidState::from_block(state)?,
|
||||
model: s.render_shape == RenderShape::Model,
|
||||
@@ -491,6 +515,25 @@ impl MeshTables {
|
||||
tint_sources: i.tint_count,
|
||||
})
|
||||
}
|
||||
/// `BlockTintSource.color(state)`: block-model colour without a level.
|
||||
/// `grass` is `GrassColor.getDefaultColor()` from the active pack's colormap.
|
||||
/// Untinted faces, blank sources and invalid tint indices return `None`.
|
||||
pub fn default_tint(&self, state: BlockStateId, index: i32, grass: u32) -> Option<u32> {
|
||||
let info = self.info.get(state.0 as usize)?;
|
||||
if index < 0 || index >= i32::from(info.tint_count) {
|
||||
return None;
|
||||
}
|
||||
Some(match info.tints[index as usize] {
|
||||
Tint::None | Tint::SugarCane | Tint::Water => return None,
|
||||
Tint::Grass | Tint::GrassBlock | Tint::TallGrassUpper => grass & 0xFF_FFFF,
|
||||
Tint::Foliage => 0x48_B518,
|
||||
Tint::DryFoliage => 0x5C_3C32,
|
||||
Tint::LilyPad => 0x71_C35C,
|
||||
Tint::Constant(c) => c,
|
||||
Tint::Redstone(p) => redstone_color(p),
|
||||
Tint::Stem(age) => stem_color(age),
|
||||
})
|
||||
}
|
||||
/// `TerrainParticle`'s colour: tint source 0's `colorAsTerrainParticle`,
|
||||
/// which is the in-world (biome-blended) colour except on grass blocks,
|
||||
/// whose dirt crumbs stay untinted. `None` when the state has no source.
|
||||
@@ -513,6 +556,56 @@ impl MeshTables {
|
||||
let mut tints = Tints::new(region, biomes, zoom, blend_radius, origin);
|
||||
Mesher::tint(info, 0, &mut tints, [x & 15, y & 15, z & 15])
|
||||
}
|
||||
/// `BlockColors` for each of the state's tint indices at `pos`, as the
|
||||
/// chunk mesher tints them (grass blocks included): `None` for an
|
||||
/// untinted (-1) layer. For block models drawn outside the chunk mesh.
|
||||
pub fn block_tints(
|
||||
&self,
|
||||
state: BlockStateId,
|
||||
pos: [i32; 3],
|
||||
region: &[[Option<&Chunk>; 3]; 3],
|
||||
biomes: &Biomes,
|
||||
zoom: BiomeZoom,
|
||||
blend_radius: i32,
|
||||
) -> impl Fn(i32) -> Option<u32> + use<> {
|
||||
let sources = self.info.get(state.0 as usize).map(|info| {
|
||||
let [x, y, z] = pos;
|
||||
let origin = [x >> 4, z >> 4, y & !15];
|
||||
let mut tints = Tints::new(region, biomes, zoom, blend_radius, origin);
|
||||
let local = [x & 15, y & 15, z & 15];
|
||||
let colors = [0, 1].map(|i| Mesher::tint(info, i, &mut tints, local));
|
||||
(colors, i32::from(info.tint_count))
|
||||
});
|
||||
move |index| {
|
||||
let (colors, count) = sources?;
|
||||
if index < 0 || index >= count {
|
||||
return None;
|
||||
}
|
||||
colors[(index as usize).min(1)]
|
||||
}
|
||||
}
|
||||
/// `ModelBlockRenderer.tesselateFlat`'s light coords (`LightCoordsUtil`
|
||||
/// packing, the quad's own emission included) for `quad` of `state`
|
||||
/// drawn as the only block at `pos`, as `MovingBlockRenderState` gives
|
||||
/// it: `light` is the world's stored light (sky << 4 | block) at a
|
||||
/// position. For block models drawn outside the chunk mesh.
|
||||
pub fn flat_light(
|
||||
&self,
|
||||
state: BlockStateId,
|
||||
pos: [i32; 3],
|
||||
quad: &horizon_assets::bake::ModelQuad,
|
||||
light: impl Fn([i32; 3]) -> u8,
|
||||
) -> u32 {
|
||||
let own = self.info.get(state.0 as usize);
|
||||
let full = own.is_some_and(|i| i.full);
|
||||
let at = lighting::flat_position(pos, &quad.positions, quad.facing, quad.cull, full);
|
||||
let packed = light(at);
|
||||
let coords = match own {
|
||||
Some(own) => lighting::packed_coords(own, packed),
|
||||
None => u32::from(packed & 15) << 4 | u32::from(packed >> 4) << 20,
|
||||
};
|
||||
lighting::with_emission(coords, quad.light_emission)
|
||||
}
|
||||
/// `BlockState.getOffset` for blocks with a random model offset.
|
||||
pub fn offset(&self, state: BlockStateId, x: i32, z: i32) -> Option<[f64; 3]> {
|
||||
offset(self.info.get(state.0 as usize)?, x, z)
|
||||
@@ -580,13 +673,37 @@ impl Padded {
|
||||
}
|
||||
/// 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 the light its face emits, for
|
||||
/// HDR output's highlights (`gfx/hdr.rs`): 15 minus the level, so the usual 0xFF means none.
|
||||
/// The byte's other four bits stay set.
|
||||
/// The bits of `TerrainVertex::color`'s alpha byte that hold how brightly its face shines
|
||||
/// in HDR output (`gfx/hdr.rs`): 15 minus the tier, so the usual 0xFF means none. The
|
||||
/// byte's other four bits stay set.
|
||||
pub const EMISSION_BITS: u32 = 0x0F00_0000;
|
||||
/// `color` marked as emitting light `level` (0 to 15).
|
||||
pub fn emitting(color: u32, level: u8) -> u32 {
|
||||
color & !EMISSION_BITS | u32::from(15 - level.min(15)) << 24
|
||||
/// HDR tiers, by the kind of light rather than its level: a candle's flame is as bright as
|
||||
/// a torch's, it only lights less around it. 0 stays as in SDR.
|
||||
pub const LOW: u8 = 1;
|
||||
pub const INCANDESCENT: u8 = 2;
|
||||
pub const BRIGHT: u8 = 3;
|
||||
/// The sun and lightning.
|
||||
pub const VERY_BRIGHT: u8 = 4;
|
||||
/// `color` marked as shining at `tier` (0 to `VERY_BRIGHT`).
|
||||
pub fn emitting(color: u32, tier: u8) -> u32 {
|
||||
color & !EMISSION_BITS | u32::from(15 - tier.min(VERY_BRIGHT)) << 24
|
||||
}
|
||||
/// The HDR tier of block `name` (no namespace), which emits light `level`.
|
||||
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,
|
||||
"torch" | "wall_torch" | "lantern" | "lava" | "crying_obsidian" | "fire"
|
||||
| "sea_lantern" => INCANDESCENT,
|
||||
"redstone_torch" | "redstone_wall_torch" | "magma_block" => LOW,
|
||||
// Its flame is a particle.
|
||||
_ if name.ends_with("candle") || name.ends_with("candle_cake") => LOW,
|
||||
// A faint glow in the dark, as in SDR.
|
||||
"glow_lichen" => 0,
|
||||
_ if level >= 10 => INCANDESCENT,
|
||||
_ => LOW,
|
||||
}
|
||||
}
|
||||
const UNSET: u32 = u32::MAX;
|
||||
const NO_BIOME: u16 = u16::MAX;
|
||||
@@ -838,17 +955,15 @@ impl Mesher<'_> {
|
||||
}
|
||||
Some(match state.tints[(index as usize).min(1)] {
|
||||
Tint::None => return None,
|
||||
Tint::Grass | Tint::GrassBlock => tints.get(Resolver::Grass, x, y, z),
|
||||
Tint::Grass | Tint::GrassBlock | Tint::SugarCane => tints.get(Resolver::Grass, x, y, z),
|
||||
Tint::TallGrassUpper => tints.get(Resolver::Grass, x, y, z),
|
||||
Tint::Foliage => tints.get(Resolver::Foliage, x, y, z),
|
||||
Tint::DryFoliage => tints.get(Resolver::Dry, x, y, z),
|
||||
Tint::Water => tints.get(Resolver::Water, x, y, z),
|
||||
Tint::Constant(c) => c,
|
||||
Tint::LilyPad => 0x20_8030,
|
||||
Tint::Redstone(p) => redstone_color(p),
|
||||
Tint::Stem(age) => {
|
||||
let a = u32::from(age);
|
||||
(a * 32) << 16 | (255 - a * 8) << 8 | (a * 4)
|
||||
}
|
||||
Tint::Stem(age) => stem_color(age),
|
||||
})
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -864,6 +979,7 @@ impl Mesher<'_> {
|
||||
state: &Info,
|
||||
) {
|
||||
let q = &face.quad;
|
||||
let tier = state.tier.max(hdr_tier("", q.light_emission));
|
||||
let shade_face = q.shade_direction_override.unwrap_or(q.facing);
|
||||
let directional = lighting::cardinal(self.nether_shading, shade_face);
|
||||
// Grey level per vertex before tinting, and vanilla light coords.
|
||||
@@ -903,7 +1019,7 @@ impl Mesher<'_> {
|
||||
} else {
|
||||
face.layer as u16
|
||||
},
|
||||
emitting(pack_rgb(rgb), state.emission.max(q.light_emission)),
|
||||
emitting(pack_rgb(rgb), tier),
|
||||
pack_coords(lighting::with_emission(coords[i], q.light_emission)),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -227,8 +227,11 @@ pub(super) fn tesselate(
|
||||
} else {
|
||||
0xFF_FFFF
|
||||
};
|
||||
// Lava emits light level 15.
|
||||
let emission = if water { 0 } else { 15 };
|
||||
let emission = if water {
|
||||
0
|
||||
} else {
|
||||
crate::mesher::INCANDESCENT
|
||||
};
|
||||
let color = |by: f32| {
|
||||
let rgb = [16, 8, 0].map(|shift| lighting::scale((tint >> shift & 255) as i32, by) as u32);
|
||||
crate::mesher::emitting(pack_rgb(rgb), emission)
|
||||
|
||||
@@ -39,10 +39,13 @@ fn state<'a>(pad: &Padded, info: &'a [Info], p: [i32; 3]) -> &'a Info {
|
||||
/// `LightCoordsUtil.getLightCoords`: light at `p` as seen through `state`
|
||||
/// (block level << 4 | sky level << 20).
|
||||
pub(super) fn coords(pad: &Padded, state: &Info, p: [i32; 3]) -> u32 {
|
||||
packed_coords(state, pad.light[pi(p[0], p[1], p[2])])
|
||||
}
|
||||
/// `coords` of a stored light byte (sky level << 4 | block level).
|
||||
pub(super) fn packed_coords(state: &Info, packed: u8) -> u32 {
|
||||
if state.emissive {
|
||||
return FULL_BRIGHT;
|
||||
}
|
||||
let packed = pad.light[pi(p[0], p[1], p[2])];
|
||||
let block = u32::from((packed & 15).max(state.emission));
|
||||
block << 4 | u32::from(packed >> 4) << 20
|
||||
}
|
||||
@@ -274,8 +277,28 @@ pub(super) fn flat(
|
||||
positions: &[[f32; 3]; 4],
|
||||
face: D,
|
||||
) -> u32 {
|
||||
let cubic = shape(positions, face, own.full).cubic;
|
||||
coords(pad, own, if cubic { step(pos, face) } else { pos })
|
||||
coords(
|
||||
pad,
|
||||
own,
|
||||
flat_position(pos, positions, face, None, own.full),
|
||||
)
|
||||
}
|
||||
/// Where `tesselateFlat` reads the light of a quad of a block at `pos`: the
|
||||
/// neighbour on its cull face, else `prepareQuadFlat`'s choice (the neighbour
|
||||
/// when the quad is cubic, else the block itself). `full` is the block's
|
||||
/// `isCollisionShapeFullBlock`.
|
||||
pub(super) fn flat_position(
|
||||
pos: [i32; 3],
|
||||
positions: &[[f32; 3]; 4],
|
||||
face: D,
|
||||
cull: Option<D>,
|
||||
full: bool,
|
||||
) -> [i32; 3] {
|
||||
match cull {
|
||||
Some(d) => step(pos, d),
|
||||
None if shape(positions, face, full).cubic => step(pos, face),
|
||||
None => pos,
|
||||
}
|
||||
}
|
||||
/// `prepareQuadAmbientOcclusion`: per-vertex AO brightness (before `ARGB.gray`)
|
||||
/// and smooth light coords, by vertex index.
|
||||
@@ -398,6 +421,39 @@ mod tests {
|
||||
assert_eq!(max_coords(packed(3, 9), packed(7, 2)), packed(7, 9));
|
||||
}
|
||||
#[test]
|
||||
fn flat_light_reads_the_neighbour_a_face_looks_at() {
|
||||
let pos = [4, 64, -2];
|
||||
let full_top = [
|
||||
[0.0, 1.0, 0.0],
|
||||
[0.0, 1.0, 1.0],
|
||||
[1.0, 1.0, 1.0],
|
||||
[1.0, 1.0, 0.0],
|
||||
];
|
||||
// A cull face samples its neighbour, whatever the quad's shape.
|
||||
let inset = full_top.map(|[x, _, z]| [x, 0.75, z]);
|
||||
assert_eq!(
|
||||
flat_position(pos, &inset, D::Up, Some(D::Up), false),
|
||||
[4, 65, -2]
|
||||
);
|
||||
// A flush quad without a cull face samples its neighbour too: a block
|
||||
// drawn at a cell that is now opaque (a moving block, #3002) still
|
||||
// takes the light around it.
|
||||
assert_eq!(
|
||||
flat_position(pos, &full_top, D::Up, None, false),
|
||||
[4, 65, -2]
|
||||
);
|
||||
let west = [
|
||||
[0.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 1.0],
|
||||
[0.0, 1.0, 1.0],
|
||||
[0.0, 1.0, 0.0],
|
||||
];
|
||||
assert_eq!(flat_position(pos, &west, D::West, None, false), [3, 64, -2]);
|
||||
// An inset quad (a piston head's arm) takes the block's own light.
|
||||
assert_eq!(flat_position(pos, &inset, D::Up, None, false), pos);
|
||||
assert_eq!(flat_position(pos, &inset, D::Up, None, true), [4, 65, -2]);
|
||||
}
|
||||
#[test]
|
||||
fn quad_shape_classifies_partial_and_cubic_faces() {
|
||||
let full_top = [
|
||||
[0.0, 1.0, 0.0],
|
||||
|
||||
@@ -54,3 +54,40 @@ fn terrain_particles_use_the_particle_sprite_and_vanilla_tint_sources() {
|
||||
assert_eq!(tint("minecraft:pink_petals"), None);
|
||||
assert_eq!(tint("minecraft:stone"), None);
|
||||
}
|
||||
|
||||
/// Blocks drawn outside the chunk mesh (moved by a piston) take the chunk
|
||||
/// mesher's per-layer tints: a grass block's top and side overlay are tinted
|
||||
/// as in the world, not left gray like its particles.
|
||||
#[test]
|
||||
fn block_tints_match_the_chunk_mesh_for_grass_blocks() {
|
||||
let root =
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
|
||||
if !root.join("horizon-resources.json").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let visibility = crate::models::load_visibility(&root, assets.registry).unwrap();
|
||||
let tables = MeshTables::new(&assets, &render, &facts, visibility).unwrap();
|
||||
let biomes = Biomes::load(&root).unwrap();
|
||||
let state = |name| assets.registry.default_state(name).unwrap();
|
||||
let center = Chunk::empty(0, 0);
|
||||
let mut region = [[None; 3]; 3];
|
||||
region[1][1] = Some(¢er);
|
||||
let tints = |name| {
|
||||
tables.block_tints(
|
||||
state(name),
|
||||
[5, 70, 9],
|
||||
®ion,
|
||||
&biomes,
|
||||
BiomeZoom::new(0),
|
||||
2,
|
||||
)
|
||||
};
|
||||
let grass = tints("minecraft:grass_block");
|
||||
assert!(grass(0).is_some());
|
||||
assert_eq!(grass(-1), None);
|
||||
assert_eq!(tints("minecraft:spruce_leaves")(0), Some(0x61_9961));
|
||||
assert_eq!(tints("minecraft:stone")(0), None);
|
||||
}
|
||||
@@ -77,6 +77,7 @@ fn info(solid: bool) -> Info {
|
||||
permeable: false,
|
||||
shade: 1.0,
|
||||
emission: 0,
|
||||
tier: 0,
|
||||
emissive: false,
|
||||
fluid: FluidState::EMPTY,
|
||||
model: false,
|
||||
|
||||
@@ -36,11 +36,8 @@ pub struct Installed {
|
||||
pub description: String,
|
||||
/// Why it is not running, when it failed.
|
||||
pub problem: Option<String>,
|
||||
/// Loaded with `--dev-mod`: always on, reloaded when rebuilt.
|
||||
/// Loaded with `--dev-mod`: always on.
|
||||
pub dev: bool,
|
||||
/// The running mod set a page of widgets (capability `ui`): its row
|
||||
/// gains a Settings button.
|
||||
pub has_settings: bool,
|
||||
/// A native mod (`game/native_hooks.rs`): built into the game, on until
|
||||
/// the player turns it off, and its settings page.
|
||||
pub native: Option<NativePage>,
|
||||
@@ -178,7 +175,6 @@ mod tests {
|
||||
description: String::new(),
|
||||
problem: None,
|
||||
dev,
|
||||
has_settings: false,
|
||||
native: None,
|
||||
};
|
||||
let mut list = ModList {
|
||||
|
||||
@@ -87,6 +87,9 @@ const MODEL_PARTS: [&str; 7] = [
|
||||
];
|
||||
const GRAPHICS_BACKEND: &[&str] = &["default", "opengl", "vulkan"];
|
||||
const GRAPHICS_PRESET: &[&str] = &["fast", "fancy", "fabulous", "custom"];
|
||||
/// Horizon's render distance cap (vanilla stops at 32): the server protocol's
|
||||
/// view distance tops out at 127 anyway.
|
||||
pub const MAX_RENDER_DISTANCE: i32 = 128;
|
||||
const INACTIVITY: &[&str] = &["minimized", "afk"];
|
||||
const CLOUDS: &[&str] = &["false", "fast", "true"];
|
||||
const MAIN_HAND: &[&str] = &["left", "right"];
|
||||
@@ -159,7 +162,7 @@ static TABLE: LazyLock<Vec<Def>> = LazyLock::new(|| {
|
||||
b("reducedDebugInfo", false),
|
||||
def("renderClouds", O(Clouds), V::Int(2)),
|
||||
i("cloudRange", 2, 128, 128),
|
||||
i("renderDistance", 2, 64, 12),
|
||||
i("renderDistance", 2, MAX_RENDER_DISTANCE, 12),
|
||||
i("simulationDistance", 5, 32, 12),
|
||||
u("screenEffectScale", 1.0),
|
||||
def("soundDevice", O(AnyStr), V::Str(String::new())),
|
||||
|
||||
@@ -84,6 +84,27 @@ pub(crate) const ACCESSIBILITY_OPTIONS: &[Opt] = &[
|
||||
Opt::HighContrastBlockOutlines,
|
||||
];
|
||||
|
||||
/// `ControlsScreen.options`: the controls added by Java Edition 26.3, in
|
||||
/// their vanilla order. The macOS-only Control-click switch is omitted on
|
||||
/// other platforms just as `InputQuirks.EMULATE_RIGHT_CLICK_WITH_CTRL_KEY`
|
||||
/// does in Java.
|
||||
pub(crate) fn vanilla_control_options() -> Vec<Opt> {
|
||||
let mut options = vec![
|
||||
Opt::ToggleCrouch,
|
||||
Opt::ToggleSprint,
|
||||
Opt::ToggleAttack,
|
||||
Opt::ToggleUse,
|
||||
Opt::AutoJump,
|
||||
Opt::SprintWindow,
|
||||
Opt::QuitShortcuts,
|
||||
];
|
||||
if cfg!(target_os = "macos") {
|
||||
options.push(Opt::CtrlClickEmulatesRightClick);
|
||||
}
|
||||
options.push(Opt::OperatorItemsTab);
|
||||
options
|
||||
}
|
||||
|
||||
/// `MouseSettingsScreen.options` in Java Edition 26.3, in vanilla order.
|
||||
pub(crate) const MOUSE_OPTIONS: &[Opt] = &[
|
||||
Opt::Sensitivity,
|
||||
@@ -120,6 +141,11 @@ impl Opt {
|
||||
| Opt::ChunkFade
|
||||
| Opt::ImprovedTransparency
|
||||
| Opt::ChunkBuilder
|
||||
// Saved, but the creative tabs, the quit keys and the mouse
|
||||
// don't read them yet.
|
||||
| Opt::OperatorItemsTab
|
||||
| Opt::QuitShortcuts
|
||||
| Opt::CtrlClickEmulatesRightClick
|
||||
)
|
||||
}
|
||||
fn file_key(self) -> Option<&'static str> {
|
||||
@@ -144,6 +170,10 @@ impl Opt {
|
||||
Opt::RotateWithMinecarts => "rotateWithMinecart",
|
||||
Opt::HighContrastBlockOutlines => "highContrastBlockOutline",
|
||||
Opt::MusicFrequency => "musicFrequency",
|
||||
Opt::SoundDevice => "soundDevice",
|
||||
Opt::OperatorItemsTab => "operatorItemsTab",
|
||||
Opt::QuitShortcuts => "quitShortcuts",
|
||||
Opt::CtrlClickEmulatesRightClick => "ctrlClickEmulatesRightClick",
|
||||
Opt::ModelPart(i) => MODEL_PART_KEYS[i],
|
||||
Opt::AllowServerListing => "allowServerListing",
|
||||
Opt::RealmsNotifications => "realmsNotifications",
|
||||
@@ -167,6 +197,7 @@ impl Opt {
|
||||
Opt::DiscreteScroll => "options.discrete_mouse_scroll",
|
||||
Opt::AllowCursorChanges => "options.allowCursorChanges",
|
||||
Opt::Volume(_) => "soundCategory.master",
|
||||
Opt::SoundDevice => "options.audioDevice",
|
||||
Opt::GuiScale => "options.guiScale",
|
||||
Opt::ViewBobbing => "options.viewBobbing",
|
||||
Opt::Gamma => "options.gamma",
|
||||
@@ -194,7 +225,13 @@ impl Opt {
|
||||
Opt::MenuBlur => "options.accessibility.menu_background_blurriness",
|
||||
Opt::ToggleCrouch => "key.sneak",
|
||||
Opt::ToggleSprint => "key.sprint",
|
||||
Opt::ToggleAttack => "key.attack",
|
||||
Opt::ToggleUse => "key.use",
|
||||
Opt::AutoJump => "options.autoJump",
|
||||
Opt::SprintWindow => "options.sprintWindow",
|
||||
Opt::QuitShortcuts => "options.quitShortcuts",
|
||||
Opt::CtrlClickEmulatesRightClick => "options.ctrlClickEmulatesRightClick",
|
||||
Opt::OperatorItemsTab => "options.operatorItemsTab",
|
||||
Opt::ChatVisibility => "options.chat.visibility",
|
||||
Opt::ChatColors => "options.chat.color",
|
||||
Opt::ChatLinks => "options.chat.links",
|
||||
@@ -217,7 +254,7 @@ impl Opt {
|
||||
Opt::NotificationDisplayTime => "options.notifications.display_time",
|
||||
Opt::Narrator => "options.narrator",
|
||||
Opt::TextBackground => "options.accessibility.text_background",
|
||||
Opt::ChatDelay => "Chat Delay",
|
||||
Opt::ChatDelay => "horizon.options.chat_delay",
|
||||
Opt::DistortionEffects => "options.screenEffectScale",
|
||||
Opt::FovEffects => "options.fovEffectScale",
|
||||
Opt::DarknessPulsing => "options.darknessEffectScale",
|
||||
@@ -278,7 +315,11 @@ impl Opt {
|
||||
| Opt::GlintStrength
|
||||
| Opt::PanoramaSpeed => Kind::Slider(s.file.double(self.file_key().unwrap()) as f32),
|
||||
Opt::Fov => unit(s.fov, 30.0, 110.0),
|
||||
Opt::RenderDistance => unit(s.render_distance as f32, 2.0, 64.0),
|
||||
Opt::RenderDistance => unit(
|
||||
s.render_distance as f32,
|
||||
2.0,
|
||||
crate::options_file::MAX_RENDER_DISTANCE as f32,
|
||||
),
|
||||
Opt::SimulationDistance => unit(s.simulation_distance as f32, 5.0, 32.0),
|
||||
Opt::Sensitivity => Kind::Slider(s.sensitivity),
|
||||
Opt::WheelSensitivity => unit(s.mouse_wheel_sensitivity, 0.01, 10.0),
|
||||
@@ -308,6 +349,7 @@ impl Opt {
|
||||
Opt::PadLookSensitivity => Kind::Slider(s.controller.look_sensitivity),
|
||||
Opt::PadMoveDeadzone => unit(s.controller.move_deadzone, 0.0, 0.5),
|
||||
Opt::PadLookDeadzone => unit(s.controller.look_deadzone, 0.0, 0.5),
|
||||
Opt::SprintWindow => unit(s.sprint_window as f32, 0.0, 10.0),
|
||||
_ => Kind::Cycle,
|
||||
}
|
||||
}
|
||||
@@ -356,6 +398,14 @@ impl Opt {
|
||||
}
|
||||
}
|
||||
Opt::PanoramaSpeed => percent(c, s.file.double("panoramaScrollSpeed") as f32),
|
||||
Opt::SprintWindow => {
|
||||
let value = if s.sprint_window == 0 {
|
||||
tr("options.off")
|
||||
} else {
|
||||
Text::tr("options.value", &[&s.sprint_window])
|
||||
};
|
||||
generic(c, value)
|
||||
}
|
||||
Opt::Fov => match s.fov as i32 {
|
||||
70 => generic(c, tr("options.fov.min")),
|
||||
110 => generic(c, tr("options.fov.max")),
|
||||
@@ -573,7 +623,20 @@ impl Opt {
|
||||
} else {
|
||||
"options.key.hold"
|
||||
}),
|
||||
Opt::ToggleAttack => tr(if s.toggle_attack {
|
||||
"options.key.toggle"
|
||||
} else {
|
||||
"options.key.hold"
|
||||
}),
|
||||
Opt::ToggleUse => tr(if s.toggle_use {
|
||||
"options.key.toggle"
|
||||
} else {
|
||||
"options.key.hold"
|
||||
}),
|
||||
Opt::AutoJump => on_off(s.auto_jump),
|
||||
Opt::QuitShortcuts => on_off(s.quit_shortcuts),
|
||||
Opt::CtrlClickEmulatesRightClick => on_off(s.ctrl_click_emulates_right_click),
|
||||
Opt::OperatorItemsTab => on_off(s.operator_items_tab),
|
||||
Opt::ChatVisibility => tr([
|
||||
"options.chat.visibility.full",
|
||||
"options.chat.visibility.system",
|
||||
@@ -586,6 +649,13 @@ impl Opt {
|
||||
Opt::ReducedDebugInfo => on_off(s.reduced_debug_info),
|
||||
Opt::HighContrast => on_off(s.high_contrast),
|
||||
Opt::ShowSubtitles => on_off(s.show_subtitles),
|
||||
Opt::SoundDevice => {
|
||||
if s.sound_device.is_empty() {
|
||||
tr("options.audioDevice.default")
|
||||
} else {
|
||||
Text::literal(&s.sound_device)
|
||||
}
|
||||
}
|
||||
Opt::MusicFrequency => tr([
|
||||
"options.music_frequency.default",
|
||||
"options.music_frequency.frequent",
|
||||
@@ -603,25 +673,25 @@ impl Opt {
|
||||
} else {
|
||||
"options.key.hold"
|
||||
}),
|
||||
Opt::PadLightbar => Text::literal(match s.controller.lightbar {
|
||||
gamepad::Lightbar::Health => "Health",
|
||||
gamepad::Lightbar::Dimension => "Dimension",
|
||||
gamepad::Lightbar::Off => "Off",
|
||||
Opt::PadLightbar => tr(match s.controller.lightbar {
|
||||
gamepad::Lightbar::Health => "horizon.options.lightbar.health",
|
||||
gamepad::Lightbar::Dimension => "horizon.options.lightbar.dimension",
|
||||
gamepad::Lightbar::Off => "options.off",
|
||||
}),
|
||||
Opt::PadGlyphs => Text::literal(match s.controller.glyphs {
|
||||
GlyphStyle::Auto => "Auto",
|
||||
GlyphStyle::Xbox => "Xbox",
|
||||
GlyphStyle::PlayStation => "PlayStation",
|
||||
GlyphStyle::Nintendo => "Nintendo",
|
||||
Opt::PadGlyphs => tr(match s.controller.glyphs {
|
||||
GlyphStyle::Auto => "horizon.options.glyphs.auto",
|
||||
GlyphStyle::Xbox => "horizon.options.glyphs.xbox",
|
||||
GlyphStyle::PlayStation => "horizon.options.glyphs.playstation",
|
||||
GlyphStyle::Nintendo => "horizon.options.glyphs.nintendo",
|
||||
}),
|
||||
Opt::PadMenuNav => Text::literal(match s.controller.menu_nav {
|
||||
gamepad::MenuNav::Focus => "Focus",
|
||||
gamepad::MenuNav::Pointer => "Pointer",
|
||||
Opt::PadMenuNav => tr(match s.controller.menu_nav {
|
||||
gamepad::MenuNav::Focus => "horizon.options.menu_focus",
|
||||
gamepad::MenuNav::Pointer => "horizon.options.menu_pointer",
|
||||
}),
|
||||
Opt::PadKeyboard => Text::literal(match s.controller.keyboard {
|
||||
gamepad::TextKeyboard::Auto => "Auto",
|
||||
gamepad::TextKeyboard::Horizon => "Horizon",
|
||||
gamepad::TextKeyboard::Steam => "Steam",
|
||||
Opt::PadKeyboard => tr(match s.controller.keyboard {
|
||||
gamepad::TextKeyboard::Auto => "horizon.options.keyboard.auto",
|
||||
gamepad::TextKeyboard::Horizon => "horizon.options.keyboard.horizon",
|
||||
gamepad::TextKeyboard::Steam => "horizon.options.keyboard.steam",
|
||||
}),
|
||||
_ => Text::empty(),
|
||||
}
|
||||
@@ -647,7 +717,10 @@ impl Opt {
|
||||
crate::options_file::V::Double(f64::from(v)),
|
||||
),
|
||||
Opt::Fov => s.fov = step(30.0, 110.0),
|
||||
Opt::RenderDistance => s.render_distance = step(2.0, 64.0) as i32,
|
||||
Opt::RenderDistance => {
|
||||
s.render_distance =
|
||||
step(2.0, crate::options_file::MAX_RENDER_DISTANCE as f32) as i32
|
||||
}
|
||||
Opt::SimulationDistance => s.simulation_distance = step(5.0, 32.0) as i32,
|
||||
Opt::Sensitivity => s.sensitivity = v,
|
||||
Opt::WheelSensitivity => s.mouse_wheel_sensitivity = 0.01 + v * 9.99,
|
||||
@@ -674,6 +747,7 @@ impl Opt {
|
||||
Opt::PadLookSensitivity => s.controller.look_sensitivity = v,
|
||||
Opt::PadMoveDeadzone => s.controller.move_deadzone = step(0.0, 50.0) / 100.0,
|
||||
Opt::PadLookDeadzone => s.controller.look_deadzone = step(0.0, 50.0) / 100.0,
|
||||
Opt::SprintWindow => s.sprint_window = step(0.0, 10.0) as i32,
|
||||
_ => {}
|
||||
}
|
||||
if self.affects_preset() {
|
||||
@@ -770,7 +844,14 @@ impl Opt {
|
||||
Opt::ChunkBuilder => s.prioritize_chunk_updates = next(s.prioritize_chunk_updates, 3),
|
||||
Opt::ToggleCrouch => s.toggle_crouch = !s.toggle_crouch,
|
||||
Opt::ToggleSprint => s.toggle_sprint = !s.toggle_sprint,
|
||||
Opt::ToggleAttack => s.toggle_attack = !s.toggle_attack,
|
||||
Opt::ToggleUse => s.toggle_use = !s.toggle_use,
|
||||
Opt::AutoJump => s.auto_jump = !s.auto_jump,
|
||||
Opt::QuitShortcuts => s.quit_shortcuts = !s.quit_shortcuts,
|
||||
Opt::CtrlClickEmulatesRightClick => {
|
||||
s.ctrl_click_emulates_right_click = !s.ctrl_click_emulates_right_click
|
||||
}
|
||||
Opt::OperatorItemsTab => s.operator_items_tab = !s.operator_items_tab,
|
||||
Opt::ChatVisibility => s.chat_visibility = next(s.chat_visibility, 3),
|
||||
Opt::ChatColors => s.chat_colors = !s.chat_colors,
|
||||
Opt::ChatLinks => s.chat_links = !s.chat_links,
|
||||
@@ -779,6 +860,23 @@ impl Opt {
|
||||
Opt::ReducedDebugInfo => s.reduced_debug_info = !s.reduced_debug_info,
|
||||
Opt::HighContrast => s.high_contrast = !s.high_contrast,
|
||||
Opt::ShowSubtitles => s.show_subtitles = !s.show_subtitles,
|
||||
Opt::SoundDevice => {
|
||||
let devices = crate::audio::output_devices();
|
||||
let mut all = vec![String::new()];
|
||||
for d in devices {
|
||||
if !all.contains(&d) {
|
||||
all.push(d);
|
||||
}
|
||||
}
|
||||
let current_idx = all.iter().position(|d| d == &s.sound_device).unwrap_or(0);
|
||||
let count = all.len();
|
||||
let next_idx = if back {
|
||||
(current_idx + count - 1) % count
|
||||
} else {
|
||||
(current_idx + 1) % count
|
||||
};
|
||||
s.sound_device = all[next_idx].clone();
|
||||
}
|
||||
Opt::MusicFrequency => {
|
||||
let n = next(s.file.int("musicFrequency").clamp(0, 2) as u8, 3);
|
||||
s.file
|
||||
@@ -921,6 +1019,7 @@ pub enum Page {
|
||||
/// Horizon's graphics settings (`graphics_ui`).
|
||||
Graphics,
|
||||
GraphicsAdvanced,
|
||||
GraphicsHdr,
|
||||
/// Horizon's controller settings and button binds.
|
||||
Controller,
|
||||
PadBinds,
|
||||
@@ -955,8 +1054,7 @@ enum Act {
|
||||
/// Pick `s.shader_packs[i]`, or turn it off when it is the pick.
|
||||
PickShaderPack(usize),
|
||||
OpenShaderPackFolder,
|
||||
/// The installed mod's own settings page (capability `ui`), shown as a
|
||||
/// screen: `Event::ModSettings` has the game open it.
|
||||
/// The installed native mod's settings page.
|
||||
ModSettings(usize),
|
||||
/// A native mod's key (mod, key) waits for a key; a button of its page.
|
||||
NativeKey(usize, usize),
|
||||
@@ -1012,16 +1110,20 @@ pub enum Event {
|
||||
Language(String),
|
||||
/// The pack selection changed (reload resources).
|
||||
Packs(Vec<String>),
|
||||
/// Create World's Data Packs screen closed with a new selection.
|
||||
DataPacks(Vec<String>),
|
||||
OpenPackFolder,
|
||||
/// Data Packs' Open Pack Folder: create the folder if needed and open it.
|
||||
OpenDataPackFolder(std::path::PathBuf),
|
||||
OpenModFolder,
|
||||
/// The Shader Packs page opened: list `shaderpacks/` into `shader_packs`.
|
||||
ListShaderPacks,
|
||||
/// A shader pack was picked or turned off: save and apply it.
|
||||
ShaderPack,
|
||||
OpenShaderPackFolder,
|
||||
/// Open the mod's own settings page (capability `ui`): the installed
|
||||
/// mod's index in `mods.installed`.
|
||||
ModSettings(usize),
|
||||
/// Native mod `.0`'s page opened: the game asks the mod for its labels
|
||||
/// again (they were made with the list, maybe in another language).
|
||||
NativeSettings(usize),
|
||||
/// Button `.1` of native mod `.0`'s page was pressed: the game tells the
|
||||
/// mod and refreshes the page.
|
||||
NativeButton(usize, usize),
|
||||
@@ -1075,6 +1177,13 @@ pub struct OptionsUi {
|
||||
pub pack_selection: Vec<String>,
|
||||
/// The open pack screen, made at its first frame.
|
||||
pub pack_screen: Option<crate::pack_screen::PackScreen>,
|
||||
/// The Packs page is Create World's Data Packs screen on this folder.
|
||||
data_pack_dir: Option<std::path::PathBuf>,
|
||||
/// The game's resources, for the built-in data pack's icon.
|
||||
data_pack_resources: std::path::PathBuf,
|
||||
/// The folder's entries when its packs were listed, and when they were
|
||||
/// last compared with it.
|
||||
data_pack_stamp: (Vec<(String, Option<std::time::SystemTime>)>, Instant),
|
||||
/// A reload failure, dismissed before returning to the current page.
|
||||
pub resource_error: Option<String>,
|
||||
/// When the pack screen's search box took the focus (its cursor blink).
|
||||
@@ -1109,6 +1218,9 @@ impl Default for OptionsUi {
|
||||
pack_infos: Vec::new(),
|
||||
pack_selection: Vec::new(),
|
||||
pack_screen: None,
|
||||
data_pack_dir: None,
|
||||
data_pack_resources: Default::default(),
|
||||
data_pack_stamp: (Vec::new(), Instant::now()),
|
||||
resource_error: None,
|
||||
pack_focus: Instant::now(),
|
||||
in_world: false,
|
||||
@@ -1161,22 +1273,14 @@ fn option_widget(s: &Settings, opt: Opt, x: f32, y: f32, w: f32) -> Widget {
|
||||
/// What a button opening one of Horizon's pages says on hover.
|
||||
fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
|
||||
Some(match page {
|
||||
Page::Horizon => "Horizon's own settings: graphics, controllers, extras and mods.",
|
||||
Page::Graphics => "Graphics, simply: each setting says what it costs.",
|
||||
Page::GraphicsAdvanced => {
|
||||
"Every graphics setting, Horizon's included, with what each costs."
|
||||
}
|
||||
Page::Controller => "Controllers: their buttons, sticks, vibration and menu navigation.",
|
||||
Page::Extras => {
|
||||
"Optional additions, all off by default: the game stays vanilla until you turn one on."
|
||||
}
|
||||
Page::Agent => "Let your own AI agent play: horizon-ctl and MCP clients on this computer.",
|
||||
Page::ShaderPacks => {
|
||||
"Shader packs from the shaderpacks folder: Horizon's own and those in the shaders/ format. One runs at a time."
|
||||
}
|
||||
Page::Mods => {
|
||||
"Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on."
|
||||
}
|
||||
Page::Horizon => "horizon.pages.settings.tooltip",
|
||||
Page::Graphics => "horizon.pages.graphics.tooltip",
|
||||
Page::GraphicsAdvanced => "horizon.pages.graphics_advanced.tooltip",
|
||||
Page::Controller => "horizon.pages.controller.tooltip",
|
||||
Page::Extras => "horizon.pages.extras.tooltip",
|
||||
Page::Agent => "horizon.pages.agent.tooltip",
|
||||
Page::ShaderPacks => "horizon.pages.shader_packs.tooltip",
|
||||
Page::Mods => "horizon.pages.mods.tooltip",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -1216,9 +1320,48 @@ impl OptionsUi {
|
||||
pub fn open(&mut self, in_world: bool) {
|
||||
self.open_at(in_world, Page::Main);
|
||||
}
|
||||
/// Create World's Data Packs (`openDataPackSelectionScreen`): the Packs
|
||||
/// page on the new world's pack folder `dir`, `selected` chosen;
|
||||
/// `resources` holds the built-in pack's icon.
|
||||
pub fn open_data_packs(
|
||||
&mut self,
|
||||
dir: std::path::PathBuf,
|
||||
selected: Vec<String>,
|
||||
resources: std::path::PathBuf,
|
||||
) {
|
||||
self.open_at(false, Page::Packs);
|
||||
self.pack_infos = crate::datapack::screen_packs(&dir, &resources);
|
||||
self.data_pack_resources = resources;
|
||||
self.pack_selection = selected;
|
||||
self.data_pack_stamp = (crate::datapack::folder_stamp(&dir), Instant::now());
|
||||
self.data_pack_dir = Some(dir);
|
||||
}
|
||||
/// `PackSelectionScreen.tick`'s watcher: once a second, the Data Packs
|
||||
/// folder's packs are listed again if it changed, the selection kept
|
||||
/// (the search text and scroll start over).
|
||||
fn watch_data_packs(&mut self, atlas: &HudAtlas, size: (f32, f32)) {
|
||||
let Some(dir) = &self.data_pack_dir else {
|
||||
return;
|
||||
};
|
||||
if self.data_pack_stamp.1.elapsed().as_secs() < 1 {
|
||||
return;
|
||||
}
|
||||
let stamp = crate::datapack::folder_stamp(dir);
|
||||
let changed = stamp != self.data_pack_stamp.0;
|
||||
self.data_pack_stamp = (stamp, Instant::now());
|
||||
if !changed {
|
||||
return;
|
||||
}
|
||||
self.pack_infos = crate::datapack::screen_packs(dir, &self.data_pack_resources);
|
||||
if let Some(p) = self.pack_screen.take() {
|
||||
self.pack_selection = p.commit();
|
||||
self.packs(atlas, size).changed = p.changed;
|
||||
}
|
||||
}
|
||||
/// Open a page whose Done/Escape returns directly to the calling screen.
|
||||
pub fn open_at(&mut self, in_world: bool, page: Page) {
|
||||
self.stack.clear();
|
||||
self.data_pack_dir = None;
|
||||
self.push(page);
|
||||
self.in_world = in_world;
|
||||
self.selected_key = None;
|
||||
@@ -1239,7 +1382,10 @@ impl OptionsUi {
|
||||
if page == Page::Packs {
|
||||
self.pack_screen = None;
|
||||
}
|
||||
if matches!(page, Page::Graphics | Page::GraphicsAdvanced) {
|
||||
if matches!(
|
||||
page,
|
||||
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr
|
||||
) {
|
||||
self.graphics.reset();
|
||||
}
|
||||
}
|
||||
@@ -1248,6 +1394,7 @@ impl OptionsUi {
|
||||
match page {
|
||||
Page::Graphics => Some(crate::graphics_ui::Level::Simple),
|
||||
Page::GraphicsAdvanced => Some(crate::graphics_ui::Level::Advanced),
|
||||
Page::GraphicsHdr => Some(crate::graphics_ui::Level::Hdr),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1266,16 +1413,16 @@ impl OptionsUi {
|
||||
Page::Language => "options.language.title",
|
||||
Page::Font => "options.font.title",
|
||||
Page::Packs => "resourcePack.title",
|
||||
Page::Mods => "Mods",
|
||||
Page::Mods => "horizon.pages.mods",
|
||||
Page::NativeMod(_) => "Mod Settings",
|
||||
Page::ShaderPacks => "Shader Packs",
|
||||
Page::Graphics | Page::GraphicsAdvanced => "options.videoTitle",
|
||||
Page::Controller => "Controller Settings",
|
||||
Page::PadBinds => "Controller Binds",
|
||||
Page::Extras => "Horizon Extras",
|
||||
Page::ShaderPacks => "horizon.pages.shader_packs",
|
||||
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr => "options.videoTitle",
|
||||
Page::Controller => "horizon.pages.controller",
|
||||
Page::PadBinds => "horizon.pages.controller_binds",
|
||||
Page::Extras => "horizon.pages.extras",
|
||||
Page::Credits => "credits_and_attribution.screen.title",
|
||||
Page::Horizon => "Horizon Settings",
|
||||
Page::Agent => "AI Agent",
|
||||
Page::Horizon => "horizon.pages.settings",
|
||||
Page::Agent => "horizon.pages.agent",
|
||||
}
|
||||
}
|
||||
fn scrollbar_x(&self, width: f32) -> f32 {
|
||||
@@ -1413,21 +1560,23 @@ impl OptionsUi {
|
||||
Page::Horizon => {
|
||||
// Two to a row, as vanilla's OptionsList.addSmall lays out
|
||||
// buttons; each opens a page built from vanilla's widgets.
|
||||
let open = |label: &str, page: Page| {
|
||||
button(0.0, 0.0, 150.0, Text::literal(label), Act::Push(page))
|
||||
};
|
||||
let open =
|
||||
|key: &str, page: Page| button(0.0, 0.0, 150.0, tr(key), Act::Push(page));
|
||||
vec![
|
||||
Row::Buttons(
|
||||
open("Graphics...", Page::Graphics),
|
||||
Some(open("Advanced Graphics...", Page::GraphicsAdvanced)),
|
||||
open("horizon.pages.open.graphics", Page::Graphics),
|
||||
Some(open(
|
||||
"horizon.pages.open.graphics_advanced",
|
||||
Page::GraphicsAdvanced,
|
||||
)),
|
||||
),
|
||||
Row::Buttons(
|
||||
open("Shader Packs...", Page::ShaderPacks),
|
||||
Some(open("Controller Settings...", Page::Controller)),
|
||||
open("horizon.pages.open.shader_packs", Page::ShaderPacks),
|
||||
Some(open("horizon.pages.open.controller", Page::Controller)),
|
||||
),
|
||||
Row::Buttons(
|
||||
open("Extras...", Page::Extras),
|
||||
Some(open("Mods...", Page::Mods)),
|
||||
open("horizon.pages.open.extras", Page::Extras),
|
||||
Some(open("horizon.pages.open.mods", Page::Mods)),
|
||||
),
|
||||
// No AI hides the AI Agent page and everything AI.
|
||||
Row::Buttons(
|
||||
@@ -1435,7 +1584,7 @@ impl OptionsUi {
|
||||
Some(if s.extras.no_ai {
|
||||
self.extra_button(s, crate::extras::Item::NoAi)
|
||||
} else {
|
||||
open("AI Agent...", Page::Agent)
|
||||
open("horizon.pages.open.agent", Page::Agent)
|
||||
}),
|
||||
),
|
||||
]
|
||||
@@ -1454,7 +1603,7 @@ impl OptionsUi {
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
Text::literal("Copy Connection Details"),
|
||||
tr("horizon.agent.copy_details"),
|
||||
Act::CopyAgentDetails,
|
||||
)),
|
||||
)];
|
||||
@@ -1468,14 +1617,14 @@ impl OptionsUi {
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
Text::literal("Horizon Graphics..."),
|
||||
tr("horizon.pages.open.horizon_graphics"),
|
||||
Act::Push(Page::Graphics),
|
||||
),
|
||||
Some(button(
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
Text::literal("Advanced Graphics..."),
|
||||
tr("horizon.pages.open.graphics_advanced"),
|
||||
Act::Push(Page::GraphicsAdvanced),
|
||||
)),
|
||||
)];
|
||||
@@ -1524,6 +1673,7 @@ impl OptionsUi {
|
||||
rows.extend(pairs(&others));
|
||||
rows.extend(pairs(&[Opt::ShowSubtitles, Opt::DirectionalAudio]));
|
||||
rows.extend(pairs(&[Opt::MusicFrequency, Opt::MusicToast]));
|
||||
rows.push(Row::Big(Opt::SoundDevice));
|
||||
rows
|
||||
}
|
||||
Page::Skin => {
|
||||
@@ -1559,18 +1709,15 @@ impl OptionsUi {
|
||||
Act::Push(Page::KeyBinds),
|
||||
)),
|
||||
)];
|
||||
rows.extend(pairs(&[
|
||||
Opt::ToggleCrouch,
|
||||
Opt::ToggleSprint,
|
||||
Opt::AutoJump,
|
||||
]));
|
||||
let controls = vanilla_control_options();
|
||||
rows.extend(pairs(&controls));
|
||||
// Horizon's controller page, after vanilla's rows.
|
||||
rows.push(Row::Buttons(
|
||||
button(
|
||||
0.0,
|
||||
0.0,
|
||||
310.0,
|
||||
Text::literal("Controller Settings..."),
|
||||
tr("horizon.pages.open.controller"),
|
||||
Act::Push(Page::Controller),
|
||||
),
|
||||
None,
|
||||
@@ -1583,7 +1730,7 @@ impl OptionsUi {
|
||||
0.0,
|
||||
0.0,
|
||||
310.0,
|
||||
Text::literal("Controller Binds..."),
|
||||
tr("horizon.pages.open.controller_binds"),
|
||||
Act::Push(Page::PadBinds),
|
||||
),
|
||||
None,
|
||||
@@ -1641,9 +1788,7 @@ impl OptionsUi {
|
||||
Page::Font => pairs(&[Opt::ForceUnicode, Opt::JapaneseGlyphs]),
|
||||
Page::Mods => {
|
||||
if s.mods.installed.is_empty() {
|
||||
return vec![Row::Header(
|
||||
"No mods: put .hmod files in the horizon-mods folder",
|
||||
)];
|
||||
return vec![Row::Header("horizon.mods.empty")];
|
||||
}
|
||||
s.mods
|
||||
.installed
|
||||
@@ -1653,20 +1798,20 @@ impl OptionsUi {
|
||||
let mut label = Text::literal(format!("{}: ", installed.title))
|
||||
.append(on_off(s.mods.is_on(i)));
|
||||
if installed.problem.is_some() {
|
||||
label = label.append(" (Stopped)");
|
||||
label = label.append(tr("horizon.mods.stopped"));
|
||||
} else if installed.dev {
|
||||
label = label.append(" (Dev)");
|
||||
label = label.append(tr("horizon.mods.dev"));
|
||||
}
|
||||
let dead = installed.dev || installed.id.is_empty();
|
||||
// A running mod with a settings page (capability
|
||||
// `ui`) gets its own button beside the toggle.
|
||||
if installed.has_settings || installed.native.is_some() {
|
||||
// A native mod gets its settings page's button beside
|
||||
// the toggle.
|
||||
if installed.native.is_some() {
|
||||
let toggle = button(0.0, 0.0, 150.0, label, Act::ToggleMod(i));
|
||||
let settings = button(
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
Text::literal("Settings"),
|
||||
tr("horizon.mods.settings"),
|
||||
Act::ModSettings(i),
|
||||
);
|
||||
Row::Buttons(
|
||||
@@ -1683,17 +1828,17 @@ impl OptionsUi {
|
||||
Page::NativeMod(i) => self.native_rows(s, i),
|
||||
Page::ShaderPacks => {
|
||||
if s.shader_packs.is_empty() {
|
||||
return vec![Row::Header(
|
||||
"No shader packs: put them in the shaderpacks folder",
|
||||
)];
|
||||
return vec![Row::Header("horizon.shader_packs.empty")];
|
||||
}
|
||||
s.shader_packs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, pack)| {
|
||||
let kind = match pack.kind {
|
||||
crate::shader_pack_list::Kind::Horizon => " (Horizon)",
|
||||
crate::shader_pack_list::Kind::Classic => "",
|
||||
crate::shader_pack_list::Kind::Horizon => {
|
||||
lang::get("horizon.shader_packs.kind_horizon")
|
||||
}
|
||||
crate::shader_pack_list::Kind::Classic => String::new(),
|
||||
};
|
||||
let label = Text::literal(format!("{}{kind}: ", pack.title))
|
||||
.append(on_off(s.horizon.shader_pack == pack.file));
|
||||
@@ -1753,8 +1898,8 @@ impl OptionsUi {
|
||||
// Vanilla's Telemetry Data and Credits slots: Horizon's
|
||||
// settings and its mods, as a mod adds its button here.
|
||||
// Credits & Attribution (WinScreen's credits) follows them.
|
||||
("Horizon Settings...", Some(Page::Horizon)),
|
||||
("Mods...", Some(Page::Mods)),
|
||||
("horizon.pages.open.horizon_settings", Some(Page::Horizon)),
|
||||
("horizon.pages.open.mods", Some(Page::Mods)),
|
||||
("options.credits_and_attribution", Some(Page::Credits)),
|
||||
];
|
||||
let rows = entries.len().div_ceil(2) as f32;
|
||||
@@ -2187,18 +2332,12 @@ impl OptionsUi {
|
||||
content = y + self.scroll - top + 2.0;
|
||||
out[list_start..].iter_mut().for_each(|w| w.list = true);
|
||||
let folder = match page {
|
||||
Page::Mods => Some(("Open Mod Folder", Act::OpenModFolder)),
|
||||
Page::ShaderPacks => Some(("Open Pack Folder", Act::OpenShaderPackFolder)),
|
||||
Page::Mods => Some(("horizon.mods.open_folder", Act::OpenModFolder)),
|
||||
Page::ShaderPacks => Some(("pack.openFolder", Act::OpenShaderPackFolder)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some((label, act)) = folder {
|
||||
out.push(button(
|
||||
cx - 154.0,
|
||||
h - 27.0,
|
||||
150.0,
|
||||
Text::literal(label),
|
||||
act,
|
||||
));
|
||||
out.push(button(cx - 154.0, h - 27.0, 150.0, tr(label), act));
|
||||
out.push(button(cx + 4.0, h - 27.0, 150.0, tr("gui.done"), Act::Done));
|
||||
} else {
|
||||
out.push(done(h - 27.0));
|
||||
@@ -2270,6 +2409,7 @@ impl OptionsUi {
|
||||
return;
|
||||
}
|
||||
if self.page() == Page::Packs {
|
||||
self.watch_data_packs(atlas, (w, h));
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.in_world);
|
||||
let blink = self.pack_focus.elapsed().as_millis() as u64;
|
||||
let m = crate::screen_draw::Measure(&atlas.font);
|
||||
@@ -2310,7 +2450,7 @@ impl OptionsUi {
|
||||
} = &wd.kind
|
||||
&& let Some(text) = horizon_page_tooltip(*page)
|
||||
{
|
||||
tooltip = Some(atlas.font.split(&Text::literal(text), 170.0));
|
||||
tooltip = Some(atlas.font.split(&tr(text), 170.0));
|
||||
}
|
||||
let key = match &wd.kind {
|
||||
WKind::Button {
|
||||
@@ -2337,10 +2477,7 @@ impl OptionsUi {
|
||||
} = &wd.kind
|
||||
&& !opt.available()
|
||||
{
|
||||
tooltip = Some(atlas.font.split(
|
||||
&Text::literal("This feature is not yet available in Horizon."),
|
||||
170.0,
|
||||
));
|
||||
tooltip = Some(atlas.font.split(&tr("horizon.options.unavailable"), 170.0));
|
||||
}
|
||||
if let WKind::Button {
|
||||
act: Act::ToggleMod(i),
|
||||
@@ -2481,9 +2618,7 @@ impl OptionsUi {
|
||||
if search.value.is_empty() && !search.focused {
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&Text::literal("Search settings...")
|
||||
.with_color(text::GRAY)
|
||||
.italic(),
|
||||
&tr("horizon.options.search").with_color(text::GRAY).italic(),
|
||||
search.text_x(),
|
||||
search.text_y(),
|
||||
u32::MAX,
|
||||
@@ -2530,6 +2665,7 @@ impl OptionsUi {
|
||||
Action::Changed => events.push(Event::Changed),
|
||||
Action::SliderDrag => events.push(Event::SliderDrag),
|
||||
Action::OpenAdvanced => self.push(Page::GraphicsAdvanced),
|
||||
Action::OpenHdr => self.push(Page::GraphicsHdr),
|
||||
Action::OpenSimple => {
|
||||
if self.stack.len() >= 2
|
||||
&& self.stack[self.stack.len() - 2] == Page::Graphics
|
||||
@@ -2565,7 +2701,10 @@ impl OptionsUi {
|
||||
self.pack_focus = Instant::now();
|
||||
}
|
||||
match action {
|
||||
crate::pack_screen::Action::OpenFolder => events.push(Event::OpenPackFolder),
|
||||
crate::pack_screen::Action::OpenFolder => events.push(match &self.data_pack_dir {
|
||||
Some(dir) => Event::OpenDataPackFolder(dir.clone()),
|
||||
None => Event::OpenPackFolder,
|
||||
}),
|
||||
crate::pack_screen::Action::Done => events.extend(self.back(s)),
|
||||
crate::pack_screen::Action::None => {}
|
||||
}
|
||||
@@ -2712,11 +2851,8 @@ impl OptionsUi {
|
||||
}
|
||||
Act::ModSettings(i) => {
|
||||
events.push(Event::ButtonClick);
|
||||
if s.mods.installed.get(i).is_some_and(|m| m.native.is_some()) {
|
||||
self.push(Page::NativeMod(i));
|
||||
} else {
|
||||
events.push(Event::ModSettings(i));
|
||||
}
|
||||
self.push(Page::NativeMod(i));
|
||||
events.push(Event::NativeSettings(i));
|
||||
}
|
||||
Act::NativeKey(i, k) => {
|
||||
self.selected_key = Some(NATIVE_KEY);
|
||||
@@ -2816,8 +2952,14 @@ impl OptionsUi {
|
||||
}
|
||||
None => {
|
||||
self.pack_focus = Instant::now();
|
||||
let title = if self.data_pack_dir.is_some() {
|
||||
"dataPack.title"
|
||||
} else {
|
||||
"resourcePack.title"
|
||||
};
|
||||
self.pack_screen = Some(crate::pack_screen::PackScreen::new(
|
||||
&m,
|
||||
crate::text::Text::translate(title, &[]),
|
||||
w,
|
||||
h,
|
||||
self.in_world,
|
||||
@@ -2849,13 +2991,18 @@ impl OptionsUi {
|
||||
}
|
||||
Page::Packs => {
|
||||
// PackSelectionScreen.onClose: PackSelectionModel.commit.
|
||||
let data = self.data_pack_dir.take();
|
||||
if let Some(p) = self.pack_screen.take()
|
||||
&& p.changed
|
||||
{
|
||||
let ids = p.commit();
|
||||
s.resource_packs = ids.clone();
|
||||
self.pack_selection = ids.clone();
|
||||
events.push(Event::Packs(ids));
|
||||
if data.is_some() {
|
||||
events.push(Event::DataPacks(ids));
|
||||
} else {
|
||||
s.resource_packs = ids.clone();
|
||||
self.pack_selection = ids.clone();
|
||||
events.push(Event::Packs(ids));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -3151,10 +3298,48 @@ mod tests {
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn controls_match_java_26_3_option_order() {
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::Controls);
|
||||
let options =
|
||||
ui.list_rows(&Settings::default())
|
||||
.into_iter()
|
||||
.fold(Vec::new(), |mut options, row| {
|
||||
match row {
|
||||
Row::Big(opt) => options.push(opt),
|
||||
Row::Pair(a, b) => {
|
||||
options.push(a);
|
||||
options.extend(b);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
options
|
||||
});
|
||||
assert_eq!(options, vanilla_control_options());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprint_window_and_new_control_switches_round_trip() {
|
||||
let mut settings = Settings::default();
|
||||
set_slider(Opt::SprintWindow, &mut settings, 0.5);
|
||||
settings.operator_items_tab = true;
|
||||
settings.quit_shortcuts = false;
|
||||
settings.ctrl_click_emulates_right_click = true;
|
||||
settings.toggle_attack = true;
|
||||
settings.toggle_use = true;
|
||||
let loaded = Settings::parse(&settings.serialize());
|
||||
assert_eq!(loaded.sprint_window, 5);
|
||||
assert!(loaded.operator_items_tab);
|
||||
assert!(!loaded.quit_shortcuts);
|
||||
assert!(loaded.ctrl_click_emulates_right_click);
|
||||
assert!(loaded.toggle_attack && loaded.toggle_use);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_distance_slider_round_trips_through_extended_options_range() {
|
||||
let mut s = Settings::default();
|
||||
for (position, distance) in [(0.0, 2), (0.5, 33), (1.0, 64)] {
|
||||
for (position, distance) in [(0.0, 2), (0.5, 65), (1.0, 128)] {
|
||||
set_slider(Opt::RenderDistance, &mut s, position);
|
||||
assert_eq!(s.render_distance, distance);
|
||||
let reloaded = Settings::parse(&s.serialize());
|
||||
@@ -3165,10 +3350,10 @@ mod tests {
|
||||
);
|
||||
}
|
||||
let mut file = crate::options_file::OptionsFile::default();
|
||||
file.set_text("renderDistance", "64").unwrap();
|
||||
assert!(file.set_text("renderDistance", "65").is_err());
|
||||
file.set_text("renderDistance", "128").unwrap();
|
||||
assert!(file.set_text("renderDistance", "129").is_err());
|
||||
assert!(file.set_text("renderDistance", "1").is_err());
|
||||
assert_eq!(file.int("renderDistance"), 64);
|
||||
assert_eq!(file.int("renderDistance"), 128);
|
||||
}
|
||||
fn atlas() -> HudAtlas {
|
||||
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
|
||||
@@ -3242,8 +3427,15 @@ mod tests {
|
||||
ui.show(Page::Mods);
|
||||
let empty = ui.layout(&s, size.0, size.1).0;
|
||||
assert!(empty.iter().any(|w| {
|
||||
matches!(&w.kind, WKind::Text { text, .. } if text.plain().contains(".hmod"))
|
||||
matches!(&w.kind, WKind::Text { text, .. } if text.plain() == "horizon.mods.empty")
|
||||
}));
|
||||
// The empty page points at the bundled key; the English line behind it
|
||||
// is what a player reads, so both halves are checked here.
|
||||
let en = crate::lang::Language::load(&[], "en_us");
|
||||
assert!(
|
||||
en.get("horizon.mods.empty")
|
||||
.is_some_and(|line| line.contains(".hmod"))
|
||||
);
|
||||
let open_folder = empty
|
||||
.iter()
|
||||
.find(|w| {
|
||||
@@ -3276,7 +3468,6 @@ mod tests {
|
||||
description: "May show HUD panels.".into(),
|
||||
problem: id.is_empty().then(|| "Invalid package".into()),
|
||||
dev,
|
||||
has_settings: false,
|
||||
native: None,
|
||||
});
|
||||
}
|
||||
@@ -3332,6 +3523,49 @@ mod tests {
|
||||
assert_eq!(ui.page(), Page::Main);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_a_native_mods_page_asks_the_game_for_its_labels_again() {
|
||||
let atlas = atlas();
|
||||
let mut s = Settings::default();
|
||||
let mut ui = OptionsUi::default();
|
||||
let size = (480.0, 240.0);
|
||||
s.mods.installed.push(crate::mod_list::Installed {
|
||||
id: "native".into(),
|
||||
title: "Native 1.0".into(),
|
||||
description: String::new(),
|
||||
problem: None,
|
||||
dev: false,
|
||||
native: Some(crate::mod_list::NativePage::default()),
|
||||
});
|
||||
ui.show(Page::Mods);
|
||||
let (widgets, ..) = ui.layout(&s, size.0, size.1);
|
||||
let settings = widgets
|
||||
.iter()
|
||||
.find(|w| {
|
||||
matches!(
|
||||
w.kind,
|
||||
WKind::Button {
|
||||
act: Act::ModSettings(0),
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.expect("a native mod's row has a Settings button");
|
||||
let events = ui.click(
|
||||
&mut s,
|
||||
&atlas,
|
||||
settings.x + 1.0,
|
||||
settings.y + 1.0,
|
||||
size,
|
||||
[480, 240],
|
||||
false,
|
||||
false,
|
||||
);
|
||||
// Its labels were made with the list, maybe in another language.
|
||||
assert_eq!(events.last(), Some(&Event::NativeSettings(0)));
|
||||
assert_eq!(ui.page(), Page::NativeMod(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn option_tooltip_keys_follow_value_dependent_descriptions() {
|
||||
let mut s = Settings::default();
|
||||
@@ -4374,4 +4608,29 @@ mod tests {
|
||||
ui.stack = vec![Page::Main, Page::Controls];
|
||||
assert!(pushes(&mut ui).contains(&Page::Controller));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sound_device_button_in_sound_page_and_cycles() {
|
||||
let mut s = Settings::default();
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::Sound);
|
||||
let rows = ui.list_rows(&s);
|
||||
assert!(
|
||||
rows.iter().any(|r| matches!(r, Row::Big(Opt::SoundDevice))),
|
||||
"Sound page should contain Row::Big(Opt::SoundDevice)"
|
||||
);
|
||||
assert_eq!(s.sound_device, "");
|
||||
let devices = crate::audio::output_devices();
|
||||
let count = 1 + devices.len();
|
||||
// Cycling forward 'count' times wraps around back to the initial selection ("")
|
||||
for _ in 0..count {
|
||||
Opt::SoundDevice.cycle(&mut s, false, [854, 480]);
|
||||
}
|
||||
assert_eq!(s.sound_device, "");
|
||||
// Cycling backward 'count' times also wraps around back to ""
|
||||
for _ in 0..count {
|
||||
Opt::SoundDevice.cycle(&mut s, true, [854, 480]);
|
||||
}
|
||||
assert_eq!(s.sound_device, "");
|
||||
}
|
||||
}
|
||||
@@ -318,6 +318,8 @@ pub enum Action {
|
||||
/// The pack screen and its state.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PackScreen {
|
||||
/// `resourcePack.title`, or `dataPack.title` from Create World.
|
||||
pub title: Text,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
pub in_world: bool,
|
||||
@@ -342,6 +344,7 @@ impl PackScreen {
|
||||
/// `PackSelectionScreen.init`: the lists filled, the search box focused.
|
||||
pub fn new(
|
||||
m: &Measure,
|
||||
title: Text,
|
||||
width: i32,
|
||||
height: i32,
|
||||
in_world: bool,
|
||||
@@ -360,6 +363,7 @@ impl PackScreen {
|
||||
.filter_map(|p| Some((icon_texture(&p.id), p.icon.clone()?)))
|
||||
.collect();
|
||||
let mut s = Self {
|
||||
title,
|
||||
width,
|
||||
height,
|
||||
in_world,
|
||||
@@ -382,7 +386,7 @@ impl PackScreen {
|
||||
}
|
||||
/// The header layout: title, drop info and search box, centred.
|
||||
fn header(&self, m: &Measure) -> [(i32, i32); 3] {
|
||||
let tw = m.width(&tr("resourcePack.title"));
|
||||
let tw = m.width(&self.title);
|
||||
let dw = m.width(&tr("pack.dropInfo"));
|
||||
let col = tw.max(dw).max(200);
|
||||
let frame = self.width.max(col);
|
||||
@@ -837,7 +841,7 @@ impl PackScreen {
|
||||
return out;
|
||||
}
|
||||
let [(tx, ty), (dx, dy), _] = self.header(m);
|
||||
d::string_widget(&mut out, m, &tr("resourcePack.title"), tx, ty, 0);
|
||||
d::string_widget(&mut out, m, &self.title, tx, ty, 0);
|
||||
d::string_widget(
|
||||
&mut out,
|
||||
m,
|
||||
@@ -1105,7 +1109,7 @@ mod icon_tests {
|
||||
pack("high_contrast", Some(contrast.clone())),
|
||||
pack("file/Iconless", None),
|
||||
];
|
||||
let screen = PackScreen::new(&m, 480, 320, false, packs, &[]);
|
||||
let screen = PackScreen::new(&m, tr("resourcePack.title"), 480, 320, false, packs, &[]);
|
||||
let mut draws = screen.draw(&m, (0, 0), 0);
|
||||
let mut patches = Vec::new();
|
||||
screen.bind_icons(&mut draws, &atlas, &mut patches);
|
||||
@@ -1152,7 +1156,8 @@ mod click_sound_tests {
|
||||
crate::hud::HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets"))
|
||||
.unwrap();
|
||||
let m = Measure(&atlas.font);
|
||||
let mut screen = PackScreen::new(&m, 480, 320, false, Vec::new(), &[]);
|
||||
let title = tr("resourcePack.title");
|
||||
let mut screen = PackScreen::new(&m, title, 480, 320, false, Vec::new(), &[]);
|
||||
assert!(!screen.button_at(&m, 0.0, 0.0));
|
||||
assert!(!screen.button_at(
|
||||
&m,
|
||||
|
||||
@@ -181,9 +181,15 @@ pub fn compatible(meta: &serde_json::Value) -> bool {
|
||||
/// `PackCompatibility.forVersion`: too old, too new, compatible, or unknown
|
||||
/// when the metadata names no format.
|
||||
pub fn compatibility(meta: &serde_json::Value) -> crate::pack_screen::Compat {
|
||||
compatibility_for(meta, RESOURCE_FORMAT)
|
||||
}
|
||||
/// `compatibility` against `current`, the resource or the data pack format.
|
||||
pub fn compatibility_for(
|
||||
meta: &serde_json::Value,
|
||||
current: (i64, i64),
|
||||
) -> crate::pack_screen::Compat {
|
||||
use crate::pack_screen::Compat;
|
||||
let pack = &meta["pack"];
|
||||
let current = RESOURCE_FORMAT;
|
||||
let range = if let (Some(min), Some(max)) = (
|
||||
format_of(&pack["min_format"]),
|
||||
format_of(&pack["max_format"]),
|
||||
@@ -275,6 +281,10 @@ fn read_meta(source: &Source) -> Option<serde_json::Value> {
|
||||
serde_json::from_slice(&bytes).ok()
|
||||
}
|
||||
fn pack(id: String, title: Text, source: Source) -> Option<Pack> {
|
||||
pack_for(id, title, source, RESOURCE_FORMAT)
|
||||
}
|
||||
/// A pack whose compatibility is against `format`.
|
||||
fn pack_for(id: String, title: Text, source: Source, format: (i64, i64)) -> Option<Pack> {
|
||||
let meta = read_meta(&source)?;
|
||||
let (raw_description, description_style) = component_root(&meta["pack"]["description"]);
|
||||
Some(Pack {
|
||||
@@ -284,7 +294,7 @@ fn pack(id: String, title: Text, source: Source) -> Option<Pack> {
|
||||
raw_description,
|
||||
description_style,
|
||||
compatible: compatible(&meta),
|
||||
compatibility: compatibility(&meta),
|
||||
compatibility: compatibility_for(&meta, format),
|
||||
overlays: overlays_of(&meta),
|
||||
filter: filter_of(&meta),
|
||||
source,
|
||||
@@ -305,7 +315,13 @@ pub fn discover(resources: &Path, packs_dir: &Path) -> Vec<Pack> {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
let mut user: Vec<Pack> = fs::read_dir(packs_dir)
|
||||
out.extend(folder(packs_dir, RESOURCE_FORMAT));
|
||||
out
|
||||
}
|
||||
/// The packs (folders and zips) in `dir`, by name, as `FolderRepositorySource`
|
||||
/// finds them; their compatibility is against `format`.
|
||||
pub fn folder(dir: &Path, format: (i64, i64)) -> Vec<Pack> {
|
||||
let mut user: Vec<Pack> = fs::read_dir(dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
@@ -319,12 +335,11 @@ pub fn discover(resources: &Path, packs_dir: &Path) -> Vec<Pack> {
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
pack(format!("file/{name}"), Text::literal(name), source)
|
||||
pack_for(format!("file/{name}"), Text::literal(name), source, format)
|
||||
})
|
||||
.collect();
|
||||
user.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
out.extend(user);
|
||||
out
|
||||
user
|
||||
}
|
||||
/// A file a pack provides: its path (folder packs) and its name inside the pack.
|
||||
type Provided = (Option<PathBuf>, String);
|
||||
@@ -380,11 +395,45 @@ fn link(target: &Path, at: &Path) -> io::Result<()> {
|
||||
if target.is_dir() {
|
||||
Ok(())
|
||||
} else {
|
||||
fs::copy(target, at).map(|_| ())
|
||||
// The mirror only reads these files, and unix links them: a hardlink gives
|
||||
// the same view without writing the 639 MB prepared root again, which was
|
||||
// 33 s of a cold Windows launch with a pack on. Another volume (or a
|
||||
// filesystem without hardlinks) still copies.
|
||||
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.
|
||||
#[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"))
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
fn junction(_: &Path, _: &Path) -> io::Result<()> {
|
||||
Err(io::ErrorKind::Unsupported.into())
|
||||
}
|
||||
/// Build `out` as `vanilla` with the packs' files layered on top (packs in
|
||||
/// load order, last wins). Untouched directories are symlinks into vanilla.
|
||||
/// load order, last wins). Untouched directories are links into vanilla.
|
||||
pub fn build_overlay(vanilla: &Path, packs: &[Pack], out: &Path) -> io::Result<()> {
|
||||
cache::build(vanilla, packs, out)
|
||||
}
|
||||
@@ -417,6 +466,22 @@ fn build_overlay_uncached(
|
||||
files.insert(name, (i, provided));
|
||||
}
|
||||
}
|
||||
// FallbackResourceManager: a file's `.mcmeta` counts only from the pack that
|
||||
// supplies the file or one above it; vanilla's own sits below every pack.
|
||||
let hidden: BTreeSet<String> = files
|
||||
.iter()
|
||||
.filter(|(name, _)| !name.ends_with(".mcmeta"))
|
||||
.filter_map(|(name, (owner, _))| {
|
||||
let meta = format!("{name}.mcmeta");
|
||||
match files.get(&meta) {
|
||||
Some((meta_owner, _)) if meta_owner >= owner => None,
|
||||
_ => Some(meta),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
for name in &hidden {
|
||||
files.remove(name);
|
||||
}
|
||||
// Vanilla sits below every pack, so filters hide its files too.
|
||||
let filters: Vec<&Filter> = packs.iter().flat_map(|p| &p.filter).collect();
|
||||
let touched_dirs: BTreeSet<String> = files
|
||||
@@ -468,6 +533,7 @@ fn build_overlay_uncached(
|
||||
vanilla: &Path,
|
||||
out: &Path,
|
||||
touched: &BTreeSet<String>,
|
||||
hidden: &BTreeSet<String>,
|
||||
files: &BTreeMap<String, (usize, Provided)>,
|
||||
merged: &BTreeMap<String, Vec<u8>>,
|
||||
filters: &[&Filter],
|
||||
@@ -504,6 +570,9 @@ fn build_overlay_uncached(
|
||||
}
|
||||
for name in names {
|
||||
let child = format!("{prefix}{name}");
|
||||
if hidden.contains(&child) {
|
||||
continue;
|
||||
}
|
||||
if let Some(bytes) = merged.get(&child) {
|
||||
fs::write(out.join(&child), bytes)?;
|
||||
} else if let Some((owner, (path, inner))) = files.get(&child) {
|
||||
@@ -512,15 +581,29 @@ fn build_overlay_uncached(
|
||||
Some(p) => link(p, &target)?,
|
||||
None => {
|
||||
if let Some(Some(z)) = zips.get(*owner) {
|
||||
fs::write(&target, z.read(inner)?)?;
|
||||
// One unreadable entry costs that file, not the
|
||||
// pack: an error here failed the build and the
|
||||
// reload dropped every server pack in the stack.
|
||||
match z.read(inner) {
|
||||
Ok(bytes) => fs::write(&target, bytes)?,
|
||||
Err(e) => log::warn!(
|
||||
"Horizon: skipping unreadable pack entry {inner}: {e}"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if filters.is_empty()
|
||||
&& !touched.contains(&child)
|
||||
&& vanilla.join(&child).is_dir()
|
||||
&& junction(&vanilla.join(&child), &out.join(&child)).is_ok()
|
||||
{
|
||||
// Linked whole, like the unix symlink: no file in it is visited or copied.
|
||||
} else if touched.contains(&child)
|
||||
// Windows cannot link a directory the way unix does (`link` above), so an
|
||||
// untouched one has to be mirrored: skipping it hid textures/font and the
|
||||
// title panorama from the overlay, and a font json that names those sheets
|
||||
// then failed to load (#1688).
|
||||
// Without a junction Windows cannot link a directory the way unix does (`link`
|
||||
// above), so an untouched one has to be mirrored: skipping it hid textures/font
|
||||
// and the title panorama from the overlay, and a font json that names those
|
||||
// sheets then failed to load (#1688).
|
||||
|| (vanilla.join(&child).is_dir() && (cfg!(not(unix)) || !filters.is_empty()))
|
||||
{
|
||||
mirror(
|
||||
@@ -528,6 +611,7 @@ fn build_overlay_uncached(
|
||||
vanilla,
|
||||
out,
|
||||
touched,
|
||||
hidden,
|
||||
files,
|
||||
merged,
|
||||
filters,
|
||||
@@ -551,6 +635,7 @@ fn build_overlay_uncached(
|
||||
vanilla,
|
||||
out,
|
||||
&touched_dirs,
|
||||
&hidden,
|
||||
&files,
|
||||
&merged,
|
||||
&filters,
|
||||
@@ -741,7 +826,9 @@ impl SelectionModel {
|
||||
/// `available` in any order; `selected` as options.txt lists them,
|
||||
/// bottom first.
|
||||
pub fn new(mut available: Vec<Selectable>, selected: &[String]) -> Self {
|
||||
// PackRepository.available is a TreeMap by id (String.compareTo).
|
||||
// PackRepository.discoverAvailable: every source's packs go into one
|
||||
// TreeMap by id (String.compareTo), so `file/` packs sort among the
|
||||
// built-in ones.
|
||||
available.sort_by(|a, b| a.id.encode_utf16().cmp(b.id.encode_utf16()));
|
||||
available.dedup_by(|a, b| a.id == b.id);
|
||||
let mut m = Self {
|
||||
@@ -939,7 +1026,8 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn overlay_layers_packs_over_vanilla() {
|
||||
let base = std::env::temp_dir().join(format!("horizon-packs-{}", std::process::id()));
|
||||
// `&` would split an unquoted `cmd /C mklink` line.
|
||||
let base = std::env::temp_dir().join(format!("horizon-packs-&{}", 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();
|
||||
@@ -960,6 +1048,13 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(vanilla.join("facts.json"), b"[]").unwrap();
|
||||
// A directory no pack touches (#1688: a font json names sheets in it).
|
||||
fs::create_dir_all(vanilla.join("assets/minecraft/textures/font")).unwrap();
|
||||
fs::write(
|
||||
vanilla.join("assets/minecraft/textures/font/ascii.png"),
|
||||
b"font",
|
||||
)
|
||||
.unwrap();
|
||||
let pack_dir = base.join("packs/Mine");
|
||||
fs::create_dir_all(pack_dir.join("assets/minecraft/textures/block")).unwrap();
|
||||
fs::write(
|
||||
@@ -997,6 +1092,17 @@ mod tests {
|
||||
br#"{"a":"vanilla","b":"pack"}"#
|
||||
);
|
||||
assert_eq!(fs::read(out.join("facts.json")).unwrap(), b"[]");
|
||||
assert_eq!(
|
||||
fs::read(out.join("assets/minecraft/textures/font/ascii.png")).unwrap(),
|
||||
b"font"
|
||||
);
|
||||
// Linked, not mirrored: a junction on Windows, a symlink on unix.
|
||||
assert!(
|
||||
fs::symlink_metadata(out.join("assets/minecraft/textures/font"))
|
||||
.unwrap()
|
||||
.file_type()
|
||||
.is_symlink()
|
||||
);
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
#[test]
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
use super::{Pack, Source, build_overlay_uncached};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
#[cfg(any(test, not(unix)))]
|
||||
use std::io::Read;
|
||||
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 = 4;
|
||||
const VERSION: u32 = 5;
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
pub(super) struct Dependencies {
|
||||
@@ -59,7 +59,6 @@ fn field(hash: &mut Sha256, bytes: &[u8]) {
|
||||
hash.update((bytes.len() as u64).to_le_bytes());
|
||||
hash.update(bytes);
|
||||
}
|
||||
#[cfg(any(test, not(unix)))]
|
||||
fn content(hash: &mut Sha256, path: &Path) -> io::Result<()> {
|
||||
let mut file = fs::File::open(path)?;
|
||||
hash.update(file.metadata()?.len().to_le_bytes());
|
||||
@@ -237,7 +236,10 @@ fn pack_signature(packs: &[Pack]) -> io::Result<String> {
|
||||
}
|
||||
Source::Zip(path) => {
|
||||
field(&mut hash, absolute(path)?.as_os_str().as_encoded_bytes());
|
||||
stamp(&mut hash, path)?;
|
||||
// By its bytes, not its stamp: a server pack found in the download cache is
|
||||
// marked used on every join (`HttpUtil.updateModificationTime`), and a new
|
||||
// time rebuilt the whole overlay each time. One file reads in a moment.
|
||||
content(&mut hash, path)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -348,6 +350,27 @@ mod tests {
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, bytes).unwrap();
|
||||
}
|
||||
/// Waits for the filesystem's clock to move on, as it does between a build and a
|
||||
/// person's next edit. On Unix the cache compares stamps (length, mtime, ctime, inode),
|
||||
/// and Linux takes them from a coarse clock (a few milliseconds a tick): an edit in the
|
||||
/// tick of the build before kept them all, and the tests failed at random.
|
||||
fn next_tick(&self) {
|
||||
let probe = self.0.with_extension("tick");
|
||||
// New bytes each time: NTFS keeps the time of a write that changes nothing.
|
||||
let stamp = |n: u32| {
|
||||
fs::write(&probe, n.to_le_bytes()).unwrap();
|
||||
fs::metadata(&probe).unwrap().modified().unwrap()
|
||||
};
|
||||
let start = stamp(0);
|
||||
// A second at most: no clock is that coarse, and a test never hangs on one.
|
||||
for n in 1..1000 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
if stamp(n) != start {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = fs::remove_file(&probe);
|
||||
}
|
||||
fn dir_pack(&self, name: &str) -> Pack {
|
||||
self.write(
|
||||
&format!("{name}/pack.mcmeta"),
|
||||
@@ -370,6 +393,7 @@ mod tests {
|
||||
output_signature(&cached).unwrap(),
|
||||
output_signature(&self.0.join("reference")).unwrap()
|
||||
);
|
||||
self.next_tick();
|
||||
}
|
||||
}
|
||||
impl Drop for Fixture {
|
||||
@@ -646,6 +670,38 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A ZIP whose bytes are the same keeps its overlay when only its time changes: a server
|
||||
/// pack found in the download cache is marked used on every join
|
||||
/// (`HttpUtil.updateModificationTime`), and rebuilding the overlay took most of a minute.
|
||||
#[test]
|
||||
fn a_zip_touched_but_unchanged_keeps_its_overlay() {
|
||||
let f = Fixture::new();
|
||||
f.write("vanilla/assets/minecraft/textures/b", b"vanilla");
|
||||
let files = vec![
|
||||
(
|
||||
"pack.mcmeta",
|
||||
br#"{"pack":{"description":"test","pack_format":97}}"#.to_vec(),
|
||||
),
|
||||
("assets/minecraft/textures/a", b"aaa".to_vec()),
|
||||
];
|
||||
f.write("pack.zip", &archive(&files, false));
|
||||
let zip = f.0.join("pack.zip");
|
||||
let pack = packs::pack("zip".into(), Text::empty(), Source::Zip(zip.clone())).unwrap();
|
||||
let (vanilla, out) = (f.0.join("vanilla"), f.0.join("cached"));
|
||||
packs::build_overlay(&vanilla, std::slice::from_ref(&pack), &out).unwrap();
|
||||
// A rebuild empties the output first: this file survives a cache hit only.
|
||||
fs::write(out.join("kept"), b"").unwrap();
|
||||
let later = std::time::SystemTime::now() + std::time::Duration::from_secs(60);
|
||||
fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&zip)
|
||||
.unwrap()
|
||||
.set_modified(later)
|
||||
.unwrap();
|
||||
packs::build_overlay(&vanilla, std::slice::from_ref(&pack), &out).unwrap();
|
||||
assert!(out.join("kept").exists(), "the overlay was rebuilt");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn unrelated_directory_pack_symlink_cycle_disables_reuse() {
|
||||
|
||||
@@ -3818,7 +3818,7 @@ impl ParticleEngine {
|
||||
layer: if layer {
|
||||
crate::gfx::TRANSLUCENT_PARTICLE_OVERLAY
|
||||
} else {
|
||||
0
|
||||
u32::from(hdr_tier(p.ty)) << 16
|
||||
},
|
||||
color,
|
||||
light,
|
||||
@@ -3830,6 +3830,27 @@ impl ParticleEngine {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// How brightly particle `ty` shines in HDR output, as `mesher::hdr_tier`: flames and
|
||||
/// glowing drops, not their smoke. Only opaque-layer particles are marked.
|
||||
fn hdr_tier(ty: ParticleType) -> u8 {
|
||||
use ParticleType as P;
|
||||
match ty {
|
||||
P::Flame
|
||||
| P::SoulFireFlame
|
||||
| P::CopperFireFlame
|
||||
| P::SmallFlame
|
||||
| P::Lava
|
||||
| P::DrippingLava
|
||||
| P::FallingLava
|
||||
| P::LandingLava
|
||||
| P::DrippingDripstoneLava
|
||||
| P::FallingDripstoneLava => crate::mesher::INCANDESCENT,
|
||||
P::DrippingObsidianTear | P::FallingObsidianTear | P::LandingObsidianTear => {
|
||||
crate::mesher::LOW
|
||||
}
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
/// `Particle.LifetimeAlpha.currentAlphaForAge`.
|
||||
fn lifetime_alpha(a: [f32; 4], age: i32, lifetime: i32, partial: f32) -> f32 {
|
||||
if (a[0] - a[1]).abs() < 1.0e-5 {
|
||||
|
||||
@@ -101,6 +101,8 @@ pub struct Settings {
|
||||
pub master_volume: f32,
|
||||
/// Volumes of every other sound source, indexed like SOUND_SOURCES.
|
||||
pub volumes: [f32; 11],
|
||||
/// Empty for system default, or a specific audio device name.
|
||||
pub sound_device: String,
|
||||
/// Requested scale (0 = auto); rendering caps it to the current framebuffer.
|
||||
pub gui_scale: u32,
|
||||
pub view_bobbing: bool,
|
||||
@@ -174,6 +176,12 @@ pub struct Settings {
|
||||
pub toggle_use: bool,
|
||||
/// Taps of the sprint key counted as double taps (sprintWindow).
|
||||
pub sprint_window: i32,
|
||||
/// Show the operator items tab in a creative inventory.
|
||||
pub operator_items_tab: bool,
|
||||
/// Allow platform quit shortcuts to close the game window.
|
||||
pub quit_shortcuts: bool,
|
||||
/// On macOS, turn Control-click into a right click.
|
||||
pub ctrl_click_emulates_right_click: bool,
|
||||
/// Horizon's own graphics settings (`horizon-graphics.txt`, not options.txt).
|
||||
pub horizon: crate::graphics::Graphics,
|
||||
/// Controller settings (`horizon-controller.txt`, Horizon's own).
|
||||
@@ -195,6 +203,7 @@ impl Default for Settings {
|
||||
sensitivity: 0.5,
|
||||
master_volume: 1.0,
|
||||
volumes: [1.0; 11],
|
||||
sound_device: String::new(),
|
||||
gui_scale: 0,
|
||||
view_bobbing: true,
|
||||
gamma: 0.5,
|
||||
@@ -256,6 +265,9 @@ impl Default for Settings {
|
||||
toggle_attack: false,
|
||||
toggle_use: false,
|
||||
sprint_window: 7,
|
||||
operator_items_tab: false,
|
||||
quit_shortcuts: true,
|
||||
ctrl_click_emulates_right_click: false,
|
||||
horizon: Default::default(),
|
||||
controller: Default::default(),
|
||||
extras: Default::default(),
|
||||
@@ -383,6 +395,7 @@ impl Settings {
|
||||
sensitivity: f.double("mouseSensitivity") as f32,
|
||||
master_volume: volumes[0],
|
||||
volumes,
|
||||
sound_device: f.string("soundDevice"),
|
||||
gui_scale: f.int("guiScale").max(0) as u32,
|
||||
view_bobbing: f.bool("bobView"),
|
||||
gamma: f.double("gamma") as f32,
|
||||
@@ -418,6 +431,9 @@ impl Settings {
|
||||
toggle_attack: f.bool("toggleAttack"),
|
||||
toggle_use: f.bool("toggleUse"),
|
||||
sprint_window: f.int("sprintWindow"),
|
||||
operator_items_tab: f.bool("operatorItemsTab"),
|
||||
quit_shortcuts: f.bool("quitShortcuts"),
|
||||
ctrl_click_emulates_right_click: f.bool("ctrlClickEmulatesRightClick"),
|
||||
horizon: Default::default(),
|
||||
controller: Default::default(),
|
||||
extras: Default::default(),
|
||||
@@ -475,6 +491,7 @@ impl Settings {
|
||||
};
|
||||
put_f(&mut f, &format!("soundCategory_{source}"), v);
|
||||
}
|
||||
put_s(&mut f, "soundDevice", &self.sound_device);
|
||||
put_i(&mut f, "guiScale", self.gui_scale as i32);
|
||||
put_b(&mut f, "bobView", self.view_bobbing);
|
||||
put_f(&mut f, "gamma", self.gamma);
|
||||
@@ -518,6 +535,13 @@ impl Settings {
|
||||
put_b(&mut f, "toggleAttack", self.toggle_attack);
|
||||
put_b(&mut f, "toggleUse", self.toggle_use);
|
||||
put_i(&mut f, "sprintWindow", self.sprint_window);
|
||||
put_b(&mut f, "operatorItemsTab", self.operator_items_tab);
|
||||
put_b(&mut f, "quitShortcuts", self.quit_shortcuts);
|
||||
put_b(
|
||||
&mut f,
|
||||
"ctrlClickEmulatesRightClick",
|
||||
self.ctrl_click_emulates_right_click,
|
||||
);
|
||||
put_b(&mut f, "autoJump", self.auto_jump);
|
||||
put_s(&mut f, "lang", &self.language);
|
||||
put_b(&mut f, "forceUnicodeFont", self.force_unicode);
|
||||
@@ -637,6 +661,7 @@ pub enum Opt {
|
||||
InvertY,
|
||||
AllowCursorChanges,
|
||||
Volume(usize),
|
||||
SoundDevice,
|
||||
GuiScale,
|
||||
ViewBobbing,
|
||||
Gamma,
|
||||
@@ -664,7 +689,13 @@ pub enum Opt {
|
||||
MenuBlur,
|
||||
ToggleCrouch,
|
||||
ToggleSprint,
|
||||
ToggleAttack,
|
||||
ToggleUse,
|
||||
AutoJump,
|
||||
SprintWindow,
|
||||
QuitShortcuts,
|
||||
CtrlClickEmulatesRightClick,
|
||||
OperatorItemsTab,
|
||||
ChatVisibility,
|
||||
ChatColors,
|
||||
ChatLinks,
|
||||
@@ -820,4 +851,15 @@ mod tests {
|
||||
1.0
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn sound_device_option() {
|
||||
let s = Settings::parse("soundDevice:\"Headphones\"\n");
|
||||
assert_eq!(s.sound_device, "Headphones");
|
||||
let serialized = s.serialize();
|
||||
assert!(serialized.contains("soundDevice:\"Headphones\""));
|
||||
let again = Settings::parse(&serialized);
|
||||
assert_eq!(again.sound_device, "Headphones");
|
||||
let empty = Settings::parse("");
|
||||
assert_eq!(empty.sound_device, "");
|
||||
}
|
||||
}
|
||||
@@ -468,7 +468,10 @@ pub fn head_skin(
|
||||
{
|
||||
let location = skin_location(&hash);
|
||||
match downloads.state(Kind::Skin, &url) {
|
||||
Download::Ready if atlas(&location) => return location,
|
||||
Download::Ready if atlas(&location) => {
|
||||
downloads.drawn(&location);
|
||||
return location;
|
||||
}
|
||||
// Its slot was evicted: offer the image again.
|
||||
Download::Ready => {
|
||||
downloads.again(&location);
|
||||
@@ -681,6 +684,8 @@ pub struct Downloader {
|
||||
/// atlas uploads again (`again`); the disk cache backs anything dropped.
|
||||
images: std::collections::HashMap<String, Vec<u8>>,
|
||||
image_order: std::collections::VecDeque<String>,
|
||||
/// Skins and capes drawn from the atlas since `take_drawn`.
|
||||
drawn: std::collections::HashSet<String>,
|
||||
}
|
||||
/// `images` is bounded only so a long session on a busy server does not grow
|
||||
/// without end; the disk cache holds everything it drops.
|
||||
@@ -697,6 +702,7 @@ impl Downloader {
|
||||
queued: Default::default(),
|
||||
images: Default::default(),
|
||||
image_order: Default::default(),
|
||||
drawn: Default::default(),
|
||||
}
|
||||
}
|
||||
/// How the download of `url` stands, starting it the first time.
|
||||
@@ -801,6 +807,77 @@ impl Downloader {
|
||||
}
|
||||
std::mem::take(&mut self.ready)
|
||||
}
|
||||
/// A skin or cape in the atlas drawn this frame: `SkinSlots` keeps its slot.
|
||||
pub fn drawn(&mut self, location: &str) {
|
||||
if !self.drawn.contains(location) {
|
||||
self.drawn.insert(location.to_owned());
|
||||
}
|
||||
}
|
||||
/// The skins and capes drawn since the last call.
|
||||
pub fn take_drawn(&mut self) -> std::collections::HashSet<String> {
|
||||
std::mem::take(&mut self.drawn)
|
||||
}
|
||||
}
|
||||
|
||||
/// Which downloaded skins and capes hold the atlas's skin slots
|
||||
/// (`hud::SKIN_SLOTS`). Vanilla's `SkinManager.TextureCache.getOrLoad`
|
||||
/// registers a texture per skin and keeps it. Here the slots are shared: a
|
||||
/// new skin takes a free slot, else the least recently drawn skin's, but
|
||||
/// never one drawn in the last frame. With every slot drawn, the new skin
|
||||
/// waits (its player keeps the default skin) instead of pushing out one in
|
||||
/// use, which then came back by pushing out another, so players vanished
|
||||
/// for a frame and heads swapped textures as the slots went round.
|
||||
pub struct SkinSlots {
|
||||
capacity: usize,
|
||||
/// Stored locations and their slot, the least recently drawn first.
|
||||
slots: Vec<(String, usize)>,
|
||||
/// Drawn in the last frame, or stored since: kept from eviction.
|
||||
kept: std::collections::HashSet<String>,
|
||||
}
|
||||
impl SkinSlots {
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
capacity,
|
||||
slots: Vec::new(),
|
||||
kept: Default::default(),
|
||||
}
|
||||
}
|
||||
/// Whether `location` is in a slot.
|
||||
pub fn contains(&self, location: &str) -> bool {
|
||||
self.slots.iter().any(|(l, _)| l == location)
|
||||
}
|
||||
/// A new frame: the skins `drawn` in the last one
|
||||
/// (`Downloader::take_drawn`) become the most recently drawn, and keep
|
||||
/// their slots through this frame's `place`s.
|
||||
pub fn next_frame(&mut self, drawn: std::collections::HashSet<String>) {
|
||||
let (recent, old): (Vec<_>, Vec<_>) = std::mem::take(&mut self.slots)
|
||||
.into_iter()
|
||||
.partition(|(l, _)| drawn.contains(l));
|
||||
self.slots = old;
|
||||
self.slots.extend(recent);
|
||||
self.kept = drawn;
|
||||
}
|
||||
/// A slot for `location`: its own, a free one, else the least recently
|
||||
/// drawn one not kept, with the location it held. `None` while every
|
||||
/// slot is kept.
|
||||
pub fn place(&mut self, location: String) -> Option<(usize, Option<String>)> {
|
||||
if let Some((_, slot)) = self.slots.iter().find(|(l, _)| *l == location) {
|
||||
return Some((*slot, None));
|
||||
}
|
||||
let (slot, evicted) = if self.slots.len() < self.capacity {
|
||||
(self.slots.len(), None)
|
||||
} else {
|
||||
let i = self
|
||||
.slots
|
||||
.iter()
|
||||
.position(|(l, _)| !self.kept.contains(l))?;
|
||||
let (old, slot) = self.slots.remove(i);
|
||||
(slot, Some(old))
|
||||
};
|
||||
self.kept.insert(location.clone());
|
||||
self.slots.push((location, slot));
|
||||
Some((slot, evicted))
|
||||
}
|
||||
}
|
||||
|
||||
/// A GET over HTTP or HTTPS (textures.minecraft.net serves both; vanilla
|
||||
@@ -999,6 +1076,63 @@ mod tests {
|
||||
assert!(super::resolve_profile(&super::NameOrId::Name("Notch".into()), &doc).is_none());
|
||||
}
|
||||
|
||||
/// More skins drawn every frame than there are slots (#3829): a frame's
|
||||
/// uploads used to push out skins drawn the frame before, which then
|
||||
/// uploaded again and pushed out others, so the drawn skins went missing
|
||||
/// in turn. Those drawn keep their slots; the rest wait.
|
||||
#[test]
|
||||
fn skins_drawn_last_frame_keep_their_slots() {
|
||||
let mut d = super::Downloader::new(std::env::temp_dir().join("horizon-test-skin-slots"));
|
||||
let skins: Vec<String> = (0..6).map(|i| format!("skins/{i}")).collect();
|
||||
for (i, s) in skins.iter().enumerate() {
|
||||
d.remember(s.clone(), vec![i as u8]);
|
||||
}
|
||||
let mut slots = super::SkinSlots::new(4);
|
||||
let mut last: Option<Vec<String>> = None;
|
||||
for frame in 0..8 {
|
||||
// draw_tab_list: the downloads that finished go to the atlas.
|
||||
slots.next_frame(d.take_drawn());
|
||||
let mut uploads = 0;
|
||||
for (location, _) in d.take_ready() {
|
||||
if slots.place(location).is_some() {
|
||||
uploads += 1;
|
||||
}
|
||||
}
|
||||
// update_remote_skins: every skin is drawn, from its slot, or
|
||||
// asked for again while it has none.
|
||||
let mut shown = Vec::new();
|
||||
for s in &skins {
|
||||
if slots.contains(s) {
|
||||
d.drawn(s);
|
||||
shown.push(s.clone());
|
||||
} else {
|
||||
d.again(s);
|
||||
}
|
||||
}
|
||||
if frame >= 2 {
|
||||
assert_eq!(uploads, 0, "frame {frame} uploaded again");
|
||||
assert_eq!(Some(&shown), last.as_ref(), "frame {frame} lost a skin");
|
||||
}
|
||||
// The first frame asks for the skins; from the next, four show.
|
||||
assert_eq!(shown.len(), if frame == 0 { 0 } else { 4 });
|
||||
last = Some(shown);
|
||||
}
|
||||
// A skin no longer drawn gives up its slot to a waiting one.
|
||||
let mut slots = super::SkinSlots::new(2);
|
||||
slots.next_frame(Default::default());
|
||||
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.next_frame(["skins/b".to_owned()].into());
|
||||
assert_eq!(
|
||||
slots.place("skins/c".into()),
|
||||
Some((0, Some("skins/a".into())))
|
||||
);
|
||||
// Stored this frame, kept through it.
|
||||
assert_eq!(slots.place("skins/d".into()), None);
|
||||
assert!(slots.contains("skins/b") && slots.contains("skins/c"));
|
||||
}
|
||||
|
||||
/// A skin evicted from the atlas re-uploads (`again`), once per ask.
|
||||
#[test]
|
||||
fn evicted_skins_upload_again() {
|
||||
|
||||
@@ -58,6 +58,8 @@ pub enum Ticking {
|
||||
Bee { bee: u32, aggressive: bool },
|
||||
/// `GuardianAttackSoundInstance`.
|
||||
GuardianAttack { guardian: u32 },
|
||||
/// `ElytraOnPlayerSoundInstance`: ticks since it started.
|
||||
Elytra { time: i32 },
|
||||
}
|
||||
/// What a minecart's sound instances read about it.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -95,6 +97,12 @@ pub struct TickContext {
|
||||
pub mobs: Vec<MobSound>,
|
||||
/// `!tickRateManager().runsNormally()`.
|
||||
pub frozen: bool,
|
||||
/// The player's position.
|
||||
pub player_position: [f64; 3],
|
||||
/// `getDeltaMovement().lengthSqr()` of the player.
|
||||
pub player_speed_sqr: f64,
|
||||
/// `isFallFlying()` of the player.
|
||||
pub player_fall_flying: bool,
|
||||
}
|
||||
/// `AbstractSoundInstance` and its subclasses' fields.
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -390,6 +398,24 @@ impl SoundInstance {
|
||||
};
|
||||
s
|
||||
}
|
||||
/// `new ElytraOnPlayerSoundInstance(player)`: looping at volume 0.1 until
|
||||
/// its first tick.
|
||||
pub fn elytra(position: [f64; 3], random: LegacyRandom) -> Self {
|
||||
let mut s = Self::new(
|
||||
"minecraft:item.elytra.flying",
|
||||
PLAYERS,
|
||||
0.1,
|
||||
1.0,
|
||||
random,
|
||||
true,
|
||||
0,
|
||||
Attenuation::Linear,
|
||||
position,
|
||||
false,
|
||||
);
|
||||
s.ticking = Ticking::Elytra { time: 0 };
|
||||
s
|
||||
}
|
||||
pub fn tickable(&self) -> bool {
|
||||
self.ticking != Ticking::No
|
||||
}
|
||||
@@ -494,6 +520,37 @@ impl SoundInstance {
|
||||
self.volume = 0.0;
|
||||
}
|
||||
}
|
||||
Ticking::Elytra { time } => {
|
||||
*time += 1;
|
||||
let time = *time;
|
||||
if ctx.player_removed || (time > 20 && !ctx.player_fall_flying) {
|
||||
self.stop();
|
||||
return;
|
||||
}
|
||||
let [x, y, z] = ctx.player_position;
|
||||
self.position = [
|
||||
f64::from(x as f32),
|
||||
f64::from(y as f32),
|
||||
f64::from(z as f32),
|
||||
];
|
||||
let speed = ctx.player_speed_sqr as f32;
|
||||
self.volume = if speed >= 1.0e-7 {
|
||||
(speed / 4.0).clamp(0.0, 1.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
// Silent for a second, then a second's fade in.
|
||||
if time < 20 {
|
||||
self.volume = 0.0;
|
||||
} else if time < 40 {
|
||||
self.volume *= (time - 20) as f32 / 20.0;
|
||||
}
|
||||
self.pitch = if self.volume > 0.8 {
|
||||
1.0 + (self.volume - 0.8)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
}
|
||||
Ticking::GuardianAttack { guardian } => {
|
||||
let id = *guardian;
|
||||
// The client's guardian never has a target of its own, so
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
//! Splitscreen's layout, pure data tested without a window: where each player's view goes in
|
||||
//! its window (`place`).
|
||||
use winit::dpi::PhysicalSize;
|
||||
|
||||
/// Players at most: P1 and fifteen more.
|
||||
pub const MAX_PLAYERS: usize = 16;
|
||||
|
||||
/// A player's view: its window (an index into the windows), its corner there and its size.
|
||||
pub type View = (usize, [u32; 2], PhysicalSize<u32>);
|
||||
|
||||
/// Rows of five to sixteen players sharing a window, widest first (columns on a tall window): every
|
||||
/// view stays near a screen's shape, where a full grid would leave a long thin one.
|
||||
const ROWS: [&[u32]; MAX_PLAYERS - 4] = [
|
||||
&[3, 2],
|
||||
&[3, 3],
|
||||
&[3, 2, 2],
|
||||
&[3, 3, 2],
|
||||
&[3, 3, 3],
|
||||
&[4, 3, 3],
|
||||
&[4, 4, 3],
|
||||
&[4, 4, 4],
|
||||
&[4, 3, 3, 3],
|
||||
&[4, 4, 3, 3],
|
||||
&[4, 4, 4, 3],
|
||||
&[4, 4, 4, 4],
|
||||
];
|
||||
|
||||
/// `order` made a whole order of `n` players: those it names (once, if there are), then the
|
||||
/// others, P1 first.
|
||||
pub fn ordered(order: &[usize], n: usize) -> Vec<usize> {
|
||||
let mut out: Vec<usize> = Vec::with_capacity(n);
|
||||
for &i in order {
|
||||
if i < n && !out.contains(&i) {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
out.extend((0..n).filter(|i| !order.contains(i)));
|
||||
out
|
||||
}
|
||||
|
||||
/// `place` with the players sharing a window in `order` (`ordered`): the first left or on top.
|
||||
/// The views are still by player.
|
||||
pub fn place_ordered(
|
||||
windows: &[usize],
|
||||
order: &[usize],
|
||||
sizes: &[PhysicalSize<u32>],
|
||||
two: u8,
|
||||
three: u8,
|
||||
) -> Vec<View> {
|
||||
let order = ordered(order, windows.len());
|
||||
let in_order: Vec<usize> = order.iter().map(|&i| windows[i]).collect();
|
||||
let placed = place(&in_order, sizes, two, three);
|
||||
let mut views = placed.clone();
|
||||
for (k, &i) in order.iter().enumerate() {
|
||||
views[i] = placed[k];
|
||||
}
|
||||
views
|
||||
}
|
||||
|
||||
/// Each player's view, given the window each is in and the windows' sizes. Players in one window
|
||||
/// share it in the order they come: two take halves (`two`: 0 side by side when wider than
|
||||
/// tall, 1 side by side, 2 top and bottom); three (`three`: 0 the first wide on top and two below,
|
||||
/// 1 the first tall on the left and two on the right, 2 three columns); four its quarters; five
|
||||
/// to sixteen in balanced rows (`ROWS`).
|
||||
pub fn place(windows: &[usize], sizes: &[PhysicalSize<u32>], two: u8, three: u8) -> Vec<View> {
|
||||
windows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &window)| {
|
||||
let here = windows.iter().filter(|&&w| w == window).count();
|
||||
let slot = windows[..i].iter().filter(|&&w| w == window).count() as u32;
|
||||
let size = sizes
|
||||
.get(window)
|
||||
.copied()
|
||||
.unwrap_or(PhysicalSize::new(1, 1));
|
||||
let (w, h) = (size.width, size.height);
|
||||
let side_by_side = match two {
|
||||
1 => true,
|
||||
2 => false,
|
||||
_ => w >= h,
|
||||
};
|
||||
// The view's cell as fractions of the window: (x0, x1, y0, y1) in halves or thirds.
|
||||
let cell = |x0: u32, x1: u32, y0: u32, y1: u32, of: u32| {
|
||||
[w * x0 / of, w * x1 / of, h * y0 / of, h * y1 / of]
|
||||
};
|
||||
let [x0, x1, y0, y1] = match (here, slot) {
|
||||
(1, _) => cell(0, 1, 0, 1, 1),
|
||||
(2, s) if side_by_side => cell(s, s + 1, 0, 2, 2),
|
||||
(2, s) => cell(0, 2, s, s + 1, 2),
|
||||
(3, s) => match (three, s) {
|
||||
(2, s) => cell(s, s + 1, 0, 3, 3),
|
||||
(1, 0) => cell(0, 1, 0, 2, 2),
|
||||
(1, s) => cell(1, 2, s - 1, s, 2),
|
||||
(_, 0) => cell(0, 2, 0, 1, 2),
|
||||
(_, s) => cell(s - 1, s, 1, 2, 2),
|
||||
},
|
||||
(4, s) => cell(s % 2, s % 2 + 1, s / 2, s / 2 + 1, 2),
|
||||
(n, s) => rows(n, s, w, h),
|
||||
};
|
||||
(
|
||||
window,
|
||||
[x0, y0],
|
||||
PhysicalSize::new((x1 - x0).max(1), (y1 - y0).max(1)),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Five to sixteen players: `slot`'s cell `[x0, x1, y0, y1]` in balanced rows (`ROWS`), or
|
||||
/// in columns when the window is taller than wide.
|
||||
fn rows(n: usize, slot: u32, w: u32, h: u32) -> [u32; 4] {
|
||||
let counts = ROWS[n.clamp(5, MAX_PLAYERS) - 5];
|
||||
let bands = counts.len() as u32;
|
||||
let (mut band, mut k) = (0, slot);
|
||||
while band + 1 < bands && k >= counts[band as usize] {
|
||||
k -= counts[band as usize];
|
||||
band += 1;
|
||||
}
|
||||
let across = counts[band as usize];
|
||||
// Along: the long side, divided between a band's views; down: the bands.
|
||||
let (along, down) = if w >= h { (w, h) } else { (h, w) };
|
||||
let (a0, a1) = (along * k / across, along * (k + 1) / across);
|
||||
let (d0, d1) = (down * band / bands, down * (band + 1) / bands);
|
||||
if w >= h {
|
||||
[a0, a1, d0, d1]
|
||||
} else {
|
||||
[d0, d1, a0, a1]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn size(w: u32, h: u32) -> PhysicalSize<u32> {
|
||||
PhysicalSize::new(w, h)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn players_on_one_screen_share_it() {
|
||||
let wide = [size(1920, 1080)];
|
||||
assert_eq!(
|
||||
place(&[0, 0], &wide, 0, 0)[1],
|
||||
(0, [960, 0], size(960, 1080))
|
||||
);
|
||||
assert_eq!(
|
||||
place(&[0, 0], &wide, 2, 0)[1],
|
||||
(0, [0, 540], size(1920, 540))
|
||||
);
|
||||
// Auto on a tall screen: top and bottom.
|
||||
assert_eq!(place(&[0, 0], &[size(1080, 1920)], 0, 0)[1].1, [0, 960]);
|
||||
assert_eq!(
|
||||
place(&[0; 4], &wide, 0, 0)[3],
|
||||
(0, [960, 540], size(960, 540))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desk_and_couch() {
|
||||
// P1 alone at the desk; P2 and P3 share the TV.
|
||||
let screens = [size(3024, 1964), size(3840, 2160)];
|
||||
let views = place(&[0, 1, 1], &screens, 0, 0);
|
||||
assert_eq!(views[0], (0, [0, 0], size(3024, 1964)));
|
||||
assert_eq!(views[1], (1, [0, 0], size(1920, 2160)));
|
||||
assert_eq!(views[2], (1, [1920, 0], size(1920, 2160)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_on_a_screen() {
|
||||
let tv = [size(1920, 1080)];
|
||||
// One wide on top, two below.
|
||||
let top = place(&[0; 3], &tv, 0, 0);
|
||||
assert_eq!(top[0], (0, [0, 0], size(1920, 540)));
|
||||
assert_eq!(top[2], (0, [960, 540], size(960, 540)));
|
||||
// One tall on the left, two stacked on the right.
|
||||
let left = place(&[0; 3], &tv, 0, 1);
|
||||
assert_eq!(left[0], (0, [0, 0], size(960, 1080)));
|
||||
assert_eq!(left[2], (0, [960, 540], size(960, 540)));
|
||||
// Three columns.
|
||||
assert_eq!(
|
||||
place(&[0; 3], &tv, 0, 2)[2],
|
||||
(0, [1280, 0], size(640, 1080))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_windows_two_players_each() {
|
||||
// P1 and P2 at the desk, P3 and P4 on the TV: halves in each window.
|
||||
let screens = [size(3024, 1964), size(3840, 2160)];
|
||||
let views = place(&[0, 0, 1, 1], &screens, 0, 0);
|
||||
assert_eq!(views[1], (0, [1512, 0], size(1512, 1964)));
|
||||
assert_eq!(views[2], (1, [0, 0], size(1920, 2160)));
|
||||
assert_eq!(views[3], (1, [1920, 0], size(1920, 2160)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn five_to_eight_in_rows_of_three_and_two() {
|
||||
let tv = [size(1920, 1080)];
|
||||
let eight = place(&[0; 8], &tv, 0, 0);
|
||||
assert_eq!(eight[0], (0, [0, 0], size(640, 360)));
|
||||
assert_eq!(eight[5], (0, [1280, 360], size(640, 360)));
|
||||
assert_eq!(eight[7], (0, [960, 720], size(960, 360)));
|
||||
let seven = place(&[0; 7], &tv, 0, 0);
|
||||
assert_eq!(seven[3], (0, [0, 360], size(960, 360)));
|
||||
// Every count tiles the window exactly: no gap, no overlap.
|
||||
for n in 5..=MAX_PLAYERS {
|
||||
for screen in [size(1920, 1080), size(1080, 1920), size(1711, 997)] {
|
||||
let views = place(&vec![0; n], &[screen], 0, 0);
|
||||
for (i, (_, [x, y], size)) in views.iter().enumerate() {
|
||||
assert!(x + size.width <= screen.width && y + size.height <= screen.height);
|
||||
for (_, [ox, oy], other) in &views[..i] {
|
||||
assert!(
|
||||
x + size.width <= *ox
|
||||
|| ox + other.width <= *x
|
||||
|| y + size.height <= *oy
|
||||
|| oy + other.height <= *y,
|
||||
"overlapping views for {n} in {screen:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
let area: u64 = views
|
||||
.iter()
|
||||
.map(|v| u64::from(v.2.width) * u64::from(v.2.height))
|
||||
.sum();
|
||||
assert_eq!(
|
||||
area,
|
||||
u64::from(screen.width) * u64::from(screen.height),
|
||||
"{n} in {screen:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
// A tall window: columns.
|
||||
let tall = place(&[0; 5], &[size(1080, 1920)], 0, 0);
|
||||
assert_eq!(tall[0], (0, [0, 0], size(540, 640)));
|
||||
assert_eq!(tall[4], (0, [540, 960], size(540, 960)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sixteen_players_form_a_four_by_four_grid() {
|
||||
let views = place(&[0; 16], &[size(3840, 2160)], 0, 0);
|
||||
for (i, view) in views.iter().enumerate() {
|
||||
assert_eq!(
|
||||
*view,
|
||||
(
|
||||
0,
|
||||
[(i as u32 % 4) * 960, (i as u32 / 4) * 540],
|
||||
size(960, 540)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_order_names_each_player_once_and_the_others_come_last() {
|
||||
assert_eq!(ordered(&[], 3), [0, 1, 2]);
|
||||
assert_eq!(ordered(&[2, 0], 3), [2, 0, 1]);
|
||||
// Gone players and repeats are dropped.
|
||||
assert_eq!(ordered(&[5, 1, 1], 3), [1, 0, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn players_sharing_a_window_fill_it_in_their_order() {
|
||||
let wide = [size(2000, 1000)];
|
||||
// P2 first: P2 on the left, P1 on the right.
|
||||
let views = place_ordered(&[0, 0], &[1, 0], &wide, 0, 0);
|
||||
assert_eq!(views[1].1, [0, 0]);
|
||||
assert_eq!(views[0].1, [1000, 0]);
|
||||
// P3 first of three: the wide view on top is P3's.
|
||||
let views = place_ordered(&[0, 0, 0], &[2], &wide, 0, 0);
|
||||
assert_eq!(views[2].2, size(2000, 500));
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,10 @@ use horizon_core::biome::BiomeZoom;
|
||||
use horizon_world::block_render::FaceVisibility;
|
||||
use horizon_world::worldgen::decoration_order::DecorationOrder;
|
||||
use std::{
|
||||
collections::{BinaryHeap, HashSet},
|
||||
collections::{BinaryHeap, HashSet, VecDeque},
|
||||
sync::{
|
||||
Arc, Condvar, Mutex, RwLock,
|
||||
Arc, Condvar, Mutex, MutexGuard, RwLock, TryLockError,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
mpsc,
|
||||
},
|
||||
thread,
|
||||
};
|
||||
@@ -99,6 +98,9 @@ pub trait LodSampler: Send {
|
||||
fn drain_details(&mut self) -> Vec<LodDetail> {
|
||||
Vec::new()
|
||||
}
|
||||
/// Reduced far detail (`graphics::FarDetail::Reduced`): `grid` may then
|
||||
/// approximate wide cells.
|
||||
fn set_approximate(&mut self, _approximate: bool) {}
|
||||
fn sample(&mut self, x: i32, z: i32) -> Option<crate::lod::Surface>;
|
||||
fn column(&mut self, x: i32, z: i32) -> crate::lod::Column {
|
||||
crate::lod::Column::from_surface(self.sample(x, z))
|
||||
@@ -168,6 +170,9 @@ enum Job {
|
||||
/// Proto chunks checked out to be saved (`ChunkGenerator::save_protos`),
|
||||
/// off the main thread like vanilla's IOWorker.
|
||||
Save(Vec<((i32, i32), Proto)>),
|
||||
/// Meshes the renderer is done with (`Streaming::free`): their memory
|
||||
/// is given back here, not in the frame.
|
||||
Free(Vec<((i32, i32, i32), SectionMesh)>),
|
||||
}
|
||||
pub enum Done {
|
||||
Generated(Chunk),
|
||||
@@ -214,11 +219,52 @@ struct Shared {
|
||||
/// What meshing reads (block models and sprites), swapped when resource
|
||||
/// packs reload; each mesh job takes the current one.
|
||||
context: RwLock<Arc<WorkerContext>>,
|
||||
done: Mailbox,
|
||||
}
|
||||
/// 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
|
||||
/// held the frame for a scheduler quantum (10 to 30 ms, the RD 32 ocean
|
||||
/// flight's 1% lows). The frame takes what is there without waiting; while a
|
||||
/// worker holds the lock, it takes it the next frame.
|
||||
#[derive(Default)]
|
||||
struct Mailbox {
|
||||
done: Mutex<VecDeque<Done>>,
|
||||
arrived: Condvar,
|
||||
}
|
||||
impl Mailbox {
|
||||
fn send(&self, done: Done) {
|
||||
self.done.lock().unwrap().push_back(done);
|
||||
self.arrived.notify_one();
|
||||
}
|
||||
fn try_lock(&self) -> Option<MutexGuard<'_, VecDeque<Done>>> {
|
||||
match self.done.try_lock() {
|
||||
Ok(done) => Some(done),
|
||||
Err(TryLockError::WouldBlock) => None,
|
||||
Err(TryLockError::Poisoned(e)) => panic!("{e}"),
|
||||
}
|
||||
}
|
||||
/// Every finished job, or none while a worker is sending.
|
||||
fn take(&self) -> VecDeque<Done> {
|
||||
self.try_lock()
|
||||
.map(|mut done| std::mem::take(&mut *done))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
fn try_recv(&self) -> Option<Done> {
|
||||
self.try_lock()?.pop_front()
|
||||
}
|
||||
fn recv_timeout(&self, timeout: std::time::Duration) -> Option<Done> {
|
||||
let done = self.done.lock().unwrap();
|
||||
let (mut done, _) = self
|
||||
.arrived
|
||||
.wait_timeout_while(done, timeout, |done| done.is_empty())
|
||||
.unwrap();
|
||||
done.pop_front()
|
||||
}
|
||||
}
|
||||
pub struct Streaming {
|
||||
shared: Arc<Shared>,
|
||||
light_view: Arc<dyn Fn(&Proto) -> Option<Chunk> + Send + Sync>,
|
||||
results: mpsc::Receiver<Done>,
|
||||
threads: Vec<thread::JoinHandle<()>>,
|
||||
order: u64,
|
||||
/// Ticks since the columns and protos that fell out of range were swept.
|
||||
@@ -229,6 +275,11 @@ pub struct Streaming {
|
||||
generation_scan: GenerationScan,
|
||||
/// Proto chunks: `None` while checked out to a decoration or save job.
|
||||
protos: FxHashMap<(i32, i32), Option<Proto>>,
|
||||
/// The light jobs' views of proto chunks past the area (`light_region`),
|
||||
/// built once per proto: each is a neighbour of up to three columns and
|
||||
/// waits in failed regions until the last one loads. Dropped whenever
|
||||
/// the proto is checked out, replaced or removed.
|
||||
light_views: FxHashMap<(i32, i32), Arc<Chunk>>,
|
||||
decorated: FxHashSet<(i32, i32)>,
|
||||
decorating: usize,
|
||||
/// Save jobs in flight.
|
||||
@@ -398,8 +449,14 @@ impl GenerationScan {
|
||||
wanted.push((input.ranges.priority(x, z, input.loading), x, z));
|
||||
}
|
||||
}
|
||||
// Forced chunks and their generation neighbourhood.
|
||||
let reach = margin - 1;
|
||||
// Forced chunks and their generation neighbourhood: the ring that
|
||||
// is decorated (or finished, without decoration) and the one its
|
||||
// 3x3 needs.
|
||||
let reach = if input.decorates {
|
||||
FORCED_TERRAIN_RING
|
||||
} else {
|
||||
FORCED_FEATURES_RING
|
||||
};
|
||||
for &(fx, fz) in input.ranges.forced {
|
||||
for dz in -reach..=reach {
|
||||
for dx in -reach..=reach {
|
||||
@@ -455,14 +512,13 @@ impl Streaming {
|
||||
ready: Condvar::new(),
|
||||
stop: AtomicBool::new(false),
|
||||
context: RwLock::new(context.clone()),
|
||||
done: Mailbox::default(),
|
||||
});
|
||||
let (send, results) = mpsc::channel();
|
||||
let light_tables = context.light.clone();
|
||||
let saves: Arc<dyn Saves> = generator.clone();
|
||||
let threads = (0..threads.max(1))
|
||||
.map(|i| {
|
||||
let shared = shared.clone();
|
||||
let send = send.clone();
|
||||
let generator = generator.clone();
|
||||
let light = context.light.clone();
|
||||
thread::Builder::new()
|
||||
@@ -510,6 +566,10 @@ impl Streaming {
|
||||
generator.save_protos(&refs);
|
||||
Done::Saved(protos)
|
||||
}
|
||||
Job::Free(meshes) => {
|
||||
drop(meshes);
|
||||
continue;
|
||||
}
|
||||
Job::Light(x, z, region, seq, sky) => {
|
||||
Done::Lit(x, z, light_chunk(&borrow(®ion), &light, sky), seq)
|
||||
}
|
||||
@@ -541,9 +601,7 @@ impl Streaming {
|
||||
Done::Meshed(x, sy, z, mesh, seq)
|
||||
}
|
||||
};
|
||||
if send.send(done).is_err() {
|
||||
return;
|
||||
}
|
||||
shared.done.send(done);
|
||||
}
|
||||
})
|
||||
.expect("spawn worker")
|
||||
@@ -556,7 +614,6 @@ impl Streaming {
|
||||
Self {
|
||||
shared,
|
||||
light_view,
|
||||
results,
|
||||
threads,
|
||||
order: 0,
|
||||
scan_age: 0,
|
||||
@@ -564,6 +621,7 @@ impl Streaming {
|
||||
generating: Default::default(),
|
||||
generation_scan: GenerationScan::default(),
|
||||
protos: Default::default(),
|
||||
light_views: Default::default(),
|
||||
decorated: Default::default(),
|
||||
decorating: 0,
|
||||
saving: 0,
|
||||
@@ -660,6 +718,17 @@ impl Streaming {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Give back meshes the renderer has copied: a worker frees them, ahead of
|
||||
/// other jobs, as vanilla's compile task closes its mesh data on its own
|
||||
/// thread once copied (`SectionRenderDispatcher.RenderSection.CompileTask.doTask`).
|
||||
/// Freed in the frame, the hundreds of meshes a chunk border brings waited
|
||||
/// on the allocator behind every busy worker: up to 20 ms of a frame each
|
||||
/// time while flying at render distance 32.
|
||||
pub fn free(&mut self, meshes: Vec<((i32, i32, i32), SectionMesh)>) {
|
||||
if !meshes.is_empty() {
|
||||
self.submit(None, 0, Job::Free(meshes));
|
||||
}
|
||||
}
|
||||
/// A column the server unloaded (ClientboundForgetLevelChunkPacket).
|
||||
pub fn forget(&mut self, world: &mut World, x: i32, z: i32) -> Option<Arc<Chunk>> {
|
||||
self.lit.remove(&(x, z));
|
||||
@@ -676,7 +745,7 @@ impl Streaming {
|
||||
}
|
||||
/// Queue a job for the column `at`, nearest first: its distance (`distance`)
|
||||
/// times four plus `bias`, which orders a column's jobs. A job without a
|
||||
/// column (a save) goes ahead of every other.
|
||||
/// column (a save, meshes to free) goes ahead of every other.
|
||||
fn submit(&mut self, at: Option<(i32, i32)>, bias: i64, job: Job) {
|
||||
self.order += 1;
|
||||
let priority = at.map_or(i64::MIN, |(x, z)| self.distance(x, z) * 4 + bias);
|
||||
@@ -713,6 +782,7 @@ impl Streaming {
|
||||
self.decorated.insert((x, z));
|
||||
for (i, proto) in region.into_iter().enumerate() {
|
||||
let key = (x + (i % 3) as i32 - 1, z + (i / 3) as i32 - 1);
|
||||
self.light_views.remove(&key);
|
||||
self.protos.insert(key, Some(proto));
|
||||
self.written.insert(key);
|
||||
}
|
||||
@@ -723,6 +793,7 @@ impl Streaming {
|
||||
self.generation_scan.exhausted = false;
|
||||
self.saving -= 1;
|
||||
for (key, proto) in protos {
|
||||
self.light_views.remove(&key);
|
||||
self.protos.insert(key, Some(proto));
|
||||
}
|
||||
}
|
||||
@@ -730,7 +801,11 @@ impl Streaming {
|
||||
fn save_checked_out(&mut self, keys: impl IntoIterator<Item = (i32, i32)>) {
|
||||
let protos: Vec<((i32, i32), Proto)> = keys
|
||||
.into_iter()
|
||||
.filter_map(|k| Some((k, self.protos.get_mut(&k)?.take()?)))
|
||||
.filter_map(|k| {
|
||||
let proto = self.protos.get_mut(&k)?.take()?;
|
||||
self.light_views.remove(&k);
|
||||
Some((k, proto))
|
||||
})
|
||||
.collect();
|
||||
if !protos.is_empty() {
|
||||
self.saving += 1;
|
||||
@@ -799,7 +874,7 @@ impl Streaming {
|
||||
/// The 3x3 a light job reads: the finished columns, and for a neighbour
|
||||
/// that will not finish (past the area), its decorated proto chunk, as
|
||||
/// vanilla lights its last FULL ring against neighbours at FEATURES.
|
||||
fn light_region(&self, world: &World, x: i32, z: i32) -> Option<Box<Region>> {
|
||||
fn light_region(&mut self, world: &World, x: i32, z: i32) -> Option<Box<Region>> {
|
||||
let mut region: Region = Default::default();
|
||||
for dz in 0..3 {
|
||||
for dx in 0..3 {
|
||||
@@ -807,8 +882,14 @@ impl Streaming {
|
||||
region[dz][dx] = Some(match world.chunks.get(&n) {
|
||||
Some(chunk) => chunk.clone(),
|
||||
None if !self.in_range(n.0, n.1, 1) && self.decorated.contains(&n) => {
|
||||
let proto = self.protos.get(&n)?.as_ref()?;
|
||||
Arc::new((self.light_view)(proto)?)
|
||||
if let Some(view) = self.light_views.get(&n) {
|
||||
view.clone()
|
||||
} else {
|
||||
let proto = self.protos.get(&n)?.as_ref()?;
|
||||
let view = Arc::new((self.light_view)(proto)?);
|
||||
self.light_views.insert(n, view.clone());
|
||||
view
|
||||
}
|
||||
}
|
||||
None => return None,
|
||||
});
|
||||
@@ -909,13 +990,16 @@ impl Streaming {
|
||||
self.requeue();
|
||||
self.relight_edits(world);
|
||||
let deferred = std::mem::take(&mut self.deferred);
|
||||
let received = std::iter::from_fn(|| self.results.try_recv().ok());
|
||||
for done in deferred.into_iter().chain(received.collect::<Vec<_>>()) {
|
||||
let received = self.shared.done.take();
|
||||
// Meshes made stale meanwhile, freed with the uploaded ones (`free`).
|
||||
let mut stale = Vec::new();
|
||||
for done in deferred.into_iter().chain(received) {
|
||||
match done {
|
||||
Done::Proto(x, z, proto, decorated) => {
|
||||
self.generation_scan.exhausted = false;
|
||||
self.generating.remove(&(x, z));
|
||||
if self.in_range(x, z, 4) || self.near_finished(x, z, 2) {
|
||||
self.light_views.remove(&(x, z));
|
||||
self.protos.insert((x, z), Some(proto));
|
||||
if decorated || !self.decorates {
|
||||
self.decorated.insert((x, z));
|
||||
@@ -966,12 +1050,14 @@ impl Streaming {
|
||||
}
|
||||
Done::Meshed(x, sy, z, mesh, seq) => {
|
||||
if self.meshing.get(&(x, sy, z)) != Some(&seq) {
|
||||
stale.push(((x, sy, z), mesh));
|
||||
continue;
|
||||
}
|
||||
// Its column unloaded while it was meshed: nothing would
|
||||
// ever remove the uploaded section.
|
||||
if !world.chunks.contains_key(&(x, z)) {
|
||||
self.meshing.remove(&(x, sy, z));
|
||||
stale.push(((x, sy, z), mesh));
|
||||
continue;
|
||||
}
|
||||
self.meshing.remove(&(x, sy, z));
|
||||
@@ -980,6 +1066,7 @@ impl Streaming {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.free(stale);
|
||||
let mut removed = Vec::new();
|
||||
if sweep {
|
||||
// Unload far columns.
|
||||
@@ -1021,10 +1108,10 @@ impl Streaming {
|
||||
&& i64::from(x - center.0).pow(2) + i64::from(z - center.1).pow(2) > r4 * r4
|
||||
&& !ranges.in_extra(x, z, 4)
|
||||
&& !ranges.in_band(x, z, 4)
|
||||
&& !ranges
|
||||
.forced
|
||||
.iter()
|
||||
.any(|f| (x - f.0).abs() <= 3 && (z - f.1).abs() <= 3)
|
||||
&& !ranges.forced.iter().any(|f| {
|
||||
(x - f.0).abs() <= FORCED_TERRAIN_RING
|
||||
&& (z - f.1).abs() <= FORCED_TERRAIN_RING
|
||||
})
|
||||
})
|
||||
.map(|(k, _)| *k)
|
||||
.collect();
|
||||
@@ -1039,6 +1126,7 @@ impl Streaming {
|
||||
written.push(key);
|
||||
} else {
|
||||
self.protos.remove(&key);
|
||||
self.light_views.remove(&key);
|
||||
}
|
||||
}
|
||||
self.save_checked_out(written);
|
||||
@@ -1068,6 +1156,7 @@ impl Streaming {
|
||||
.remove(&(x, z))
|
||||
.flatten()
|
||||
.expect("present proto");
|
||||
self.light_views.remove(&(x, z));
|
||||
self.finishing.insert((x, z));
|
||||
self.written.remove(&(x, z));
|
||||
self.submit(Some((x, z)), 0, Job::Finish(proto));
|
||||
@@ -1166,6 +1255,7 @@ impl Streaming {
|
||||
let region: Vec<Proto> = keys
|
||||
.iter()
|
||||
.map(|k| {
|
||||
self.light_views.remove(k);
|
||||
self.protos
|
||||
.insert(*k, None)
|
||||
.flatten()
|
||||
@@ -1418,12 +1508,12 @@ impl Streaming {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
while self.decorating > 0 || !self.finishing.is_empty() || self.saving > 0 {
|
||||
let left = deadline.saturating_duration_since(std::time::Instant::now());
|
||||
match self.results.recv_timeout(left) {
|
||||
Ok(Done::Decorated(x, z, region)) => self.receive_decorated(x, z, *region),
|
||||
Ok(Done::Generated(chunk)) => self.receive_generated(chunk, world),
|
||||
Ok(Done::Saved(protos)) => self.receive_saved(protos),
|
||||
Ok(other) => self.deferred.push(other),
|
||||
Err(_) => {
|
||||
match self.shared.done.recv_timeout(left) {
|
||||
Some(Done::Decorated(x, z, region)) => self.receive_decorated(x, z, *region),
|
||||
Some(Done::Generated(chunk)) => self.receive_generated(chunk, world),
|
||||
Some(Done::Saved(protos)) => self.receive_saved(protos),
|
||||
Some(other) => self.deferred.push(other),
|
||||
None => {
|
||||
log::warn!("Horizon: chunk generation did not finish before saving");
|
||||
break;
|
||||
}
|
||||
@@ -1445,7 +1535,7 @@ impl Streaming {
|
||||
/// in `save(true)`, so older generation cannot overwrite the final chunks.
|
||||
pub fn poll_save(&mut self, world: &mut World) -> bool {
|
||||
let started = std::time::Instant::now();
|
||||
while let Ok(done) = self.results.try_recv() {
|
||||
while let Some(done) = self.shared.done.try_recv() {
|
||||
match done {
|
||||
Done::Decorated(x, z, region) => self.receive_decorated(x, z, *region),
|
||||
Done::Generated(chunk) => self.receive_generated(chunk, world),
|
||||
@@ -1530,7 +1620,7 @@ impl Streaming {
|
||||
/// Wait up to `timeout` for a worker to finish a job; the next `update`
|
||||
/// takes it. A server pumps generation with this between its ticks.
|
||||
pub fn wait(&mut self, timeout: std::time::Duration) {
|
||||
if let Ok(done) = self.results.recv_timeout(timeout) {
|
||||
if let Some(done) = self.shared.done.recv_timeout(timeout) {
|
||||
self.deferred.push(done);
|
||||
}
|
||||
}
|
||||
@@ -1544,6 +1634,14 @@ pub fn in_view_distance(center: (i32, i32), radius: i32, x: i32, z: i32) -> bool
|
||||
let (dx, dz) = (gap(x - center.0), gap(z - center.1));
|
||||
dx * dx + dz * dz < i64::from(radius) * i64::from(radius)
|
||||
}
|
||||
/// The rings around a `/forceload`ed chunk, in chunks. Vanilla's FORCED
|
||||
/// ticket is level 31 (`ChunkMap.FORCED_TICKET_LEVEL`), the level rises one
|
||||
/// per ring (`ChunkTracker.computeLevelFromNeighbor`), and the level picks
|
||||
/// the column's status (`ChunkLevel.generationStatus`): full (33) two rings
|
||||
/// out, features (34) at three, terrain (35) at four.
|
||||
const FORCED_FULL_RING: i32 = 2;
|
||||
const FORCED_FEATURES_RING: i32 = 3;
|
||||
const FORCED_TERRAIN_RING: i32 = 4;
|
||||
/// The areas kept loaded: circles around the player and `extra`, and forced chunks.
|
||||
struct Ranges<'a> {
|
||||
center: (i32, i32),
|
||||
@@ -1799,7 +1897,16 @@ impl Ranges<'_> {
|
||||
if dx * dx + dz * dz <= r * r {
|
||||
return true;
|
||||
}
|
||||
let reach = (margin - 1).max(0);
|
||||
// A forced chunk holds a FORCED ticket at level 31
|
||||
// (`ChunkMap.FORCED_TICKET_LEVEL`), and the level rises one per ring
|
||||
// (`ChunkTracker.computeLevelFromNeighbor`): the margin is the
|
||||
// vanilla status ring the caller asks for.
|
||||
let reach = match margin {
|
||||
1 => FORCED_FULL_RING,
|
||||
2 => FORCED_FEATURES_RING,
|
||||
3 => FORCED_TERRAIN_RING,
|
||||
_ => margin + 1,
|
||||
};
|
||||
self.forced
|
||||
.iter()
|
||||
.any(|f| (x - f.0).abs() <= reach && (z - f.1).abs() <= reach)
|
||||
@@ -1811,10 +1918,10 @@ impl Drop for Streaming {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
while self.saving > 0 {
|
||||
let left = deadline.saturating_duration_since(std::time::Instant::now());
|
||||
match self.results.recv_timeout(left) {
|
||||
Ok(Done::Saved(_)) => self.saving -= 1,
|
||||
Ok(_) => {}
|
||||
Err(_) => break,
|
||||
match self.shared.done.recv_timeout(left) {
|
||||
Some(Done::Saved(_)) => self.saving -= 1,
|
||||
Some(_) => {}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
self.shared.stop.store(true, Ordering::Relaxed);
|
||||
@@ -1852,6 +1959,37 @@ fn rank_again(queue: &mut BinaryHeap<Queued>, distance: impl Fn(i32, i32) -> i64
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 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]
|
||||
fn the_frame_takes_finished_jobs_without_waiting() {
|
||||
let mailbox = Mailbox::default();
|
||||
let lit = |seq| Done::Lit(0, 0, Vec::new(), seq);
|
||||
mailbox.send(lit(1));
|
||||
mailbox.send(lit(2));
|
||||
let sending = mailbox.done.lock().unwrap();
|
||||
assert!(mailbox.take().is_empty());
|
||||
assert!(mailbox.try_recv().is_none());
|
||||
drop(sending);
|
||||
let seqs: Vec<u64> = mailbox
|
||||
.take()
|
||||
.into_iter()
|
||||
.map(|done| match done {
|
||||
Done::Lit(_, _, _, seq) => seq,
|
||||
_ => unreachable!(),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(seqs, [1, 2]);
|
||||
assert!(mailbox.take().is_empty());
|
||||
let waiting = std::time::Instant::now();
|
||||
assert!(
|
||||
mailbox
|
||||
.recv_timeout(std::time::Duration::from_millis(10))
|
||||
.is_none()
|
||||
);
|
||||
assert!(waiting.elapsed() >= std::time::Duration::from_millis(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_jobs_follow_the_centre_and_the_loading_screen() {
|
||||
let forced = HashSet::new();
|
||||
@@ -2037,9 +2175,14 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
let forced_reach = if input.decorates {
|
||||
FORCED_TERRAIN_RING
|
||||
} else {
|
||||
FORCED_FEATURES_RING
|
||||
};
|
||||
for &(fx, fz) in input.ranges.forced {
|
||||
for dz in -(margin - 1)..=(margin - 1) {
|
||||
for dx in -(margin - 1)..=(margin - 1) {
|
||||
for dz in -forced_reach..=forced_reach {
|
||||
for dx in -forced_reach..=forced_reach {
|
||||
let (x, z) = (fx + dx, fz + dz);
|
||||
if missing(x, z) {
|
||||
wanted.push((input.ranges.distance(x, z), x, z));
|
||||
@@ -2441,6 +2584,57 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `/forceload`ed chunk holds a FORCED ticket at level 31
|
||||
/// (`ChunkMap.FORCED_TICKET_LEVEL`): the level rises one per ring
|
||||
/// (`ChunkTracker.computeLevelFromNeighbor`) and picks the column's
|
||||
/// status (`ChunkLevel.generationStatus`). So vanilla finishes the two
|
||||
/// rings around it (33), gives the third its features (34) and the
|
||||
/// fourth its terrain (35), and the rings past that are left alone.
|
||||
#[test]
|
||||
fn forced_chunks_reach_vanillas_rings() {
|
||||
let forced: HashSet<(i32, i32)> = [(40, -40)].into();
|
||||
let r = Ranges {
|
||||
// The player's own area, far away.
|
||||
center: (-400, 300),
|
||||
radius: 2,
|
||||
extra: &[],
|
||||
forced: &forced,
|
||||
cover: None,
|
||||
band: None,
|
||||
};
|
||||
let level = |x: i32, z: i32| 31 + (x - 40).abs().max((z + 40).abs());
|
||||
for x in 40 - 6..=40 + 6 {
|
||||
for z in -40 - 6..=-40 + 6 {
|
||||
// The full ring: the column becomes a level chunk.
|
||||
assert_eq!(r.in_range(x, z, 1), level(x, z) <= 33, "({x}, {z})");
|
||||
// The features ring, and the terrain ring.
|
||||
assert_eq!(r.in_range(x, z, 2), level(x, z) <= 34, "({x}, {z})");
|
||||
assert_eq!(r.in_range(x, z, 3), level(x, z) <= 35, "({x}, {z})");
|
||||
assert_eq!(r.requested(x, z, true), level(x, z) <= 35, "({x}, {z})");
|
||||
assert_eq!(r.chunk_kept(x, z, true), level(x, z) <= 35, "({x}, {z})");
|
||||
assert_eq!(r.unloaded(x, z, true), level(x, z) > 35, "({x}, {z})");
|
||||
}
|
||||
}
|
||||
// Generation reaches the terrain ring: the features ring is
|
||||
// decorated from it.
|
||||
let mut fixture = GenerationFixture::new(2, true);
|
||||
fixture.center = (-400, 300);
|
||||
fixture.forced = forced;
|
||||
let candidates = GenerationScan::default()
|
||||
.candidates(fixture.input(), usize::MAX)
|
||||
.expect("a scan");
|
||||
for (dx, dz) in [(-4, 0), (4, 0), (0, -4), (0, 4), (3, 3)] {
|
||||
assert!(
|
||||
candidates
|
||||
.iter()
|
||||
.any(|&(_, x, z)| (x, z) == (40 + dx, -40 + dz)),
|
||||
"the terrain ring at ({}, {}) is not generated",
|
||||
40 + dx,
|
||||
-40 + dz
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -146,9 +146,10 @@ fn apply_fog(color: vec4<f32>, spherical: f32, cylindrical: f32) -> vec4<f32> {
|
||||
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
|
||||
let color = sprite(in.uv, in.layer & 0x7fffu) * in.color;
|
||||
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
|
||||
// Alpha 0 tells HDR output (hdr.wgsl) a light-emitting block's bright pixels
|
||||
// may shine. Nothing else reads the frame's alpha.
|
||||
let emissive = select(1.0, 0.0, in.emission > 0u);
|
||||
// Below 0.75, alpha tells HDR output (hdr.wgsl) a light source's bright
|
||||
// pixels may shine, and at which tier (mesher::hdr_tier): 0.525 at the
|
||||
// lowest, 0 at the brightest. Nothing else reads the frame's alpha.
|
||||
let emissive = select(1.0, 0.7 * f32(4u - min(in.emission, 4u)) / 4.0, in.emission > 0u);
|
||||
return apply_fog(vec4(color.rgb, emissive), in.spherical, in.cylindrical);
|
||||
}
|
||||
@fragment fn fs_translucent(in: TerrainOut) -> @location(0) vec4<f32> {
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
//! Resident client world: streamed chunk columns plus the bookkeeping the game
|
||||
//! loop needs to relight and remesh after edits.
|
||||
use crate::chunk::{Chunk, MIN_Y, SECTIONS};
|
||||
use crate::fast_hash::{FxHashMap, FxHashSet};
|
||||
use horizon_core::random::LegacyRandom;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
sync::Arc,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Sections (x, section index, z) waiting to be remeshed, kept by column
|
||||
/// (a bit per section index), so the mesh scheduler looks at each column once
|
||||
@@ -65,14 +63,14 @@ pub struct World {
|
||||
/// Saved entities of chunks that just loaded, spawned by the game.
|
||||
pub loaded_entities: Vec<((i32, i32), Vec<horizon_world::nbt::Tag>)>,
|
||||
/// Chunks changed since the last save.
|
||||
pub modified: HashSet<(i32, i32)>,
|
||||
pub modified: FxHashSet<(i32, i32)>,
|
||||
/// Block entities, and the order they tick in.
|
||||
pub block_entities: crate::block_entities::BlockEntities,
|
||||
/// Each chest's `ContainerOpenersCounter`: opener count and largest
|
||||
/// interaction range.
|
||||
pub chest_openers: HashMap<(i32, i32, i32), (i32, f64)>,
|
||||
pub chest_openers: FxHashMap<(i32, i32, i32), (i32, f64)>,
|
||||
/// `BaseContainerBlockEntity.name`: custom names of container block entities.
|
||||
pub block_names: HashMap<(i32, i32, i32), String>,
|
||||
pub block_names: FxHashMap<(i32, i32, i32), String>,
|
||||
/// States (by id) whose writes are recorded in `watched_writes` (sculk
|
||||
/// blocks, which need a block entity).
|
||||
pub watched: Vec<bool>,
|
||||
@@ -80,7 +78,7 @@ pub struct World {
|
||||
pub watched_writes: Vec<((i32, i32, i32), u32, u32)>,
|
||||
/// `ChunkAccess.inhabitedTime` of resident chunks: ticks a player spent
|
||||
/// within spawning range (local difficulty).
|
||||
pub inhabited: HashMap<(i32, i32), i64>,
|
||||
pub inhabited: FxHashMap<(i32, i32), i64>,
|
||||
pub game_time: i64,
|
||||
pub day_time: i64,
|
||||
pub weather: Weather,
|
||||
@@ -177,13 +175,13 @@ impl World {
|
||||
dirty_light: Default::default(),
|
||||
light_edits: Vec::new(),
|
||||
loaded_entities: Vec::new(),
|
||||
modified: HashSet::new(),
|
||||
modified: FxHashSet::default(),
|
||||
block_entities: Default::default(),
|
||||
chest_openers: HashMap::new(),
|
||||
block_names: HashMap::new(),
|
||||
chest_openers: FxHashMap::default(),
|
||||
block_names: FxHashMap::default(),
|
||||
watched: Vec::new(),
|
||||
watched_writes: Vec::new(),
|
||||
inhabited: HashMap::new(),
|
||||
inhabited: FxHashMap::default(),
|
||||
game_time: 0,
|
||||
day_time: 1000,
|
||||
weather: Weather::default(),
|
||||
@@ -274,20 +272,40 @@ impl World {
|
||||
log.push((x, y, z));
|
||||
}
|
||||
self.modified.insert((x >> 4, z >> 4));
|
||||
// The mesher reads a 3x3x3 neighbourhood for AO, so a write marks
|
||||
// the sections it can reach: this section, the one above/below
|
||||
// only on a section face, and a neighbour column only on a chunk
|
||||
// border (the diagonal only where both borders meet).
|
||||
let sy = (y - MIN_Y) >> 4;
|
||||
for dz in -1..=1 {
|
||||
for dy in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
let (bx, by, bz) = (x + dx, y + dy, z + dz);
|
||||
let s = (by - MIN_Y) >> 4;
|
||||
if !(0..SECTIONS as i32).contains(&s) {
|
||||
continue;
|
||||
}
|
||||
self.dirty_sections.insert((bx >> 4, s, bz >> 4));
|
||||
}
|
||||
let local_y = y & 15;
|
||||
let mark = |dirty: &mut DirtySections, bx: i32, bz: i32| {
|
||||
dirty.insert((bx >> 4, sy, bz >> 4));
|
||||
if local_y == 0 && sy > 0 {
|
||||
dirty.insert((bx >> 4, sy - 1, bz >> 4));
|
||||
}
|
||||
if local_y == 15 && sy + 1 < SECTIONS as i32 {
|
||||
dirty.insert((bx >> 4, sy + 1, bz >> 4));
|
||||
}
|
||||
};
|
||||
let (bx, bz) = (x & 15, z & 15);
|
||||
mark(&mut self.dirty_sections, x, z);
|
||||
if bx == 0 {
|
||||
mark(&mut self.dirty_sections, x - 1, z);
|
||||
} else if bx == 15 {
|
||||
mark(&mut self.dirty_sections, x + 1, z);
|
||||
}
|
||||
if bz == 0 {
|
||||
mark(&mut self.dirty_sections, x, z - 1);
|
||||
} else if bz == 15 {
|
||||
mark(&mut self.dirty_sections, x, z + 1);
|
||||
}
|
||||
if (bx == 0 || bx == 15) && (bz == 0 || bz == 15) {
|
||||
mark(
|
||||
&mut self.dirty_sections,
|
||||
if bx == 0 { x - 1 } else { x + 1 },
|
||||
if bz == 0 { z - 1 } else { z + 1 },
|
||||
);
|
||||
}
|
||||
let _ = sy;
|
||||
if self.chunks.get(&(x >> 4, z >> 4)).is_some_and(|c| c.lit) {
|
||||
self.light_edits.push(((x, y, z), previous, state.0));
|
||||
} else {
|
||||
@@ -407,11 +425,59 @@ impl Default for HealthRules {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::World;
|
||||
use crate::chunk::Chunk;
|
||||
use super::{DirtySections, World};
|
||||
use crate::chunk::{Chunk, MIN_Y, SECTIONS};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Every block write marks the same section set the 27-cell loop did, but
|
||||
/// only the distinct keys: one in the interior, a neighbour section on a
|
||||
/// section face, a neighbour column on a chunk border and the diagonal at
|
||||
/// a corner (eight keys at a corner on a section face).
|
||||
#[test]
|
||||
fn writes_mark_exactly_the_reachable_sections() {
|
||||
let mut world = World::new(BlockStateId(0), 0);
|
||||
world.chunks.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
for (x, y, z) in [
|
||||
(3, 70, 3),
|
||||
(0, 70, 3),
|
||||
(15, 70, 3),
|
||||
(3, 70, 0),
|
||||
(0, 70, 0),
|
||||
(15, 70, 15),
|
||||
(0, MIN_Y, 0),
|
||||
(0, 80, 0),
|
||||
(15, MIN_Y + SECTIONS as i32 * 16 - 1, 15),
|
||||
] {
|
||||
world.dirty_sections.clear();
|
||||
world.set(x, y, z, BlockStateId(1)).unwrap();
|
||||
let mut expected = DirtySections::default();
|
||||
for dz in -1..=1 {
|
||||
for dy in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
let (bx, by, bz) = (x + dx, y + dy, z + dz);
|
||||
let s = (by - MIN_Y) >> 4;
|
||||
if (0..SECTIONS as i32).contains(&s) {
|
||||
expected.insert((bx >> 4, s, bz >> 4));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut got: Vec<_> = world.dirty_sections.columns().collect();
|
||||
let mut want: Vec<_> = expected.columns().collect();
|
||||
got.sort_unstable();
|
||||
want.sort_unstable();
|
||||
assert_eq!(got, want, "write at {x} {y} {z}");
|
||||
if (x & 15, z & 15) == (3, 3) {
|
||||
assert_eq!(world.dirty_sections.len(), 1);
|
||||
}
|
||||
if (x & 15, z & 15, y & 15) == (0, 0, 0) && y > MIN_Y {
|
||||
// Four columns, each with the section below and this one.
|
||||
assert_eq!(world.dirty_sections.len(), 8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A box misses a state only where no section it reaches lists it, and
|
||||
/// unloaded columns or heights past the world count as their air.
|
||||
#[test]
|
||||
|
||||
@@ -14,11 +14,13 @@ use zip::{CompressionMethod, ZipArchive};
|
||||
/// bomb stops here.
|
||||
const MAX_ENTRY: usize = 1 << 30;
|
||||
|
||||
/// One archive with its entries indexed by name (`\` read as `/`, folders
|
||||
/// left out).
|
||||
/// One archive with its entries indexed by name (`\` read as `/`), files
|
||||
/// and folders apart.
|
||||
pub struct Zip {
|
||||
archive: ZipArchive<Cursor<Arc<[u8]>>>,
|
||||
entries: BTreeMap<String, usize>,
|
||||
/// Folder entries (`name/`) by their name without the slash.
|
||||
folders: BTreeMap<String, usize>,
|
||||
/// The archive's length: no size an entry claims can be bigger.
|
||||
len: usize,
|
||||
}
|
||||
@@ -33,43 +35,73 @@ impl Zip {
|
||||
normalize_end(&mut data);
|
||||
let len = data.len();
|
||||
let archive = ZipArchive::new(Cursor::new(Arc::from(data))).map_err(invalid)?;
|
||||
let entries = (0..archive.len())
|
||||
.filter_map(|i| Some((archive.name_for_index(i)?.replace('\\', "/"), i)))
|
||||
.filter(|(name, _)| !name.ends_with('/'))
|
||||
.collect();
|
||||
let (mut entries, mut folders) = (BTreeMap::new(), BTreeMap::new());
|
||||
for i in 0..archive.len() {
|
||||
let Some(name) = archive.name_for_index(i) else {
|
||||
continue;
|
||||
};
|
||||
let name = name.replace('\\', "/");
|
||||
match name.strip_suffix('/') {
|
||||
Some(folder) => folders.insert(folder.to_owned(), i),
|
||||
None => entries.insert(name, i),
|
||||
};
|
||||
}
|
||||
Ok(Self {
|
||||
archive,
|
||||
entries,
|
||||
folders,
|
||||
len,
|
||||
})
|
||||
}
|
||||
/// The files, as `FilePackResources.listResources` lists them (folder
|
||||
/// entries skipped).
|
||||
pub fn names(&self) -> impl Iterator<Item = &str> {
|
||||
self.entries.keys().map(String::as_str)
|
||||
}
|
||||
/// `FilePackResources.getResource` through `ZipFile.getEntry`: the file
|
||||
/// `name`, else the folder entry `name/`, whose bytes Java reads like a
|
||||
/// file's (Loka's pack stores its `pack.mcmeta` and `pack.png` that way).
|
||||
fn index(&self, name: &str) -> Option<usize> {
|
||||
self.entries
|
||||
.get(name)
|
||||
.or_else(|| self.folders.get(name))
|
||||
.copied()
|
||||
}
|
||||
pub fn contains(&self, name: &str) -> bool {
|
||||
self.entries.contains_key(name)
|
||||
self.index(name).is_some()
|
||||
}
|
||||
pub fn read(&self, name: &str) -> io::Result<Vec<u8>> {
|
||||
let &index = self
|
||||
.entries
|
||||
.get(name)
|
||||
let index = self
|
||||
.index(name)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, name.to_owned()))?;
|
||||
// A clone shares the bytes and the central directory: reading needs `&mut`.
|
||||
let mut archive = self.archive.clone();
|
||||
let mut entry = archive.by_index_raw(index).map_err(invalid)?;
|
||||
let (method, size) = (entry.compression(), entry.size() as usize);
|
||||
let method = entry.compression();
|
||||
let mut raw = Vec::with_capacity((entry.compressed_size() as usize).min(self.len));
|
||||
entry.read_to_end(&mut raw)?;
|
||||
match method {
|
||||
CompressionMethod::Stored => Ok(raw),
|
||||
CompressionMethod::Deflated => miniz_oxide::inflate::decompress_to_vec_with_limit(
|
||||
&raw,
|
||||
size.max(1)
|
||||
.saturating_mul(2)
|
||||
.saturating_add(1024)
|
||||
.min(MAX_ENTRY),
|
||||
)
|
||||
.map_err(|_| invalid("corrupt deflate stream")),
|
||||
CompressionMethod::Deflated => {
|
||||
// Java's ZipFile trusts neither size an entry declares: it
|
||||
// inflates the stream to its end and keeps what comes out.
|
||||
// Packs in the wild (a Nexo-generated one served through
|
||||
// Dropbox, for one) carry central-directory sizes that point
|
||||
// at other files, and miniz_oxide, which refuses input past
|
||||
// the stream's end, failed whole entries with "corrupt
|
||||
// deflate stream" and with them the whole pack. Only the
|
||||
// size limit stands between the archive and a zip bomb.
|
||||
let decoder = flate2::read::DeflateDecoder::new(raw.as_slice());
|
||||
let mut decoded = Vec::with_capacity(raw.len().min(MAX_ENTRY));
|
||||
decoder
|
||||
.take(MAX_ENTRY as u64 + 1)
|
||||
.read_to_end(&mut decoded)
|
||||
.map_err(|_| invalid("corrupt deflate stream"))?;
|
||||
if decoded.len() > MAX_ENTRY {
|
||||
return Err(invalid("entry inflates past the size limit"));
|
||||
}
|
||||
Ok(decoded)
|
||||
}
|
||||
_ => Err(invalid("unsupported zip compression")),
|
||||
}
|
||||
}
|
||||
@@ -133,13 +165,16 @@ mod tests {
|
||||
use super::*;
|
||||
/// A hand-built archive with one stored and one deflated entry.
|
||||
fn archive() -> Vec<u8> {
|
||||
let files: [(&str, &[u8], bool); 2] = [
|
||||
archive_of(&[
|
||||
("pack.mcmeta", b"{\"pack\":{}}", false),
|
||||
("a/b.txt", b"hello hello hello hello", true),
|
||||
];
|
||||
])
|
||||
}
|
||||
/// A hand-built archive of `(name, body, deflated)` entries.
|
||||
fn archive_of(files: &[(&str, &[u8], bool)]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let mut central = Vec::new();
|
||||
for (name, body, deflate) in files {
|
||||
for &(name, body, deflate) in files {
|
||||
let data = if deflate {
|
||||
miniz_oxide::deflate::compress_to_vec(body, 6)
|
||||
} else {
|
||||
@@ -174,8 +209,8 @@ mod tests {
|
||||
out.extend(¢ral);
|
||||
out.extend(0x0605_4b50u32.to_le_bytes());
|
||||
out.extend([0; 4]);
|
||||
out.extend(2u16.to_le_bytes());
|
||||
out.extend(2u16.to_le_bytes());
|
||||
out.extend((files.len() as u16).to_le_bytes());
|
||||
out.extend((files.len() as u16).to_le_bytes());
|
||||
out.extend((central.len() as u32).to_le_bytes());
|
||||
out.extend(start.to_le_bytes());
|
||||
out.extend([0; 2]);
|
||||
@@ -191,6 +226,93 @@ mod tests {
|
||||
assert!(zip.read("missing").is_err());
|
||||
}
|
||||
|
||||
/// The offset of the central-directory header of `name`.
|
||||
fn central_header(bytes: &[u8], name: &str) -> usize {
|
||||
let mut at = 0;
|
||||
while let Some(i) = bytes[at..]
|
||||
.windows(4)
|
||||
.position(|s| s == [0x50, 0x4b, 0x01, 0x02])
|
||||
{
|
||||
let p = at + i;
|
||||
let nlen = u16::from_le_bytes(bytes[p + 28..p + 30].try_into().unwrap()) as usize;
|
||||
if &bytes[p + 46..p + 46 + nlen] == name.as_bytes() {
|
||||
return p;
|
||||
}
|
||||
at = p + 4;
|
||||
}
|
||||
panic!("no central header for {name}");
|
||||
}
|
||||
|
||||
/// Java's `ZipFile` inflates the stream and keeps what comes out, so a
|
||||
/// central directory that names a size other than the stream's (the
|
||||
/// Nexo pack Dropbox serves for one server does) changes nothing.
|
||||
#[test]
|
||||
fn reads_a_deflate_stream_larger_than_its_declared_size() {
|
||||
use std::io::Write;
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
writer
|
||||
.start_file(
|
||||
"large.txt",
|
||||
SimpleFileOptions::default().compression_method(CompressionMethod::Deflated),
|
||||
)
|
||||
.unwrap();
|
||||
writer.write_all(&vec![b'x'; 4096]).unwrap();
|
||||
let mut bytes = writer.finish().unwrap().into_inner();
|
||||
let central = central_header(&bytes, "large.txt");
|
||||
bytes[central + 24..central + 28].copy_from_slice(&1u32.to_le_bytes());
|
||||
|
||||
let zip = Zip::from_bytes(bytes).unwrap();
|
||||
assert_eq!(zip.read("large.txt").unwrap(), vec![b'x'; 4096]);
|
||||
}
|
||||
|
||||
/// Some pack writers leave an entry's compressed size longer than its
|
||||
/// deflate stream, so the bytes past the stream's end are part of it.
|
||||
/// Java inflates to the stream's end and ignores them; miniz_oxide
|
||||
/// refused the input and failed the entry, and with it the whole pack.
|
||||
#[test]
|
||||
fn reads_past_trailing_bytes_after_the_deflate_stream() {
|
||||
let mut bytes = written();
|
||||
let name = "Pack-main/assets/minecraft/lang/en_us.json";
|
||||
let central = central_header(&bytes, name);
|
||||
let local =
|
||||
u32::from_le_bytes(bytes[central + 42..central + 46].try_into().unwrap()) as usize;
|
||||
for at in [central + 20, local + 18] {
|
||||
let csize = u32::from_le_bytes(bytes[at..at + 4].try_into().unwrap());
|
||||
bytes[at..at + 4].copy_from_slice(&(csize + 8).to_le_bytes());
|
||||
}
|
||||
|
||||
let zip = Zip::from_bytes(bytes).unwrap();
|
||||
assert_eq!(
|
||||
zip.read(name).unwrap(),
|
||||
"{\"a\":\"b\"}".repeat(100).as_bytes()
|
||||
);
|
||||
}
|
||||
|
||||
/// Loka's server pack (#3413) names its metadata `pack.mcmeta/`.
|
||||
/// `ZipFile.getEntry("pack.mcmeta")` falls back to that folder entry and
|
||||
/// vanilla reads its bytes; a file of the exact name still comes first.
|
||||
#[test]
|
||||
fn a_name_falls_back_to_its_folder_entry() {
|
||||
let zip = Zip::from_bytes(archive_of(&[
|
||||
("pack.mcmeta/", b"{\"pack\":{\"pack_format\":75}}", true),
|
||||
("pack.png/", b"png", false),
|
||||
("a/", b"folder", false),
|
||||
("a", b"file", false),
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(zip.names().collect::<Vec<_>>(), ["a"]);
|
||||
assert!(zip.contains("pack.mcmeta"));
|
||||
assert_eq!(
|
||||
zip.read("pack.mcmeta").unwrap(),
|
||||
b"{\"pack\":{\"pack_format\":75}}"
|
||||
);
|
||||
assert_eq!(zip.read("pack.png").unwrap(), b"png");
|
||||
assert_eq!(zip.read("a").unwrap(), b"file");
|
||||
assert!(!zip.contains("pack"));
|
||||
}
|
||||
|
||||
/// A pack as zip tools write it: folder entries, a nested pack root,
|
||||
/// stored and deflated files, and a Windows-style name.
|
||||
fn written() -> Vec<u8> {
|
||||
|
||||
@@ -91,9 +91,6 @@ default = ["audio", "controllers"]
|
||||
audio = ["horizon-client-render/audio"]
|
||||
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
|
||||
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
|
||||
# Runs code mods (wasmtime and cranelift: a quarter of a clean build). On in every release build
|
||||
# (release.yml); the tests and probes don't need it. Content mods load without it.
|
||||
mods = ["horizon-mods/runtime"]
|
||||
# Records the call sites of mob random draws (`mob_ai_probe` traces).
|
||||
rng-trace = ["horizon-core/rng-trace"]
|
||||
|
||||
|
||||
@@ -11,5 +11,57 @@
|
||||
"horizon.options.look_deadzone": "Look Deadzone",
|
||||
"horizon.options.button_glyphs": "Button Glyphs",
|
||||
"horizon.options.menu_navigation": "Menu Navigation",
|
||||
"horizon.options.on_screen_keyboard": "On-Screen Keyboard"
|
||||
"horizon.options.on_screen_keyboard": "On-Screen Keyboard",
|
||||
"horizon.options.chat_delay": "Chat Delay",
|
||||
"horizon.options.lightbar.health": "Health",
|
||||
"horizon.options.lightbar.dimension": "Dimension",
|
||||
"horizon.options.glyphs.auto": "Auto",
|
||||
"horizon.options.glyphs.xbox": "Xbox",
|
||||
"horizon.options.glyphs.playstation": "PlayStation",
|
||||
"horizon.options.glyphs.nintendo": "Nintendo",
|
||||
"horizon.options.menu_focus": "Focus",
|
||||
"horizon.options.menu_pointer": "Pointer",
|
||||
"horizon.options.keyboard.auto": "Auto",
|
||||
"horizon.options.keyboard.horizon": "Horizon",
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
"horizon.options.unavailable": "This feature is not yet available in Horizon.",
|
||||
"horizon.options.search": "Search settings...",
|
||||
"horizon.pages.mods": "Mods",
|
||||
"horizon.pages.shader_packs": "Shader Packs",
|
||||
"horizon.pages.controller": "Controller Settings",
|
||||
"horizon.pages.controller_binds": "Controller Binds",
|
||||
"horizon.pages.extras": "Horizon Extras",
|
||||
"horizon.pages.settings": "Horizon Settings",
|
||||
"horizon.pages.agent": "AI Agent",
|
||||
"horizon.pages.settings.tooltip": "Horizon's own settings: graphics, controllers, extras and mods.",
|
||||
"horizon.pages.graphics.tooltip": "Graphics, simply: each setting says what it costs.",
|
||||
"horizon.pages.graphics_advanced.tooltip": "Every graphics setting, Horizon's included, with what each costs.",
|
||||
"horizon.pages.controller.tooltip": "Controllers: their buttons, sticks, vibration and menu navigation.",
|
||||
"horizon.pages.extras.tooltip": "Optional additions, all off by default: the game stays vanilla until you turn one on.",
|
||||
"horizon.pages.agent.tooltip": "Let your own AI agent play: horizon-ctl and MCP clients on this computer.",
|
||||
"horizon.pages.shader_packs.tooltip": "Shader packs from the shaderpacks folder: Horizon's own and those in the shaders/ format. One runs at a time.",
|
||||
"horizon.pages.mods.tooltip": "Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on.",
|
||||
"horizon.pages.open.graphics": "Graphics...",
|
||||
"horizon.pages.open.graphics_advanced": "Advanced Graphics...",
|
||||
"horizon.pages.open.shader_packs": "Shader Packs...",
|
||||
"horizon.pages.open.controller": "Controller Settings...",
|
||||
"horizon.pages.open.extras": "Extras...",
|
||||
"horizon.pages.open.mods": "Mods...",
|
||||
"horizon.pages.open.agent": "AI Agent...",
|
||||
"horizon.pages.open.horizon_graphics": "Horizon Graphics...",
|
||||
"horizon.pages.open.controller_binds": "Controller Binds...",
|
||||
"horizon.pages.open.horizon_settings": "Horizon Settings...",
|
||||
"horizon.mods.empty": "No mods: put .hmod files in the horizon-mods folder",
|
||||
"horizon.mods.stopped": " (Stopped)",
|
||||
"horizon.mods.dev": " (Dev)",
|
||||
"horizon.mods.settings": "Settings",
|
||||
"horizon.mods.open_folder": "Open Mod Folder",
|
||||
"horizon.shader_packs.empty": "No shader packs: put them in the shaderpacks folder",
|
||||
"horizon.shader_packs.kind_horizon": " (Horizon)",
|
||||
"horizon.agent.copy_details": "Copy Connection Details",
|
||||
"horizon.multiplayer.join": "Join",
|
||||
"horizon.multiplayer.cancel_reconnecting": "Cancel Reconnecting",
|
||||
"horizon.multiplayer.join_friend": "Join a Friend",
|
||||
"horizon.multiplayer.invite_code": "Invite Code",
|
||||
"horizon.multiplayer.relay": "Relay (optional)"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"horizon.options.controller": "Kontroler",
|
||||
"horizon.options.vibration": "Wibracje",
|
||||
"horizon.options.adaptive_triggers": "Adaptacyjne spusty",
|
||||
"horizon.options.lightbar": "Pasek świetlny",
|
||||
"horizon.options.look_sensitivity": "Czułość rozglądania",
|
||||
"horizon.options.invert_x": "Odwróć oś X rozglądania",
|
||||
"horizon.options.invert_y": "Odwróć oś Y rozglądania",
|
||||
"horizon.options.sneak_button": "Przycisk skradania",
|
||||
"horizon.options.move_deadzone": "Martwa strefa ruchu",
|
||||
"horizon.options.look_deadzone": "Martwa strefa rozglądania",
|
||||
"horizon.options.button_glyphs": "Symbole przycisków",
|
||||
"horizon.options.menu_navigation": "Nawigacja po menu",
|
||||
"horizon.options.on_screen_keyboard": "Klawiatura ekranowa"
|
||||
}
|
||||
@@ -11,5 +11,57 @@
|
||||
"horizon.options.look_deadzone": "Мёртвая зона обзора",
|
||||
"horizon.options.button_glyphs": "Символы кнопок",
|
||||
"horizon.options.menu_navigation": "Навигация по меню",
|
||||
"horizon.options.on_screen_keyboard": "Экранная клавиатура"
|
||||
"horizon.options.on_screen_keyboard": "Экранная клавиатура",
|
||||
"horizon.options.chat_delay": "Задержка чата",
|
||||
"horizon.options.lightbar.health": "Здоровье",
|
||||
"horizon.options.lightbar.dimension": "Измерение",
|
||||
"horizon.options.glyphs.auto": "Авто",
|
||||
"horizon.options.glyphs.xbox": "Xbox",
|
||||
"horizon.options.glyphs.playstation": "PlayStation",
|
||||
"horizon.options.glyphs.nintendo": "Nintendo",
|
||||
"horizon.options.menu_focus": "Фокус",
|
||||
"horizon.options.menu_pointer": "Указатель",
|
||||
"horizon.options.keyboard.auto": "Авто",
|
||||
"horizon.options.keyboard.horizon": "Horizon",
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
"horizon.options.unavailable": "Эта возможность в Horizon пока недоступна.",
|
||||
"horizon.options.search": "Поиск настроек...",
|
||||
"horizon.pages.mods": "Моды",
|
||||
"horizon.pages.shader_packs": "Шейдерпаки",
|
||||
"horizon.pages.controller": "Настройки контроллера",
|
||||
"horizon.pages.controller_binds": "Назначения кнопок контроллера",
|
||||
"horizon.pages.extras": "Дополнения Horizon",
|
||||
"horizon.pages.settings": "Настройки Horizon",
|
||||
"horizon.pages.agent": "ИИ-агент",
|
||||
"horizon.pages.settings.tooltip": "Собственные настройки Horizon: графика, контроллеры, дополнения и моды.",
|
||||
"horizon.pages.graphics.tooltip": "Графика без лишнего: у каждой настройки написано, чего она стоит.",
|
||||
"horizon.pages.graphics_advanced.tooltip": "Все графические настройки, включая собственные настройки Horizon, и их цена.",
|
||||
"horizon.pages.controller.tooltip": "Контроллеры: кнопки, стики, вибрация и навигация по меню.",
|
||||
"horizon.pages.extras.tooltip": "Дополнительные возможности, все выключены по умолчанию: игра остаётся ванильной, пока вы не включите что-то.",
|
||||
"horizon.pages.agent.tooltip": "Пусть играет ваш ИИ-агент: horizon-ctl и MCP-клиенты на этом компьютере.",
|
||||
"horizon.pages.shader_packs.tooltip": "Шейдерпаки из папки shaderpacks: собственные паки Horizon и паки в формате shaders/. Одновременно работает один.",
|
||||
"horizon.pages.mods.tooltip": "Моды для Horizon из папки horizon-mods. Каждый работает в песочнице и выключен, пока вы его не включите.",
|
||||
"horizon.pages.open.graphics": "Графика...",
|
||||
"horizon.pages.open.graphics_advanced": "Продвинутая графика...",
|
||||
"horizon.pages.open.shader_packs": "Шейдерпаки...",
|
||||
"horizon.pages.open.controller": "Настройки контроллера...",
|
||||
"horizon.pages.open.extras": "Дополнения...",
|
||||
"horizon.pages.open.mods": "Моды...",
|
||||
"horizon.pages.open.agent": "ИИ-агент...",
|
||||
"horizon.pages.open.horizon_graphics": "Графика Horizon...",
|
||||
"horizon.pages.open.controller_binds": "Назначения кнопок...",
|
||||
"horizon.pages.open.horizon_settings": "Настройки Horizon...",
|
||||
"horizon.mods.empty": "Модов нет: положите файлы .hmod в папку horizon-mods",
|
||||
"horizon.mods.stopped": " (остановлен)",
|
||||
"horizon.mods.dev": " (разработка)",
|
||||
"horizon.mods.settings": "Настройки",
|
||||
"horizon.mods.open_folder": "Открыть папку модов",
|
||||
"horizon.shader_packs.empty": "Шейдерпаков нет: положите их в папку shaderpacks",
|
||||
"horizon.shader_packs.kind_horizon": " (Horizon)",
|
||||
"horizon.agent.copy_details": "Скопировать данные подключения",
|
||||
"horizon.multiplayer.join": "Подключиться",
|
||||
"horizon.multiplayer.cancel_reconnecting": "Отменить переподключение",
|
||||
"horizon.multiplayer.join_friend": "Играть с другом",
|
||||
"horizon.multiplayer.invite_code": "Код приглашения",
|
||||
"horizon.multiplayer.relay": "Ретранслятор (необязательно)"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"horizon.options.controller": "Геймпад",
|
||||
"horizon.options.vibration": "Вібрація",
|
||||
"horizon.options.adaptive_triggers": "Адаптивні тригери",
|
||||
"horizon.options.lightbar": "Світлова панель",
|
||||
"horizon.options.look_sensitivity": "Чутливість огляду",
|
||||
"horizon.options.invert_x": "Інвертувати огляд по X",
|
||||
"horizon.options.invert_y": "Інвертувати огляд по Y",
|
||||
"horizon.options.sneak_button": "Кнопка присідання",
|
||||
"horizon.options.move_deadzone": "Мертва зона руху",
|
||||
"horizon.options.look_deadzone": "Мертва зона огляду",
|
||||
"horizon.options.button_glyphs": "Значки кнопок",
|
||||
"horizon.options.menu_navigation": "Навігація в меню",
|
||||
"horizon.options.on_screen_keyboard": "Екранна клавіатура"
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//! Block-model tints without a level, including blank and invalid tint indices.
|
||||
use horizon_client::{
|
||||
assets::Assets, blocks::BlockFacts, item_render::ItemRenderer, mesher::MeshTables, models,
|
||||
};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs,
|
||||
io::{BufWriter, Write},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
pub fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<_> = crate::args().collect();
|
||||
let input: Value = serde_json::from_slice(&fs::read(&args[1])?)?;
|
||||
let root = Path::new(input["resources"].as_str().unwrap());
|
||||
let assets = Assets::load(root)?;
|
||||
let render = assets.reload(root)?;
|
||||
let facts = BlockFacts::load(root, assets.registry)?;
|
||||
let tables = MeshTables::new(
|
||||
&assets,
|
||||
&render,
|
||||
&facts,
|
||||
models::load_visibility(root, assets.registry)?,
|
||||
)?;
|
||||
let grass = ItemRenderer::load(root)?.grass_color(0.5, 1.0) as u32;
|
||||
let mut output = BufWriter::new(fs::File::create(&args[2])?);
|
||||
for row in input["cases"].as_array().unwrap() {
|
||||
let state = row[0].as_u64().unwrap() as u32;
|
||||
let index = row[1].as_i64().unwrap() as i32;
|
||||
let color = tables
|
||||
.default_tint(BlockStateId(state), index, grass)
|
||||
.map_or(-1, |rgb| (0xFF00_0000 | rgb) as i32);
|
||||
writeln!(output, "{state} {index} {color}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -43,6 +43,7 @@ pub fn registries(r: &Value, permission: u8) -> Registries {
|
||||
reg.timelines = strings(&r["timelines"]);
|
||||
reg.dimensions = strings(&r["dimensions"]);
|
||||
reg.players = strings(&r["players"]);
|
||||
reg.selected_entities = strings(&r["selected_entities"]);
|
||||
reg.teams = strings(&r["teams"]);
|
||||
reg.objectives = strings(&r["objectives"]);
|
||||
reg.tracked = strings(&r["tracked"]);
|
||||
|
||||
@@ -178,6 +178,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
reg.timelines = strings(&r["timelines"]);
|
||||
reg.dimensions = strings(&r["dimensions"]);
|
||||
reg.players = strings(&r["players"]);
|
||||
reg.selected_entities = strings(&r["selected_entities"]);
|
||||
reg.teams = strings(&r["teams"]);
|
||||
reg.objectives = strings(&r["objectives"]);
|
||||
reg.tracked = strings(&r["tracked"]);
|
||||
|
||||
@@ -150,7 +150,7 @@ fn entity_state(mob: &mut Mob, species: Species, case: &Value) {
|
||||
mob.extra.crouching = flag("isCrouching");
|
||||
mob.extra.passenger = flag("isPassenger");
|
||||
if flag("isHoldingBow") {
|
||||
mob.extra.main_hand = Some("minecraft:bow".into());
|
||||
mob.extra.set_main_hand(Some("minecraft:bow".into()));
|
||||
}
|
||||
}
|
||||
Species::IronGolem => {
|
||||
@@ -298,7 +298,7 @@ fn entity_state(mob: &mut Mob, species: Species, case: &Value) {
|
||||
}
|
||||
}
|
||||
Species::Witch if flag("isHoldingItem") => {
|
||||
mob.extra.main_hand = Some("minecraft:potion".into());
|
||||
mob.extra.set_main_hand(Some("minecraft:potion".into()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -386,7 +386,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
.cloned()
|
||||
.unwrap_or_else(|| kind.model.clone());
|
||||
let mut mob = Mob::new(7, kind, Vec3::new(0.0, 64.0, 0.0), 0.0);
|
||||
mob.extra.main_hand = None;
|
||||
mob.extra.set_main_hand(None);
|
||||
let case = &v["case"];
|
||||
let f = |k: &str| case[k].as_f64().unwrap_or(0.0) as f32;
|
||||
if baby {
|
||||
|
||||
Loaded 100 of 647 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user