mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
1262
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29f427dabe | ||
|
|
b156515edd | ||
|
|
0fdf1aeee1 | ||
|
|
ee2fb39605 | ||
|
|
559e36ee04 | ||
|
|
7037510e65 | ||
|
|
417a1cebc1 | ||
|
|
9edc7eb80a | ||
|
|
d9628314ab | ||
|
|
4984a1329a | ||
|
|
d22b63b9ca | ||
|
|
dc5e8c45f7 | ||
|
|
2782a9057a | ||
|
|
66620f22a3 | ||
|
|
18070bb4ce | ||
|
|
b575402076 | ||
|
|
5b3c3684b1 | ||
|
|
29b4b72dd5 | ||
|
|
9bc8447871 | ||
|
|
56c1100644 | ||
|
|
a881d284a7 | ||
|
|
87a6a7a161 | ||
|
|
5ceba01475 | ||
|
|
da9d0dcbe1 | ||
|
|
e2a9909521 | ||
|
|
19faf89699 | ||
|
|
f230017ec1 | ||
|
|
ba86a4d76d | ||
|
|
a734609ab4 | ||
|
|
eab3195848 | ||
|
|
0d2f7b86d5 | ||
|
|
e79992e80b | ||
|
|
248aa8b302 | ||
|
|
cf586e4159 | ||
|
|
7443236850 | ||
|
|
5e391eb673 | ||
|
|
c10553eebf | ||
|
|
215d8e779a | ||
|
|
58a4fd8263 | ||
|
|
3c63c5913e | ||
|
|
7f870d7447 | ||
|
|
f49659c78b | ||
|
|
62ff5241a4 | ||
|
|
4d460c7374 | ||
|
|
d99f7d6d7b | ||
|
|
49c6c47f98 | ||
|
|
b029efa3d3 | ||
|
|
37b4ee3abd | ||
|
|
b07f2775a2 | ||
|
|
e831cb0333 | ||
|
|
6a5cd3cc28 | ||
|
|
3b5643628a | ||
|
|
4a9501d2d9 | ||
|
|
8a290d810a | ||
|
|
43e7700791 | ||
|
|
4baa571566 | ||
|
|
a7e279cfed | ||
|
|
7c3364d30d | ||
|
|
2fe5fa0939 | ||
|
|
64990cd321 | ||
|
|
78b778463f | ||
|
|
76864807ba | ||
|
|
1f3cc4b80e | ||
|
|
1816cf0b52 | ||
|
|
258c5946b5 | ||
|
|
452224a28a | ||
|
|
f8988d5a73 | ||
|
|
184e1ee3d6 | ||
|
|
c5702d6c29 | ||
|
|
e6412d6e5c | ||
|
|
90b583896a | ||
|
|
7db6a085bf | ||
|
|
0b74b68aac | ||
|
|
1db57affe6 | ||
|
|
e1844d064e | ||
|
|
1231679c4d | ||
|
|
9e9b9862ee | ||
|
|
7c31259376 | ||
|
|
40c1ab898f | ||
|
|
13d7d9995d | ||
|
|
945a98f8bc | ||
|
|
7fa0fe50f7 | ||
|
|
611d1ef51e | ||
|
|
49866a4482 | ||
|
|
6a06853032 | ||
|
|
25f4545038 | ||
|
|
54e56ca215 | ||
|
|
92c462a9b1 | ||
|
|
b8f48f3ad0 | ||
|
|
abca441ad4 | ||
|
|
99b52c2617 | ||
|
|
77bfff8eb5 | ||
|
|
8ea9a2c849 | ||
|
|
cc15e8ebb3 | ||
|
|
dbeb13ff6b | ||
|
|
39d23ac256 | ||
|
|
d900488d12 | ||
|
|
e7827deabb | ||
|
|
d2d5dd00ad | ||
|
|
916150f02c | ||
|
|
5302f6a22a | ||
|
|
aa7a149858 | ||
|
|
504aa1dca6 | ||
|
|
3497737ecc | ||
|
|
9db690b1e0 | ||
|
|
2b294bbe3a | ||
|
|
f220157a0f | ||
|
|
ddd1dbdf91 | ||
|
|
3f28d6dbd1 | ||
|
|
5bc76cbba9 | ||
|
|
c3dceac2aa | ||
|
|
d203ca04be | ||
|
|
ced6323bb2 | ||
|
|
698e7fd662 | ||
|
|
7808916266 | ||
|
|
53beacfd13 | ||
|
|
ab9ceb6a6d | ||
|
|
46d466f7c3 | ||
|
|
ab640c7646 | ||
|
|
11494e17f9 | ||
|
|
ff0e16e3b9 | ||
|
|
b1d3c9b92b | ||
|
|
2a7ee57bab | ||
|
|
70b51333d3 | ||
|
|
8f0c178957 | ||
|
|
5a30084c1d | ||
|
|
624be7df86 | ||
|
|
48ab8b0934 | ||
|
|
9e49037875 | ||
|
|
9169aec896 | ||
|
|
cddd325067 | ||
|
|
b19b4e92d3 | ||
|
|
1727cea578 | ||
|
|
5c0f6aa56c | ||
|
|
364922fa5e | ||
|
|
1d8f853946 | ||
|
|
fb27a63dce | ||
|
|
33f3347d84 | ||
|
|
b27d4b85e1 | ||
|
|
8f69fb313f | ||
|
|
d5a7e8420b | ||
|
|
bd80ffd540 | ||
|
|
1df28d1793 | ||
|
|
2d966fb3ad | ||
|
|
8bb8239603 | ||
|
|
702d7cf2cc | ||
|
|
38712c49ec | ||
|
|
62a01db8f0 | ||
|
|
a287f701e6 | ||
|
|
8871ba0b28 | ||
|
|
299c0b495d | ||
|
|
c29d37de1d | ||
|
|
cbacadae22 | ||
|
|
caa1be7abe | ||
|
|
1970ab1a7b | ||
|
|
85032102a8 | ||
|
|
0e57b1f6cf | ||
|
|
c3987df0b2 | ||
|
|
c9d61dcab7 | ||
|
|
6c50b7d980 | ||
|
|
1fa4a780f0 | ||
|
|
8dfce86201 | ||
|
|
7a8c5a2642 | ||
|
|
dd9c9433b6 | ||
|
|
e037d39314 | ||
|
|
3c514d5c17 | ||
|
|
9b12c89191 | ||
|
|
ac17fd2b0f | ||
|
|
e9027c77c2 | ||
|
|
95bd72abaf | ||
|
|
0fc7603da8 | ||
|
|
833b9385ee | ||
|
|
68b626fc9d | ||
|
|
5edfd95da6 | ||
|
|
ae4ffbea8f | ||
|
|
5f80ddffce | ||
|
|
b9449b2348 | ||
|
|
c2c364f195 | ||
|
|
b1532a179a | ||
|
|
ff85f100ec | ||
|
|
c6af857433 | ||
|
|
dc3fc6961e | ||
|
|
1a71386829 | ||
|
|
e12c5d4f23 | ||
|
|
8f91683323 | ||
|
|
c5221cc92d | ||
|
|
471d815aeb | ||
|
|
40073ab9c0 | ||
|
|
fd0aeb5331 | ||
|
|
e9c6751a1b | ||
|
|
cf0b111bdd | ||
|
|
5c2bc0af4a | ||
|
|
11f22e26fa | ||
|
|
dbb03a42d3 | ||
|
|
01f3d91d04 | ||
|
|
99b6b8f3e8 | ||
|
|
e6bb2f74c7 | ||
|
|
08c67f3d2f | ||
|
|
db8c6d6331 | ||
|
|
fc409d28f4 | ||
|
|
e665b6bf00 | ||
|
|
0048f04f1b | ||
|
|
fa727a627a | ||
|
|
511eb49629 | ||
|
|
791d8b8950 | ||
|
|
b9a040dc21 | ||
|
|
fe3333f3f0 | ||
|
|
1dce2ab885 | ||
|
|
b56048d996 | ||
|
|
a278572906 | ||
|
|
78e449714a | ||
|
|
ab0f591673 | ||
|
|
859b6be783 | ||
|
|
142388e1c1 | ||
|
|
93b7ddd09e | ||
|
|
6a221890bd | ||
|
|
a7841ccb18 | ||
|
|
c9a103852c | ||
|
|
a15faef940 | ||
|
|
58cd73ee0b | ||
|
|
e2a3e92424 | ||
|
|
6f8c7a0e5d | ||
|
|
04cbda1b14 | ||
|
|
2de8bbd84b | ||
|
|
a7177cfbd7 | ||
|
|
b558a845a0 | ||
|
|
7826732a5c | ||
|
|
9f1a998403 | ||
|
|
527cd7225c | ||
|
|
f64fdeaa75 | ||
|
|
a0c79c8b26 | ||
|
|
0284d3bb16 | ||
|
|
72b4d8ecbb | ||
|
|
1ed31e54eb | ||
|
|
b3aaa04129 | ||
|
|
bb38695695 | ||
|
|
6bda112225 | ||
|
|
dcd733c7c1 | ||
|
|
77fa9fb89f | ||
|
|
153d18a102 | ||
|
|
e2b2b2ccd0 | ||
|
|
95ecc9d771 | ||
|
|
e470f3a0c9 | ||
|
|
f93c28a230 | ||
|
|
b9721d816d | ||
|
|
a49e72201a | ||
|
|
2034db1df6 | ||
|
|
5091896e2a | ||
|
|
6edc4e950d | ||
|
|
d4120a9ef0 | ||
|
|
795a0db7e4 | ||
|
|
75b8a176d4 | ||
|
|
21a0656530 | ||
|
|
071580e716 | ||
|
|
ae01d872fb | ||
|
|
d2f5a0d678 | ||
|
|
d6ee462614 | ||
|
|
430e5e4c92 | ||
|
|
f3d8535fcd | ||
|
|
b276d65b5f | ||
|
|
559ecb02f6 | ||
|
|
11b8757bc7 | ||
|
|
b44da42ca4 | ||
|
|
9669600c38 | ||
|
|
528c322d16 | ||
|
|
a9f8e64331 | ||
|
|
01d0f45c2d | ||
|
|
228af35f2a | ||
|
|
abd1755f95 | ||
|
|
81f339814b | ||
|
|
0590a85d16 | ||
|
|
be6beb6ec7 | ||
|
|
9cdfc5bd44 | ||
|
|
26af55000a | ||
|
|
3782473f52 | ||
|
|
9cbc96ee7b | ||
|
|
af0fef9196 | ||
|
|
5a2d71d2ea | ||
|
|
bbec03c14f | ||
|
|
c6efb367b0 | ||
|
|
3c9bb38e88 | ||
|
|
dc943f95e8 | ||
|
|
312218a47f | ||
|
|
83b5c5ed53 | ||
|
|
578cd262a5 | ||
|
|
868256f1a8 | ||
|
|
1149c92cb8 | ||
|
|
360272fd79 | ||
|
|
1d6d4cf0a0 | ||
|
|
a1bdb0d22d | ||
|
|
930252e2e4 | ||
|
|
7dba3b4d73 | ||
|
|
8339f6d12f | ||
|
|
e0bf5f6599 | ||
|
|
c7504433c0 | ||
|
|
41266ae70b | ||
|
|
a3acb5f0d2 | ||
|
|
49b77529dd | ||
|
|
dafa794cb3 | ||
|
|
b7df7bd9e0 | ||
|
|
40e2fe0608 | ||
|
|
0320e7d593 | ||
|
|
a1e56a4d1b | ||
|
|
ab32a64d08 | ||
|
|
f843f28a27 | ||
|
|
abcc2e7c80 | ||
|
|
ac78481794 | ||
|
|
650601848f | ||
|
|
f9ea299952 | ||
|
|
52dc27fa40 | ||
|
|
b6eb028bb6 | ||
|
|
9dffa8a2a9 | ||
|
|
cd89bc6dc1 | ||
|
|
8a863ad378 | ||
|
|
a27561da88 | ||
|
|
ed7213df2e | ||
|
|
b1b27240d4 | ||
|
|
8a2a31e6a3 | ||
|
|
0264b39aa6 | ||
|
|
83935015cf | ||
|
|
506a9c8f01 | ||
|
|
475b22ff19 | ||
|
|
0d1f56e015 | ||
|
|
958c02593c | ||
|
|
a266eee6d2 | ||
|
|
62898b4cf6 | ||
|
|
d08e05283a | ||
|
|
e7a07f88b3 | ||
|
|
4e59dab892 | ||
|
|
a1f4841526 | ||
|
|
4e804cd813 | ||
|
|
7dd4b772c9 | ||
|
|
03dd78d67b | ||
|
|
6267cd3e15 | ||
|
|
265640ac18 | ||
|
|
f5a3088f0d | ||
|
|
3ccbba76df | ||
|
|
c519ff6c56 | ||
|
|
400d15ab3f | ||
|
|
d6971605d4 | ||
|
|
7dc818eb5b | ||
|
|
2772738d47 | ||
|
|
131625c414 | ||
|
|
478ae35b53 | ||
|
|
5f868011ee | ||
|
|
67712eeeef | ||
|
|
5b7d19f02b | ||
|
|
e2f3d1a85a | ||
|
|
74c2d13d77 | ||
|
|
9ff0d98010 | ||
|
|
f66f66c3b4 | ||
|
|
42be3018dd | ||
|
|
7cbb8c4506 | ||
|
|
6a05885ee3 | ||
|
|
c979ce46e9 | ||
|
|
0c5c38ac9e | ||
|
|
ae3b9e273f | ||
|
|
d0b6e48921 | ||
|
|
61713b8359 | ||
|
|
07e4a86658 | ||
|
|
3c98062662 | ||
|
|
e1b2b0b2fd | ||
|
|
9755416a02 | ||
|
|
0281365907 | ||
|
|
a5fcb49900 | ||
|
|
6f6229c2d4 | ||
|
|
0ca3a5be98 | ||
|
|
8993772de0 | ||
|
|
6a261a00a5 | ||
|
|
f8b83eea51 | ||
|
|
88398e8bda | ||
|
|
6d3489d30b | ||
|
|
7a84c93843 | ||
|
|
cba52f4e5d | ||
|
|
9d7b4e8a2d | ||
|
|
4d477067c3 | ||
|
|
60773a0e92 | ||
|
|
9f1fa0a0a2 | ||
|
|
0291a5864f | ||
|
|
fe84cc07a1 | ||
|
|
d4e42cf726 | ||
|
|
998c94bee3 | ||
|
|
466512c90e | ||
|
|
4db071e281 | ||
|
|
d2e7a54082 | ||
|
|
2649c5e506 | ||
|
|
505de825db | ||
|
|
1f6511d0f3 | ||
|
|
1523c118d8 | ||
|
|
9239089b76 | ||
|
|
281ab9759c | ||
|
|
febad05c78 | ||
|
|
519edfac19 | ||
|
|
6561df77f6 | ||
|
|
cd7f177104 | ||
|
|
54360a74e3 | ||
|
|
07ab7ccfc1 | ||
|
|
727f6c932a | ||
|
|
cd1f58ef72 | ||
|
|
291aae00e8 | ||
|
|
bec8cce82d | ||
|
|
0ee2425d40 | ||
|
|
7f3bd901bc | ||
|
|
d308df6167 | ||
|
|
a7efe597ea | ||
|
|
5ded675a7d | ||
|
|
f70501aa42 | ||
|
|
d0a29a0f66 | ||
|
|
564162a0b0 | ||
|
|
8b5a27ed3f | ||
|
|
cd97b34460 | ||
|
|
c0505424bd | ||
|
|
137045e321 | ||
|
|
4a18a4bde4 | ||
|
|
f6b467eba9 | ||
|
|
e3fda9ce67 | ||
|
|
03f3fb057d | ||
|
|
8502d86e24 | ||
|
|
abdff5c89d | ||
|
|
3fe4834ceb | ||
|
|
23d2146066 | ||
|
|
b78ff5f55e | ||
|
|
be8b86d470 | ||
|
|
5f8afb17fc | ||
|
|
f755d7fba0 | ||
|
|
f7bb1ceb79 | ||
|
|
b9494af976 | ||
|
|
d1ead6a5fa | ||
|
|
9d9e1a97a8 | ||
|
|
3511d556ef | ||
|
|
4ff7118db7 | ||
|
|
1743bbc525 | ||
|
|
da9950c75b | ||
|
|
626068da3a | ||
|
|
cd2fea1f02 | ||
|
|
4bf633e8a9 | ||
|
|
f4d6f9c0ed | ||
|
|
93e61ef936 | ||
|
|
28e3286e40 | ||
|
|
aef4186de4 | ||
|
|
b08e04cd8a | ||
|
|
e8cb6ae83b | ||
|
|
b6c936f82b | ||
|
|
99008baad9 | ||
|
|
e1bc2cbea3 | ||
|
|
6960b65c62 | ||
|
|
c66554126d | ||
|
|
4a1c5fc54c | ||
|
|
5b13af2f75 | ||
|
|
7b93b310a0 | ||
|
|
a1b32304a3 | ||
|
|
7934a0b180 | ||
|
|
992c63eefb | ||
|
|
a967ca4340 | ||
|
|
0112a6feac | ||
|
|
c0b560527f | ||
|
|
557a827383 | ||
|
|
e06134f71a | ||
|
|
330314327a | ||
|
|
9d32bebad3 | ||
|
|
012a3cf908 | ||
|
|
0e03cac368 | ||
|
|
e24394ca3a | ||
|
|
a5ee1e588c | ||
|
|
a48fcf0d16 | ||
|
|
2eb3de2748 | ||
|
|
67511d9749 | ||
|
|
df90f62d35 | ||
|
|
f1212a0e5a | ||
|
|
df4877a10b | ||
|
|
cc575e2c6a | ||
|
|
56f968e11c | ||
|
|
ae4cea18ef | ||
|
|
296802a618 | ||
|
|
a5d3ad165a | ||
|
|
2120ffedb4 | ||
|
|
04e6e21813 | ||
|
|
acee253e1c | ||
|
|
555dce8bb3 | ||
|
|
067163ded8 | ||
|
|
704cf97780 | ||
|
|
007d38fe49 | ||
|
|
43e2ad34c7 | ||
|
|
a6dbc6651f | ||
|
|
4bb3de29e7 | ||
|
|
786f67f77b | ||
|
|
797852c62b | ||
|
|
1f8f1055d5 | ||
|
|
0121466ed2 | ||
|
|
55b0545c11 | ||
|
|
5e14e309bc | ||
|
|
9e236c7914 | ||
|
|
2d7fc7f000 | ||
|
|
644a399389 | ||
|
|
9e31ce2f3d | ||
|
|
66d03b4d1c | ||
|
|
84d5574887 | ||
|
|
4118e026c3 | ||
|
|
51b374121b | ||
|
|
9042746704 | ||
|
|
e14d6f79b1 | ||
|
|
752fc2170a | ||
|
|
356b69aea7 | ||
|
|
827bd7f609 | ||
|
|
795490271d | ||
|
|
9fe3ddfec3 | ||
|
|
efa8df83b3 | ||
|
|
58422bcc19 | ||
|
|
fdefcfeac0 | ||
|
|
5c14d18141 | ||
|
|
a96f66ce1e | ||
|
|
b561f83220 | ||
|
|
7a62c6f4a7 | ||
|
|
06fe3e8e69 | ||
|
|
158d99a528 | ||
|
|
98da8f82b8 | ||
|
|
465832b0e4 | ||
|
|
0318c132eb | ||
|
|
0f8e3a9b09 | ||
|
|
4ce1eb6b0a | ||
|
|
47d249e7dd | ||
|
|
ea6ebae264 | ||
|
|
d878f3bed2 | ||
|
|
ec5fc9d5e4 | ||
|
|
99948fdf3f | ||
|
|
73ad5f1dbb | ||
|
|
6e26990ad3 | ||
|
|
4d0962e611 | ||
|
|
ffb6594600 | ||
|
|
e0bb0881e8 | ||
|
|
632179bda6 | ||
|
|
4b4950d7c4 | ||
|
|
09043cd199 | ||
|
|
e902b6fc18 | ||
|
|
1d693cb3c3 | ||
|
|
69bb386a6d | ||
|
|
23e3cf465c | ||
|
|
0d3ca74bd0 | ||
|
|
f69088012b | ||
|
|
154aaa2051 | ||
|
|
1ab273641e | ||
|
|
11b36e3867 | ||
|
|
785d579f47 | ||
|
|
382cc39713 | ||
|
|
3fe8aa76b2 | ||
|
|
f351bcb06b | ||
|
|
bbe656e4ce | ||
|
|
1861c82434 | ||
|
|
dc75e77eb1 | ||
|
|
94e8c85fd0 | ||
|
|
28c30923f4 | ||
|
|
8ba30576d1 | ||
|
|
8f26771053 | ||
|
|
2b74cfae4a | ||
|
|
60a4edaef9 | ||
|
|
5096b41100 | ||
|
|
e9eaa66542 | ||
|
|
33921460cc | ||
|
|
2c2aa41619 | ||
|
|
50ebc9de60 | ||
|
|
f164427cba | ||
|
|
9fcfdaa1ea | ||
|
|
b77301b6eb | ||
|
|
3be828d012 | ||
|
|
e08adcd31c | ||
|
|
d480ce24dd | ||
|
|
830a9d3d84 | ||
|
|
6f0d56512d | ||
|
|
482444afc4 | ||
|
|
46c84a8b24 | ||
|
|
ae82c1b693 | ||
|
|
501e543105 | ||
|
|
0d7f842b09 | ||
|
|
a1dfcbf2b3 | ||
|
|
5703fdfdc4 | ||
|
|
57494e6871 | ||
|
|
ad8ef0b7e8 | ||
|
|
f9fca5688d | ||
|
|
4156a6ac93 | ||
|
|
6c3defa821 | ||
|
|
8057d27511 | ||
|
|
466fef4486 | ||
|
|
4e814a152a | ||
|
|
3f378dda96 | ||
|
|
70068076f2 | ||
|
|
2466ccec98 | ||
|
|
d3121cea67 | ||
|
|
59b67af6a8 | ||
|
|
f1a8cc6a0d | ||
|
|
e34f01715c | ||
|
|
1b469a542f | ||
|
|
567e92e2a2 | ||
|
|
5388064d88 | ||
|
|
20ffd8e538 | ||
|
|
5dd3bf02d3 | ||
|
|
c3e19973cf | ||
|
|
7aaa4b433b | ||
|
|
3cf601f760 | ||
|
|
39327fca33 | ||
|
|
6489cdf829 | ||
|
|
1727f2acf1 | ||
|
|
079963f27e | ||
|
|
3c72bf2d79 | ||
|
|
16cda1637b | ||
|
|
7bfc737d81 | ||
|
|
2e8c8e1dc7 | ||
|
|
727bd7f2dc | ||
|
|
e54cf96271 | ||
|
|
47bb5c668f | ||
|
|
a90e3bc36d | ||
|
|
513aed64c5 | ||
|
|
305ffc4e39 | ||
|
|
cac7fa95fd | ||
|
|
1db835fa0c | ||
|
|
ad61c30f46 | ||
|
|
f35ee16412 | ||
|
|
5216403562 | ||
|
|
60c5d9fefd | ||
|
|
7ab2b20bd5 | ||
|
|
2aea6138b7 | ||
|
|
dddcc99726 | ||
|
|
736842d40f | ||
|
|
ba37fa75d6 | ||
|
|
e9fbcfef78 | ||
|
|
206b7acee4 | ||
|
|
222dc28be9 | ||
|
|
98d0c6d1a3 | ||
|
|
81be26d686 | ||
|
|
1a8d0ef420 | ||
|
|
1deb3bd36c | ||
|
|
5b41d9ebf3 | ||
|
|
afd40fdbfa | ||
|
|
511f2c42d8 | ||
|
|
2ad7b68710 | ||
|
|
52f3dcc5ef | ||
|
|
2607d06f94 | ||
|
|
203fb2db89 | ||
|
|
7d99d23aef | ||
|
|
2f7edd1524 | ||
|
|
b8f0cde900 | ||
|
|
732be56485 | ||
|
|
ab955d66c4 | ||
|
|
1feb28c045 | ||
|
|
c4348be8d2 | ||
|
|
8fdcb37ddd | ||
|
|
7f6e8b634e | ||
|
|
e9b71c288a | ||
|
|
db27568929 | ||
|
|
9c9ce0b435 | ||
|
|
af41d0325c | ||
|
|
e21957efd7 | ||
|
|
2e69ac9886 | ||
|
|
6790913e0b | ||
|
|
c309d331ad | ||
|
|
7acfc655e8 | ||
|
|
d485c688d6 | ||
|
|
deea1a5b83 | ||
|
|
93d77962ee | ||
|
|
47ca0312df | ||
|
|
c9fa1c8115 | ||
|
|
d2b0347777 | ||
|
|
78c028321c | ||
|
|
64c174118a | ||
|
|
f21e09510e | ||
|
|
127434c46c | ||
|
|
32a0a11fed | ||
|
|
7b40465f85 | ||
|
|
27c3559421 | ||
|
|
a91711afaf | ||
|
|
4df2461b65 | ||
|
|
35540bda73 | ||
|
|
f66e6a944e | ||
|
|
f6807fe7cd | ||
|
|
a34a41e1b5 | ||
|
|
9861a1ba04 | ||
|
|
26547a5f29 | ||
|
|
d9ab241406 | ||
|
|
41c2c2205c | ||
|
|
43134f4e41 | ||
|
|
85c8276e06 | ||
|
|
95bf2811fc | ||
|
|
d87b081c34 | ||
|
|
cf62fe270b | ||
|
|
b784b15b8a | ||
|
|
38b508e5a7 | ||
|
|
de24a16ef4 | ||
|
|
9b53749263 | ||
|
|
cdccd03c7d | ||
|
|
27943eb6c7 | ||
|
|
28fbc7ff47 | ||
|
|
33ced209a0 | ||
|
|
8155baa6f3 | ||
|
|
423a87cf68 | ||
|
|
ff8a13c99a | ||
|
|
0e1fcaa3cc | ||
|
|
22cab3eeb3 | ||
|
|
1b45824beb | ||
|
|
a5053a898c | ||
|
|
980807b0f5 | ||
|
|
610c2f3c10 | ||
|
|
7afb41a128 | ||
|
|
f6e96e484c | ||
|
|
ce64c617e9 | ||
|
|
783c25e9bc | ||
|
|
9b6faeb8cf | ||
|
|
1cfb4def89 | ||
|
|
b2fd94f49f | ||
|
|
b8eac21b6f | ||
|
|
fad711f55f | ||
|
|
4bc514829c | ||
|
|
b8ae7233e0 | ||
|
|
5001e3f57d | ||
|
|
f731ca1737 | ||
|
|
b124beafeb | ||
|
|
c7f448c523 | ||
|
|
c7937c5c20 | ||
|
|
ed652da572 | ||
|
|
b28797c534 | ||
|
|
42ecf0f673 | ||
|
|
e36cd17dd1 | ||
|
|
cf2e9d1bc0 | ||
|
|
f8062e8fee | ||
|
|
7f760b8eb3 | ||
|
|
8baf3cad9e | ||
|
|
05f36b054c | ||
|
|
d89a8025f9 | ||
|
|
674596103f | ||
|
|
43d8bd01cd | ||
|
|
18156172a3 | ||
|
|
e6c1a91a65 | ||
|
|
4c49c45a04 | ||
|
|
7bd8747909 | ||
|
|
40e66ea972 | ||
|
|
0acee0a978 | ||
|
|
324239ea84 | ||
|
|
8cfc13fe2c | ||
|
|
93c4acf169 | ||
|
|
2f8c781f04 | ||
|
|
3cdd63e01d | ||
|
|
aace479a0c | ||
|
|
d54cab9565 | ||
|
|
a3c05538ca | ||
|
|
fddd6b3864 | ||
|
|
ee217acc19 | ||
|
|
18510648ea | ||
|
|
f65a4e45cf | ||
|
|
28c105fa9b | ||
|
|
2cd77f6d49 | ||
|
|
a045782e04 | ||
|
|
c5f0414a0a | ||
|
|
2c82bbee6a | ||
|
|
a7e3bcf79f | ||
|
|
0529ae8250 | ||
|
|
1c876168bd | ||
|
|
48a3d8d175 | ||
|
|
f8040cb977 | ||
|
|
1af3b2ebd9 | ||
|
|
1e8396db70 | ||
|
|
6a031aa651 | ||
|
|
98d53642fa | ||
|
|
60bcab5d61 | ||
|
|
c083b9ed02 | ||
|
|
1ada2e33c8 | ||
|
|
2a6bf0fae5 | ||
|
|
5f14c125ed | ||
|
|
152afb5529 | ||
|
|
3972efa333 | ||
|
|
4c348837e4 | ||
|
|
521a761745 | ||
|
|
bf11ca8ac9 | ||
|
|
df08ffda39 | ||
|
|
7d3aeb0315 | ||
|
|
d4b7ef198c | ||
|
|
6b011e436d | ||
|
|
d98450a575 | ||
|
|
d860b9bfc0 | ||
|
|
7f4ae40cc5 | ||
|
|
7a53c8833b | ||
|
|
f8e7517393 | ||
|
|
42cbbb565e | ||
|
|
4c7ada0d60 | ||
|
|
1788bf8134 | ||
|
|
62bb83857c | ||
|
|
be26c6db88 | ||
|
|
8d2b298253 | ||
|
|
9914a8af41 | ||
|
|
9972feb710 | ||
|
|
e776eb1716 | ||
|
|
78e635fe3e | ||
|
|
5e582420ed | ||
|
|
3ff797d73d | ||
|
|
1d52fe8101 | ||
|
|
2487d29318 | ||
|
|
3b8a8eeb91 | ||
|
|
063b6ee2b7 | ||
|
|
4414c3c1af | ||
|
|
6cf5b046fa | ||
|
|
d6ecd23a6e | ||
|
|
12a8782268 | ||
|
|
028f608816 | ||
|
|
0f8f8fce11 | ||
|
|
18b317186a | ||
|
|
708db4627e | ||
|
|
e93299fb80 | ||
|
|
e12d4c9d07 | ||
|
|
505d17a425 | ||
|
|
2d89e9ae5f | ||
|
|
14470343d1 | ||
|
|
9d9389310d | ||
|
|
8649edaf16 | ||
|
|
3a8706d0d7 | ||
|
|
0762f49f20 | ||
|
|
d980703786 | ||
|
|
efd7b94c15 | ||
|
|
9f2e4485ad | ||
|
|
bbd5c1c833 | ||
|
|
639ac0e4c6 | ||
|
|
76e3a0f7b7 | ||
|
|
47e6541731 | ||
|
|
361020fc23 | ||
|
|
417ee432c5 | ||
|
|
accd2718ac | ||
|
|
854bcb158f | ||
|
|
1bd74d1b2f | ||
|
|
967a9363ce | ||
|
|
de286f236f | ||
|
|
e7f5201c1f | ||
|
|
c06a7d4511 | ||
|
|
ed10d376fa | ||
|
|
8d5424408c | ||
|
|
99ce9afd63 | ||
|
|
91d401aa8a | ||
|
|
a7d0586d95 | ||
|
|
21ac444f28 | ||
|
|
7efd8ef33a | ||
|
|
c6633ea1bb | ||
|
|
6c0e262aa0 | ||
|
|
1e7e2ffb27 | ||
|
|
c9990e43e9 | ||
|
|
79eddec09c | ||
|
|
0ff1df58a2 | ||
|
|
58beb98327 | ||
|
|
8e36842891 | ||
|
|
7b841af095 | ||
|
|
033c89a1b2 | ||
|
|
0580626784 | ||
|
|
96f84abfeb | ||
|
|
2dc154ff7f | ||
|
|
0879f3dbd2 | ||
|
|
f6e63d252d | ||
|
|
4f13db42b0 | ||
|
|
f507a067cd | ||
|
|
27af1879d7 | ||
|
|
84d204327f | ||
|
|
096814ade9 | ||
|
|
d177ad48e5 | ||
|
|
28f906c37d | ||
|
|
f8f2f904bc | ||
|
|
c3fb1656e6 | ||
|
|
21123aaa3c | ||
|
|
65096c5b2e | ||
|
|
9948e9d3ee | ||
|
|
b98633b677 | ||
|
|
3c7942d2bd | ||
|
|
3c3884ff38 | ||
|
|
30c58b6591 | ||
|
|
fd2265dc19 | ||
|
|
84dcd156f8 | ||
|
|
5ca6bd7de6 | ||
|
|
2a6b4c0ae6 | ||
|
|
d5aa3f6e42 | ||
|
|
fbc7c628f6 | ||
|
|
c20565ee49 | ||
|
|
65bca4ce98 | ||
|
|
4e9c68e3da | ||
|
|
712144297a | ||
|
|
651ac2cfd5 | ||
|
|
2853015f0e | ||
|
|
0914229800 | ||
|
|
e350b87b3c | ||
|
|
6567f95aa3 | ||
|
|
1361ae8338 | ||
|
|
d4412d160f | ||
|
|
38ddc48cab | ||
|
|
ef0d62e47e | ||
|
|
574f3587ed | ||
|
|
6f4b1bf4c2 | ||
|
|
691341b2b0 | ||
|
|
83979b38d6 | ||
|
|
b189f1b44c | ||
|
|
958db5f40f | ||
|
|
6b9d3309cc | ||
|
|
c5d737b536 | ||
|
|
57fe750035 | ||
|
|
3849e37393 | ||
|
|
9b091ed657 | ||
|
|
de89e758cc | ||
|
|
777e0e2eb5 | ||
|
|
04ecdee7a2 | ||
|
|
6ddc3226d4 | ||
|
|
1903865cd5 | ||
|
|
d5d54a8775 | ||
|
|
c27e2d2c2d | ||
|
|
66b705cec3 | ||
|
|
d463d793a9 | ||
|
|
f10425675c | ||
|
|
aa8ab4253d | ||
|
|
fa56070097 | ||
|
|
ee6b5bf585 | ||
|
|
dd5863edc2 | ||
|
|
37cabdcb02 | ||
|
|
a40ef4eee0 | ||
|
|
5f5a43fd54 | ||
|
|
914a6956e9 | ||
|
|
698b49e3a5 | ||
|
|
c0bf77f3c3 | ||
|
|
ec067f1e39 | ||
|
|
6081a66629 | ||
|
|
ee69413a68 | ||
|
|
fc4e7af854 | ||
|
|
0e069c31d7 | ||
|
|
780abb9832 | ||
|
|
2812cf201d | ||
|
|
ce848af067 | ||
|
|
d3be8dbf05 | ||
|
|
9bbc54bd4c | ||
|
|
fbbff39e07 | ||
|
|
49c2000de3 | ||
|
|
f561d4845b | ||
|
|
d6c4e5a027 | ||
|
|
ad271604af | ||
|
|
52c667f423 | ||
|
|
86474679db | ||
|
|
dcb7476d12 | ||
|
|
152334eee3 | ||
|
|
c4a39757a1 | ||
|
|
4fdc93257f | ||
|
|
7690ad27c2 | ||
|
|
67e0d54062 | ||
|
|
74da2fe6b7 | ||
|
|
53cb3a19d1 | ||
|
|
84390f6dc1 | ||
|
|
9d191c28f2 | ||
|
|
5d42108a98 | ||
|
|
1972ef3bef | ||
|
|
e8145b07fa | ||
|
|
5e7295a631 | ||
|
|
761a770174 | ||
|
|
7f0a61954b | ||
|
|
966c38092d | ||
|
|
826147d716 | ||
|
|
a8903cba07 | ||
|
|
0b4fa473e7 | ||
|
|
045cabb7f2 | ||
|
|
6d4b5c345f | ||
|
|
8dcb4289a9 | ||
|
|
6df1dfd4da | ||
|
|
560aa61e91 | ||
|
|
98ec18fcfe | ||
|
|
683ff6f73a | ||
|
|
a791368257 | ||
|
|
af548f5ae8 | ||
|
|
1e741d1528 | ||
|
|
8fad68ab3e | ||
|
|
32c8def114 | ||
|
|
7c1055df97 | ||
|
|
d25648f729 | ||
|
|
70dd250ae6 | ||
|
|
c6276dbbde | ||
|
|
a0b4721c5b | ||
|
|
d136f966f8 | ||
|
|
1f457ff81e | ||
|
|
b85ce82160 | ||
|
|
49ce3e6d5a | ||
|
|
9663d6f53e | ||
|
|
0d554de016 | ||
|
|
1bddc392b2 | ||
|
|
00df6c646f | ||
|
|
1f040d06eb | ||
|
|
4a8cb8c4f9 | ||
|
|
6ef20dc9af | ||
|
|
e219a40ebc | ||
|
|
d13b478ca2 | ||
|
|
64ca4d485f | ||
|
|
66b9b3323c | ||
|
|
24d06f5ce2 | ||
|
|
19de683b66 | ||
|
|
ed8418a2ac | ||
|
|
c33a2a5d19 | ||
|
|
9b49a0b727 | ||
|
|
1b42652e96 | ||
|
|
1400da9637 | ||
|
|
62794a3fde | ||
|
|
c17da16907 | ||
|
|
62c5a6d27b | ||
|
|
9a9de6a936 | ||
|
|
fe9b20733a | ||
|
|
50f9255472 | ||
|
|
70181252d8 | ||
|
|
dc3762b533 | ||
|
|
4de85f000d | ||
|
|
4de42d0dc4 | ||
|
|
747c2031f1 | ||
|
|
14353e67d4 | ||
|
|
8027bda6bf | ||
|
|
0c5e30ae5e | ||
|
|
3e0f4cf653 | ||
|
|
35689a58ba | ||
|
|
79efeab0ce | ||
|
|
b38fe43b09 | ||
|
|
ea7df1d0dc | ||
|
|
91de744250 | ||
|
|
1654cb51db | ||
|
|
be5d7ef703 | ||
|
|
ee49ec5071 | ||
|
|
fea8346f23 | ||
|
|
e6b92995f3 | ||
|
|
3094896ad1 | ||
|
|
9f0c201851 | ||
|
|
6d55c17322 | ||
|
|
9305cc3439 | ||
|
|
35581f52e2 | ||
|
|
f105583803 | ||
|
|
c216561daa | ||
|
|
8187f1ef66 | ||
|
|
05f3355c57 | ||
|
|
e36a480063 | ||
|
|
f90cdb04a5 | ||
|
|
78d4d7ede4 | ||
|
|
618d20bd08 | ||
|
|
985ece811b | ||
|
|
c734c72fc9 | ||
|
|
fff7116b51 | ||
|
|
ca351542ef | ||
|
|
fa940d5b6b | ||
|
|
e5ca012ed8 | ||
|
|
bcdbc252fe | ||
|
|
d811847b2d | ||
|
|
b042426180 | ||
|
|
0c58226e9c | ||
|
|
745a3ef110 | ||
|
|
eace14a013 | ||
|
|
5adf5c43a2 | ||
|
|
0075879d79 | ||
|
|
1727f95575 | ||
|
|
73a9345c43 | ||
|
|
0cecbef5c3 | ||
|
|
4dec228d0c | ||
|
|
9b4b2e2db3 | ||
|
|
d511139408 | ||
|
|
3ec521749b | ||
|
|
27671eac9b | ||
|
|
f9e11e7302 | ||
|
|
186198afe6 | ||
|
|
a77639022c | ||
|
|
3093f8c594 | ||
|
|
831efabfbd | ||
|
|
cae7cc02d3 | ||
|
|
7d54dc4eaa | ||
|
|
9b98998991 | ||
|
|
fcc7a818b4 | ||
|
|
a3e581b56f | ||
|
|
1db0eda4ae | ||
|
|
4c4fcedf40 | ||
|
|
d4137afb8c | ||
|
|
ee9d8a656f | ||
|
|
905fd0d193 | ||
|
|
de8dd66ac2 | ||
|
|
6fc2006447 | ||
|
|
ef54b481a7 | ||
|
|
3a5dd71152 | ||
|
|
2c783d099a | ||
|
|
7e68f1ed32 | ||
|
|
9ebccd6a59 | ||
|
|
e314c2fa81 | ||
|
|
0f565f4e4c | ||
|
|
3c85d2558b | ||
|
|
d8cc59dd55 | ||
|
|
a05dc35c39 | ||
|
|
5d68512994 | ||
|
|
377fecbf79 | ||
|
|
a23a7ec35a | ||
|
|
50cd5576f9 | ||
|
|
dae67a88cc | ||
|
|
44850bdd55 | ||
|
|
b201dafa75 | ||
|
|
7c58bf004f | ||
|
|
4119284a4f | ||
|
|
957a3c6622 | ||
|
|
8648470c47 | ||
|
|
eb664d50c1 | ||
|
|
e42ec2360c | ||
|
|
beac63849c | ||
|
|
1758b147b8 | ||
|
|
716cc7f102 | ||
|
|
c35436a7da | ||
|
|
fc06585938 | ||
|
|
f8582fb678 | ||
|
|
9777b525b7 | ||
|
|
97d119b4e8 | ||
|
|
bca4513187 | ||
|
|
28b72d46c5 | ||
|
|
cece5aca30 | ||
|
|
713de31f64 | ||
|
|
e2a48d1c36 | ||
|
|
9142f57bc8 | ||
|
|
306c6625e9 | ||
|
|
46f674f842 | ||
|
|
dca3bb89fe | ||
|
|
e74a1fe461 | ||
|
|
581828bf94 | ||
|
|
2c0d3c1a86 | ||
|
|
62556429dc | ||
|
|
33e81f464e | ||
|
|
eeceb9c2de | ||
|
|
2a1132387f | ||
|
|
ca83704cbb | ||
|
|
180e2f612a | ||
|
|
7b84a7ae91 | ||
|
|
ffad9e1f0a | ||
|
|
1131a2910d | ||
|
|
18217532af | ||
|
|
6d69bba15e | ||
|
|
71b2f110eb | ||
|
|
b6c77e12f3 | ||
|
|
5bbb61aec2 | ||
|
|
1f98f3ca89 | ||
|
|
0cd3410a2d | ||
|
|
4afb5748f2 | ||
|
|
6b1d376e6f | ||
|
|
008c4f91cd | ||
|
|
85f34393fe | ||
|
|
59bdb00599 | ||
|
|
29ab66935e | ||
|
|
80fd68e1c2 | ||
|
|
f92cf816b1 | ||
|
|
0c9bddecda | ||
|
|
0ee20da35c | ||
|
|
b7749dd0d2 | ||
|
|
18b544770f | ||
|
|
03cc30ae45 | ||
|
|
80e28dfe63 | ||
|
|
27f73e1db3 | ||
|
|
75bcbce441 | ||
|
|
2f489f54c5 | ||
|
|
7250944a7c | ||
|
|
ac33fb74ec | ||
|
|
5ac1c82eb9 | ||
|
|
5f73d0d02d | ||
|
|
024415e09d | ||
|
|
e9797d69d0 | ||
|
|
4b4361fbc0 | ||
|
|
b0ce58adea | ||
|
|
6145077d7e | ||
|
|
512e003cbd | ||
|
|
2a297a43eb | ||
|
|
7eaa5f592b | ||
|
|
be714b38bb | ||
|
|
1a5e5eb358 | ||
|
|
fba004eb3d | ||
|
|
2d70e55fbb | ||
|
|
d0df238972 | ||
|
|
d97b58fd4a | ||
|
|
e872d3ae54 | ||
|
|
fbdd04d817 | ||
|
|
aed4e26f59 | ||
|
|
b5adad15eb | ||
|
|
d17df3ca05 | ||
|
|
2d72e987c5 | ||
|
|
9ae3882c1a | ||
|
|
89c195fa6e | ||
|
|
de808e3612 | ||
|
|
4575f4f1df | ||
|
|
54b1c4f941 | ||
|
|
8a309c393d | ||
|
|
58a99a2ce9 | ||
|
|
23019a7bf9 | ||
|
|
78a5ee0e55 | ||
|
|
c30d51d65e | ||
|
|
c439b806b8 | ||
|
|
50fdcad10f | ||
|
|
2886b8f9ba | ||
|
|
9ca7162169 | ||
|
|
de2df51193 | ||
|
|
dc36089156 | ||
|
|
08c959807d | ||
|
|
796dc320d9 | ||
|
|
f1949c3bda | ||
|
|
4d9ead721e | ||
|
|
b8d0590d6d | ||
|
|
36ebd0c545 | ||
|
|
bc2dd35cc3 | ||
|
|
b5a735c8d1 | ||
|
|
8c0ed0e591 | ||
|
|
849b5b663b | ||
|
|
b65b0a6a7d | ||
|
|
f1181d4a25 | ||
|
|
c5a37c02de | ||
|
|
74dc389321 | ||
|
|
7d7a85d184 | ||
|
|
de1e5f7292 | ||
|
|
9bc05faf53 | ||
|
|
575cef1d26 | ||
|
|
0cc3f567fc | ||
|
|
cb97c5496e | ||
|
|
8ca15ae2ca | ||
|
|
10872bdd64 | ||
|
|
fc8e505c95 | ||
|
|
3b24aceb04 | ||
|
|
a4ccf205f7 | ||
|
|
452fca8bdd | ||
|
|
9dbd0a4baf | ||
|
|
9aabeb39ee | ||
|
|
605e8a1488 | ||
|
|
dfd0601163 | ||
|
|
034af45777 | ||
|
|
0b3e40f9d4 | ||
|
|
a44581eaf3 | ||
|
|
886a84dea0 | ||
|
|
79c3896449 | ||
|
|
6823e34451 | ||
|
|
f25fbb6f2e | ||
|
|
872409ea57 | ||
|
|
81b4c09ee6 | ||
|
|
a07c6d57dc | ||
|
|
3c94b5441e | ||
|
|
5b89508fe9 | ||
|
|
b92dd80566 | ||
|
|
4084bcdf75 | ||
|
|
5657080884 | ||
|
|
708702917c | ||
|
|
4c8ff02969 | ||
|
|
fe167781c4 | ||
|
|
0d02a4c722 | ||
|
|
9d4c984e03 | ||
|
|
e4cd56ed7b | ||
|
|
f2424043f3 | ||
|
|
45996d8d78 | ||
|
|
472da6509d | ||
|
|
b283f2ad31 | ||
|
|
6dcf4b3e8b | ||
|
|
bf508c2842 | ||
|
|
d18197a176 | ||
|
|
c987778e97 | ||
|
|
9841ce7824 | ||
|
|
83a6f5c7b9 | ||
|
|
775e5b19cb | ||
|
|
f56bf1ba38 | ||
|
|
ca119bb069 | ||
|
|
3c013dac75 | ||
|
|
e87e839c97 | ||
|
|
94592a35ac | ||
|
|
581dc08cf5 | ||
|
|
1b4fe0f067 | ||
|
|
d2105fe66c | ||
|
|
20630b1230 | ||
|
|
66637220ef | ||
|
|
27d750f1c9 | ||
|
|
d338bcd070 | ||
|
|
5d72fdd8c2 | ||
|
|
946f47b3ec | ||
|
|
408a75aeb3 |
No files matched your search
@@ -0,0 +1,76 @@
|
||||
---
|
||||
name: vanilla-parity
|
||||
description: Fix a Horizon bug or gap so it behaves 1:1 like vanilla 26.3, starting from the port ledger (tools/port_ledger.py) instead of grepping. Use for any issue, Discord report or PR that makes Horizon match vanilla: "X doesn't work like in Java", a missing sound, particle, animation, mob behaviour, block rule, screen or packet.
|
||||
---
|
||||
|
||||
# Vanilla parity with the port ledger
|
||||
|
||||
The port ledger pairs every vanilla method with the Rust fn that ports it (or says it is missing, or needs
|
||||
no port) and lists where a port isn't wired like vanilla. Ask it first; grep only for what it can't answer.
|
||||
Full reference: `docs/port-ledger.md`.
|
||||
|
||||
## 0. Have a fresh ledger
|
||||
|
||||
```sh
|
||||
python3 tools/port_ledger.py build # ~20 s; again after pulling main
|
||||
```
|
||||
|
||||
It needs the vanilla jar (the launcher's `versions/26.3/26.3.jar`, or `--jar`) and/or the lab's decompiled
|
||||
sources (`python3 tools/lab.py decompile`, or `--sources DIR`). If `build` says it has no vanilla code, run
|
||||
the lab first; don't guess vanilla from memory.
|
||||
|
||||
## 1. From the report to the code
|
||||
|
||||
```sh
|
||||
python3 tools/port_ledger.py issue 3236 # a GitHub issue number
|
||||
python3 tools/port_ledger.py issue "mooshroom stays red after lightning" # or the report's text
|
||||
```
|
||||
|
||||
It ranks the vanilla classes the report names and lists, per class, its Rust home files, where each method is
|
||||
ported, and the known gaps (`missing` rows from the verdict map, seam/client/drift gaps). Often the cause is
|
||||
already written there ("Type is not saved"). Then drill in:
|
||||
|
||||
```sh
|
||||
python3 tools/port_ledger.py where MushroomCow.readAdditionalSaveData # ports, callers, callees, gaps
|
||||
python3 tools/port_ledger.py class MushroomCow # every method: where, or missing
|
||||
python3 tools/port_ledger.py what crates/horizon-client/src/mobs/farm.rs:120 # which vanilla method a fn ports
|
||||
```
|
||||
|
||||
A ledger answer is a lead, not a verdict: about 1 in 12 map rows is wrong. Read the vanilla method (path and
|
||||
line are in the output) and the Rust fn before changing anything.
|
||||
|
||||
## 2. Port it the vanilla way
|
||||
|
||||
- Translate the vanilla method, don't reinvent it: same order of operations, same constants, the same random
|
||||
draws in the same order (`horizon_core::random`), Java `Math` through `horizon_core::jmath`.
|
||||
- Open the fn's doc with the vanilla method it ports: `` /// `MushroomCow.readAdditionalSaveData`: ... ``.
|
||||
That one line is what makes the next agent find it.
|
||||
- Fix the path players run: single player is a client of the integrated server, so server logic lives in
|
||||
`game/lan*.rs` and `src/lan/`, and what only the client's copy shows (particles, local sounds, hand
|
||||
animation) must be reached from `game/remote*.rs`. A fix on the old in-frame path alone fixes nothing.
|
||||
- Look at the neighbours the ledger lists for the same class: a `client` or `seam` gap next to your fix is
|
||||
usually the same bug on the other path.
|
||||
|
||||
## 3. Before the PR
|
||||
|
||||
```sh
|
||||
python3 tools/port_ledger.py check --base origin/main
|
||||
```
|
||||
|
||||
It lists the ports your branch touches with their open gaps, citations to add, stale citations, and (against
|
||||
the base) the gaps the branch closes and opens. Close what belongs to your fix, cite what you ported, leave
|
||||
no new stale citation. If you ported a method the verdict map called `missing`, change its row in
|
||||
`tools/port_map/*.tsv` to `port` with the fn (`file.rs::Owner::fn`) and run
|
||||
`python3 tools/port_ledger.py map-check tools/port_map/<file>.tsv`.
|
||||
|
||||
Then the usual: the harness for the area (`docs/architecture.md`), `cargo check`/tests per `AGENTS.md`, and
|
||||
a small PR that says which vanilla method it now matches.
|
||||
|
||||
## Finding work
|
||||
|
||||
```sh
|
||||
python3 tools/port_ledger.py gaps --kind seam # ported on two paths, wired on one: the best leads
|
||||
python3 tools/port_ledger.py gaps --kind client # client-only effects the server path never reaches
|
||||
python3 tools/port_ledger.py gaps --kind drift # a port missing a sound, particle, item or constant
|
||||
rg -n "player:" tools/port_map/*.tsv # missing behaviour a player would see
|
||||
```
|
||||
+10
-1
@@ -1,2 +1,11 @@
|
||||
#!/bin/sh
|
||||
exec python3 tools/check_public_tree.py
|
||||
# Git for Windows puts a `python3` on PATH that is only the Microsoft Store
|
||||
# stub: it prints "Python" and exits non-zero, so the hook fails with no other
|
||||
# output. Use the first name that actually runs Python 3.
|
||||
for py in python3 python py; do
|
||||
if "$py" -c "import sys; sys.exit(sys.version_info < (3,))" >/dev/null 2>&1; then
|
||||
exec "$py" tools/check_public_tree.py
|
||||
fi
|
||||
done
|
||||
echo "check_public_tree: no working Python 3 found" >&2
|
||||
exit 1
|
||||
+10
-1
@@ -1,2 +1,11 @@
|
||||
#!/bin/sh
|
||||
exec python3 tools/check_public_tree.py --pre-push
|
||||
# Git for Windows puts a `python3` on PATH that is only the Microsoft Store
|
||||
# stub: it prints "Python" and exits non-zero, so the hook fails with no other
|
||||
# output. Use the first name that actually runs Python 3.
|
||||
for py in python3 python py; do
|
||||
if "$py" -c "import sys; sys.exit(sys.version_info < (3,))" >/dev/null 2>&1; then
|
||||
exec "$py" tools/check_public_tree.py --pre-push
|
||||
fi
|
||||
done
|
||||
echo "check_public_tree: no working Python 3 found" >&2
|
||||
exit 1
|
||||
@@ -0,0 +1,9 @@
|
||||
# Trusted reviewers: one list for two things (docs/release-flow.md "Trusted reviewers").
|
||||
# - `/nightly 2640 2642` picks the pull requests the Nightly holds (tools/ci/nightly_set.py);
|
||||
# - `/close`, `/reopen`, `/label`, `/unlabel` triage issues and pull requests (tools/ci/triage_command.py).
|
||||
# Ludovic is always allowed and isn't listed. One GitHub login per line, with who added it and when. Removing a
|
||||
# line revokes that login at once. A line with only `paused` stops both for every reviewer (Ludovic's still work).
|
||||
kacpherek # added by Ludovic, 2026-10-27
|
||||
rdbtCVS # added by Ludovic, 2026-10-27
|
||||
Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
|
||||
levzzz5154 # added by Ludovic, 2026-10-28 (Horizon Team; author of horizon-mixins)
|
||||
@@ -16,12 +16,14 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
BRANCH: ${{ github.event.pull_request.head.ref }}
|
||||
SHA: ${{ github.event.pull_request.head.sha }}
|
||||
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
run: |
|
||||
# Every head the pull request had, not only its last: a run-parity label added on an older head left that
|
||||
# run queued after the merge (#3104, 2026-10-29). Its branch in its own repository names its runs (another
|
||||
# fork can have a branch of the same name).
|
||||
for status in queued in_progress waiting pending requested; do
|
||||
gh run list -R "$REPO" --branch "$BRANCH" --status "$status" --limit 100 \
|
||||
--json databaseId,event,headSha \
|
||||
-q ".[] | select(.event == \"pull_request\" and .headSha == \"$SHA\") | .databaseId"
|
||||
gh api -X GET "repos/$REPO/actions/runs" -f branch="$BRANCH" -f event=pull_request -f status="$status" \
|
||||
-f per_page=100 --jq ".workflow_runs[] | select(.head_repository.full_name == \"$HEAD_REPO\") | .id"
|
||||
done | sort -u | while read -r id; do
|
||||
gh run cancel -R "$REPO" "$id" || true
|
||||
done
|
||||
@@ -19,6 +19,15 @@ on:
|
||||
description: A nightly's "<date> <short sha>" (HORIZON_NIGHTLY), shown by the game; empty for a release
|
||||
type: string
|
||||
default: ''
|
||||
trusted:
|
||||
# Only release.yml says true: it builds main. A nightly can hold pull requests nobody reviewed yet, whose
|
||||
# build scripts and tests run here and could rewrite target/, the cargo home or a compiler cache. So an
|
||||
# untrusted build never runs on the release runner, never saves the Rust cache (its key is its own anyway),
|
||||
# and a trusted one never reads the machine's shared sccache, which pull requests' jobs write
|
||||
# (tests/test_nightly.py pins all of it).
|
||||
description: The source is main (a release); false for anything else (a nightly)
|
||||
type: boolean
|
||||
default: false
|
||||
developer_id:
|
||||
description: Sign the macOS programs with Developer ID and notarize the zip (the runner's keychain)
|
||||
type: boolean
|
||||
@@ -27,8 +36,11 @@ permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
exporters:
|
||||
# This runner holds the maintainer's own pinned Minecraft jars. Only our classes leave it.
|
||||
runs-on: ${{ vars.MAC_RUNNER || 'ludovics-mac-mini' }}
|
||||
# This runner holds the maintainer's own pinned Minecraft jars. Only our classes leave it. An untrusted build
|
||||
# goes to the Mac mini's nightly runner (macOS + horizon-nightly: ludovics-mac-mini-nightly, beside the one that
|
||||
# runs pull requests' parity jobs, so a nightly never waits behind their queue), never the release runner
|
||||
# (MAC_RUNNER: ludovics-mac-mini-release) nor parity-main's.
|
||||
runs-on: ${{ inputs.trusted && vars.MAC_RUNNER || fromJSON('["self-hosted", "macOS", "horizon-nightly"]') }}
|
||||
# Three full preparations (two isolated, one batched) besides the compile.
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
@@ -39,6 +51,8 @@ jobs:
|
||||
clean: true
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- run: bash tools/ci/runner-env.sh
|
||||
env:
|
||||
HORIZON_NO_SCCACHE: ${{ inputs.trusted && '1' || '' }}
|
||||
- run: bash tools/ci/vanilla-data.sh
|
||||
- name: Compile and validate original exporters against the pinned jars
|
||||
shell: bash
|
||||
@@ -90,7 +104,7 @@ jobs:
|
||||
mac: true
|
||||
dir: horizon-macos-arm64
|
||||
asset: horizon-macos-arm64.zip
|
||||
runs-on: ${{ matrix.mac && (vars.MAC_RUNNER || 'ludovics-mac-mini') || matrix.os }}
|
||||
runs-on: ${{ matrix.mac && (inputs.trusted && vars.MAC_RUNNER || fromJSON('["self-hosted", "macOS", "horizon-nightly"]')) || matrix.os }}
|
||||
# Notarizing the zip can take a few minutes.
|
||||
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
|
||||
env:
|
||||
@@ -117,7 +131,9 @@ jobs:
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: runner.environment == 'github-hosted'
|
||||
with:
|
||||
shared-key: release-${{ matrix.name }}
|
||||
# A release restores only what a release saved: an untrusted build has its own key and saves nothing.
|
||||
shared-key: ${{ inputs.trusted && 'release' || 'nightly' }}-${{ matrix.name }}
|
||||
save-if: ${{ inputs.trusted }}
|
||||
- name: ALSA and udev headers
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
@@ -173,6 +189,8 @@ jobs:
|
||||
done
|
||||
- name: Tools from the runner
|
||||
if: runner.environment == 'self-hosted'
|
||||
env:
|
||||
HORIZON_NO_SCCACHE: ${{ inputs.trusted && '1' || '' }}
|
||||
run: bash tools/ci/runner-env.sh
|
||||
- name: Resources prepared from the runner's own Minecraft
|
||||
if: runner.environment == 'self-hosted'
|
||||
|
||||
@@ -192,9 +192,11 @@ jobs:
|
||||
retry "$launcher" install latest
|
||||
"$launcher" versions --installed
|
||||
"$launcher" links --json
|
||||
clients=("$HORIZON_LAUNCHER_HOME"/versions/v*/horizon-client*)
|
||||
# `install latest` is the Nightly since #3342 (versions/nightly-<date>-<commit>), a vX.Y.Z before.
|
||||
clients=("$HORIZON_LAUNCHER_HOME"/versions/*/horizon-client*)
|
||||
client="${clients[0]}"
|
||||
echo "installed client: $client"
|
||||
[[ -f "$client" ]] || { echo "::error::no game installed under versions/"; exit 1; }
|
||||
# The game's Linux build is the game's to keep portable (release.yml checks it): a
|
||||
# release linked to a Nix store is a warning here, not a broken launcher.
|
||||
soft() { if [[ "$RUNNER_OS" == "Linux" ]]; then "$@" || echo "::warning::$* failed"; else "$@"; fi; }
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
name: nightly build
|
||||
# The opt-in Nightly channel (docs/release-flow.md "Nightly"; Ludovic, 2026-10-27). Once a night it builds the newest
|
||||
# union branch on origin (union/<agent>-<name>: main plus pull requests the contribution gate proved together), or
|
||||
# main when no union is newer, and publishes it as the `nightly` pre-release. Launchers install it only for players
|
||||
# who turn Nightly on. The previous nightly stays a week as nightly-<date>.
|
||||
run-name: nightly build${{ inputs.request && format(' (request {0})', inputs.request) || '' }}
|
||||
# The Nightly, the only build Horizon ships since 2026-10-28 (docs/release-flow.md "Nightly"; Ludovic). When the batch
|
||||
# train decides (release.yml, with the commit as `base`), once a night, and when a trusted
|
||||
# reviewer asks (`/nightly 2640 2642`, nightly-command.yml), it builds main plus the pull requests the reviewers
|
||||
# picked, merged in order (tools/ci/nightly_set.py), or main alone with none picked. Main is its newest commit where
|
||||
# checks and parity-main passed (or the `base` input), as for a release, and no Nightly is published while a
|
||||
# release-blocker is open. It publishes the result as the `nightly` pre-release, which every launcher and
|
||||
# `horizon-server update` install. The previous nightly stays a week as nightly-<date>.
|
||||
#
|
||||
# Why the signing key never meets the union's code:
|
||||
# Why the signing key never meets the pull requests' code:
|
||||
# - `pick` merges the picked heads onto main with `git merge-tree --write-tree`: git objects only, no working tree,
|
||||
# so none of their files reaches the disk and no hook, filter or script of theirs runs. It refuses any pull
|
||||
# request that changes a path CODEOWNERS gives to Ludovic (this folder, tools/ci/, the signing and update code),
|
||||
# pushes the merge as nightly/build-<date>-<n> and saves the set on the nightly-state branch.
|
||||
# - This file, game-build.yml and every script below run from main's copy: a schedule runs the default branch, and
|
||||
# `uses: ./…` takes the caller's commit. The union's own .github/ is never read.
|
||||
# - `build` (game-build.yml) checks out and compiles the union with no secrets: its token reads contents only and
|
||||
# `uses: ./…` takes the caller's commit. A picked pull request's own .github/ is never read.
|
||||
# - `build` (game-build.yml, `trusted: false`) never reaches a release's tools: its macOS jobs run on the runner
|
||||
# that already runs pull requests' parity jobs, never the release runner; its Rust cache has its own key and is
|
||||
# never saved; and releases don't read the machine's shared sccache.
|
||||
# - `build` (game-build.yml) checks out and compiles the merge with no secrets: its token reads contents only and
|
||||
# isn't kept in the checkout; the macOS programs are signed ad hoc (Developer ID needs the runner's keychain,
|
||||
# which stays locked for nightlies).
|
||||
# - `sign` is the only job with RELEASE_SIGNING_KEY. It checks out nothing but main's tools/ci/sign_release.sh, which
|
||||
@@ -15,6 +26,9 @@ name: nightly build
|
||||
# names the channel ("horizon release nightly"), so a nightly's signature can't pass for a release's.
|
||||
# - `publish` verifies the signature with main's horizon-release-sign, checks the smoke test, and only then moves
|
||||
# the tag. Like sign, it never executes what build produced.
|
||||
# - The source ships with the build: `pick` writes horizon-source.tar.gz with `git archive` (git objects again, nothing
|
||||
# run), `sign` signs it with the builds, and `publish` attaches it and tags the commit nightly-source/<date>-<short>
|
||||
# for good (the dated release and the build branch go after 7 days; native mods pin the commit: docs/native-mods.md).
|
||||
# gh workflow run "nightly build" # now (skips when the pick is the same commit as last night's)
|
||||
# gh workflow run "nightly build" -f force=true # build and publish even if unchanged
|
||||
# gh workflow run "nightly build" -f dry_run=true # build and sign with a throwaway key, publish nothing
|
||||
@@ -31,22 +45,51 @@ on:
|
||||
description: Build and sign with a throwaway key, publish nothing
|
||||
type: boolean
|
||||
default: false
|
||||
issue:
|
||||
description: The issue or pull request whose `/nightly` comment asked for this build (answered there)
|
||||
type: string
|
||||
default: ''
|
||||
request:
|
||||
description: The command's id, in the run's name so it can link the run
|
||||
type: string
|
||||
default: ''
|
||||
prs:
|
||||
description: A dry run's own set, `2640@<head> 2642@<head>` (the saved set stays)
|
||||
type: string
|
||||
default: ''
|
||||
base:
|
||||
description: The commit of main to build on (default main's newest with green checks and parity-main)
|
||||
type: string
|
||||
default: ''
|
||||
urgent:
|
||||
description: Past the day's cap of 12 builds (the train, for a release-now pull request or a forced run)
|
||||
type: boolean
|
||||
default: false
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
# One build at a time. A newer request replaces one still waiting: it builds the set as it is by then, so no pick
|
||||
# is lost.
|
||||
group: nightly-build
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
pick:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write # nightly/build-<date>-<n> and the nightly-state branch (main is protected)
|
||||
issues: write # says in the thread when the picked pull requests conflict
|
||||
pull-requests: write
|
||||
actions: read # main's newest green checks and parity-main runs (the base)
|
||||
outputs:
|
||||
build: ${{ steps.pick.outputs.build }}
|
||||
sha: ${{ steps.pick.outputs.sha }}
|
||||
short: ${{ steps.pick.outputs.short }}
|
||||
source: ${{ steps.pick.outputs.source }}
|
||||
date: ${{ steps.pick.outputs.date }}
|
||||
version: ${{ steps.pick.outputs.version }}
|
||||
nightly: ${{ steps.pick.outputs.nightly }}
|
||||
source_tag: ${{ steps.pick.outputs.source_tag }}
|
||||
dry_run: ${{ steps.mode.outputs.dry_run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -60,9 +103,34 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
FORCE: ${{ (inputs.force || steps.mode.outputs.dry_run == 'true') && 'yes' || '' }}
|
||||
DRY_RUN: ${{ steps.mode.outputs.dry_run }}
|
||||
NIGHTLY_ISSUE: ${{ inputs.issue }}
|
||||
NIGHTLY_PRS: ${{ inputs.prs }}
|
||||
NIGHTLY_BASE: ${{ inputs.base }}
|
||||
NIGHTLY_URGENT: ${{ inputs.urgent }}
|
||||
run: |
|
||||
python3 tools/ci/nightly.py pick ${FORCE:+--force} | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
{ echo; cat nightly-notes.md; } >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Old nightly/build-* branches deleted (scheduled runs only)
|
||||
# Ludovic, 2026-10-27: older than 7 days, but never the newest build or one a published nightly points to;
|
||||
# nothing but nightly/build-* (nightly.py stale_builds, tests/test_nightly.py).
|
||||
if: github.event_name == 'schedule'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
{ echo "### nightly/build-* branches"; python3 tools/ci/nightly.py prune-builds --days 7; } | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
- name: The source of the build (horizon-source.tar.gz)
|
||||
if: steps.pick.outputs.build == 'true'
|
||||
env:
|
||||
SHA: ${{ steps.pick.outputs.sha }}
|
||||
run: git archive --format=tar.gz --prefix="horizon-${SHA:0:7}/" -o horizon-source.tar.gz "$SHA"
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: steps.pick.outputs.build == 'true'
|
||||
with:
|
||||
name: horizon-source
|
||||
path: horizon-source.tar.gz
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: nightly-notes
|
||||
@@ -78,6 +146,7 @@ jobs:
|
||||
version: ${{ needs.pick.outputs.version }}
|
||||
nightly: ${{ needs.pick.outputs.nightly }}
|
||||
developer_id: false
|
||||
trusted: false # it can hold unreviewed pull requests: their own cache key, never saved, not the release runner
|
||||
|
||||
sign:
|
||||
needs: [pick, build]
|
||||
@@ -103,7 +172,7 @@ jobs:
|
||||
EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }}
|
||||
run: |
|
||||
bash tools/ci/sign_release.sh nightly dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
|
||||
dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz
|
||||
dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz dist/horizon-source.tar.gz
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: signature
|
||||
@@ -123,6 +192,7 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: write
|
||||
issues: read # the open release-blocker issues (gate)
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -137,30 +207,72 @@ jobs:
|
||||
run: |
|
||||
key=(); [[ -n "$EPHEMERAL" ]] && key=(--key "$(cat dist/ephemeral.pub)")
|
||||
cargo run --locked --quiet -p horizon-release-sign -- verify --tag nightly --publishing "${key[@]}" dist
|
||||
- name: The smoke test drew a world
|
||||
# No release-blocker check: a nightly is how blockers get tested.
|
||||
run: python3 tools/release_train.py gate --kind game --smoke dist/smoke-test.json
|
||||
- name: Ready to publish (the smoke test drew a world, no release-blocker is open)
|
||||
# Right before publishing, as for a release: an issue labelled while the builds ran still holds it. A dry run
|
||||
# only reports what a real run would do (that is how a blocker's fix is tried).
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
DRY_RUN: ${{ needs.pick.outputs.dry_run }}
|
||||
run: |
|
||||
if ! python3 tools/release_train.py gate --kind game --smoke dist/smoke-test.json --blockers; then
|
||||
[[ "$DRY_RUN" == true ]] || { echo "::error::Nothing published"; exit 1; }
|
||||
echo "::warning::A real run would publish nothing"
|
||||
fi
|
||||
- name: Publish as the `nightly` pre-release
|
||||
if: needs.pick.outputs.dry_run != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
SHA: ${{ needs.pick.outputs.sha }}
|
||||
DATE: ${{ needs.pick.outputs.date }}
|
||||
SOURCE_TAG: ${{ needs.pick.outputs.source_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 tools/ci/nightly.py rotate
|
||||
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
|
||||
# A pre-release, never GitHub's latest: launchers on the release channel never see it.
|
||||
# A pre-release, never GitHub's latest: the latest stays the last vX.Y.Z, which launchers and servers from
|
||||
# before the Nightly-only switch keep (they don't read Nightlies).
|
||||
gh release create nightly --prerelease --latest=false --target "$SHA" \
|
||||
--title "Nightly $DATE · ${SHA:0:7}" --notes-file dist/nightly-notes.md \
|
||||
"dist/horizon-linux-x86_64.tar.gz#Nightly game files: Linux (horizon-linux-x86_64.tar.gz)" \
|
||||
"dist/horizon-windows-x64.zip#Nightly game files: Windows (horizon-windows-x64.zip)" \
|
||||
"dist/horizon-macos-arm64.zip#Nightly game files: macOS (horizon-macos-arm64.zip)" \
|
||||
"dist/horizon-server-linux-x86_64.tar.gz#Nightly dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
|
||||
"dist/horizon-source.tar.gz#Source of this Nightly, commit ${SHA:0:7} (horizon-source.tar.gz; kept for good as the tag $SOURCE_TAG)" \
|
||||
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
|
||||
python3 tools/ci/nightly.py keep-source "$SHA" "$DATE"
|
||||
python3 tools/ci/nightly.py prune --days 7
|
||||
- name: Dry run, the nightly that would be published
|
||||
if: needs.pick.outputs.dry_run == 'true'
|
||||
run: |
|
||||
echo "Would publish nightly at ${{ needs.pick.outputs.sha }} (${{ needs.pick.outputs.source }})"
|
||||
ls -l dist
|
||||
|
||||
answer:
|
||||
# Tells the thread that asked for this build how it ended. A conflict `pick` already explained there.
|
||||
needs: [pick, build, sign, publish]
|
||||
if: always() && inputs.issue != '' && needs.pick.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
ISSUE: ${{ inputs.issue }}
|
||||
BUILD: ${{ needs.pick.outputs.build }}
|
||||
PUBLISHED: ${{ needs.publish.result }}
|
||||
DRY_RUN: ${{ needs.pick.outputs.dry_run }}
|
||||
NAME: Nightly ${{ needs.pick.outputs.date }} · ${{ needs.pick.outputs.short }}
|
||||
RUN: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
if [[ "$BUILD" != true ]]; then
|
||||
text="nothing new: the published Nightly is already this build."
|
||||
elif [[ "$PUBLISHED" == success && "$DRY_RUN" == true ]]; then
|
||||
text="the dry run built and signed $NAME; nothing was published. $RUN"
|
||||
elif [[ "$PUBLISHED" == success ]]; then
|
||||
text="published $NAME: https://github.com/$GITHUB_REPOSITORY/releases/tag/nightly. Launchers install it at their next check."
|
||||
else
|
||||
text="the build failed, nothing was published: $RUN"
|
||||
fi
|
||||
gh api "repos/$GITHUB_REPOSITORY/issues/$ISSUE/comments" -f body="🤖 Nightly: $text"
|
||||
@@ -0,0 +1,46 @@
|
||||
name: nightly command
|
||||
# `/nightly …` comments: the trusted reviewers in .github/trusted-reviewers.txt (and Ludovic) pick the pull requests
|
||||
# the Nightly holds (docs/release-flow.md "Nightly channel", tools/ci/nightly_set.py). This job only checks who asked,
|
||||
# saves the set on the nightly-state branch, starts `nightly build` and answers in the thread.
|
||||
#
|
||||
# Why no pull request's code runs here, though the token can write:
|
||||
# - `issue_comment` always runs this file from main, whoever comments and wherever; a pull request's own copy of it
|
||||
# is never read.
|
||||
# - It checks out main and runs main's nightly_set.py. The comment's text and author reach it as env vars, never in
|
||||
# a shell line.
|
||||
# - It never checks out, fetches or merges a pull request: it reads their file lists through the API. The merge
|
||||
# happens in nightly-build.yml's `pick`, with git objects only; their code runs in its `build`, without secrets.
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
command:
|
||||
# Every other comment skips here, before a runner starts.
|
||||
if: startsWith(github.event.comment.body, '/nightly') && github.event.comment.user.type != 'Bot'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: write # the nightly-state branch (main is protected)
|
||||
actions: write # starts `nightly build`
|
||||
issues: write # answers in the thread
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
.github/CODEOWNERS
|
||||
.github/trusted-reviewers.txt
|
||||
tools/ci/nightly_set.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_URL: ${{ github.event.comment.html_url }}
|
||||
run: python3 tools/ci/nightly_set.py command
|
||||
+27
-138
@@ -1,22 +1,15 @@
|
||||
name: release
|
||||
# Automatic game releases (docs/release-flow.md). After every green `checks` run on main, every 10 minutes (so a
|
||||
# release waiting for merges to settle goes out without another merge) and by hand, tools/release_train.py decides,
|
||||
# for a commit whose checks and parity-main (the vanilla harnesses on main) both passed:
|
||||
# a release when 5 pull requests merged since the last one changed what players run and the newest merged 5 minutes
|
||||
# ago, or when the oldest has waited 6 hours, or at once for a pull request labelled release-now.
|
||||
# RELEASES_PAUSED=1 (repository variable) stops them; so does an open issue labelled release-blocker, forced runs too.
|
||||
# Builds Linux (an older Ubuntu, so the binary runs on any recent distribution and on NixOS through nix-ld) and
|
||||
# Windows on GitHub, macOS on the Mac mini's runner (MAC_RUNNER=macos-latest sends it to GitHub), then
|
||||
# publishes vX.Y.Z on the release line in .github/release-line.txt (docs/versioning.md; without it, the dated
|
||||
# vYYYY.MM.DD[.N] from before 0.2) with notes from the merged pull requests, as the release and GitHub's latest: one
|
||||
# channel, which launchers, `horizon-server update` and install.sh follow at once. So it is published only after the
|
||||
# checks that used to gate a promotion pass in this run: the Mac mini's smoke test drew a world, and no release-blocker
|
||||
# is open (tools/release_train.py gate); otherwise nothing is published. macOS: with the repository variable
|
||||
# MACOS_SIGN_IDENTITY set, the programs are signed with
|
||||
# Developer ID and the hardened runtime and the zip is notarized, from the runner's signing keychain
|
||||
# (packaging/macos/README.md); unset (forks), ad hoc.
|
||||
# gh workflow run release --ref <branch> # dry run: builds and notes, publishes nothing
|
||||
# gh workflow run release -f dry_run=false -f force=true # on main: release now, whatever the rules say
|
||||
# Automatic game releases (docs/release-flow.md). Horizon ships only the Nightly since 2026-10-28 (Ludovic): this
|
||||
# workflow is the batch train that decides when a new Nightly goes out, and `nightly build` (nightly-build.yml) builds,
|
||||
# signs and publishes it. After every green `checks` run on main, every 10 minutes (so a batch waiting for merges to
|
||||
# settle goes out without another merge) and by hand, tools/release_train.py decides, for a commit whose checks and
|
||||
# parity-main (the vanilla harnesses on main) both passed: a Nightly when 5 pull requests merged since the published
|
||||
# Nightly changed what players run and the newest merged 5 minutes ago, or when the oldest has waited 6 hours, or at
|
||||
# once for a pull request labelled release-now. RELEASES_PAUSED=1 (repository variable) stops it; so does an open issue
|
||||
# labelled release-blocker, forced runs too (nightly-build.yml checks again before publishing). One Nightly build at a
|
||||
# time, at most 12 a day (release-now and force=true pass the cap). No `vX.Y.Z` game release is made any more.
|
||||
# gh workflow run release --ref <branch> # dry run: decides, starts nothing
|
||||
# gh workflow run release -f dry_run=false -f force=true # on main: a Nightly now, whatever the rules say
|
||||
on:
|
||||
workflow_run:
|
||||
# A game release ships a commit both passed on (parity-main runs on batches: the newest commit it passed on).
|
||||
@@ -28,17 +21,19 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Build everything and write the notes, but publish nothing
|
||||
description: Decide only; start no Nightly build
|
||||
type: boolean
|
||||
default: true
|
||||
force:
|
||||
description: Release even without player-facing changes, before merges settle or while paused
|
||||
description: A Nightly even without player-facing changes, before merges settle, while paused or past the day's cap
|
||||
type: boolean
|
||||
default: false
|
||||
permissions:
|
||||
contents: read
|
||||
# The open release-blocker issues (decide).
|
||||
issues: read
|
||||
# The checks, parity-main and nightly build runs (decide).
|
||||
actions: read
|
||||
concurrency:
|
||||
# One release at a time. A hand-started dry run publishes nothing, so it can't race a real release: it gets a
|
||||
# group of its own. In the shared group, a release queued behind it replaced it as the pending run and GitHub
|
||||
@@ -54,9 +49,8 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
release: ${{ steps.decide.outputs.release }}
|
||||
tag: ${{ steps.decide.outputs.tag }}
|
||||
version: ${{ steps.decide.outputs.version }}
|
||||
head: ${{ steps.decide.outputs.head }}
|
||||
urgent: ${{ steps.decide.outputs.urgent }}
|
||||
dry_run: ${{ steps.mode.outputs.dry_run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -76,126 +70,21 @@ jobs:
|
||||
run: |
|
||||
python3 tools/release_train.py decide --kind game ${HEAD_SHA:+--head "$HEAD_SHA"} ${FORCE:+--force} | tee decision.json
|
||||
echo "### $(jq -r '.reason' decision.json)" >> "$GITHUB_STEP_SUMMARY"
|
||||
# The version the programs are built with (docs/versioning.md): the tag without its v.
|
||||
tag=$(jq -r '.tag' decision.json)
|
||||
echo "version=${tag#v}" >> "$GITHUB_OUTPUT"
|
||||
# A dry run builds whatever the decision, to test the builds.
|
||||
if [[ "${{ steps.mode.outputs.dry_run }}" == true ]]; then echo "release=true" >> "$GITHUB_OUTPUT"; fi
|
||||
|
||||
build:
|
||||
nightly:
|
||||
# The Nightly is the release: nightly-build.yml builds the decided commit plus the reviewers' picks, signs it for
|
||||
# the Nightly channel and publishes it once its smoke test passed and no release-blocker is open.
|
||||
needs: decide
|
||||
if: needs.decide.outputs.release == 'true'
|
||||
# Every platform's archive, and macOS's smoke test (game-build.yml).
|
||||
uses: ./.github/workflows/game-build.yml
|
||||
with:
|
||||
ref: ${{ needs.decide.outputs.head }}
|
||||
version: ${{ needs.decide.outputs.version }}
|
||||
|
||||
sign:
|
||||
needs: [decide, build]
|
||||
if: needs.decide.outputs.release == 'true' && needs.decide.outputs.dry_run != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
# The only job that holds the release signing key, a secret of the `release` environment, which only main's real
|
||||
# releases enter (dry runs sign with a throwaway key). It runs no code from the repository but
|
||||
# tools/ci/sign_release.sh, which calls only the runner's sha256sum and OpenSSL (docs/release-flow.md).
|
||||
environment: ${{ needs.decide.outputs.dry_run != 'true' && 'release' || '' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: tools/ci/sign_release.sh
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
pattern: horizon-*
|
||||
merge-multiple: true
|
||||
- name: Sign the checksums (SHA256SUMS, SHA256SUMS.sig)
|
||||
env:
|
||||
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
|
||||
RELEASE_SIGNING_KEY_NEXT: ${{ secrets.RELEASE_SIGNING_KEY_NEXT }}
|
||||
EPHEMERAL: ${{ needs.decide.outputs.dry_run == 'true' && 'yes' || '' }}
|
||||
TAG: ${{ needs.decide.outputs.tag }}
|
||||
run: |
|
||||
bash tools/ci/sign_release.sh "$TAG" dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
|
||||
dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: signature
|
||||
path: dist/SHA256SUMS*
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: needs.decide.outputs.dry_run == 'true'
|
||||
with:
|
||||
name: ephemeral.pub
|
||||
path: dist/ephemeral.pub
|
||||
retention-days: 3
|
||||
|
||||
publish:
|
||||
needs: [decide, build, sign]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: write
|
||||
issues: read
|
||||
actions: write # starts `nightly build`
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Notes
|
||||
env:
|
||||
- env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
HEAD_SHA: ${{ needs.decide.outputs.head }}
|
||||
URGENT: ${{ needs.decide.outputs.urgent }}
|
||||
run: |
|
||||
python3 tools/release_train.py notes --kind game --tag "${{ needs.decide.outputs.tag }}" \
|
||||
--head "${{ needs.decide.outputs.head }}" --out notes > /dev/null
|
||||
{ echo "## $(cat notes/title.txt)"; echo; cat notes/body.md; } >> "$GITHUB_STEP_SUMMARY"
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
- name: The signature, checked as launchers check it
|
||||
env:
|
||||
TAG: ${{ needs.decide.outputs.tag }}
|
||||
EPHEMERAL: ${{ needs.decide.outputs.dry_run == 'true' && 'yes' || '' }}
|
||||
run: |
|
||||
key=(); [[ -n "$EPHEMERAL" ]] && key=(--key "$(cat dist/ephemeral.pub)")
|
||||
cargo run --locked --quiet -p horizon-release-sign -- verify --tag "$TAG" --publishing "${key[@]}" dist
|
||||
- name: Ready to publish (the smoke test passed, no release-blocker is open)
|
||||
# Right before publishing: an issue labelled while the builds ran still holds this release. A dry run only
|
||||
# reports what a real run would do.
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
DRY_RUN: ${{ needs.decide.outputs.dry_run }}
|
||||
run: |
|
||||
if ! python3 tools/release_train.py gate --kind game --smoke dist/smoke-test.json --blockers; then
|
||||
[[ "$DRY_RUN" == true ]] || { echo "::error::Nothing published"; exit 1; }
|
||||
echo "::warning::A real run would publish nothing"
|
||||
fi
|
||||
- name: Publish ${{ needs.decide.outputs.tag }}
|
||||
if: needs.decide.outputs.dry_run != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.decide.outputs.tag }}
|
||||
run: |
|
||||
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
|
||||
# The release and the latest at once: there is one channel (docs/release-flow.md). Each archive gets a
|
||||
# label (file#label), which GitHub's page shows instead of the file name, so nobody takes the game's
|
||||
# files for the download (#1647). Launchers, servers and install.sh match the name, which stays.
|
||||
gh release create "$TAG" --target "${{ needs.decide.outputs.head }}" --title "$(cat notes/title.txt)" \
|
||||
--notes-file notes/body.md --latest \
|
||||
"dist/horizon-linux-x86_64.tar.gz#Game files the launcher installs: Linux (horizon-linux-x86_64.tar.gz)" \
|
||||
"dist/horizon-windows-x64.zip#Game files the launcher installs: Windows (horizon-windows-x64.zip)" \
|
||||
"dist/horizon-macos-arm64.zip#Game files the launcher installs: macOS (horizon-macos-arm64.zip)" \
|
||||
"dist/horizon-server-linux-x86_64.tar.gz#Dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
|
||||
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
|
||||
- name: Dry run, the release that would be published
|
||||
if: needs.decide.outputs.dry_run == 'true'
|
||||
run: |
|
||||
echo "Would publish ${{ needs.decide.outputs.tag }} at ${{ needs.decide.outputs.head }} as the release and latest: $(cat notes/title.txt)"
|
||||
ls -l dist
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-notes
|
||||
path: notes/
|
||||
retention-days: 3
|
||||
gh workflow run nightly-build.yml --repo "$GITHUB_REPOSITORY" --ref main -f base="$HEAD_SHA" -f urgent="$URGENT"
|
||||
echo "### Started \`nightly build\` on ${HEAD_SHA:0:9}" >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -5,11 +5,15 @@ name: run-parity
|
||||
# skipped `verify`, and GitHub counts a skipped required check as passed.
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
# A push starts a run only to cancel the old head's (see concurrency): the job runs on the label alone.
|
||||
types: [labeled, synchronize]
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: run-parity-${{ github.event.pull_request.number }}
|
||||
# The run-parity label and pushes share one group per pull request, so the newest run cancels the one before: a
|
||||
# pushed pull request's run tested a head the merge would no longer land. Any other label gets a group of its own:
|
||||
# in the shared one, adding needs-proof cancelled the parity run queued seconds before (run 40766180150).
|
||||
group: run-parity-${{ github.event.pull_request.number }}${{ github.event.action == 'labeled' && github.event.label.name != 'run-parity' && format('-{0}', github.run_id) || '' }}
|
||||
cancel-in-progress: true
|
||||
env:
|
||||
# Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md).
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
name: triage command
|
||||
# `/close`, `/reopen`, `/label`, `/unlabel` comments: the trusted reviewers in .github/trusted-reviewers.txt (and
|
||||
# Ludovic) triage issues and pull requests without any repository role (docs/release-flow.md "Trusted reviewers",
|
||||
# tools/ci/triage_command.py).
|
||||
#
|
||||
# Why no pull request's code runs here, though the token can write:
|
||||
# - `issue_comment` always runs 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.
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
command:
|
||||
# Every other comment skips here, before a runner starts.
|
||||
if: >-
|
||||
github.event.comment.user.type != 'Bot' && (
|
||||
startsWith(github.event.comment.body, '/close') ||
|
||||
startsWith(github.event.comment.body, '/reopen') ||
|
||||
startsWith(github.event.comment.body, '/label') ||
|
||||
startsWith(github.event.comment.body, '/unlabel'))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: 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/triage_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 }}
|
||||
REPO: ${{ github.repository }}
|
||||
COMMENT_URL: ${{ github.event.comment.html_url }}
|
||||
run: python3 tools/ci/triage_command.py command
|
||||
@@ -35,9 +35,12 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
|
||||
| Crate | Owns |
|
||||
|---|---|
|
||||
| `horizon-client` | The game: a lib (`src/lib.rs`, ~180 flat modules) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
|
||||
| `horizon-client` | The game: a lib (`src/lib.rs`, ~100 flat modules: gameplay, networking, title screens) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
|
||||
| `horizon-items` | Blocks and items below gameplay: `blocks.rs` (block facts), `items.rs`, `item_data.rs` (components), `enchant.rs`, `cooldowns.rs`, `bundle.rs`, `item_nbt.rs`. Re-exported as `horizon_client::items`... |
|
||||
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
|
||||
| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) |
|
||||
| `horizon-client-input` | Key bindings and key names, toggle keys, mouse options, controller settings and glyphs, the clipboard, window options (`keybinds.rs`, `gamepad.rs`, `mouse.rs`, `window.rs`...). Re-exported under the old paths |
|
||||
| `horizon-client-render` | The client's world data, rendering and screens (chunks, meshing, light, streaming, `gfx`, HUD, menus, settings): ~80 modules that horizon-client re-exports under the same paths. Nothing in it reaches gameplay |
|
||||
| `horizon-world` | Vanilla world data with no gameplay loop: NBT, regions, chunks, `datafix/`, `worldgen/`, `level/` (server-side block behaviour, ticks, redstone), entity physics, items, effects, food, damage |
|
||||
| `horizon-core` | Simulation primitives: Java `Random`s, `jmath` (HotSpot-exact `Math`), positions, noise, density, paletted storage, `version.rs` (the pinned version) |
|
||||
| `horizon-protocol` | Network protocol 777: codecs, framing, encryption, `packets/{handshake,status,login,configuration,play}.rs`, `entity_data.rs` |
|
||||
@@ -57,7 +60,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
|
||||
Other top-level folders: `tools/` (harnesses and dev scripts), `tests/` (Python unit tests: `python3 -m
|
||||
unittest discover -s tests`), `mods/horizon/src/test/java/dev/horizonlab/parity/` (Java oracles),
|
||||
`docs/`, `.github/workflows/`. The website (playhorizon.dev) is the private ludovic111/horizon-site: its build
|
||||
`docs/`, `.github/workflows/`. The website (playhorizon.dev) is the private horizon-rust/horizon-site: its build
|
||||
reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` and `crates/horizon-theme/tokens.css` from main.
|
||||
|
||||
## Build and test fast
|
||||
@@ -97,6 +100,9 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
|
||||
## Vanilla reference
|
||||
|
||||
- Start parity work from the port ledger, not grep: `python3 tools/port_ledger.py issue N` (or `where
|
||||
Class.method`, `what file:line`), and `check --base origin/main` before the PR. How:
|
||||
[.claude/skills/vanilla-parity/SKILL.md](.claude/skills/vanilla-parity/SKILL.md), [docs/port-ledger.md](docs/port-ledger.md).
|
||||
- Decompiled sources: `.local/minecraft/26.3/sources/{client,server}/` (`python3 tools/lab.py decompile`).
|
||||
- Bytecode queries (callers through lambdas, every overload):
|
||||
`python3 tools/rea_vanilla.py method net.minecraft.world.level.block.DispenserBlock dispenseFrom`
|
||||
@@ -108,19 +114,20 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
|
||||
## horizon-client: where things live
|
||||
|
||||
`L:` = lib (`crates/horizon-client/src/`), `G:` = bin (`crates/horizon-client/src/game/`). The lib can't see
|
||||
`L:` = lib (`crates/horizon-client/src/`, or `crates/horizon-client-render/src/` for the modules that crate holds, under
|
||||
the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/src/game/`). The lib can't see
|
||||
`Game`; bin code reaches the lib as `horizon_client::<module>`.
|
||||
|
||||
| Subsystem | Files |
|
||||
|---|---|
|
||||
| Game loop, tick, frame | `game.rs`: `fn run`, `impl ApplicationHandler for Game` (`window_event`, `about_to_wait`), `fn tick`, `fn tick_world`, `fn render`; L: `clock.rs`, `window.rs`, `spans.rs` |
|
||||
| Game loop, tick, frame | `game.rs`: `fn run`, `impl ApplicationHandler for Game` (`window_event`, `about_to_wait`), `fn tick`, `fn tick_world`, `fn render`; L: `clock.rs`, `spans.rs`; `horizon-client-input/src/window.rs` |
|
||||
| Rendering | L: `gfx.rs` (`Renderer::new`, `Renderer::render`), `gfx/` (cull, occlusion, translucency, upload, timing, lod, weather, stars, end_sky), `*.wgsl`, `particles.rs`, `block_entity_render.rs`, `item_render.rs`, `fog.rs`, `clouds.rs`, `camera.rs`, `first_person.rs`; G: `render_tick.rs`, `item_draw.rs` |
|
||||
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `ticket_levels.rs`; G: `world_loading.rs`, `postprocess.rs`; game.rs `fn update_world` |
|
||||
| World generation | `crates/horizon-world/src/worldgen/` (`generator*.rs`, `feature_graph.rs`, `structure/`, `carver/`) |
|
||||
| Block behaviour | `crates/horizon-world/src/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `redstone.rs`, `place.rs`, `interact.rs`); game.rs `fn tick_blocks`, `fn process_level_events` |
|
||||
| Mobs | L: `mobs.rs` (types), `mobs/` (`ai.rs`, `goals.rs`, `brain.rs`, `*_brain.rs`, `nav.rs`, `path.rs`, `species.rs`, `spawning.rs`, `render.rs`, `anim.rs`); G: `creatures.rs` (ticking, spawning, mob events), one file per mob family (`dragon.rs`, `wither.rs`, `villagers.rs`, `piglins.rs`, `golems.rs`...), `natural_spawning.rs` |
|
||||
| Other entities | L: `entities.rs`, `projectiles.rs`, `arrows.rs`; G: `boats.rs`, `minecarts.rs`, `mounts.rs`, `armor_stands.rs`, `paintings.rs`, `frames.rs`, `fireworks.rs`, `tnt.rs`, `explosion.rs` |
|
||||
| Player actions | game.rs (`fn use_hand`, `fn use_item`, `fn attack`, `fn tick_mining`, `fn break_block`); G: `hand.rs`, `combat.rs`, `consume.rs`, `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`, `items.rs`, `item_data.rs` |
|
||||
| Player actions | game.rs (`fn use_hand`, `fn use_item`, `fn attack`, `fn tick_mining`, `fn break_block`); G: `hand.rs`, `combat.rs`, `consume.rs`, `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`; crate `horizon-items`: `items.rs`, `item_data.rs`, `enchant.rs`, `blocks.rs` |
|
||||
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs` |
|
||||
| Commands | parse: crate `horizon-command` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`; `horizon-client-core`: `nbt_path.rs`, `scoreboard.rs`; G: `functions.rs`, `command_blocks.rs` |
|
||||
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs` |
|
||||
@@ -129,7 +136,7 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
| Integrated server | G: `integrated.rs` (the server thread), `levels.rs` (one level per dimension, `fn tick_server`), `server_travel.rs` |
|
||||
| Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server` |
|
||||
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
|
||||
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; L: `keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `osk.rs`, `clipboard.rs`; G: `pad.rs`, `pad_menu.rs` |
|
||||
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs` |
|
||||
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
|
||||
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`; `horizon-client-core/src/lang.rs`; crate `horizon-assets`; G: `packs.rs` |
|
||||
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
|
||||
|
||||
+4
-1
@@ -86,6 +86,9 @@ configuration and toolchain changes select the whole workspace; CI also keeps it
|
||||
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
|
||||
generation, the launcher's updates) wait for a local proof first ([merging](docs/release-flow.md#merging)).
|
||||
|
||||
Every merge to main is released automatically ([release flow](docs/release-flow.md)), so main always stays playable.
|
||||
How agents, checks, releases and signed updates keep this safe: [security](docs/security.md).
|
||||
|
||||
@@ -98,5 +101,5 @@ new pushes, we add it again.
|
||||
|
||||
## Talk to us
|
||||
|
||||
[Discord](https://discord.gg/HcTeMZxe5E) for questions and ideas, [issues](https://github.com/ludovic111/horizon/issues)
|
||||
[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.
|
||||
Generated
+110
-8
@@ -876,6 +876,12 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
||||
|
||||
[[package]]
|
||||
name = "dunce"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.19.0"
|
||||
@@ -1370,21 +1376,22 @@ name = "horizon-client"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"arboard",
|
||||
"bytemuck",
|
||||
"chrono",
|
||||
"cpal",
|
||||
"flate2",
|
||||
"gilrs",
|
||||
"glam",
|
||||
"horizon-assets",
|
||||
"horizon-bench",
|
||||
"horizon-client-core",
|
||||
"horizon-client-input",
|
||||
"horizon-client-render",
|
||||
"horizon-command",
|
||||
"horizon-core",
|
||||
"horizon-dualsense",
|
||||
"horizon-friends",
|
||||
"horizon-hdr",
|
||||
"horizon-items",
|
||||
"horizon-mods",
|
||||
"horizon-protocol",
|
||||
"horizon-server-files",
|
||||
@@ -1397,6 +1404,7 @@ dependencies = [
|
||||
"log",
|
||||
"miniz_oxide 0.8.9",
|
||||
"naga",
|
||||
"open",
|
||||
"png",
|
||||
"pollster",
|
||||
"rustix 1.1.5",
|
||||
@@ -1404,7 +1412,6 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"symphonia",
|
||||
"unicode-bidi",
|
||||
"ureq",
|
||||
"wgpu",
|
||||
@@ -1421,6 +1428,60 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-client-input"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"horizon-client-core",
|
||||
"horizon-dualsense",
|
||||
"horizon-theme",
|
||||
"png",
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-client-render"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"bytemuck",
|
||||
"chrono",
|
||||
"cpal",
|
||||
"flate2",
|
||||
"glam",
|
||||
"horizon-assets",
|
||||
"horizon-bench",
|
||||
"horizon-client-core",
|
||||
"horizon-client-input",
|
||||
"horizon-command",
|
||||
"horizon-core",
|
||||
"horizon-hdr",
|
||||
"horizon-items",
|
||||
"horizon-protocol",
|
||||
"horizon-shader-api",
|
||||
"horizon-shaders",
|
||||
"horizon-telemetry",
|
||||
"horizon-theme",
|
||||
"horizon-threads",
|
||||
"horizon-world",
|
||||
"log",
|
||||
"miniz_oxide 0.8.9",
|
||||
"naga",
|
||||
"png",
|
||||
"pollster",
|
||||
"rustix 1.1.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"symphonia",
|
||||
"unicode-bidi",
|
||||
"ureq",
|
||||
"wgpu",
|
||||
"winit",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-command"
|
||||
version = "0.1.0"
|
||||
@@ -1469,6 +1530,17 @@ dependencies = [
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-items"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-client-core",
|
||||
"horizon-core",
|
||||
"horizon-world",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mod-sdk"
|
||||
version = "0.1.0"
|
||||
@@ -1853,6 +1925,25 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-docker"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-wsl"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
|
||||
dependencies = [
|
||||
"is-docker",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
@@ -2667,6 +2758,17 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8d8c8b8e725ad26404d6896eb2f064294a0a7b4f1d9120210d85544684c4ca95"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"is-wsl",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "orbclient"
|
||||
version = "0.3.55"
|
||||
@@ -3182,9 +3284,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
version = "0.103.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -4362,14 +4464,14 @@ version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.9",
|
||||
"webpki-roots 1.0.10",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.9"
|
||||
version = "1.0.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||
checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
+7
-2
@@ -8,7 +8,10 @@ members = [
|
||||
"crates/horizon-world",
|
||||
"crates/horizon-client",
|
||||
"crates/horizon-client-core",
|
||||
"crates/horizon-client-input",
|
||||
"crates/horizon-client-render",
|
||||
"crates/horizon-command",
|
||||
"crates/horizon-items",
|
||||
"crates/horizon-server-files",
|
||||
"crates/horizon-server",
|
||||
"crates/horizon-bots",
|
||||
@@ -59,8 +62,10 @@ large_enum_variant = "allow"
|
||||
# wgpu-hal 30.0.1 with gfx-rs/wgpu#10124: wgpu 30 creates a Vulkan descriptor
|
||||
# pool with no pool sizes while it creates the device, which Vulkan allows only
|
||||
# since 1.3.215, and Intel's Windows driver for 6th-10th gen iGPUs (HD 520/530,
|
||||
# UHD 620/630) crashes on it with an access violation (#746). Drop this and the
|
||||
# folder once a wgpu release has the fix.
|
||||
# UHD 620/630) crashes on it with an access violation (#746). It also fixes the
|
||||
# Metal backend's lookup of the CoreGraphics HDR colour space names, which handed
|
||||
# CoreGraphics a symbol's address as the string and crashed HDR output on macOS.
|
||||
# Drop this and the folder once a wgpu release has both fixes.
|
||||
[patch.crates-io]
|
||||
wgpu-hal = { path = "third_party/wgpu-hal" }
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Ludovic
|
||||
Copyright (c) 2026 Ludovic Marie and the Horizon contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<p align="center">
|
||||
<img src="docs/media/banner.webp" alt="Horizon: Minecraft: Java Edition 26.3, rewritten in Rust and checked against vanilla by 281 differential harnesses" width="100%">
|
||||
<img src="docs/media/banner.webp" alt="Horizon: Minecraft: Java Edition 26.3, rewritten in Rust and checked against vanilla by more than 300 differential harnesses" width="100%">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/ludovic111/horizon/actions/workflows/ci.yml"><img src="https://github.com/ludovic111/horizon/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>
|
||||
<a href="https://github.com/horizon-rust/horizon/actions/workflows/ci.yml"><img src="https://github.com/horizon-rust/horizon/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>
|
||||
<img src="https://img.shields.io/badge/Minecraft-Java%2026.3-5D9C3F" alt="Minecraft Java 26.3">
|
||||
<img src="https://img.shields.io/badge/Rust-stable-B7410E?logo=rust&logoColor=white" alt="Rust">
|
||||
<img src="https://img.shields.io/badge/vanilla%20parity-100%25-F0795A" alt="Vanilla parity 100%">
|
||||
@@ -15,7 +15,7 @@
|
||||
<a href="https://playhorizon.dev"><b>Website</b></a> ·
|
||||
<a href="#download"><b>Download</b></a> ·
|
||||
<a href="https://playhorizon.dev/docs/"><b>Documentation</b></a> ·
|
||||
<a href="https://github.com/ludovic111/horizon/releases"><b>Release notes</b></a> ·
|
||||
<a href="https://github.com/horizon-rust/horizon/releases"><b>Release notes</b></a> ·
|
||||
<a href="ROADMAP.md"><b>Roadmap</b></a> ·
|
||||
<a href="#project-status"><b>Project status</b></a> ·
|
||||
<a href="#contributing"><b>Contributing</b></a>
|
||||
@@ -23,15 +23,15 @@
|
||||
|
||||
# Horizon
|
||||
|
||||
Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that reads the same worlds, follows the same rules and generates the same terrain as the Java game, and is checked against it by 281 differential harnesses. You play it through a small launcher, on the files from your own copy of Minecraft. It is free, open source and unofficial.
|
||||
Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that reads the same worlds, follows the same rules and generates the same terrain as the Java game, and is checked against it by more than 300 differential harnesses. You play it through a small launcher, on the files from your own copy of Minecraft. It is free, open source and unofficial.
|
||||
|
||||
> Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft. Horizon contains no Minecraft code, assets or data: you need your own legally obtained copy of Minecraft: Java Edition 26.3.
|
||||
|
||||
## Download
|
||||
|
||||
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/ludovic111/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs and updates the game, prepares its files from your own copy of Minecraft, and updates itself.
|
||||
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs the game and keeps it up to date with each new build (Horizon ships on one channel, the Nightly), prepares its files from your copy of Minecraft (your own install, or downloaded from Mojang for your account), and updates itself.
|
||||
|
||||
**You need** Minecraft: Java Edition 26.3, played once (the official launcher, Prism, MultiMC and other launchers all work); a Java 25 runtime, for the one-time preparation; and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
|
||||
**You need** a Microsoft account that owns Minecraft: Java Edition (the launcher signs in with it, as other launchers do; see [Microsoft account](docs/microsoft-account.md)) and a GPU with Vulkan or OpenGL 3.3 on Linux, Vulkan or DirectX 12 on Windows, or any Apple Silicon Mac. Nothing else: with no Minecraft on your computer, Play downloads 26.3 and the Java it needs from Mojang for your account, as the official launcher does, then prepares it once. An install you already have (the official launcher, Prism, MultiMC and other launchers all work) is used instead. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
@@ -47,8 +47,8 @@ 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.
|
||||
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining offline-mode servers. Horizon cannot sign in to a Microsoft account yet, so it cannot join servers that check accounts. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
|
||||
- **Mods:** Horizon has its own mod system (Rust compiled to WebAssembly). Java mod jars do not run. See [Mods and data packs](https://playhorizon.dev/docs/mods).
|
||||
- **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).
|
||||
|
||||
Screenshots, captured in the Rust client itself with vanilla assets and no mods or shaders:
|
||||
|
||||
@@ -93,13 +93,13 @@ Commands block until the action is done and reply with what happened; errors are
|
||||
|
||||
For developers. Requirements: Rust 1.90 or newer, Python 3.10 or newer, JDK 25, and on Linux the ALSA and udev development headers (`libasound2-dev libudev-dev` on Debian and Ubuntu). Development and the vanilla harnesses run on Linux.
|
||||
|
||||
Horizon contains no game files and never downloads them. It builds its resources from **your own copy** of Minecraft: Java Edition 26.3, verified against the checksums in `research-lock.json`:
|
||||
Horizon contains no game files, and `horizon-prepare` never downloads them (the launcher downloads them from Mojang only for a signed-in account that owns the game). It builds its resources from **your own copy** of Minecraft: Java Edition 26.3, verified against the checksums in `research-lock.json`:
|
||||
|
||||
1. **Client:** install Java Edition with the official launcher and play 26.3 once. Horizon reads `versions/26.3/`, `libraries/` and `assets/` from the launcher's game directory (`~/.minecraft` on Linux). If yours lives elsewhere, pass `--minecraft-dir` or set `HORIZON_MINECRAFT_DIR`.
|
||||
2. **Server jar:** the 26.3 server jar, from the `downloads.server.url` in your launcher's `versions/26.3/26.3.json` (or [minecraft.net/download/server](https://www.minecraft.net/en-us/download/server) while 26.3 is the current release). Pass it with `--server-jar` or set `HORIZON_SERVER_JAR`. It is only needed the first time.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/ludovic111/horizon.git
|
||||
git clone https://github.com/horizon-rust/horizon.git
|
||||
cd horizon
|
||||
cargo run --release -p horizon-prepare -- --prepare --server-jar ~/Downloads/server.jar # needs JDK 25
|
||||
cargo run --release -p horizon-prepare -- sounds # optional: sounds from your launcher install
|
||||
@@ -139,6 +139,11 @@ average; `tests/test_parity.py` keeps it in sync with this table. Last audited
|
||||
rerun whenever one of its inputs changed, and the Mac mini now runs every one of
|
||||
them too (Rust uses Java's own float maths on each platform).
|
||||
|
||||
The harnesses measure behaviour. A second measure is method by method: the [port ledger](tools/port_ledger.py)
|
||||
lists every method of vanilla 26.3 next to the Rust functions that port it, with a verdict for each one
|
||||
(ported, folded into another function, missing, or needing no port), and the untested ports. Run
|
||||
`python3 tools/port_ledger.py build`, then `coverage`, with the vanilla data in `.local/`.
|
||||
|
||||
| Subsystem | Score | Main gaps |
|
||||
| --- | ---: | --- |
|
||||
| Window, input and game loop | 1 | None: the game loop, options, camera, auto-jump, toggle sprint/sneak, local input and the frame limiter match vanilla |
|
||||
@@ -158,7 +163,7 @@ them too (Rust uses Java's own float maths on each platform).
|
||||
|
||||
### Known issues
|
||||
|
||||
Horizon can't sign in to a Microsoft account yet, so it can't join online-mode servers (it joins offline-mode ones); skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; the creative hotbar keys (C/X) aren't wired to the hotbar loader; item tooltips lack attribute lines.
|
||||
Skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; the creative hotbar keys (C/X) aren't wired to the hotbar loader; item tooltips lack attribute lines.
|
||||
|
||||
## Licence
|
||||
|
||||
|
||||
+173
-92
@@ -16,7 +16,7 @@ Horizon is Minecraft: Java Edition 26.3 rewritten from scratch in Rust, so it ru
|
||||
- **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.
|
||||
|
||||
**Next**: Microsoft sign-in (coming soon); 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); Lua and other languages for mods; 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.
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -32,7 +32,7 @@ Making Horizon faster than vanilla on every computer, with smooth camera movemen
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Frame pacing at render distance 32 ([#678](https://github.com/ludovic111/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/ludovic111/horizon/issues/1760)), the worker pool leaves one core as vanilla does ([#1778](https://github.com/ludovic111/horizon/issues/1778)), and the terrain vertex arena is read as storage where flat draws run ([#1742](https://github.com/ludovic111/horizon/issues/1742)). Still open: far terrain at 48 to 64 chunks ([#718](https://github.com/ludovic111/horizon/issues/718)) and CPU-bound fields of block entities ([#717](https://github.com/ludovic111/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: 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)).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -42,7 +42,7 @@ Speeding up the code that reads and writes the blocks of the world, which almost
|
||||
|
||||
<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/ludovic111/horizon/issues/1790), [#1794](https://github.com/ludovic111/horizon/issues/1794)); meshing looks up baked models by index, not by name ([#1747](https://github.com/ludovic111/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)).
|
||||
`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>
|
||||
|
||||
@@ -54,7 +54,7 @@ Making the world-generation mods of popular modpacks (new structures, YUNG's dun
|
||||
|
||||
<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/ludovic111/horizon/issues/1746)), Lithostitched's limited and guaranteed elements with pool and processor modifiers ([#1777](https://github.com/ludovic111/horizon/issues/1777)), its processors as the game's own types ([#1765](https://github.com/ludovic111/horizon/issues/1765)). Proved by the structure blocks and layout harnesses ([#1753](https://github.com/ludovic111/horizon/issues/1753), [#1757](https://github.com/ludovic111/horizon/issues/1757)). Mods whose Java code does more than world generation don't run ([docs/modpacks.md](docs/modpacks.md)).
|
||||
`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>
|
||||
|
||||
@@ -66,7 +66,7 @@ When you play on a server, other players, mobs, items and menus look and behave
|
||||
|
||||
<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/ludovic111/horizon/issues/1802), [#1798](https://github.com/ludovic111/horizon/issues/1798)), ghost items after a remote menu ([#1795](https://github.com/ludovic111/horizon/issues/1795)), skins ([#1652](https://github.com/ludovic111/horizon/issues/1652)), cookies and transfer packets for proxy networks ([#1650](https://github.com/ludovic111/horizon/issues/1650)), firework boosts ([#1671](https://github.com/ludovic111/horizon/issues/1671)), living-entity attributes and simulation distance ([#1731](https://github.com/ludovic111/horizon/issues/1731)), styled item names ([#1702](https://github.com/ludovic111/horizon/issues/1702)), primed TNT between updates ([#1710](https://github.com/ludovic111/horizon/issues/1710)).
|
||||
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>
|
||||
|
||||
@@ -76,7 +76,7 @@ Servers that show dialogs, such as the register and login screens of AuthMe, wil
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
26.3's dialog packets and model ([#1720](https://github.com/ludovic111/horizon/issues/1720)), the dialog screen with its buttons and actions ([#1734](https://github.com/ludovic111/horizon/issues/1734)), then text and boolean inputs ([#1735](https://github.com/ludovic111/horizon/issues/1735)).
|
||||
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>
|
||||
|
||||
@@ -86,7 +86,7 @@ A /pregen command that builds an area of the world ahead of time, so players don
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Generates, lights and saves an area before anyone walks there ([#1805](https://github.com/ludovic111/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'.
|
||||
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'.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -98,43 +98,55 @@ One place for mods, modpacks and resource packs made for Horizon: browse them on
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Merged: the registry index (schema 1, `.hpack`, checks before install, [#1634](https://github.com/ludovic111/horizon/issues/1634)), `cargo horizon-mod check` ([#1642](https://github.com/ludovic111/horizon/issues/1642)) and the submission service (`crates/horizon-mod-registry`, [#1659](https://github.com/ludovic111/horizon/issues/1659)). In review: the website's /mods ([#1596](https://github.com/ludovic111/horizon/issues/1596)) and /mods/submit ([#1635](https://github.com/ludovic111/horizon/issues/1635)), and in the launcher the registry as a source ([#1636](https://github.com/ludovic111/horizon/issues/1636)), install, enable and takedowns ([#1637](https://github.com/ludovic111/horizon/issues/1637)), the Mods page ([#1638](https://github.com/ludovic111/horizon/issues/1638)) and modpacks as their own profiles ([#1640](https://github.com/ludovic111/horizon/issues/1640)).
|
||||
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)).
|
||||
|
||||
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>
|
||||
|
||||
API 0.3, experimental first ([#1833](https://github.com/ludovic111/horizon/issues/1833), in review): the same sandboxed component runs on the server thread and the dedicated server, with world events (block broken or placed, entity spawned or killed, player joined or left), game rules and server commands with typed arguments. The world, item and entity interfaces come after (see 'Mods that read and change the world'). [docs/api/mods.md](docs/api/mods.md), [docs/mods.md](docs/mods.md).
|
||||
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 and react to keys and item use, so bigger mods, such as the Orbital Laser, can run on the normal game.
|
||||
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>
|
||||
|
||||
Capability `ui` is in review ([#1864](https://github.com/ludovic111/horizon/issues/1864)): mod-owned screens and a settings page under Options > Mods, built from vanilla's widgets. Still to design: `input` (an item's use, key binds, the crosshair target), `render` (a budgeted draw list on the native shader API) and `world` (changing blocks). Orbital Laser is the acceptance test: it needs all four today through its author's fork (see 'Orbital Laser on the normal game').
|
||||
|
||||
More: [docs/mods.md](docs/mods.md)
|
||||
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: a test runner that needs no game window, templates for each kind of mod, and clear promises about what keeps working from one version to the next.
|
||||
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>
|
||||
|
||||
`cargo horizon-mod test` runs a mod's test scripts against a fake game snapshot ([#1816](https://github.com/ludovic111/horizon/issues/1816)) and, for server mods, scripted server events ([#1837](https://github.com/ludovic111/horizon/issues/1837)); the content template and the guides are merged ([#1859](https://github.com/ludovic111/horizon/issues/1859), [#1810](https://github.com/ludovic111/horizon/issues/1810), [docs/api/stability.md](docs/api/stability.md)). Left: templates for hud, server and machine mods, typed wrappers in the SDK, a frozen 0.1 compatibility mod that every runtime must load, a CI check that fails when a stable reference entry is removed without a new major version, and the SDK on crates.io once the API is stable.
|
||||
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: [https://playhorizon.dev/docs/test-a-mod](https://playhorizon.dev/docs/test-a-mod)
|
||||
More: [#3388](https://github.com/horizon-rust/horizon/issues/3388) · [docs/native-mods.md](docs/native-mods.md)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -146,7 +158,7 @@ Single player now runs a small server in the background, as vanilla does. We are
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Since [#1514](https://github.com/ludovic111/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/ludovic111/horizon/issues/1797)), potions keep their potion over the connection ([#1796](https://github.com/ludovic111/horizon/issues/1796)), mobs' aggression and arm swings ([#1802](https://github.com/ludovic111/horizon/issues/1802)). [docs/server-levels.md](docs/server-levels.md).
|
||||
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>
|
||||
|
||||
@@ -156,7 +168,7 @@ Item names, container titles and server messages follow the language you pick, a
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Translatable components resolved through the selected language for items, containers and server text ([#1806](https://github.com/ludovic111/horizon/issues/1806)), on top of the bundled languages the prepared resources carry ([#1594](https://github.com/ludovic111/horizon/issues/1594)). Left: the client's own screens ([#1672](https://github.com/ludovic111/horizon/issues/1672)) and the main menu with zh_cn ([#1774](https://github.com/ludovic111/horizon/issues/1774)).
|
||||
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>
|
||||
|
||||
@@ -166,9 +178,9 @@ After you throw an ender pearl, its slot should show the cooldown sweeping away,
|
||||
|
||||
<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/ludovic111/horizon/issues/1763).
|
||||
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/ludovic111/horizon/issues/1933)
|
||||
More: [#1933](https://github.com/horizon-rust/horizon/issues/1933)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -178,9 +190,9 @@ The Hidden and Commands Only chat settings should hide chat, and team prefixes s
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Vanilla checks `ChatVisiblity` before it delivers chat and system messages ([#1376](https://github.com/ludovic111/horizon/issues/1376)), and chat uses the team-formatted display name ([#1378](https://github.com/ludovic111/horizon/issues/1378)). Both issues have exact steps to reproduce.
|
||||
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/ludovic111/horizon/issues/1932) · [#1841](https://github.com/ludovic111/horizon/issues/1841)
|
||||
More: [#1932](https://github.com/horizon-rust/horizon/issues/1932) · [#1841](https://github.com/horizon-rust/horizon/issues/1841)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -190,9 +202,9 @@ The mouse pointer should turn into a pointing hand over menu buttons, as in vani
|
||||
|
||||
<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/ludovic111/horizon/issues/1347).
|
||||
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/ludovic111/horizon/issues/1897)
|
||||
More: [#1897](https://github.com/horizon-rust/horizon/issues/1897)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -202,9 +214,9 @@ Ctrl+Backspace should delete a whole word in the creative search, and every list
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`EditBox` deletes by word with Ctrl ([#1764](https://github.com/ludovic111/horizon/issues/1764)); the Game Rules and Statistics lists' scroll bars can't be dragged ([#1782](https://github.com/ludovic111/horizon/issues/1782), after [#978](https://github.com/ludovic111/horizon/issues/978)).
|
||||
`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/ludovic111/horizon/issues/1884) · [#1931](https://github.com/ludovic111/horizon/issues/1931)
|
||||
More: [#1884](https://github.com/horizon-rust/horizon/issues/1884) · [#1931](https://github.com/horizon-rust/horizon/issues/1931)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -214,9 +226,9 @@ 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/ludovic111/horizon/issues/1706).
|
||||
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/ludovic111/horizon/issues/1892)
|
||||
More: [#1892](https://github.com/horizon-rust/horizon/issues/1892)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -228,9 +240,9 @@ Shader packs were removed and are being rebuilt from scratch. They come back, of
|
||||
|
||||
<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/ludovic111/horizon/issues/1584), [#1585](https://github.com/ludovic111/horizon/issues/1585), [#1586](https://github.com/ludovic111/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/ludovic111/horizon/issues/1698) to [#1801](https://github.com/ludovic111/horizon/issues/1801)).
|
||||
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/ludovic111/horizon/issues/1561) · [#1771](https://github.com/ludovic111/horizon/issues/1771) · [#1786](https://github.com/ludovic111/horizon/issues/1786)
|
||||
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>
|
||||
|
||||
@@ -240,7 +252,21 @@ Horizon's own way to make shader packs, with tools to write and test them. Every
|
||||
|
||||
<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/ludovic111/horizon/issues/1820)) and the tool, templates and guide ([#1822](https://github.com/ludovic111/horizon/issues/1822)). In review: the post stage running in the game with hot reload ([#1861](https://github.com/ludovic111/horizon/issues/1861)), the sky stage ([#1863](https://github.com/ludovic111/horizon/issues/1863)) and registry checks that use the game's own check ([#1855](https://github.com/ludovic111/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'.
|
||||
`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'.
|
||||
|
||||
</details>
|
||||
|
||||
### Launcher
|
||||
|
||||
#### Minecraft Java instances in the launcher
|
||||
|
||||
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.
|
||||
|
||||
<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.
|
||||
|
||||
More: [#3506](https://github.com/horizon-rust/horizon/issues/3506)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -252,7 +278,7 @@ Your Discord profile can show that you are playing Horizon, with a switch to tur
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A Discord IPC client on its own thread ([#1787](https://github.com/ludovic111/horizon/issues/1787)) and the presence with its setting ([#1788](https://github.com/ludovic111/horizon/issues/1788)). Merged: it ships with the next release.
|
||||
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>
|
||||
|
||||
@@ -264,7 +290,17 @@ Cleaning up Horizon's code: removing what nothing uses and making it quicker to
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A review of the whole tree: dead code and stubs removed ([#1432](https://github.com/ludovic111/horizon/issues/1432), [#1658](https://github.com/ludovic111/horizon/issues/1658)), one probe binary per crate ([#1453](https://github.com/ludovic111/horizon/issues/1453), [#1454](https://github.com/ludovic111/horizon/issues/1454)), shared harness helpers ([#1448](https://github.com/ludovic111/horizon/issues/1448)), clippy clean on the whole tree ([#1707](https://github.com/ludovic111/horizon/issues/1707)). [docs/architecture.md](docs/architecture.md) maps each feature to its files and harnesses.
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
<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).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -278,7 +314,7 @@ New public numbers comparing Horizon with vanilla on many kinds of computers, me
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Redo the comparison after the current performance work ([#1073](https://github.com/ludovic111/horizon/issues/1073)); reliability, coverage and presentation ([#1346](https://github.com/ludovic111/horizon/issues/1346)); a scene must count as loaded only once its first chunk arrives ([#1749](https://github.com/ludovic111/horizon/issues/1749)). Next: a performance history across releases (an automatic comparison of each release with the last, on a timeline), more report fields (server TPS and MSPT, power, battery drain, temperature, throttling) and more hardware (a MacBook on battery, low-end laptops, community reports). Players' reports come in through `--benchmark` and `horizon-launcher data benchmark` ([docs/benchmark.md](docs/benchmark.md)).
|
||||
Redo the comparison after the current performance work ([#1073](https://github.com/horizon-rust/horizon/issues/1073)); reliability, coverage and presentation ([#1346](https://github.com/horizon-rust/horizon/issues/1346)); a scene must count as loaded only once its first chunk arrives ([#1749](https://github.com/horizon-rust/horizon/issues/1749)). Next: a performance history across releases (an automatic comparison of each release with the last, on a timeline), more report fields (server TPS and MSPT, power, battery drain, temperature, throttling) and more hardware (a MacBook on battery, low-end laptops, community reports). Players' reports come in through `--benchmark` and `horizon-launcher data benchmark` ([docs/benchmark.md](docs/benchmark.md)).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -288,7 +324,7 @@ Creating a world and joining it should be quick even on a four-thread laptop, wh
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Cut the per-column generation cost (noise sampling, surface rules, aquifers) and the lock convoy on structure starts, and leave one core free for the rest of the game as vanilla does ([#1760](https://github.com/ludovic111/horizon/issues/1760) and [#1778](https://github.com/ludovic111/horizon/issues/1778), in review, are listed under 'Steady frames at long render distances'). Still to profile on small CPUs: the first seconds of a fresh world deliver no chunk, and fluids remesh for about a minute after loading. Measured with `tools/perf_bench.py` ([docs/benchmark.md](docs/benchmark.md), [docs/profiling.md](docs/profiling.md)).
|
||||
Cut the per-column generation cost (noise sampling, surface rules, aquifers) and the lock convoy on structure starts, and leave one core free for the rest of the game as vanilla does ([#1760](https://github.com/horizon-rust/horizon/issues/1760) and [#1778](https://github.com/horizon-rust/horizon/issues/1778), in review, are listed under 'Steady frames at long render distances'). Still to profile on small CPUs: the first seconds of a fresh world deliver no chunk, and fluids remesh for about a minute after loading. Measured with `tools/perf_bench.py` ([docs/benchmark.md](docs/benchmark.md), [docs/profiling.md](docs/profiling.md)).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -320,7 +356,7 @@ Draw the game at a lower resolution and sharpen it to your screen's size, in the
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
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/ludovic111/horizon/issues/1403).
|
||||
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>
|
||||
|
||||
@@ -330,7 +366,7 @@ Several smaller speed-ups, each measured: idle block entities that sleep, cheape
|
||||
|
||||
<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/ludovic111/horizon/issues/1461)). Profile first, keep what pays, show before and after numbers.
|
||||
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)
|
||||
|
||||
@@ -364,7 +400,7 @@ Making mods that add machines, magic and mobs work in Horizon, in steps, startin
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
333 of the 496 mods in a big pack (ATM10) contain code. Issue [#1854](https://github.com/ludovic111/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/ludovic111/horizon/issues/1189), see 'Machines and blocks with inventories'). The path isn't started yet ([docs/modpacks.md](docs/modpacks.md)).
|
||||
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>
|
||||
|
||||
@@ -386,7 +422,7 @@ Set up Horizon, a server first, without Java on your computer: published tables
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Today `horizon-prepare` runs Mojang's exporters with Java. Issue [#1750](https://github.com/ludovic111/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)).
|
||||
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>
|
||||
|
||||
@@ -406,7 +442,7 @@ Smoother hosting: a real Windows service, a backup before every update, backups
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A Windows service wrapper instead of the logon scheduled task; `backups.before_update`; hot reload of config for view distance and the message of the day (needs engine support); backups that keep world and player state ([#1808](https://github.com/ludovic111/horizon/issues/1808), a contributor's draft, in review). [docs/hosting.md](docs/hosting.md), docs/server.md.
|
||||
A Windows service wrapper instead of the logon scheduled task; `backups.before_update`; hot reload of config for view distance and the message of the day (needs engine support); backups that keep world and player state ([#1808](https://github.com/horizon-rust/horizon/issues/1808), a contributor's draft, in review). [docs/hosting.md](docs/hosting.md), docs/server.md.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -444,6 +480,16 @@ A player's idea (#horizon-ideas). `horizon-server` already runs headless; left:
|
||||
|
||||
</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
|
||||
@@ -452,9 +498,9 @@ Mods that can see and change blocks, read inventories and items, and find and af
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
After server mods: read blocks, states and biomes in loaded chunks and change them through the server's block log, so clients and the parity harnesses see vanilla's updates; the player's inventory, held item, open container and item stacks with components; entity queries near a position, a safe subset of entity data and data-driven mod entity types. Snapshots and batched calls per tick, never per-block host calls in hot paths. [docs/api/mods.md](docs/api/mods.md) ('Not there yet').
|
||||
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/modpacks.md](docs/modpacks.md)
|
||||
More: [docs/native-mods.md](docs/native-mods.md) · [docs/modpacks.md](docs/modpacks.md)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -464,17 +510,7 @@ Mods that add machines: blocks that tick, hold items and have their own menus. T
|
||||
|
||||
<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/ludovic111/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).
|
||||
|
||||
</details>
|
||||
|
||||
#### Lua and other languages for mods
|
||||
|
||||
Write mods as Lua scripts, in the style of KubeJS, with no compiler, and later in other languages that pass the tests. Every language runs in the same sandbox.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Lua first: Horizon ships a Lua interpreter that is itself a WebAssembly mod, and a script mod's manifest names it and the capabilities it uses, so a script can do exactly what a compiled mod with the same manifest can, with hot reload of `main.lua`. Then C, C++, Zig, Go (TinyGo), AssemblyScript and JavaScript or TypeScript are measured against Rust on the same tick-heavy example, and each is supported only with a template, an example and a CI test. [docs/api/mods.md](docs/api/mods.md) ('Ways in').
|
||||
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).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -486,7 +522,7 @@ Pistons and slime blocks will push players, mobs and items as in vanilla. Today
|
||||
|
||||
<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/ludovic111/horizon/issues/1496), [#1609](https://github.com/ludovic111/horizon/issues/1609), [#1608](https://github.com/ludovic111/horizon/issues/1608), [#1704](https://github.com/ludovic111/horizon/issues/1704), [#1758](https://github.com/ludovic111/horizon/issues/1758).
|
||||
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>
|
||||
|
||||
@@ -506,7 +542,7 @@ Villages and fights behaving as in vanilla: iron golems defending villages, vill
|
||||
|
||||
<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/ludovic111/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)).
|
||||
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>
|
||||
|
||||
@@ -516,7 +552,7 @@ 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/ludovic111/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.
|
||||
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>
|
||||
|
||||
@@ -536,15 +572,13 @@ More: [docs/shaders.md](docs/shaders.md)
|
||||
|
||||
### Launcher
|
||||
|
||||
#### Microsoft sign-in (coming soon)
|
||||
#### A new launcher design
|
||||
|
||||
Coming soon: sign in with your Microsoft account to play on online-mode servers. The code is ready; it waits for Microsoft's approval.
|
||||
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.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Device-code sign-in in the launcher and online-mode login in the client ([docs/microsoft-account.md](docs/microsoft-account.md)). It is turned on by setting the app's client id once Minecraft API access is approved; until then the launcher shows "Coming soon".
|
||||
|
||||
More: [#1270](https://github.com/ludovic111/horizon/issues/1270)
|
||||
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>
|
||||
|
||||
@@ -566,7 +600,7 @@ 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/ludovic111/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/ludovic111/horizon/issues/1161), [#1792](https://github.com/ludovic111/horizon/issues/1792).
|
||||
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>
|
||||
|
||||
@@ -576,7 +610,7 @@ Fixes for Linux desktops that use Wayland and Hyprland: fullscreen, the clipboar
|
||||
|
||||
<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/ludovic111/horizon/issues/1866)). Earlier reports covered fullscreen dropping on menu clicks, the clipboard and a white overlay ([#1007](https://github.com/ludovic111/horizon/issues/1007), closed). Checked on a real Wayland session.
|
||||
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>
|
||||
|
||||
@@ -626,7 +660,7 @@ Safer releases: changes to the release machinery need the owner's review, and AI
|
||||
|
||||
<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/ludovic111/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)).
|
||||
`.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>
|
||||
|
||||
@@ -636,7 +670,7 @@ Every download Horizon makes is checked against the project's signature, includi
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Releases carry signed checksums, and the launcher and `horizon-server update` refuse anything unsigned ([#1011](https://github.com/ludovic111/horizon/issues/1011), [#1014](https://github.com/ludovic111/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)).
|
||||
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>
|
||||
|
||||
@@ -650,7 +684,7 @@ Far terrain drawn from the world you really built and explored, caves and builds
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Keep a 3D mip store on disk built from real chunks, drawn by the GPU beside the seed preview for areas nobody has visited. Written from scratch and measured before it is kept. Builds on Far Detail and the far-terrain levels of detail already shipped; the open work on them is [#718](https://github.com/ludovic111/horizon/issues/718).
|
||||
Keep a 3D mip store on disk built from real chunks, drawn by the GPU beside the seed preview for areas nobody has visited. Written from scratch and measured before it is kept. Builds on Far Detail and the far-terrain levels of detail already shipped; the open work on them is [#718](https://github.com/horizon-rust/horizon/issues/718).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -692,7 +726,7 @@ Run Java mods that nobody has rewritten for Horizon yet, through a bridge or a c
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Earlier research ([#527](https://github.com/ludovic111/horizon/issues/527), closed) found that Java mods can't simply run, because they call Minecraft's own classes. Options to look at again: a jar-to-WebAssembly backend, or a bridge for simple Fabric mods. Whatever is chosen has to run inside the same sandbox and capability checks as native mods.
|
||||
Earlier research ([#527](https://github.com/horizon-rust/horizon/issues/527), closed) found that Java mods can't simply run, because they call Minecraft's own classes. Options to look at again: a jar-to-WebAssembly backend, or a bridge for simple Fabric mods. Whatever is chosen has to run inside the same sandbox and capability checks as native mods.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -704,7 +738,19 @@ Sign in with other account providers, such as Ely.by or any Yggdrasil-compatible
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Issue [#1270](https://github.com/ludovic111/horizon/issues/1270) asks for Ely.by and Yggdrasil-compatible servers with an account selector in the launcher. It builds on the Microsoft sign-in ([docs/microsoft-account.md](docs/microsoft-account.md)).
|
||||
Issue [#1270](https://github.com/horizon-rust/horizon/issues/1270) asks for Ely.by and Yggdrasil-compatible servers with an account selector in the launcher. It builds on the Microsoft sign-in ([docs/microsoft-account.md](docs/microsoft-account.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Join servers on older versions
|
||||
|
||||
Join servers that run older Minecraft versions, such as 1.21 or 1.8 PvP servers, from Horizon.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Asked often in the suggestion queue. Horizon speaks 26.3's protocol; the path is a native protocol adapter in Rust that translates older versions' packets, not a downloaded Java proxy. Java instances in the launcher already play any version with the real game.
|
||||
|
||||
More: [#3023](https://github.com/horizon-rust/horizon/issues/3023)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -720,6 +766,18 @@ Play with Friends joins over an encrypted tunnel with an invite code ([docs/play
|
||||
|
||||
### Mods and API
|
||||
|
||||
#### Scripts for mods
|
||||
|
||||
Small mods written as scripts, in the style of KubeJS, with no compiler.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Waits for native mods to settle: the earlier plan ran Lua inside the WebAssembly sandbox, which is being removed. A script layer over the native hook points is to be designed.
|
||||
|
||||
More: [docs/native-mods.md](docs/native-mods.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Orbital Laser on the normal game
|
||||
|
||||
The community mod Orbital Laser, made for a fork of Horizon, running on the normal game and listed in the registry as the showcase of the mod API.
|
||||
@@ -732,13 +790,13 @@ A pull request on [https://github.com/Stink-O/orbital-laser](https://github.com/
|
||||
|
||||
#### Native versions of popular client mods
|
||||
|
||||
Built-in versions of the client mods players ask for: block info on hover, a recipe viewer like JEI, a world map and schematic overlays.
|
||||
Built-in versions of the client mods players ask for: block info on hover, a world map, inventory tweaks and schematic overlays.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Done as extras: a mod list and menu, a minimap and a zoom ([docs/extras.md](docs/extras.md)). Left: block info tooltips, a recipe viewer (a contributor offered to port JEI if it becomes a native feature), a world map and a schematic overlay. They need the read-only interfaces `inventory-read`, `recipes`, `world-read` and `render` in the mod API, or they go into the game directly.
|
||||
Done: a mod list and menu, minimap, zoom, keystrokes, an FPS counter and a recipe viewer, as native mods (horizon-mods/native/). Asked for: an angled world map that shows hills, overhangs and caves, inventory tweaks (drag and scroll to move items), block info tooltips and schematic overlays.
|
||||
|
||||
More: [docs/mods.md](docs/mods.md)
|
||||
More: [docs/extras.md](docs/extras.md) · [docs/native-mods.md](docs/native-mods.md)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -748,7 +806,7 @@ Contraptions and physics like the Create and Aeronautics mods, as a mod for Hori
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Issue [#805](https://github.com/ludovic111/horizon/issues/805) (porting QOL mods) closed with this answer: it would come as a native Horizon mod on the mod API. It needs server mods, entities and rendering first.
|
||||
Issue [#805](https://github.com/horizon-rust/horizon/issues/805) (porting QOL mods) closed with this answer: it would come as a native Horizon mod on the mod API. It needs server mods, entities and rendering first.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -784,13 +842,23 @@ 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.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Today F3 shows position, chunk, light, biome and FPS ([docs/native-client.md](docs/native-client.md)). Vanilla also has F3+F6 debug options with profiles, F3+1 the profiler pie, F3+L a ten-second capture and F3+S the atlas dump ([#562](https://github.com/ludovic111/horizon/issues/562) asked for the F3 editor).
|
||||
Today F3 shows position, chunk, light, biome and FPS ([docs/native-client.md](docs/native-client.md)). Vanilla also has F3+F6 debug options with profiles, F3+1 the profiler pie, F3+L a ten-second capture and F3+S the atlas dump ([#562](https://github.com/horizon-rust/horizon/issues/562) asked for the F3 editor).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -814,7 +882,7 @@ An optional ray-tracing mode for graphics cards that support it (RTX and recent
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A spike with ray-traced sun shadows, off by default, is a draft ([#1180](https://github.com/ludovic111/horizon/issues/1180)). Reflections, global illumination, Apple M3 and newer if it is cheap, and a performance report per card come after.
|
||||
A spike with ray-traced sun shadows, off by default, is a draft ([#1180](https://github.com/horizon-rust/horizon/issues/1180)). Reflections, global illumination, Apple M3 and newer if it is cheap, and a performance report per card come after.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -824,7 +892,7 @@ Output for HDR monitors: brighter highlights and richer colour where your screen
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Issue [#1441](https://github.com/ludovic111/horizon/issues/1441). Needs a surface format and a tone-mapping path per backend, and tests on real HDR screens on Windows, macOS and Linux.
|
||||
Issue [#1441](https://github.com/horizon-rust/horizon/issues/1441). Needs a surface format and a tone-mapping path per backend, and tests on real HDR screens on Windows, macOS and Linux.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -834,7 +902,7 @@ More graphics options: multisample anti-aliasing, anisotropic filtering and an e
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Not implemented ([docs/graphics.md](docs/graphics.md)). Anisotropic filtering needs linear magnification in wgpu, which blurs close blocks, so close-up sharpness has to be kept first; MSAA would sit beside FXAA; exclusive fullscreen where the backend supports it.
|
||||
Not implemented ([docs/graphics.md](docs/graphics.md)). Anisotropic filtering needs linear magnification in wgpu, which blurs close blocks, so close-up sharpness has to be kept first; MSAA would sit beside FXAA, and SMAA or TAA are asked for too; exclusive fullscreen where the backend supports it.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -858,7 +926,19 @@ Game and launcher builds for 64-bit ARM Linux computers.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Issue [#1709](https://github.com/ludovic111/horizon/issues/1709): aarch64 builds of the game and the launcher in the release workflow, tested on a real ARM machine with its Vulkan driver. The server build already drops audio and controllers (`--no-default-features`).
|
||||
Issue [#1709](https://github.com/horizon-rust/horizon/issues/1709): aarch64 builds of the game and the launcher in the release workflow, tested on a real ARM machine with its Vulkan driver. The server build already drops audio and controllers (`--no-default-features`).
|
||||
|
||||
</details>
|
||||
|
||||
#### Horizon on Windows ARM64
|
||||
|
||||
A native Horizon for Windows on ARM computers, such as Snapdragon laptops.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Releases are x64 for Windows and Linux and arm64 for macOS. Needs an aarch64-pc-windows-msvc build in the release workflow and a machine to test on.
|
||||
|
||||
More: [docs/release-flow.md](docs/release-flow.md)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -868,7 +948,7 @@ Versions of Horizon for Android, iOS and web browsers. Nothing official yet; con
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
No official build. Android was first closed as not planned, with touch controls, a mobile interface, packaging and loading the player's own assets all missing; the roadmap now includes it, iOS and a WebAssembly build for the browser as long-term goals. First pieces from contributors: `horizon-dualsense` compiling on iOS ([#1813](https://github.com/ludovic111/horizon/issues/1813)) and helper-thread limits on Android ([#1751](https://github.com/ludovic111/horizon/issues/1751)). Preparing without Java ([#1750](https://github.com/ludovic111/horizon/issues/1750)) matters here too. Horizon ships none of Mojang's files on any platform.
|
||||
No official build. Android was first closed as not planned, with touch controls, a mobile interface, packaging and loading the player's own assets all missing; the roadmap now includes it, iOS and a WebAssembly build for the browser as long-term goals. First pieces from contributors: `horizon-dualsense` compiling on iOS ([#1813](https://github.com/horizon-rust/horizon/issues/1813)) and helper-thread limits on Android ([#1751](https://github.com/horizon-rust/horizon/issues/1751)). Preparing without Java ([#1750](https://github.com/horizon-rust/horizon/issues/1750)) matters here too. Horizon ships none of Mojang's files on any platform.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -912,7 +992,7 @@ An optional AI teammate you can talk to by voice. It plays beside you, helps or
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Draft pull requests: the companion ([#1068](https://github.com/ludovic111/horizon/issues/1068)), its safety modes and their evaluation ([#1069](https://github.com/ludovic111/horizon/issues/1069)) and a route that finishes the game ([#1070](https://github.com/ludovic111/horizon/issues/1070)). In the drafts: a companion that follows, fights, mines, crafts, sleeps and respawns; chat and push-to-talk voice, with speech recognition and a choice of voices; presets (very strong, helper, beat the game); a guard model with Clean, Kid and Salty modes; perception levels from Fair to All-knowing; hardware detection with a simple mode and an Advanced tab; local models first, cloud keys optional. The No AI switch is already in the game. Left: the speedrun route doesn't finish the game reliably yet, voices for more languages, and the safety evaluation, which must pass before it ships.
|
||||
Draft pull requests: the companion ([#1068](https://github.com/horizon-rust/horizon/issues/1068)), its safety modes and their evaluation ([#1069](https://github.com/horizon-rust/horizon/issues/1069)) and a route that finishes the game ([#1070](https://github.com/horizon-rust/horizon/issues/1070)). In the drafts: a companion that follows, fights, mines, crafts, sleeps and respawns; chat and push-to-talk voice, with speech recognition and a choice of voices; presets (very strong, helper, beat the game); a guard model with Clean, Kid and Salty modes; perception levels from Fair to All-knowing; hardware detection with a simple mode and an Advanced tab; local models first, cloud keys optional. The No AI switch is already in the game. Left: the speedrun route doesn't finish the game reliably yet, voices for more languages, and the safety evaluation, which must pass before it ships.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -938,7 +1018,7 @@ Run Horizon's built-in benchmark and share the report: every new computer makes
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`horizon-launcher play --arg=--benchmark --arg=standard` writes a JSON report; `horizon-launcher data benchmark <report>` shows what would be sent and uploads it (playhorizon.dev/docs/performance). Results from three installs with the same hardware appear on the Benchmarks page. Example: [#1711](https://github.com/ludovic111/horizon/issues/1711).
|
||||
`horizon-launcher play --arg=--benchmark --arg=standard` writes a JSON report; `horizon-launcher data benchmark <report>` shows what would be sent and uploads it (playhorizon.dev/docs/performance). Results from three installs with the same hardware appear on the Benchmarks page. Example: [#1711](https://github.com/horizon-rust/horizon/issues/1711).
|
||||
|
||||
More: [https://playhorizon.dev/docs/performance](https://playhorizon.dev/docs/performance)
|
||||
|
||||
@@ -952,7 +1032,7 @@ Banners in End cities show as plain magenta instead of with their pattern.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Compare the banner block entities the End city templates place with vanilla's (`EndCityPieces`, the templates' block entity NBT, `BannerBlockEntity`'s patterns), in `crates/horizon-world/src/worldgen/structure/`. Issue [#1438](https://github.com/ludovic111/horizon/issues/1438); possibly related: [#526](https://github.com/ludovic111/horizon/issues/526).
|
||||
Compare the banner block entities the End city templates place with vanilla's (`EndCityPieces`, the templates' block entity NBT, `BannerBlockEntity`'s patterns), in `crates/horizon-world/src/worldgen/structure/`. Issue [#1438](https://github.com/horizon-rust/horizon/issues/1438); possibly related: [#526](https://github.com/horizon-rust/horizon/issues/526).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -962,19 +1042,20 @@ Armour and tools in End city chests should come enchanted, as in vanilla.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`loot.rs` implements `enchant_with_levels`: find why the End city chest table's rolls come out bare, and compare them with vanilla's for a few seeds. Issues [#1437](https://github.com/ludovic111/horizon/issues/1437), [#1660](https://github.com/ludovic111/horizon/issues/1660).
|
||||
`loot.rs` implements `enchant_with_levels`: find why the End city chest table's rolls come out bare, and compare them with vanilla's for a few seeds. Issues [#1437](https://github.com/horizon-rust/horizon/issues/1437), [#1660](https://github.com/horizon-rust/horizon/issues/1660).
|
||||
|
||||
</details>
|
||||
|
||||
## Recently shipped
|
||||
|
||||
- **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/ludovic111/horizon/issues/1917) · [#1920](https://github.com/ludovic111/horizon/issues/1920) · [#1936](https://github.com/ludovic111/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/ludovic111/horizon/issues/1562) · [#1569](https://github.com/ludovic111/horizon/issues/1569) · [#1570](https://github.com/ludovic111/horizon/issues/1570)
|
||||
- **Single player on its own server thread** (v0.2.39): Single player runs on a server thread, as vanilla does, so the world keeps ticking smoothly while the window draws. [#1514](https://github.com/ludovic111/horizon/issues/1514) · [docs/server-levels.md](docs/server-levels.md)
|
||||
- **GPU occlusion culling on Macs** (v0.2.17): On Macs the GPU now skips terrain hidden behind hills and caves: 8 to 48% more frames per second at 32 chunks on an M4 Pro. [#1434](https://github.com/ludovic111/horizon/issues/1434)
|
||||
- **Animated textures on the GPU** (v0.2.5): Water, lava and other animated blocks are now animated by the graphics card: 1.7 to 2.4 times better 1% lows on Intel graphics. [#1239](https://github.com/ludovic111/horizon/issues/1239)
|
||||
- **Far Detail for integrated graphics** (v0.2.5): An optional Reduced setting for far terrain, suggested on integrated graphics, that saves 2 to 4 ms of GPU time per frame. [#1199](https://github.com/ludovic111/horizon/issues/1199) · [#1029](https://github.com/ludovic111/horizon/issues/1029)
|
||||
- **The launcher's mod browser** (launcher@0.2.0): Search Modrinth for mods, modpacks and resource packs from the launcher; it says before installing what works in Horizon. [#1154](https://github.com/ludovic111/horizon/issues/1154) · [#1242](https://github.com/ludovic111/horizon/issues/1242) · [#1243](https://github.com/ludovic111/horizon/issues/1243) · [#1246](https://github.com/ludovic111/horizon/issues/1246) · [docs/mod-browser.md](docs/mod-browser.md)
|
||||
- **A blocky, Minecraft-like launcher** (launcher@0.2.0): The launcher got a new blocky look in Horizon's purple, in dark and light. [#1269](https://github.com/ludovic111/horizon/issues/1269) · [docs/design-system.md](docs/design-system.md)
|
||||
- **Dynamic lights** (v2026.10.22): An optional extra: torches you hold, drop or set on fire light up what is around them. [#1162](https://github.com/ludovic111/horizon/issues/1162)
|
||||
- **Host a server from the launcher** (launcher-v0.15.0): The launcher's Servers page creates, starts, backs up and updates a Horizon server with buttons, and invites friends. [#1026](https://github.com/ludovic111/horizon/issues/1026) · [docs/hosting.md](docs/hosting.md)
|
||||
- **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)
|
||||
- **Single player on its own server thread** (v0.2.39): Single player runs on a server thread, as vanilla does, so the world keeps ticking smoothly while the window draws. [#1514](https://github.com/horizon-rust/horizon/issues/1514) · [docs/server-levels.md](docs/server-levels.md)
|
||||
- **GPU occlusion culling on Macs** (v0.2.17): On Macs the GPU now skips terrain hidden behind hills and caves: 8 to 48% more frames per second at 32 chunks on an M4 Pro. [#1434](https://github.com/horizon-rust/horizon/issues/1434)
|
||||
- **Animated textures on the GPU** (v0.2.5): Water, lava and other animated blocks are now animated by the graphics card: 1.7 to 2.4 times better 1% lows on Intel graphics. [#1239](https://github.com/horizon-rust/horizon/issues/1239)
|
||||
- **Far Detail for integrated graphics** (v0.2.5): An optional Reduced setting for far terrain, suggested on integrated graphics, that saves 2 to 4 ms of GPU time per frame. [#1199](https://github.com/horizon-rust/horizon/issues/1199) · [#1029](https://github.com/horizon-rust/horizon/issues/1029)
|
||||
- **The launcher's mod browser** (launcher@0.2.0): Search Modrinth for mods, modpacks and resource packs from the launcher; it says before installing what works in Horizon. [#1154](https://github.com/horizon-rust/horizon/issues/1154) · [#1242](https://github.com/horizon-rust/horizon/issues/1242) · [#1243](https://github.com/horizon-rust/horizon/issues/1243) · [#1246](https://github.com/horizon-rust/horizon/issues/1246) · [docs/mod-browser.md](docs/mod-browser.md)
|
||||
- **A blocky, Minecraft-like launcher** (launcher@0.2.0): The launcher got a new blocky look in Horizon's purple, in dark and light. [#1269](https://github.com/horizon-rust/horizon/issues/1269) · [docs/design-system.md](docs/design-system.md)
|
||||
- **Dynamic lights** (v2026.10.22): An optional extra: torches you hold, drop or set on fire light up what is around them. [#1162](https://github.com/horizon-rust/horizon/issues/1162)
|
||||
- **Host a server from the launcher** (launcher-v0.15.0): The launcher's Servers page creates, starts, backs up and updates a Horizon server with buttons, and invites friends. [#1026](https://github.com/horizon-rust/horizon/issues/1026) · [docs/hosting.md](docs/hosting.md)
|
||||
@@ -119,7 +119,7 @@ machines during the runs.
|
||||
|
||||
## Reproduce
|
||||
|
||||
curl -fsSL https://raw.githubusercontent.com/ludovic111/horizon/main/packaging/server/install.sh | sh
|
||||
curl -fsSL https://raw.githubusercontent.com/horizon-rust/horizon/main/packaging/server/install.sh | sh
|
||||
horizon-prepare --prepare --server-jar /path/to/server.jar # your own copy
|
||||
cargo build --release -p horizon-bots # on the machine that runs the bots
|
||||
# in a checkout whose .local/minecraft holds the pinned vanilla jars (tools/lab.py prepare):
|
||||
|
||||
@@ -148,7 +148,7 @@ early, wrote no report. Its lines come from its output.
|
||||
|
||||
## Reproduce
|
||||
|
||||
curl -fsSL https://raw.githubusercontent.com/ludovic111/horizon/main/packaging/server/install.sh | sh
|
||||
curl -fsSL https://raw.githubusercontent.com/horizon-rust/horizon/main/packaging/server/install.sh | sh
|
||||
horizon-prepare --prepare --server-jar /path/to/server.jar # your own copy
|
||||
cargo build --release -p horizon-bots # on the machine that runs the bots
|
||||
# in a checkout whose .local/minecraft holds the pinned vanilla jars (tools/lab.py prepare):
|
||||
|
||||
@@ -103,7 +103,7 @@ the Nix store, so the run set `LD_LIBRARY_PATH` to them (see #898).
|
||||
|
||||
## Reproduce
|
||||
|
||||
curl -fsSL https://raw.githubusercontent.com/ludovic111/horizon/main/packaging/server/install.sh | sh
|
||||
curl -fsSL https://raw.githubusercontent.com/horizon-rust/horizon/main/packaging/server/install.sh | sh
|
||||
horizon-prepare --prepare --server-jar /path/to/server.jar # your own copy
|
||||
cargo build --release -p horizon-bots # on the machine that runs the bots
|
||||
# in a checkout whose .local/minecraft holds the pinned vanilla jars (tools/lab.py prepare):
|
||||
|
||||
@@ -97,6 +97,9 @@ impl Report {
|
||||
for r in &f.requires {
|
||||
out += &format!(" needs {} {} ({})\n", r.id, r.version, &r.sha256[..12]);
|
||||
}
|
||||
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 {
|
||||
out += " no code: content only, nothing to run\n";
|
||||
} else if self.kind == Kind::Mod {
|
||||
@@ -118,6 +121,7 @@ impl Report {
|
||||
pub fn kind_of(path: &Path) -> Option<Kind> {
|
||||
match path.extension()?.to_str()? {
|
||||
"hmod" => Some(Kind::Mod),
|
||||
"hnmod" => Some(Kind::NativeMod),
|
||||
"hpack" => Some(Kind::Modpack),
|
||||
"zip" => Some(Kind::ResourcePack),
|
||||
"hshader" => Some(Kind::Shader),
|
||||
|
||||
@@ -25,7 +25,7 @@ use std::{
|
||||
};
|
||||
|
||||
const TARGET: &str = "wasm32-unknown-unknown";
|
||||
const SDK_GIT: &str = "{ git = \"https://github.com/ludovic111/horizon\" }";
|
||||
const SDK_GIT: &str = "{ git = \"https://github.com/horizon-rust/horizon\" }";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
// Cargo runs `cargo-horizon-mod horizon-mod <args>`.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! `cargo horizon-mod registry build|review|check|list`: the registry repository's CI
|
||||
//! (`ludovic111/horizon-mods`, docs/api/mod-index.md). Network-free: the
|
||||
//! (`horizon-rust/horizon-mods`, docs/api/mod-index.md). Network-free: the
|
||||
//! workflow downloads the files and publishes what this writes.
|
||||
use horizon_mods::index::{
|
||||
FILE_HOST,
|
||||
@@ -8,7 +8,7 @@ use horizon_mods::index::{
|
||||
use std::path::Path;
|
||||
|
||||
/// Where the registry's site is published (its icons).
|
||||
const SITE: &str = "https://ludovic111.github.io/horizon-mods/";
|
||||
const SITE: &str = "https://horizon-rust.github.io/horizon-mods/";
|
||||
|
||||
const USAGE: &str = "usage: cargo horizon-mod registry build <checkout> --files <dir> --out <index.json> [--release <url>] [--site <url>] [--generated <time>]
|
||||
cargo horizon-mod registry review <base checkout> <head checkout> --author <login> [--json]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! 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 horizon_world::nbt::Tag;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
/// `SnbtGrammar`, enough for components: compounds, lists, quoted and
|
||||
@@ -10,13 +11,18 @@ pub fn parse_snbt(input: &str) -> Result<(Value, usize), String> {
|
||||
let mut p = Parser {
|
||||
s: input.as_bytes(),
|
||||
i: 0,
|
||||
depth: 0,
|
||||
};
|
||||
let v = p.value()?;
|
||||
Ok((v, p.i))
|
||||
}
|
||||
/// Nesting deeper than NBT's own limit (`NbtAccounter`'s 512) is refused
|
||||
/// rather than overflowing the stack.
|
||||
const SNBT_MAX_DEPTH: usize = 512;
|
||||
struct Parser<'a> {
|
||||
s: &'a [u8],
|
||||
i: usize,
|
||||
depth: usize,
|
||||
}
|
||||
impl Parser<'_> {
|
||||
fn ws(&mut self) {
|
||||
@@ -40,57 +46,22 @@ impl Parser<'_> {
|
||||
self.ws();
|
||||
match self.peek() {
|
||||
Some(b'{') => {
|
||||
self.i += 1;
|
||||
let mut map = Map::new();
|
||||
self.ws();
|
||||
if self.peek() == Some(b'}') {
|
||||
self.i += 1;
|
||||
return Ok(Value::Object(map));
|
||||
}
|
||||
loop {
|
||||
self.ws();
|
||||
let key = self.string()?;
|
||||
self.expect(b':')?;
|
||||
map.insert(key, self.value()?);
|
||||
self.ws();
|
||||
match self.peek() {
|
||||
Some(b',') => self.i += 1,
|
||||
Some(b'}') => {
|
||||
self.i += 1;
|
||||
return Ok(Value::Object(map));
|
||||
}
|
||||
_ => return Err("expected ',' or '}'".into()),
|
||||
}
|
||||
if self.depth >= SNBT_MAX_DEPTH {
|
||||
return Err(format!("tag nesting too deep at {}", self.i));
|
||||
}
|
||||
self.depth += 1;
|
||||
let v = self.object();
|
||||
self.depth -= 1;
|
||||
v
|
||||
}
|
||||
Some(b'[') => {
|
||||
self.i += 1;
|
||||
let mut list = Vec::new();
|
||||
self.ws();
|
||||
// Typed arrays [B;...], [I;...], [L;...].
|
||||
if matches!(
|
||||
self.s.get(self.i..self.i + 2),
|
||||
Some([b'B' | b'I' | b'L', b';'])
|
||||
) {
|
||||
self.i += 2;
|
||||
}
|
||||
self.ws();
|
||||
if self.peek() == Some(b']') {
|
||||
self.i += 1;
|
||||
return Ok(Value::Array(list));
|
||||
}
|
||||
loop {
|
||||
list.push(self.value()?);
|
||||
self.ws();
|
||||
match self.peek() {
|
||||
Some(b',') => self.i += 1,
|
||||
Some(b']') => {
|
||||
self.i += 1;
|
||||
return Ok(Value::Array(list));
|
||||
}
|
||||
_ => return Err("expected ',' or ']'".into()),
|
||||
}
|
||||
if self.depth >= SNBT_MAX_DEPTH {
|
||||
return Err(format!("tag nesting too deep at {}", self.i));
|
||||
}
|
||||
self.depth += 1;
|
||||
let v = self.array();
|
||||
self.depth -= 1;
|
||||
v
|
||||
}
|
||||
Some(b'"' | b'\'') => Ok(Value::String(self.string()?)),
|
||||
Some(_) => {
|
||||
@@ -104,10 +75,20 @@ impl Parser<'_> {
|
||||
_ => {
|
||||
let digits = word
|
||||
.trim_end_matches(['b', 'B', 's', 'S', 'l', 'L', 'f', 'F', 'd', 'D']);
|
||||
match (digits.parse::<i64>(), digits.parse::<f64>()) {
|
||||
(Ok(n), _) if digits.len() + 1 >= word.len() => Value::from(n),
|
||||
(_, Ok(f)) if digits.len() + 1 >= word.len() => Value::from(f),
|
||||
_ => Value::String(word),
|
||||
// Only digits (and an exponent) make a number: `nan`
|
||||
// or `Infinity`, which Rust's parse would take, stays
|
||||
// a string.
|
||||
if digits
|
||||
.bytes()
|
||||
.any(|c| c.is_ascii_alphabetic() && c != b'e' && c != b'E')
|
||||
{
|
||||
Value::String(word)
|
||||
} else {
|
||||
match (digits.parse::<i64>(), digits.parse::<f64>()) {
|
||||
(Ok(n), _) if digits.len() + 1 >= word.len() => Value::from(n),
|
||||
(_, Ok(f)) if digits.len() + 1 >= word.len() => Value::from(f),
|
||||
_ => Value::String(word),
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -115,6 +96,59 @@ impl Parser<'_> {
|
||||
None => Err("expected a value".into()),
|
||||
}
|
||||
}
|
||||
fn object(&mut self) -> Result<Value, String> {
|
||||
self.i += 1;
|
||||
let mut map = Map::new();
|
||||
self.ws();
|
||||
if self.peek() == Some(b'}') {
|
||||
self.i += 1;
|
||||
return Ok(Value::Object(map));
|
||||
}
|
||||
loop {
|
||||
self.ws();
|
||||
let key = self.string()?;
|
||||
self.expect(b':')?;
|
||||
map.insert(key, self.value()?);
|
||||
self.ws();
|
||||
match self.peek() {
|
||||
Some(b',') => self.i += 1,
|
||||
Some(b'}') => {
|
||||
self.i += 1;
|
||||
return Ok(Value::Object(map));
|
||||
}
|
||||
_ => return Err("expected ',' or '}'".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
fn array(&mut self) -> Result<Value, String> {
|
||||
self.i += 1;
|
||||
let mut list = Vec::new();
|
||||
self.ws();
|
||||
// Typed arrays [B;...], [I;...], [L;...].
|
||||
if matches!(
|
||||
self.s.get(self.i..self.i + 2),
|
||||
Some([b'B' | b'I' | b'L', b';'])
|
||||
) {
|
||||
self.i += 2;
|
||||
}
|
||||
self.ws();
|
||||
if self.peek() == Some(b']') {
|
||||
self.i += 1;
|
||||
return Ok(Value::Array(list));
|
||||
}
|
||||
loop {
|
||||
list.push(self.value()?);
|
||||
self.ws();
|
||||
match self.peek() {
|
||||
Some(b',') => self.i += 1,
|
||||
Some(b']') => {
|
||||
self.i += 1;
|
||||
return Ok(Value::Array(list));
|
||||
}
|
||||
_ => return Err("expected ',' or ']'".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
fn unquoted(&mut self) -> String {
|
||||
let start = self.i;
|
||||
while let Some(c) = self.peek() {
|
||||
@@ -190,19 +224,36 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
|
||||
.filter(|p| *p >= 1)
|
||||
.map(crate::text::ClickEvent::ChangePage),
|
||||
Some("suggest_command") => text("command").map(crate::text::ClickEvent::SuggestCommand),
|
||||
Some("run_command") => text("command").map(crate::text::ClickEvent::RunCommand),
|
||||
Some("copy_to_clipboard") => {
|
||||
text("value").map(crate::text::ClickEvent::CopyToClipboard)
|
||||
}
|
||||
// ClickEvent.ShowDialog.CODEC: a `minecraft:dialog` registry id or
|
||||
// the dialog itself; the server may send it (isAllowedFromServer).
|
||||
Some("show_dialog") => e.get("dialog").map(|dialog| {
|
||||
crate::text::ClickEvent::ShowDialog(crate::scoreboard::json_to_tag(dialog))
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
// HoverEvent.ShowText.
|
||||
if let Some(e) = o.get("hover_event").and_then(Value::as_object)
|
||||
&& e.get("action").and_then(Value::as_str) == Some("show_text")
|
||||
&& let Some(value) = e.get("value")
|
||||
{
|
||||
style.hover = Some(Box::new(to_text(value, &mut |s| Text::literal(s))));
|
||||
// HoverEvent.ShowText and ShowItem.
|
||||
if let Some(e) = o.get("hover_event").and_then(Value::as_object) {
|
||||
match e.get("action").and_then(Value::as_str) {
|
||||
Some("show_text") => {
|
||||
if let Some(value) = e.get("value") {
|
||||
style.hover = Some(Box::new(to_text(value, &mut |s| Text::literal(s))));
|
||||
}
|
||||
}
|
||||
Some("show_item") if e.get("id").is_some_and(Value::is_string) => {
|
||||
style.hover_item = Some(Box::new(Value::Object(e.clone())));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
style.insertion = o
|
||||
.get("insertion")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned);
|
||||
style
|
||||
}
|
||||
/// `ComponentSerialization.CODEC` decoding into text. `selector` components
|
||||
@@ -287,6 +338,15 @@ pub fn to_text_with(
|
||||
}
|
||||
}
|
||||
|
||||
/// `CustomName` as text: a string keeps its legacy formatting codes, anything
|
||||
/// else is a component (`ComponentSerialization`).
|
||||
pub fn name_text(tag: &Tag) -> Text {
|
||||
match tag {
|
||||
Tag::String(s) => Text::legacy(&s.to_string_lossy()),
|
||||
t => crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -324,4 +384,22 @@ mod tests {
|
||||
let t = to_text(&v, &mut |_| Text::empty());
|
||||
assert_eq!(t.spans[0].style.font, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snbt_nesting_is_bounded() {
|
||||
let nest = |n: usize| format!("{}1{}", "[".repeat(n), "]".repeat(n));
|
||||
assert!(parse_snbt(&nest(512)).is_ok());
|
||||
assert!(parse_snbt(&nest(513)).is_err());
|
||||
assert!(parse_snbt(&nest(100_000)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_words_that_look_numeric_stay_strings() {
|
||||
let (v, _) = parse_snbt("{text:nan}").unwrap();
|
||||
assert_eq!(v["text"], Value::String("nan".into()));
|
||||
let (v, _) = parse_snbt("{text:Infinity}").unwrap();
|
||||
assert_eq!(v["text"], Value::String("Infinity".into()));
|
||||
let (v, _) = parse_snbt("{n:1.5}").unwrap();
|
||||
assert_eq!(v["n"], Value::from(1.5));
|
||||
}
|
||||
}
|
||||
@@ -16,3 +16,20 @@ pub fn parse_identifier(raw: &str) -> Option<String> {
|
||||
});
|
||||
(ns_ok && path_ok).then(|| format!("{ns}:{path}"))
|
||||
}
|
||||
|
||||
/// `Identifier.CODEC` parsing an entity id: lowercase namespace and path
|
||||
/// characters, `minecraft` when the namespace is left out.
|
||||
pub fn normalize_id(s: &str) -> Option<String> {
|
||||
let (ns, path) = match s.split_once(':') {
|
||||
Some((ns, path)) => (if ns.is_empty() { "minecraft" } else { ns }, path),
|
||||
None => ("minecraft", s),
|
||||
};
|
||||
let ok = |c: char, slash: bool| {
|
||||
c.is_ascii_lowercase()
|
||||
|| c.is_ascii_digit()
|
||||
|| matches!(c, '_' | '-' | '.')
|
||||
|| slash && c == '/'
|
||||
};
|
||||
(ns.chars().all(|c| ok(c, false)) && path.chars().all(|c| ok(c, true)))
|
||||
.then(|| format!("{ns}:{path}"))
|
||||
}
|
||||
@@ -40,6 +40,10 @@ static BIDIRECTIONAL: AtomicBool = AtomicBool::new(false);
|
||||
/// A source of resource files: the prepared root plus packs, highest priority last.
|
||||
pub trait ResourceSource {
|
||||
fn read(&self, path: &str) -> Option<Vec<u8>>;
|
||||
/// Whether `path` is there, without reading it where the source can tell.
|
||||
fn exists(&self, path: &str) -> bool {
|
||||
self.read(path).is_some()
|
||||
}
|
||||
/// The namespaces under `assets/` (resource packs may add their own).
|
||||
fn namespaces(&self) -> Vec<String> {
|
||||
vec!["minecraft".into()]
|
||||
@@ -68,6 +72,9 @@ impl ResourceSource for std::path::PathBuf {
|
||||
fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
std::fs::read(self.join(path)).ok()
|
||||
}
|
||||
fn exists(&self, path: &str) -> bool {
|
||||
self.join(path).is_file()
|
||||
}
|
||||
fn namespaces(&self) -> Vec<String> {
|
||||
std::fs::read_dir(self.join("assets"))
|
||||
.into_iter()
|
||||
@@ -190,7 +197,7 @@ pub fn available(sources: &[&dyn ResourceSource]) -> Vec<LanguageInfo> {
|
||||
for (code, info) in value["language"].as_object().into_iter().flatten() {
|
||||
// Only languages whose file exists somewhere are selectable.
|
||||
let file = format!("assets/minecraft/lang/{code}.json");
|
||||
if code != DEFAULT && !sources.iter().any(|s| s.read(&file).is_some()) {
|
||||
if code != DEFAULT && !sources.iter().any(|s| s.exists(&file)) {
|
||||
continue;
|
||||
}
|
||||
found.insert(
|
||||
@@ -284,4 +291,26 @@ mod tests {
|
||||
);
|
||||
assert_eq!(langs[1].label(), "Français (France)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn languages_are_listed_without_reading_their_files() {
|
||||
struct NoLangReads;
|
||||
impl ResourceSource for NoLangReads {
|
||||
fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
assert!(!path.contains("/lang/"), "read {path}");
|
||||
(path == "assets/pack.mcmeta").then(|| {
|
||||
br#"{"language":{"fr_fr":{"name":"French"},"de_de":{"name":"German"}}}"#
|
||||
.to_vec()
|
||||
})
|
||||
}
|
||||
fn exists(&self, path: &str) -> bool {
|
||||
path == "assets/minecraft/lang/fr_fr.json"
|
||||
}
|
||||
}
|
||||
let langs = available(&[&NoLangReads]);
|
||||
assert_eq!(
|
||||
langs.iter().map(|l| l.code.as_str()).collect::<Vec<_>>(),
|
||||
["en_us", "fr_fr"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ pub mod identifier;
|
||||
pub mod key_display;
|
||||
pub mod lang;
|
||||
pub mod nbt_path;
|
||||
pub mod nbt_value;
|
||||
pub mod particle_type;
|
||||
pub mod pattern;
|
||||
pub mod potions;
|
||||
@@ -23,4 +24,5 @@ pub mod snbt;
|
||||
pub mod sound_source;
|
||||
pub mod text;
|
||||
pub mod uuid;
|
||||
pub mod who;
|
||||
pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
@@ -0,0 +1,32 @@
|
||||
//! Reading and writing single NBT values the way vanilla's codecs accept them.
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
pub fn string(s: &str) -> Tag {
|
||||
Tag::String(s.into())
|
||||
}
|
||||
pub fn text(tag: Option<&Tag>) -> Option<String> {
|
||||
match tag {
|
||||
Some(Tag::String(s)) => Some(s.to_string_lossy()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Any numeric tag as i64 (vanilla's `getIntOr` etc. accept every number type).
|
||||
pub fn number(tag: Option<&Tag>) -> Option<i64> {
|
||||
Some(match tag? {
|
||||
Tag::Byte(v) => i64::from(*v),
|
||||
Tag::Short(v) => i64::from(*v),
|
||||
Tag::Int(v) => i64::from(*v),
|
||||
Tag::Long(v) => *v,
|
||||
Tag::Float(b) => f32::from_bits(*b) as i64,
|
||||
Tag::Double(b) => f64::from_bits(*b) as i64,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
/// Any numeric tag as f64, keeping a float's or double's fraction.
|
||||
pub fn float(tag: Option<&Tag>) -> Option<f64> {
|
||||
Some(match tag? {
|
||||
Tag::Float(b) => f64::from(f32::from_bits(*b)),
|
||||
Tag::Double(b) => f64::from_bits(*b),
|
||||
other => number(Some(other))? as f64,
|
||||
})
|
||||
}
|
||||
@@ -47,7 +47,12 @@ pub enum ClickEvent {
|
||||
/// `ClickEvent.ChangePage`: a book's page, counted from 1.
|
||||
ChangePage(i32),
|
||||
SuggestCommand(String),
|
||||
/// `ClickEvent.RunCommand`: the command, with or without its `/`.
|
||||
RunCommand(String),
|
||||
CopyToClipboard(String),
|
||||
/// `ClickEvent.ShowDialog`: a `minecraft:dialog` registry id, or the
|
||||
/// dialog itself as the component carried it.
|
||||
ShowDialog(horizon_world::nbt::Tag),
|
||||
/// A local file to open with the system handler.
|
||||
/// Only allow this in locally constructed messages, never server/LAN text.
|
||||
OpenFile(String),
|
||||
@@ -65,6 +70,10 @@ pub struct Style {
|
||||
pub click: Option<ClickEvent>,
|
||||
/// Hover text.
|
||||
pub hover: Option<Box<Text>>,
|
||||
/// `HoverEvent.ShowItem`: the event's `{id, count?, components?}` object.
|
||||
pub hover_item: Option<Box<Value>>,
|
||||
/// `Style.insertion`: the text a Shift-click puts in the chat box.
|
||||
pub insertion: Option<String>,
|
||||
/// The font (`FontDescription.Resource`), a full id like
|
||||
/// `minecraft:alt`; the default font when unset.
|
||||
pub font: Option<String>,
|
||||
@@ -94,6 +103,12 @@ impl Style {
|
||||
if self.hover.is_none() {
|
||||
self.hover.clone_from(&base.hover);
|
||||
}
|
||||
if self.hover_item.is_none() {
|
||||
self.hover_item.clone_from(&base.hover_item);
|
||||
}
|
||||
if self.insertion.is_none() {
|
||||
self.insertion.clone_from(&base.insertion);
|
||||
}
|
||||
if self.font.is_none() {
|
||||
self.font.clone_from(&base.font);
|
||||
}
|
||||
@@ -388,31 +403,7 @@ impl Text {
|
||||
continue;
|
||||
}
|
||||
let Some(code) = chars.next() else { break };
|
||||
let code = code.to_ascii_lowercase();
|
||||
let next = if let Some(c) = COLORS.iter().find(|c| c.0 == code) {
|
||||
// Style.applyLegacyFormat: a colour clears the decorations.
|
||||
Style {
|
||||
color: Some(c.2),
|
||||
bold: Some(false),
|
||||
italic: Some(false),
|
||||
underlined: Some(false),
|
||||
strikethrough: Some(false),
|
||||
obfuscated: Some(false),
|
||||
..style.clone()
|
||||
}
|
||||
} else {
|
||||
let mut s = style.clone();
|
||||
match code {
|
||||
'k' => s.obfuscated = Some(true),
|
||||
'l' => s.bold = Some(true),
|
||||
'm' => s.strikethrough = Some(true),
|
||||
'n' => s.underlined = Some(true),
|
||||
'o' => s.italic = Some(true),
|
||||
'r' => s = span.style.clone(),
|
||||
_ => {}
|
||||
}
|
||||
s
|
||||
};
|
||||
let next = legacy_format(&style, code, &span.style);
|
||||
if next != style {
|
||||
if !current.is_empty() {
|
||||
out.spans.push(Span {
|
||||
@@ -499,7 +490,7 @@ impl Text {
|
||||
}
|
||||
|
||||
/// The style fields of a component's JSON.
|
||||
const STYLE_KEYS: [&str; 9] = [
|
||||
const STYLE_KEYS: [&str; 10] = [
|
||||
"color",
|
||||
"bold",
|
||||
"italic",
|
||||
@@ -508,6 +499,7 @@ const STYLE_KEYS: [&str; 9] = [
|
||||
"obfuscated",
|
||||
"click_event",
|
||||
"hover_event",
|
||||
"insertion",
|
||||
"font",
|
||||
];
|
||||
/// Style.Serializer: the fields a style sets.
|
||||
@@ -541,6 +533,14 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
|
||||
ClickEvent::CopyToClipboard(value) => {
|
||||
json!({ "action": "copy_to_clipboard", "value": value })
|
||||
}
|
||||
ClickEvent::RunCommand(command) => {
|
||||
json!({ "action": "run_command", "command": command })
|
||||
}
|
||||
// ClickEvent.ShowDialog.CODEC: the dialog the server sent it with.
|
||||
ClickEvent::ShowDialog(dialog) => json!({
|
||||
"action": "show_dialog",
|
||||
"dialog": crate::scoreboard::tag_to_json(dialog),
|
||||
}),
|
||||
ClickEvent::OpenFile(path) => json!({ "action": "open_file", "path": path }),
|
||||
};
|
||||
out.push(("click_event".to_owned(), event));
|
||||
@@ -550,6 +550,13 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
|
||||
"hover_event".to_owned(),
|
||||
json!({ "action": "show_text", "value": hover.component() }),
|
||||
));
|
||||
} else if let Some(Value::Object(item)) = style.hover_item.as_deref() {
|
||||
let mut event = item.clone();
|
||||
event.insert("action".into(), "show_item".into());
|
||||
out.push(("hover_event".to_owned(), Value::Object(event)));
|
||||
}
|
||||
if let Some(insertion) = &style.insertion {
|
||||
out.push(("insertion".to_owned(), insertion.clone().into()));
|
||||
}
|
||||
if let Some(font) = &style.font {
|
||||
out.push(("font".to_owned(), font.clone().into()));
|
||||
@@ -557,6 +564,35 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
|
||||
out
|
||||
}
|
||||
|
||||
/// `Style.applyLegacyFormat` for the code after a `§`, `§r` going back to
|
||||
/// `reset` (`StringDecomposer.iterateFormatted`); an unknown code changes nothing.
|
||||
pub fn legacy_format(style: &Style, code: char, reset: &Style) -> Style {
|
||||
let code = code.to_ascii_lowercase();
|
||||
if let Some(c) = COLORS.iter().find(|c| c.0 == code) {
|
||||
// A colour clears the decorations.
|
||||
return Style {
|
||||
color: Some(c.2),
|
||||
bold: Some(false),
|
||||
italic: Some(false),
|
||||
underlined: Some(false),
|
||||
strikethrough: Some(false),
|
||||
obfuscated: Some(false),
|
||||
..style.clone()
|
||||
};
|
||||
}
|
||||
let mut s = style.clone();
|
||||
match code {
|
||||
'k' => s.obfuscated = Some(true),
|
||||
'l' => s.bold = Some(true),
|
||||
'm' => s.strikethrough = Some(true),
|
||||
'n' => s.underlined = Some(true),
|
||||
'o' => s.italic = Some(true),
|
||||
'r' => s = reset.clone(),
|
||||
_ => {}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// TranslatableContents.decomposeTemplate: `%s` takes the next argument,
|
||||
/// `%n$s` a numbered one and `%%` is a percent sign. Malformed templates are
|
||||
/// shown verbatim.
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
//! Who an action came from or is aimed at: the player or a mob by id.
|
||||
|
||||
/// Who caused damage or is being targeted.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Who {
|
||||
Player,
|
||||
Mob(u32),
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "horizon-client-input"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.90"
|
||||
description = "The client's input layer: key bindings and their names, mouse, controllers, clipboard and window options"
|
||||
|
||||
[dependencies]
|
||||
horizon-client-core = { path = "../horizon-client-core" }
|
||||
winit = "0.30.13"
|
||||
# The window icon.
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
png = "0.18"
|
||||
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
|
||||
# The DualSense's trigger effects (the `controllers` feature).
|
||||
horizon-dualsense = { path = "../horizon-dualsense", optional = true }
|
||||
|
||||
[features]
|
||||
controllers = ["dep:horizon-dualsense"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -145,6 +145,22 @@ const KEYS: &[(&str, KeyCode)] = &[
|
||||
("key.keyboard.period", KeyCode::Period),
|
||||
("key.keyboard.slash", KeyCode::Slash),
|
||||
("key.keyboard.caps.lock", KeyCode::CapsLock),
|
||||
("key.keyboard.world.1", KeyCode::IntlBackslash),
|
||||
("key.keyboard.international1", KeyCode::IntlRo),
|
||||
("key.keyboard.international3", KeyCode::IntlYen),
|
||||
("key.keyboard.lang1", KeyCode::Lang1),
|
||||
("key.keyboard.lang2", KeyCode::Lang2),
|
||||
("key.keyboard.lang3", KeyCode::Lang3),
|
||||
("key.keyboard.lang4", KeyCode::Lang4),
|
||||
("key.keyboard.lang5", KeyCode::Lang5),
|
||||
("key.keyboard.help", KeyCode::Help),
|
||||
("key.keyboard.select", KeyCode::Select),
|
||||
("key.keyboard.again", KeyCode::Again),
|
||||
("key.keyboard.undo", KeyCode::Undo),
|
||||
("key.keyboard.cut", KeyCode::Cut),
|
||||
("key.keyboard.copy", KeyCode::Copy),
|
||||
("key.keyboard.paste", KeyCode::Paste),
|
||||
("key.keyboard.find", KeyCode::Find),
|
||||
("key.keyboard.f1", KeyCode::F1),
|
||||
("key.keyboard.f2", KeyCode::F2),
|
||||
("key.keyboard.f3", KeyCode::F3),
|
||||
@@ -157,6 +173,18 @@ const KEYS: &[(&str, KeyCode)] = &[
|
||||
("key.keyboard.f10", KeyCode::F10),
|
||||
("key.keyboard.f11", KeyCode::F11),
|
||||
("key.keyboard.f12", KeyCode::F12),
|
||||
("key.keyboard.f13", KeyCode::F13),
|
||||
("key.keyboard.f14", KeyCode::F14),
|
||||
("key.keyboard.f15", KeyCode::F15),
|
||||
("key.keyboard.f16", KeyCode::F16),
|
||||
("key.keyboard.f17", KeyCode::F17),
|
||||
("key.keyboard.f18", KeyCode::F18),
|
||||
("key.keyboard.f19", KeyCode::F19),
|
||||
("key.keyboard.f20", KeyCode::F20),
|
||||
("key.keyboard.f21", KeyCode::F21),
|
||||
("key.keyboard.f22", KeyCode::F22),
|
||||
("key.keyboard.f23", KeyCode::F23),
|
||||
("key.keyboard.f24", KeyCode::F24),
|
||||
("key.keyboard.print.screen", KeyCode::PrintScreen),
|
||||
("key.keyboard.scroll.lock", KeyCode::ScrollLock),
|
||||
("key.keyboard.pause", KeyCode::Pause),
|
||||
@@ -188,6 +216,36 @@ const KEYS: &[(&str, KeyCode)] = &[
|
||||
("key.keyboard.keypad.0", KeyCode::Numpad0),
|
||||
("key.keyboard.keypad.period", KeyCode::NumpadDecimal),
|
||||
("key.keyboard.keypad.equal", KeyCode::NumpadEqual),
|
||||
("key.keyboard.keypad.comma", KeyCode::NumpadComma),
|
||||
("key.keyboard.keypad.backspace", KeyCode::NumpadBackspace),
|
||||
(
|
||||
"key.keyboard.keypad.left.parenthesis",
|
||||
KeyCode::NumpadParenLeft,
|
||||
),
|
||||
(
|
||||
"key.keyboard.keypad.right.parenthesis",
|
||||
KeyCode::NumpadParenRight,
|
||||
),
|
||||
("key.keyboard.keypad.hash", KeyCode::NumpadHash),
|
||||
(
|
||||
"key.keyboard.keypad.memory.store",
|
||||
KeyCode::NumpadMemoryStore,
|
||||
),
|
||||
(
|
||||
"key.keyboard.keypad.memory.recall",
|
||||
KeyCode::NumpadMemoryRecall,
|
||||
),
|
||||
(
|
||||
"key.keyboard.keypad.memory.clear",
|
||||
KeyCode::NumpadMemoryClear,
|
||||
),
|
||||
("key.keyboard.keypad.memory.add", KeyCode::NumpadMemoryAdd),
|
||||
(
|
||||
"key.keyboard.keypad.memory.subtract",
|
||||
KeyCode::NumpadMemorySubtract,
|
||||
),
|
||||
("key.keyboard.keypad.clear", KeyCode::NumpadClear),
|
||||
("key.keyboard.keypad.clear.entry", KeyCode::NumpadClearEntry),
|
||||
("key.keyboard.menu", KeyCode::ContextMenu),
|
||||
("key.keyboard.left.control", KeyCode::ControlLeft),
|
||||
("key.keyboard.left.shift", KeyCode::ShiftLeft),
|
||||
File renamed without changes.
@@ -0,0 +1,14 @@
|
||||
//! The client's input layer, below gameplay and screens: key bindings and their
|
||||
//! names, toggle keys, mouse settings, controllers, the clipboard and window
|
||||
//! options. horizon-client re-exports each module under its old path
|
||||
//! (`horizon_client::keybinds`, ...).
|
||||
#![forbid(unsafe_code)]
|
||||
pub mod clipboard;
|
||||
pub mod gamepad;
|
||||
pub mod input;
|
||||
pub mod interaction_input;
|
||||
pub mod key_names;
|
||||
pub mod keybinds;
|
||||
pub mod keymapping;
|
||||
pub mod mouse;
|
||||
pub mod window;
|
||||
@@ -324,6 +324,24 @@ pub fn ease_in_out(t: f64) -> f64 {
|
||||
0.5 - 0.5 * (std::f64::consts::PI * t).cos()
|
||||
}
|
||||
|
||||
/// `MouseHandler.grabMouse` / `releaseMouse`: the mouse position is the
|
||||
/// window's centre whenever the pointer's grab state changes, so a screen
|
||||
/// opened from gameplay starts with the cursor in the middle. A release of
|
||||
/// an already free pointer (or a grab of an already grabbed one) leaves the
|
||||
/// position where it is, as vanilla does.
|
||||
pub fn mouse_position_after_grab_change(
|
||||
grab: bool,
|
||||
was_grabbed: bool,
|
||||
size: [u32; 2],
|
||||
scale: f32,
|
||||
) -> Option<(f32, f32)> {
|
||||
if grab == was_grabbed {
|
||||
None
|
||||
} else {
|
||||
Some((size[0] as f32 / 2.0 / scale, size[1] as f32 / 2.0 / scale))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -485,4 +503,27 @@ mod tests {
|
||||
assert_eq!(look.on_scroll(0.0, 0.4, false, 1.0), None);
|
||||
assert_eq!(look.on_scroll(0.0, 0.7, false, 1.0), Some((1, (0, 1))));
|
||||
}
|
||||
#[test]
|
||||
fn the_mouse_position_is_the_centre_when_the_grab_changes() {
|
||||
// A grab of a free pointer and a release of a grabbed one both put
|
||||
// the position at the window's centre (`MouseHandler.grabMouse`,
|
||||
// `MouseHandler.releaseMouse`).
|
||||
assert_eq!(
|
||||
mouse_position_after_grab_change(true, false, [1920, 1080], 1.0),
|
||||
Some((960.0, 540.0))
|
||||
);
|
||||
assert_eq!(
|
||||
mouse_position_after_grab_change(false, true, [1920, 1080], 2.0),
|
||||
Some((480.0, 270.0))
|
||||
);
|
||||
// Without a change of the grab state the position stays where it is.
|
||||
assert_eq!(
|
||||
mouse_position_after_grab_change(false, false, [1920, 1080], 1.0),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
mouse_position_after_grab_change(true, true, [1920, 1080], 1.0),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -0,0 +1,67 @@
|
||||
[package]
|
||||
name = "horizon-client-render"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.90"
|
||||
description = "The client's world data, rendering and screens: chunks, meshing, light, streaming, the renderer, the HUD, menus and settings; no gameplay"
|
||||
|
||||
[dependencies]
|
||||
horizon-assets = { path = "../horizon-assets" }
|
||||
horizon-bench = { path = "../horizon-bench" }
|
||||
horizon-client-core = { path = "../horizon-client-core" }
|
||||
horizon-client-input = { path = "../horizon-client-input" }
|
||||
horizon-command = { path = "../horizon-command" }
|
||||
horizon-core = { path = "../horizon-core" }
|
||||
horizon-hdr = { path = "../horizon-hdr" }
|
||||
horizon-items = { path = "../horizon-items" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
horizon-world = { path = "../horizon-world" }
|
||||
ab_glyph = "0.2.32"
|
||||
bytemuck = { version = "1.24", features = ["derive"] }
|
||||
# Sound (ALSA on Linux); the `audio` feature.
|
||||
cpal = { version = "0.16", optional = true }
|
||||
chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"] }
|
||||
flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] }
|
||||
glam = "0.30"
|
||||
log = "0.4"
|
||||
miniz_oxide = "0.8"
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
|
||||
png = "0.18"
|
||||
pollster = "0.4"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1.0.151", features = ["raw_value"] }
|
||||
sha2 = "0.10.9"
|
||||
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
|
||||
unicode-bidi = "0.3.18"
|
||||
ureq = { version = "2", default-features = false, features = ["tls"] }
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
|
||||
winit = "0.30.13"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
rustix = { version = "1", default-features = false, features = ["std", "time"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
|
||||
|
||||
[target.'cfg(not(target_os = "macos"))'.dependencies]
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
|
||||
|
||||
[features]
|
||||
# Sound output: horizon-client's `audio` feature.
|
||||
audio = ["dep:cpal"]
|
||||
# Helpers horizon-client's tests build their fixtures with.
|
||||
test-support = []
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
horizon-items = { path = "../horizon-items", features = ["test-support"] }
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
|
||||
@@ -42,11 +42,11 @@ impl HeightmapState for StateFacts {
|
||||
}
|
||||
#[derive(Clone)]
|
||||
pub struct Properties(Arc<Vec<StateFacts>>);
|
||||
#[cfg(test)]
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
impl Properties {
|
||||
/// Heightmap facts for tests, per state: (air, has a fluid, blocks
|
||||
/// motion, blocks motion but is not leaves). State 0 is minecraft:air.
|
||||
pub(crate) fn for_tests(states: &[(bool, bool, bool, bool)]) -> Self {
|
||||
pub fn for_tests(states: &[(bool, bool, bool, bool)]) -> Self {
|
||||
Self(Arc::new(
|
||||
states
|
||||
.iter()
|
||||
@@ -196,7 +196,7 @@ impl Assets {
|
||||
impl RenderAssets {
|
||||
pub fn load(root: &Path, assets: &Assets) -> Result<Self> {
|
||||
let registry = assets.registry;
|
||||
let mut sprites = crate::sprites::Sprites::default();
|
||||
let mut sprites = crate::sprites::Sprites::with_disk_cache(root);
|
||||
for name in MATERIALS {
|
||||
sprites.insert(root, &format!("minecraft:block/{name}"))?;
|
||||
}
|
||||
@@ -217,7 +217,10 @@ impl RenderAssets {
|
||||
for stage in 0..10 {
|
||||
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
|
||||
}
|
||||
sprites.save_disk_cache();
|
||||
sprites.finish();
|
||||
// `finish` grows the buffer by doubling: keep only the bytes in use.
|
||||
sprites.pixels.shrink_to_fit();
|
||||
Ok(Self {
|
||||
sprite_size: sprites.size,
|
||||
sprite_minimum_size: sprites.minimum_size,
|
||||
@@ -234,3 +237,81 @@ impl RenderAssets {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::items::ConsumeEffect;
|
||||
|
||||
#[test]
|
||||
fn always_edible_and_consume_effects_loaded() {
|
||||
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3");
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let items = crate::items::Items::load(&root, assets.registry, &facts).unwrap();
|
||||
|
||||
let apple = items.get("minecraft:golden_apple").expect("golden apple");
|
||||
assert!(apple.can_always_eat);
|
||||
assert!(apple.food.is_some());
|
||||
assert!(!apple.consumable.as_ref().unwrap().effects.is_empty());
|
||||
|
||||
let enchanted = items
|
||||
.get("minecraft:enchanted_golden_apple")
|
||||
.expect("enchanted golden apple");
|
||||
assert!(enchanted.can_always_eat);
|
||||
assert!(enchanted.food.is_some());
|
||||
assert!(!enchanted.consumable.as_ref().unwrap().effects.is_empty());
|
||||
|
||||
let beef = items.get("minecraft:cooked_beef").expect("cooked beef");
|
||||
assert!(!beef.can_always_eat);
|
||||
assert!(beef.food.is_some());
|
||||
|
||||
// Consumables without food, their style and their remainders.
|
||||
use horizon_world::consumable::UseAnimation;
|
||||
let milk = items.get("minecraft:milk_bucket").expect("milk bucket");
|
||||
let drink = milk.consumable.as_ref().unwrap();
|
||||
assert!(milk.food.is_none());
|
||||
assert_eq!(drink.effects, [ConsumeEffect::ClearAllEffects]);
|
||||
assert_eq!((drink.ticks(), drink.animation), (32, UseAnimation::Drink));
|
||||
assert_eq!(drink.sound, "minecraft:entity.generic.drink");
|
||||
assert!(!drink.particles);
|
||||
assert_eq!(milk.use_remainder.as_deref(), Some("minecraft:bucket"));
|
||||
let honey = items.get("minecraft:honey_bottle").expect("honey bottle");
|
||||
let sip = honey.consumable.as_ref().unwrap();
|
||||
assert_eq!(sip.ticks(), 40);
|
||||
assert_eq!(sip.sound, "minecraft:item.honey_bottle.drink");
|
||||
assert_eq!(
|
||||
sip.effects,
|
||||
[ConsumeEffect::RemoveEffects(vec![
|
||||
"minecraft:poison".into()
|
||||
])]
|
||||
);
|
||||
assert_eq!(
|
||||
honey.use_remainder.as_deref(),
|
||||
Some("minecraft:glass_bottle")
|
||||
);
|
||||
let kelp = items.get("minecraft:dried_kelp").unwrap();
|
||||
let bite = kelp.consumable.as_ref().unwrap();
|
||||
assert_eq!((bite.ticks(), bite.animation), (16, UseAnimation::Eat));
|
||||
assert_eq!(bite.sound, "minecraft:entity.generic.eat");
|
||||
assert!(bite.particles && kelp.use_remainder.is_none());
|
||||
let chorus = items.get("minecraft:chorus_fruit").unwrap();
|
||||
assert_eq!(
|
||||
chorus.consumable.as_ref().unwrap().effects,
|
||||
[ConsumeEffect::TeleportRandomly(
|
||||
horizon_world::teleport_consume::TeleportConsume {
|
||||
diameter: 16.0,
|
||||
directional_particles: true,
|
||||
}
|
||||
)]
|
||||
);
|
||||
let chicken = items.get("minecraft:chicken").unwrap();
|
||||
assert!(matches!(
|
||||
chicken.consumable.as_ref().unwrap().effects[..],
|
||||
[ConsumeEffect::ApplyEffects { probability, .. }] if probability == 0.3
|
||||
));
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -187,7 +187,14 @@ pub fn list_textures(root: &Path) -> Vec<String> {
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
// `file_type` comes with the listing; `Path::is_dir` is a stat per
|
||||
// entry, kept for links (which it follows).
|
||||
let dir = match e.file_type() {
|
||||
Ok(t) if t.is_symlink() => p.is_dir(),
|
||||
Ok(t) => t.is_dir(),
|
||||
Err(_) => p.is_dir(),
|
||||
};
|
||||
if dir {
|
||||
stack.push(p);
|
||||
} else if p.extension().is_some_and(|x| x == "png")
|
||||
&& let Ok(rel) = p.strip_prefix(&base)
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -20,12 +20,68 @@ pub struct SpawnEntry {
|
||||
/// A `ConstantInt` count (sampling draws nothing).
|
||||
pub constant: bool,
|
||||
}
|
||||
/// A `natural_mob_spawns` / `spawn_overrides` entry list.
|
||||
pub fn parse_entries(list: &Value) -> Vec<SpawnEntry> {
|
||||
list.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let count = &e["count"];
|
||||
let (min, max, constant) = match count {
|
||||
Value::Number(n) => {
|
||||
let n = n.as_u64().unwrap_or(1) as u32;
|
||||
(n, n, true)
|
||||
}
|
||||
c if c["type"].as_str() == Some("minecraft:constant") => {
|
||||
let n = c["value"].as_u64().unwrap_or(1) as u32;
|
||||
(n, n, true)
|
||||
}
|
||||
c => (
|
||||
c["min_inclusive"].as_u64().unwrap_or(1) as u32,
|
||||
c["max_inclusive"].as_u64().unwrap_or(1) as u32,
|
||||
false,
|
||||
),
|
||||
};
|
||||
Some(SpawnEntry {
|
||||
entity: e["type"].as_str()?.to_owned(),
|
||||
weight: e["weight"].as_u64().unwrap_or(1) as u32,
|
||||
min,
|
||||
max,
|
||||
constant,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// `MobSpawnSettings.MobSpawnCost`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct SpawnCost {
|
||||
pub energy_budget: f64,
|
||||
pub charge: f64,
|
||||
}
|
||||
/// The `spawn_costs` of a `natural_mob_spawns` value.
|
||||
pub fn parse_costs(costs: &Value) -> HashMap<String, SpawnCost> {
|
||||
costs
|
||||
.as_object()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|(k, c)| {
|
||||
(
|
||||
k.clone(),
|
||||
SpawnCost {
|
||||
energy_budget: c["energy_budget"].as_f64().unwrap_or(0.0),
|
||||
charge: c["charge"].as_f64().unwrap_or(0.0),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BiomeInfo {
|
||||
/// `natural_mob_spawns` by category (creature, monster, ...).
|
||||
pub spawns: HashMap<String, Vec<SpawnEntry>>,
|
||||
/// `natural_mob_spawns` spawn costs by entity type.
|
||||
pub spawn_costs: HashMap<String, crate::mobs::spawn_selection::SpawnCost>,
|
||||
pub spawn_costs: HashMap<String, SpawnCost>,
|
||||
/// The `gameplay/creature_world_gen_spawn_probability` attribute.
|
||||
pub creature_probability: f32,
|
||||
/// The `gameplay/increased_fire_burnout` attribute (jungles, swamps, peaks,
|
||||
@@ -66,6 +122,13 @@ impl BiomeInfo {
|
||||
}
|
||||
.height_adjusted(pos, sea_level)
|
||||
}
|
||||
/// `Biome.getPrecipitationAt` in the Overworld (sea level 63): None
|
||||
/// without precipitation, else whether it falls as snow
|
||||
/// (`getHeightAdjustedTemperature` below 0.15).
|
||||
pub fn snows_at(&self, x: i32, y: i32, z: i32) -> Option<bool> {
|
||||
self.precipitation
|
||||
.then(|| self.temperature_at(BlockPos::new(x, y, z), 63) < 0.15)
|
||||
}
|
||||
}
|
||||
pub struct Biomes {
|
||||
pub list: Vec<BiomeInfo>,
|
||||
@@ -143,12 +206,12 @@ impl Biomes {
|
||||
};
|
||||
let categories = &natural["spawns_by_category"];
|
||||
for (category, entries) in categories.as_object().into_iter().flatten() {
|
||||
let list = crate::mobs::spawn_selection::parse_entries(entries);
|
||||
let list = parse_entries(entries);
|
||||
spawns.insert(category.clone(), list);
|
||||
}
|
||||
list.push(BiomeInfo {
|
||||
spawns,
|
||||
spawn_costs: crate::mobs::spawn_selection::parse_costs(&natural["spawn_costs"]),
|
||||
spawn_costs: parse_costs(&natural["spawn_costs"]),
|
||||
creature_probability:
|
||||
attributes["minecraft:gameplay/creature_world_gen_spawn_probability"]
|
||||
.as_f64()
|
||||
+11
-10
@@ -23,9 +23,10 @@
|
||||
//! Which states tick is a table by state id, built by the game from the block
|
||||
//! registry; whether a slot is made is decided when the slot is promoted at
|
||||
//! the start of the next tick, from the block state then.
|
||||
use crate::containers::BlockEntity;
|
||||
use crate::chunk::block_entity::BlockEntity;
|
||||
use crate::fast_hash::{FxHashMap, FxHashSet};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub type Pos = (i32, i32, i32);
|
||||
|
||||
@@ -46,7 +47,7 @@ pub enum Ticking {
|
||||
pub struct BlockEntities {
|
||||
map: HashMap<Pos, BlockEntity>,
|
||||
/// `Level.blockEntityTickers`: positions and the slot each was given.
|
||||
tickers: Vec<(Pos, u32)>,
|
||||
tickers: Arc<Vec<(Pos, u32)>>,
|
||||
/// `LevelChunk.tickersInLevel`: the live slot of each ticking position.
|
||||
live: FxHashMap<Pos, u32>,
|
||||
/// Positions that may need a slot, in the order vanilla would have made
|
||||
@@ -233,7 +234,7 @@ impl BlockEntities {
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.map.clear();
|
||||
self.tickers.clear();
|
||||
self.tickers = Arc::default();
|
||||
self.live.clear();
|
||||
self.added.clear();
|
||||
self.pending.clear();
|
||||
@@ -274,7 +275,7 @@ impl BlockEntities {
|
||||
/// and freed ones leave it. `state` reads the block state at a position.
|
||||
/// Returns the slots to tick, in order; check each with `is_live` before
|
||||
/// ticking it, as the ticks before it can free it.
|
||||
pub fn tickers(&mut self, state: impl Fn(Pos) -> u32) -> Vec<(Pos, u32)> {
|
||||
pub fn tickers(&mut self, state: impl Fn(Pos) -> u32) -> Arc<Vec<(Pos, u32)>> {
|
||||
for (pos, token) in std::mem::take(&mut self.added) {
|
||||
if self.pending.get(&pos) != Some(&token) {
|
||||
continue;
|
||||
@@ -288,21 +289,21 @@ impl BlockEntities {
|
||||
}
|
||||
let slot = self.next();
|
||||
self.live.insert(pos, slot);
|
||||
self.tickers.push((pos, slot));
|
||||
Arc::make_mut(&mut self.tickers).push((pos, slot));
|
||||
}
|
||||
if !std::mem::take(&mut self.freed) && self.out.is_empty() {
|
||||
return self.tickers.clone();
|
||||
return Arc::clone(&self.tickers);
|
||||
}
|
||||
self.out.clear();
|
||||
let (live, map) = (&mut self.live, &self.map);
|
||||
self.tickers.retain(|(p, slot)| {
|
||||
Arc::make_mut(&mut self.tickers).retain(|(p, slot)| {
|
||||
let mine = live.get(p) == Some(slot);
|
||||
if mine && !map.contains_key(p) {
|
||||
live.remove(p);
|
||||
}
|
||||
mine && map.contains_key(p)
|
||||
});
|
||||
self.tickers.clone()
|
||||
Arc::clone(&self.tickers)
|
||||
}
|
||||
/// Whether a slot from `tickers` still ticks.
|
||||
pub fn is_live(&self, (pos, slot): (Pos, u32)) -> bool {
|
||||
@@ -329,8 +330,8 @@ mod tests {
|
||||
}
|
||||
fn order(b: &mut BlockEntities, states: &HashMap<Pos, u32>) -> Vec<Pos> {
|
||||
b.tickers(|p| states.get(&p).copied().unwrap_or(0))
|
||||
.into_iter()
|
||||
.map(|(p, _)| p)
|
||||
.iter()
|
||||
.map(|(p, _)| *p)
|
||||
.collect()
|
||||
}
|
||||
|
||||
+2
-1
@@ -435,7 +435,8 @@ pub struct BellTick {
|
||||
pub ended: Option<(Vec<([f64; 3], i32)>, Vec<u64>)>,
|
||||
}
|
||||
|
||||
fn closer_to_center_than(pos: [i32; 3], p: [f64; 3], distance: f64) -> bool {
|
||||
/// `Vec3i.closerToCenterThan`: `p` is within `distance` of the block's centre.
|
||||
pub fn closer_to_center_than(pos: [i32; 3], p: [f64; 3], distance: f64) -> bool {
|
||||
let dx = f64::from(pos[0]) + 0.5 - p[0];
|
||||
let dy = f64::from(pos[1]) + 0.5 - p[1];
|
||||
let dz = f64::from(pos[2]) + 0.5 - p[2];
|
||||
+362
-25
@@ -5,7 +5,11 @@
|
||||
//! (`entity-models.json`); transforms, animation and textures follow each
|
||||
//! vanilla renderer. Faces are lit like the `entity` shader: the lightmap at
|
||||
//! the block plus `minecraft_mix_light` with the level's two diffuse lights.
|
||||
use crate::{hud::Rect, mesher::Vertex, world::World};
|
||||
use crate::{
|
||||
hud::Rect,
|
||||
mesher::{CULL_BACK, Vertex},
|
||||
world::World,
|
||||
};
|
||||
use glam::{DVec3, Mat4, Quat, Vec3};
|
||||
use horizon_core::geometry::{
|
||||
Direction,
|
||||
@@ -93,6 +97,16 @@ pub fn diffuse(normal: Vec3, lights: (Vec3, Vec3)) -> f32 {
|
||||
pub fn vertex_light(packed: u8) -> u32 {
|
||||
(u32::from(packed >> 4) * 16) | ((u32::from(packed & 15) * 16) << 8)
|
||||
}
|
||||
/// `ChestRenderer.xmasTextures`: `SpecialDates.isExtendedChristmas` (24 to 26
|
||||
/// December in the local time zone), read once when the renderer is created.
|
||||
fn extended_christmas() -> bool {
|
||||
use chrono::Datelike;
|
||||
static XMAS: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*XMAS.get_or_init(|| {
|
||||
let now = chrono::Local::now();
|
||||
now.month() == 12 && (24..=26).contains(&now.day())
|
||||
})
|
||||
}
|
||||
fn pack_color(rgb: u32, alpha: u32, brightness: f32) -> u32 {
|
||||
let c = |shift: u32| (((rgb >> shift) & 255) as f32 * brightness) as u32;
|
||||
alpha << 24 | c(0) << 16 | c(8) << 8 | c(16)
|
||||
@@ -583,6 +597,18 @@ fn first_line(line: &str, max_width: f32, advance: &dyn Fn(char) -> f32) -> Stri
|
||||
line.to_owned()
|
||||
}
|
||||
|
||||
/// `AbstractSignRenderer.isOutlineVisible`: the camera within 16 blocks of
|
||||
/// the sign's centre.
|
||||
fn sign_text_near(pos: [i32; 3], camera: DVec3) -> bool {
|
||||
DVec3::new(
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]) + 0.5,
|
||||
f64::from(pos[2]) + 0.5,
|
||||
)
|
||||
.distance_squared(camera)
|
||||
< 256.0
|
||||
}
|
||||
|
||||
/// `AbstractSignRenderer.submitSignText`: four centred lines, glowing text
|
||||
/// full-bright with an 8x outline in the dark colour.
|
||||
fn sign_text_calls(
|
||||
@@ -743,6 +769,9 @@ pub struct Scene<'a> {
|
||||
pub skull: &'a dyn Fn([i32; 3]) -> Option<String>,
|
||||
/// Font glyph cell (none for blank glyphs such as the space) and advance.
|
||||
pub glyph: &'a dyn Fn(char) -> (Option<Rect>, f32),
|
||||
/// Changes whenever `glyph` may answer differently (`Font::epoch`): sign
|
||||
/// text laid out under another value is laid out again.
|
||||
pub glyphs: u64,
|
||||
}
|
||||
|
||||
pub struct BlockEntityRenderer {
|
||||
@@ -754,7 +783,30 @@ pub struct BlockEntityRenderer {
|
||||
pot_patterns: HashMap<String, String>,
|
||||
/// `Block.shouldRenderFace` for End gateway faces (its occlusion pair cache needs a lock).
|
||||
visibility: Option<Mutex<FaceVisibility>>,
|
||||
sign_texts: Mutex<SignTexts>,
|
||||
}
|
||||
/// Laid-out sign text by position. `AbstractSignRenderer` lays the text out
|
||||
/// again each frame, glyph by glyph; its vertices only change with what is
|
||||
/// in the key, so they are kept relative to the block and moved to the camera.
|
||||
#[derive(Default)]
|
||||
struct SignTexts {
|
||||
signs: crate::fast_hash::FxHashMap<[i32; 3], SignTextEntry>,
|
||||
/// Game time of the last sweep of signs out of view.
|
||||
swept: i64,
|
||||
}
|
||||
struct SignTextEntry {
|
||||
state: u32,
|
||||
data: crate::signs::SignData,
|
||||
packed: u8,
|
||||
/// Within the glowing text outline's distance (only kept for glowing text).
|
||||
near: bool,
|
||||
glyphs: u64,
|
||||
/// Game time it was last drawn.
|
||||
used: i64,
|
||||
vertices: Vec<Vertex>,
|
||||
}
|
||||
/// Game ticks a sign's text is kept after it was last drawn.
|
||||
const SIGN_TEXT_KEEP: i64 = 100;
|
||||
const MODELS: [(&str, &str); 27] = [
|
||||
("chest", "minecraft:chest#main"),
|
||||
("double_chest_left", "minecraft:double_chest_left#main"),
|
||||
@@ -884,6 +936,7 @@ impl BlockEntityRenderer {
|
||||
closed_chests,
|
||||
pot_patterns: HashMap::new(),
|
||||
visibility: None,
|
||||
sign_texts: Mutex::default(),
|
||||
}
|
||||
}
|
||||
fn kind(&self, state: u32) -> &Kind {
|
||||
@@ -918,11 +971,81 @@ impl BlockEntityRenderer {
|
||||
let origin = (DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]))
|
||||
- scene.camera)
|
||||
.as_vec3();
|
||||
let base = Mat4::from_translation(origin);
|
||||
let packed = scene.world.light(pos[0], pos[1], pos[2]);
|
||||
if matches!(kind, Kind::Sign { .. }) && scene.texts.is_none() {
|
||||
self.sign_text(scene, *pos, *state, origin, packed, out);
|
||||
continue;
|
||||
}
|
||||
let base = Mat4::from_translation(origin);
|
||||
self.render_one(scene, *pos, kind, base, packed, lights, false, true, out);
|
||||
}
|
||||
}
|
||||
/// A sign's text from the cache, laid out again when its data, light,
|
||||
/// state, outline or font changed.
|
||||
fn sign_text(
|
||||
&self,
|
||||
scene: &Scene,
|
||||
pos: [i32; 3],
|
||||
state: u32,
|
||||
origin: Vec3,
|
||||
packed: u8,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let Some(data) = (scene.sign)(pos) else {
|
||||
return;
|
||||
};
|
||||
let near = (data.front.glowing || data.back.glowing) && sign_text_near(pos, scene.camera);
|
||||
let Ok(mut cache) = self.sign_texts.lock() else {
|
||||
return;
|
||||
};
|
||||
let now = scene.game_time;
|
||||
if (now - cache.swept).abs() >= SIGN_TEXT_KEEP {
|
||||
cache.swept = now;
|
||||
cache
|
||||
.signs
|
||||
.retain(|_, e| (now - SIGN_TEXT_KEEP..=now).contains(&e.used));
|
||||
}
|
||||
let glyphs = scene.glyphs;
|
||||
let signs = &mut cache.signs;
|
||||
if !signs.get(&pos).is_some_and(|e| {
|
||||
(e.state, e.packed, e.near, e.glyphs) == (state, packed, near, glyphs)
|
||||
&& e.data == *data
|
||||
}) {
|
||||
let mut vertices = signs.remove(&pos).map_or_else(Vec::new, |e| e.vertices);
|
||||
vertices.clear();
|
||||
self.render_one(
|
||||
scene,
|
||||
pos,
|
||||
self.kind(state),
|
||||
Mat4::IDENTITY,
|
||||
packed,
|
||||
diffuse_lights(scene.nether),
|
||||
false,
|
||||
true,
|
||||
&mut vertices,
|
||||
);
|
||||
signs.insert(
|
||||
pos,
|
||||
SignTextEntry {
|
||||
state,
|
||||
data: data.clone(),
|
||||
packed,
|
||||
near,
|
||||
glyphs,
|
||||
used: now,
|
||||
vertices,
|
||||
},
|
||||
);
|
||||
}
|
||||
let Some(entry) = signs.get_mut(&pos) else {
|
||||
return;
|
||||
};
|
||||
entry.used = now;
|
||||
out.extend(entry.vertices.iter().map(|v| Vertex {
|
||||
pos: (Vec3::from_array(v.pos) + origin).to_array(),
|
||||
..*v
|
||||
}));
|
||||
}
|
||||
/// One block entity at `pos` drawn under `base` with the given packed light
|
||||
/// (the parity fixture's entry point).
|
||||
pub fn render_block(
|
||||
@@ -938,6 +1061,30 @@ impl BlockEntityRenderer {
|
||||
let lights = diffuse_lights(scene.nether);
|
||||
self.render_one(scene, pos, kind, base, packed, lights, false, false, out);
|
||||
}
|
||||
/// `TheEndGatewayBlockEntity.shouldRenderFace` for each face of the gateway
|
||||
/// at `pos`: the faces its portal is drawn on.
|
||||
pub fn gateway_faces(
|
||||
&self,
|
||||
world: &World,
|
||||
state: BlockStateId,
|
||||
pos: [i32; 3],
|
||||
) -> Vec<Direction> {
|
||||
Direction::ALL
|
||||
.into_iter()
|
||||
.filter(|&d| {
|
||||
let [dx, dy, dz] = d.normal();
|
||||
let neighbor = world.block(pos[0] + dx, pos[1] + dy, pos[2] + dz);
|
||||
self.visibility.as_ref().is_none_or(|v| {
|
||||
v.lock()
|
||||
.map(|mut v| {
|
||||
v.should_render_face(state, neighbor, d, true)
|
||||
.unwrap_or(true)
|
||||
})
|
||||
.unwrap_or(true)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// Whether this block state has a block-entity renderer here.
|
||||
pub fn renders(&self, state: u32) -> bool {
|
||||
!matches!(self.kind(state), Kind::None)
|
||||
@@ -1130,13 +1277,7 @@ impl BlockEntityRenderer {
|
||||
m *= Mat4::from_translation(Vec3::new(0.0, 1.0 / 3.0, 0.046_666_667))
|
||||
* Mat4::from_scale(s * 0.010_416_667);
|
||||
}
|
||||
let near = DVec3::new(
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]) + 0.5,
|
||||
f64::from(pos[2]) + 0.5,
|
||||
)
|
||||
.distance_squared(scene.camera)
|
||||
< 256.0;
|
||||
let near = sign_text_near(pos, scene.camera);
|
||||
let calls = sign_text_calls(scene, data, text, m, packed, near);
|
||||
match scene.texts {
|
||||
Some(sink) => sink.borrow_mut().extend(calls),
|
||||
@@ -1145,11 +1286,25 @@ impl BlockEntityRenderer {
|
||||
}
|
||||
}
|
||||
Kind::Chest {
|
||||
block,
|
||||
texture,
|
||||
part,
|
||||
y_rot,
|
||||
..
|
||||
} => {
|
||||
// ChestRenderer.getChestMaterial: normal and trapped chests use the christmas
|
||||
// sprites on 24-26 December. Copper and ender chests keep theirs.
|
||||
let texture = if matches!(block.as_str(), "chest" | "trapped_chest")
|
||||
&& extended_christmas()
|
||||
{
|
||||
match part {
|
||||
ChestPart::Single => "entity/chest/christmas",
|
||||
ChestPart::Left => "entity/chest/christmas_left",
|
||||
ChestPart::Right => "entity/chest/christmas_right",
|
||||
}
|
||||
} else {
|
||||
*texture
|
||||
};
|
||||
let model = match part {
|
||||
ChestPart::Single => "chest",
|
||||
ChestPart::Left => "double_chest_left",
|
||||
@@ -1392,6 +1547,7 @@ impl BlockEntityRenderer {
|
||||
m *= around(Mat4::from_rotation_y(turn * decay), pivot);
|
||||
}
|
||||
}
|
||||
let first = out.len();
|
||||
if let Some(paint) =
|
||||
paint("entity/decorated_pot/decorated_pot_base", 0xFFFFFF, packed)
|
||||
{
|
||||
@@ -1409,6 +1565,11 @@ impl BlockEntityRenderer {
|
||||
draw_part(part, side, m, &none, paint, out);
|
||||
}
|
||||
}
|
||||
// `DecoratedPotRenderer` submits every part with `entitySolid`, which culls
|
||||
// back faces: the flat `top` and `bottom` planes show one side each.
|
||||
for vertex in &mut out[first..] {
|
||||
vertex.normal = CULL_BACK;
|
||||
}
|
||||
}
|
||||
Kind::EndPortal => {
|
||||
// TheEndPortalRenderer.TRANSFORMATION: y from 6/16 to 12/16; only the
|
||||
@@ -1439,22 +1600,8 @@ impl BlockEntityRenderer {
|
||||
out,
|
||||
);
|
||||
}
|
||||
// AbstractEndPortalRenderer.extractRenderState: TheEndGatewayBlockEntity.shouldRenderFace.
|
||||
let faces: Vec<Direction> = Direction::ALL
|
||||
.into_iter()
|
||||
.filter(|&d| {
|
||||
let [dx, dy, dz] = d.normal();
|
||||
let neighbor = scene.world.block(pos[0] + dx, pos[1] + dy, pos[2] + dz);
|
||||
self.visibility.as_ref().is_none_or(|v| {
|
||||
v.lock()
|
||||
.map(|mut v| {
|
||||
v.should_render_face(BlockStateId(*state), neighbor, d, true)
|
||||
.unwrap_or(true)
|
||||
})
|
||||
.unwrap_or(true)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// AbstractEndPortalRenderer.extractRenderState.
|
||||
let faces = self.gateway_faces(scene.world, BlockStateId(*state), pos);
|
||||
portal_cube(base, &faces, END_GATEWAY_LAYER, out);
|
||||
}
|
||||
Kind::Conduit => {
|
||||
@@ -1910,6 +2057,7 @@ mod tests {
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyphs: 0,
|
||||
};
|
||||
// Compare the cached world path with the original model renderer,
|
||||
// including atlas replacement, current light and animated fallback.
|
||||
@@ -2110,6 +2258,128 @@ mod tests {
|
||||
assert_eq!(out[2].uv, [0.5, 0.5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_sign_text_matches_and_follows_changes() {
|
||||
let renderer = BlockEntityRenderer {
|
||||
models: HashMap::new(),
|
||||
kinds: vec![
|
||||
Kind::Sign {
|
||||
hanging: false,
|
||||
wall: false,
|
||||
angle: 180.0,
|
||||
},
|
||||
Kind::Sign {
|
||||
hanging: true,
|
||||
wall: true,
|
||||
angle: 90.0,
|
||||
},
|
||||
],
|
||||
closed_chests: Default::default(),
|
||||
pot_patterns: HashMap::new(),
|
||||
visibility: None,
|
||||
sign_texts: Mutex::default(),
|
||||
};
|
||||
let world = World::new(BlockStateId(0), 1);
|
||||
let sprites = HashMap::new();
|
||||
let text = |line: &str, glowing| crate::signs::SignText {
|
||||
lines: [line.into(), "two".into(), String::new(), "four".into()],
|
||||
color: 3,
|
||||
glowing,
|
||||
};
|
||||
let first_text = crate::signs::SignData {
|
||||
front: text("one", false),
|
||||
..Default::default()
|
||||
};
|
||||
let changed = crate::signs::SignData {
|
||||
front: text("changed", false),
|
||||
..Default::default()
|
||||
};
|
||||
let glowing = crate::signs::SignData {
|
||||
back: text("glow", true),
|
||||
..changed.clone()
|
||||
};
|
||||
let wide = std::cell::Cell::new(6.0);
|
||||
let glyph = |c: char| {
|
||||
let u = f32::from(c as u8 % 16) / 16.0;
|
||||
let rect = Rect {
|
||||
u0: u,
|
||||
v0: 0.0,
|
||||
u1: u + 0.0625,
|
||||
v1: 0.0625,
|
||||
w: 8,
|
||||
h: 8,
|
||||
};
|
||||
((c != ' ').then_some(rect), wide.get())
|
||||
};
|
||||
let (first_sign, changed_sign, glowing_sign) = (
|
||||
|_| Some(&first_text),
|
||||
|_| Some(&changed),
|
||||
|_| Some(&glowing),
|
||||
);
|
||||
let mut scene = Scene {
|
||||
camera: DVec3::new(3.25, 70.5, -4.75),
|
||||
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: &first_sign,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &glyph,
|
||||
glyphs: 0,
|
||||
};
|
||||
let check = |scene: &Scene, pos: [i32; 3], state: u32| {
|
||||
let mut cached = Vec::new();
|
||||
renderer.render(scene, [(pos, state)].iter(), &mut cached);
|
||||
let corner = DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]));
|
||||
let mut original = Vec::new();
|
||||
renderer.render_one(
|
||||
scene,
|
||||
pos,
|
||||
renderer.kind(state),
|
||||
Mat4::from_translation((corner - scene.camera).as_vec3()),
|
||||
world.light(pos[0], pos[1], pos[2]),
|
||||
diffuse_lights(false),
|
||||
false,
|
||||
true,
|
||||
&mut original,
|
||||
);
|
||||
assert!(!cached.is_empty());
|
||||
assert_eq!(cached.len(), original.len());
|
||||
for (a, b) in cached.iter().zip(&original) {
|
||||
assert!((Vec3::from_array(a.pos) - Vec3::from_array(b.pos)).length() < 1e-5);
|
||||
assert_eq!((a.uv, a.color, a.light), (b.uv, b.color, b.light));
|
||||
}
|
||||
cached.len()
|
||||
};
|
||||
let (pos, other) = ([1, 70, 2], [-3, 64, 9]);
|
||||
let first = check(&scene, pos, 0);
|
||||
// The camera moves: the same text, moved.
|
||||
scene.camera.x += 0.5;
|
||||
check(&scene, pos, 0);
|
||||
// New text, glowing text near and far, another state, other glyphs.
|
||||
scene.sign = &changed_sign;
|
||||
assert_ne!(check(&scene, pos, 0), first);
|
||||
scene.sign = &glowing_sign;
|
||||
check(&scene, pos, 0);
|
||||
scene.camera.z -= 20.0;
|
||||
check(&scene, pos, 0);
|
||||
check(&scene, pos, 1);
|
||||
check(&scene, other, 1);
|
||||
wide.set(5.0);
|
||||
scene.glyphs = 1;
|
||||
check(&scene, pos, 1);
|
||||
// Signs out of view for a while are dropped.
|
||||
scene.game_time = 2 * SIGN_TEXT_KEEP;
|
||||
check(&scene, pos, 0);
|
||||
assert_eq!(renderer.sign_texts.lock().unwrap().signs.len(), 1);
|
||||
}
|
||||
#[test]
|
||||
fn first_line_wraps_on_bytes_without_splitting_a_char() {
|
||||
let advance = |c: char| if c == ' ' { 4.0 } else { 6.0 };
|
||||
@@ -2118,4 +2388,71 @@ mod tests {
|
||||
assert_eq!(first_line("\u{6771}", 3.0, &advance), "\u{6771}");
|
||||
assert_eq!(first_line("ab\ncd", 100.0, &advance), "ab");
|
||||
}
|
||||
/// `DecoratedPotRenderer` submits every part with `RenderTypes.entitySolid`,
|
||||
/// whose pipeline culls back faces. The pot's `top` and `bottom` are flat
|
||||
/// boxes, so without that cull both sides of each plane are drawn and they
|
||||
/// z-fight: the pot renders wrong.
|
||||
#[test]
|
||||
fn the_decorated_pot_culls_its_back_faces() {
|
||||
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("entity-models.json").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let registry = assets.registry;
|
||||
let renderer = BlockEntityRenderer::load(&root, registry).unwrap();
|
||||
let state = registry.default_state("minecraft:decorated_pot").unwrap();
|
||||
let sprites: HashMap<String, Rect> = [
|
||||
"entity/decorated_pot/decorated_pot_base",
|
||||
"entity/decorated_pot/decorated_pot_side",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
(
|
||||
name.to_owned(),
|
||||
Rect {
|
||||
u0: 0.25,
|
||||
v0: 0.125,
|
||||
u1: 0.375,
|
||||
v1: 0.25,
|
||||
w: 16,
|
||||
h: 16,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let world = World::new(BlockStateId(0), 1);
|
||||
let scene = Scene {
|
||||
camera: DVec3::ZERO,
|
||||
orientation: Quat::IDENTITY,
|
||||
anim: &|_| BlockAnim::default(),
|
||||
texts: None,
|
||||
partial: 0.5,
|
||||
game_time: 0,
|
||||
max_y: 320,
|
||||
world: &world,
|
||||
sprites: &sprites,
|
||||
nether: false,
|
||||
lid: &|_| 0.0,
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyphs: 0,
|
||||
};
|
||||
let mut verts = Vec::new();
|
||||
renderer.render_block(&scene, [0; 3], state.0, Mat4::IDENTITY, 0xff, &mut verts);
|
||||
// Vanilla submits 28 quads for a plain pot, and the cull keeps them all.
|
||||
assert_eq!(
|
||||
verts.len() / 4,
|
||||
28,
|
||||
"the pot submits all of vanilla's quads"
|
||||
);
|
||||
assert!(
|
||||
verts.iter().all(|v| v.normal == CULL_BACK),
|
||||
"entitySolid culls the pot's back faces"
|
||||
);
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+19
-21
@@ -2,25 +2,31 @@
|
||||
//! in vanilla, ported as tables), data-driven `minecraft:brewing` recipes from
|
||||
//! `data/minecraft/recipe/brewing`, potion colours and tooltips, and
|
||||
//! `BrewingStandBlockEntity.serverTick`.
|
||||
pub use crate::chunk::block_entity::BrewingStand;
|
||||
use crate::{
|
||||
containers::Providers,
|
||||
items::{ItemStack, Items},
|
||||
providers::Providers,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashSet, fs, path::Path, sync::Arc};
|
||||
use std::{collections::HashSet, fs, path::Path};
|
||||
|
||||
pub use horizon_client_core::potions::{
|
||||
BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, effect, namespaced,
|
||||
potion, potion_color,
|
||||
};
|
||||
/// `MobEffectUtil.formatDuration` at 20 TPS: m:ss or h:mm:ss.
|
||||
/// `MobEffectUtil.formatDuration` at 20 TPS.
|
||||
pub fn format_duration(ticks: i32) -> String {
|
||||
let secs = ticks / 20;
|
||||
format_tick_duration(ticks, 20.0)
|
||||
}
|
||||
/// `StringUtil.formatTickDuration`: mm:ss, or hh:mm:ss from an hour up, every
|
||||
/// field two digits.
|
||||
pub fn format_tick_duration(ticks: i32, tick_rate: f32) -> String {
|
||||
let secs = (ticks as f32 / tick_rate).floor() as i32;
|
||||
let (h, m, s) = (secs / 3600, secs / 60 % 60, secs % 60);
|
||||
if h > 0 {
|
||||
format!("{h}:{m:02}:{s:02}")
|
||||
format!("{h:02}:{m:02}:{s:02}")
|
||||
} else {
|
||||
format!("{m}:{s:02}")
|
||||
format!("{m:02}:{s:02}")
|
||||
}
|
||||
}
|
||||
/// `item.minecraft.potion.effect.<name>` style translation key for a potion item.
|
||||
@@ -190,18 +196,6 @@ impl Brewing {
|
||||
}
|
||||
}
|
||||
|
||||
/// `BrewingStandBlockEntity` state. Slots: 0-2 bottles, 3 ingredient, 4 fuel.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct BrewingStand {
|
||||
pub items: [Option<ItemStack>; 5],
|
||||
pub brew_time: i32,
|
||||
pub total_brew_time: i32,
|
||||
pub fuel: i32,
|
||||
pub total_fuel: i32,
|
||||
pub speed: f32,
|
||||
pub ingredient: Option<Arc<str>>,
|
||||
pub last_bottles: Option<[bool; 3]>,
|
||||
}
|
||||
/// What a brewing tick asks the world to do.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct BrewTick {
|
||||
@@ -328,10 +322,14 @@ mod tests {
|
||||
potion_name_key("minecraft:splash_potion", Some("minecraft:long_swiftness")),
|
||||
"item.minecraft.splash_potion.effect.swiftness"
|
||||
);
|
||||
assert_eq!(format_duration(9600), "8:00");
|
||||
assert_eq!(format_duration(450), "0:22");
|
||||
assert_eq!(format_duration(9600), "08:00");
|
||||
assert_eq!(format_duration(450), "00:22");
|
||||
assert_eq!(format_duration(140), "00:07");
|
||||
assert_eq!(format_duration(1800), "01:30");
|
||||
assert_eq!(format_duration(72000), "01:00:00");
|
||||
assert_eq!(format_tick_duration(9600, 40.0), "04:00");
|
||||
let t = tooltip(Some("minecraft:strong_poison"), 1.0, &|_| None);
|
||||
assert_eq!(t[0].0, "poison II (0:21)");
|
||||
assert_eq!(t[0].0, "poison II (00:21)");
|
||||
assert_eq!(POTIONS.len(), 46);
|
||||
}
|
||||
|
||||
@@ -653,18 +653,57 @@ pub fn modify_fov_based_on_death_or_fluid(
|
||||
fov
|
||||
}
|
||||
|
||||
/// `ClientAvatarState`: the walked distance and view bob of a player.
|
||||
/// `ClientAvatarState`: the walked distance, view bob and cloak of a player.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct AvatarBob {
|
||||
pub walk_dist: f32,
|
||||
pub walk_dist_o: f32,
|
||||
pub bob: f32,
|
||||
pub bob_o: f32,
|
||||
/// `xCloak`, `yCloak`, `zCloak`: a point trailing the player, which the cape leans from.
|
||||
pub cloak: [f64; 3],
|
||||
pub cloak_o: [f64; 3],
|
||||
}
|
||||
impl AvatarBob {
|
||||
/// `ClientAvatarState.tick` (at the start of `AbstractClientPlayer.tick`).
|
||||
pub fn tick(&mut self) {
|
||||
/// `ClientAvatarState.tick` (at the start of `AbstractClientPlayer.tick`)
|
||||
/// with the player's position then: `moveCloak` eases the cloak a quarter
|
||||
/// of the way there, or puts it there from 10 blocks away.
|
||||
pub fn tick(&mut self, position: [f64; 3]) {
|
||||
self.walk_dist_o = self.walk_dist;
|
||||
self.cloak_o = self.cloak;
|
||||
for i in 0..3 {
|
||||
let d = position[i] - self.cloak[i];
|
||||
if d > 10.0 || d < -10.0 {
|
||||
self.cloak[i] = position[i];
|
||||
self.cloak_o[i] = position[i];
|
||||
} else {
|
||||
self.cloak[i] += d * 0.25;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `AvatarRenderer.extractCapeState`: the cape's `capeFlap`, `capeLean`
|
||||
/// and `capeLean2` (degrees) for the player at `position` (interpolated)
|
||||
/// with its body turned `body_yaw` and its `fallFlyingScale`.
|
||||
pub fn cape(
|
||||
&self,
|
||||
partial: f32,
|
||||
position: [f64; 3],
|
||||
body_yaw: f32,
|
||||
fall_flying_scale: f32,
|
||||
) -> [f32; 3] {
|
||||
let d: [f64; 3] = std::array::from_fn(|i| {
|
||||
lerp_d(f64::from(partial), self.cloak_o[i], self.cloak[i]) - position[i]
|
||||
});
|
||||
let rad = f64::from(body_yaw * DEG_TO_RAD);
|
||||
let (sin, cos) = (f64::from(trig::sin(rad)), f64::from(-trig::cos(rad)));
|
||||
let mut flap = (d[1] as f32 * 10.0).clamp(-6.0, 32.0);
|
||||
let lean = ((d[0] * sin + d[2] * cos) as f32 * 100.0 * (1.0 - fall_flying_scale))
|
||||
.clamp(0.0, 150.0);
|
||||
let lean2 = ((d[0] * cos - d[2] * sin) as f32 * 100.0).clamp(-20.0, 20.0);
|
||||
let bob = self.interpolated_bob(partial);
|
||||
let walk = lerp(partial, self.walk_dist_o, self.walk_dist);
|
||||
flap += trig::sin(f64::from(walk * 6.0)) * 32.0 * bob;
|
||||
[flap, lean, lean2]
|
||||
}
|
||||
/// `LocalPlayer.move`: `addWalkedDistance(Mth.length(dx, dz) * 0.6F)`
|
||||
/// with the float horizontal displacement.
|
||||
@@ -772,8 +811,46 @@ pub fn bob_matrix(state: &BobState, damage_tilt_strength: f64, bob_view_option:
|
||||
pose
|
||||
}
|
||||
|
||||
/// The spinning effect `GameRenderer.renderLevel` multiplies into the level's
|
||||
/// projection after the bob: a skew about a tilted axis, from a portal's or
|
||||
/// Nausea's intensity (already scaled by Distortion Effects) and the angle in
|
||||
/// degrees. The first-person hand keeps its own projection without it.
|
||||
pub fn spinning_effect_matrix(intensity: f32, angle_degrees: f32) -> glam::Mat4 {
|
||||
if intensity <= 0.0 {
|
||||
return glam::Mat4::IDENTITY;
|
||||
}
|
||||
let mut skew = 5.0 / (intensity * intensity + 5.0) - intensity * 0.04;
|
||||
skew *= skew;
|
||||
let axis = glam::Vec3::new(
|
||||
0.0,
|
||||
std::f32::consts::SQRT_2 / 2.0,
|
||||
std::f32::consts::SQRT_2 / 2.0,
|
||||
);
|
||||
let angle = angle_degrees * DEG_TO_RAD;
|
||||
glam::Mat4::from_axis_angle(axis, angle)
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(1.0 / skew, 1.0, 1.0))
|
||||
* glam::Mat4::from_axis_angle(axis, -angle)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// `moveCloak` trails a quarter of the way, jumps from 10 blocks away; the
|
||||
/// cape leans back as the player walks forward (south, body yaw 0).
|
||||
#[test]
|
||||
fn the_cloak_trails_the_player() {
|
||||
let mut a = super::AvatarBob::default();
|
||||
a.tick([0.0, 64.0, 0.0]);
|
||||
assert_eq!((a.cloak, a.cloak_o), ([0.0, 64.0, 0.0], [0.0, 64.0, 0.0]));
|
||||
a.tick([0.0, 64.0, 1.0]);
|
||||
assert_eq!(a.cloak, [0.0, 64.0, 0.25]);
|
||||
let [flap, lean, lean2] = a.cape(1.0, [0.0, 64.0, 1.0], 0.0, 0.0);
|
||||
assert_eq!((flap, lean, lean2), (0.0, 75.0, 0.0));
|
||||
// Falling lifts the flap, at most 32 degrees.
|
||||
assert_eq!(a.cape(1.0, [0.0, 60.0, 0.25], 0.0, 0.0)[0], 32.0);
|
||||
// Gliding takes the lean away.
|
||||
assert_eq!(a.cape(1.0, [0.0, 64.0, 1.0], 0.0, 1.0)[1], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depth_far_covers_the_cloud_range() {
|
||||
// renderDistance 2 with the default Cloud Distance (128 chunks).
|
||||
@@ -556,26 +556,34 @@ impl Chat {
|
||||
pub fn hover_at(&self, font: &crate::font::Font, height: i32, x: f64, y: f64) -> Option<Text> {
|
||||
self.style_at(font, height, x, y)?.hover.map(|h| *h)
|
||||
}
|
||||
/// ChatComponent.getClickedComponentStyleAt.
|
||||
fn style_at(
|
||||
&self,
|
||||
font: &crate::font::Font,
|
||||
height: i32,
|
||||
x: f64,
|
||||
y: f64,
|
||||
) -> Option<crate::text::Style> {
|
||||
/// The wrapped line under a GUI-space cursor while chat is focused, and
|
||||
/// the cursor's x within it (the text starts at 0).
|
||||
fn line_at(&self, height: i32, x: f64, y: f64) -> Option<(&Line, f64)> {
|
||||
let scale = self.options.scale as f32;
|
||||
let mut x = x / f64::from(scale) - 4.0;
|
||||
let x = x / f64::from(scale) - 4.0;
|
||||
let bottom = hud_layout::floor((height - 40) as f32 / scale);
|
||||
let from_bottom = f64::from(bottom) - y / f64::from(scale);
|
||||
if x < 0.0 || x >= f64::from(self.max_width()) || from_bottom <= 0.0 {
|
||||
if x >= f64::from(self.max_width()) || from_bottom <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let row = (from_bottom / f64::from(self.options.line_height())).ceil() as usize - 1;
|
||||
if row >= self.lines_per_page(true) {
|
||||
return None;
|
||||
}
|
||||
let line = self.lines.get(self.scroll + row)?;
|
||||
Some((self.lines.get(self.scroll + row)?, x))
|
||||
}
|
||||
/// ChatComponent.getClickedComponentStyleAt.
|
||||
pub fn style_at(
|
||||
&self,
|
||||
font: &crate::font::Font,
|
||||
height: i32,
|
||||
x: f64,
|
||||
y: f64,
|
||||
) -> Option<crate::text::Style> {
|
||||
let (line, mut x) = self.line_at(height, x, y)?;
|
||||
if x < 0.0 {
|
||||
return None;
|
||||
}
|
||||
let content = line.content.resolve_legacy();
|
||||
for (c, style) in content.chars() {
|
||||
x -= f64::from(font.advance(c, style));
|
||||
@@ -585,6 +593,13 @@ impl Chat {
|
||||
}
|
||||
None
|
||||
}
|
||||
/// Whether the cursor is on a line's indicator bar while chat is focused
|
||||
/// (`DrawingFocusedGraphicsAccess.handleTag`), whose tooltip is the
|
||||
/// message tag's text.
|
||||
pub fn indicator_at(&self, height: i32, x: f64, y: f64) -> bool {
|
||||
self.line_at(height, x, y)
|
||||
.is_some_and(|(line, x)| line.indicator.is_some() && (-4.0..-2.0).contains(&x))
|
||||
}
|
||||
/// Plain text of the newest messages (for tests and the control socket).
|
||||
pub fn recent_lines(&self, n: usize) -> Vec<String> {
|
||||
self.messages
|
||||
File renamed without changes.
@@ -6,6 +6,8 @@ use horizon_world::nbt::{Compound, Tag};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
pub mod block_entity;
|
||||
|
||||
pub const MIN_Y: i32 = -64;
|
||||
pub const HEIGHT: i32 = 384;
|
||||
pub const SECTIONS: usize = (HEIGHT / 16) as usize;
|
||||
@@ -149,7 +151,7 @@ pub struct Chunk {
|
||||
/// Newly generated (not loaded from a save): eligible for generation-time spawns.
|
||||
pub fresh: bool,
|
||||
/// Block entities read from a save, moved into the world on arrival.
|
||||
pub block_entities: Vec<((i32, i32, i32), crate::containers::BlockEntity)>,
|
||||
pub block_entities: Vec<((i32, i32, i32), block_entity::BlockEntity)>,
|
||||
/// `CustomName` of the container block entities (shown as the screen title).
|
||||
pub block_names: Vec<((i32, i32, i32), String)>,
|
||||
/// Generation postprocessing marks (ProtoChunk.postProcessing) as world
|
||||
@@ -0,0 +1,469 @@
|
||||
//! Block entity data: what a chunk stores for its block entities (`BlockEntity`
|
||||
//! and its payloads). Plain data, as vanilla's block entities are world data;
|
||||
//! their behaviour lives with the features that use them (`containers`,
|
||||
//! `brewing`, `signs`, ...), which re-export these types under their old paths.
|
||||
use horizon_client_core::{text::Text, who::Who};
|
||||
use horizon_items::{banner_layer::BannerLayer, items::ItemStack};
|
||||
use horizon_world::{
|
||||
level::sculk::SculkSpreader,
|
||||
nbt::{Compound, Tag},
|
||||
};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
/// An entity's UUID as its four ints (`UUIDUtil.CODEC`).
|
||||
pub type Uuid = [i32; 4];
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FurnaceKind {
|
||||
Furnace,
|
||||
BlastFurnace,
|
||||
Smoker,
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Furnace {
|
||||
pub kind: FurnaceKind,
|
||||
/// input, fuel, result
|
||||
pub items: [Option<ItemStack>; 3],
|
||||
pub lit_remaining: i32,
|
||||
pub lit_total: i32,
|
||||
pub cooking_timer: i32,
|
||||
pub cooking_total: i32,
|
||||
pub speed: f32,
|
||||
/// `AbstractFurnaceBlockEntity.recipesUsed`: how many of each recipe this furnace has
|
||||
/// made since a player last took from its output. The experience is owed per recipe
|
||||
/// rather than per item, and a save keeps it as the `RecipesUsed` compound.
|
||||
pub recipes_used: HashMap<String, u32>,
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum BlockEntity {
|
||||
Chest(Vec<Option<ItemStack>>),
|
||||
Furnace(Furnace),
|
||||
Brewing(Box<BrewingStand>),
|
||||
/// Signs and hanging signs (`SignBlockEntity`).
|
||||
Sign(SignData),
|
||||
/// `JukeboxBlockEntity`: the record and its `JukeboxSongPlayer`.
|
||||
Jukebox(Jukebox),
|
||||
/// `BeaconBlockEntity`.
|
||||
Beacon(Box<Beacon>),
|
||||
/// `LecternBlockEntity`: the book and the open page.
|
||||
Lectern(Lectern),
|
||||
/// Sculk sensors, shriekers and catalysts (`crate::sculk`).
|
||||
Sculk(Box<SculkEntity>),
|
||||
/// `CreakingHeartBlockEntity`: its protector and the hurt emitter.
|
||||
CreakingHeart(Box<CreakingHeart>),
|
||||
/// `BannerBlockEntity`: the pattern layers.
|
||||
Banner(Banner),
|
||||
/// `CrafterBlockEntity`.
|
||||
Crafter(Crafter),
|
||||
/// `SpawnerBlockEntity` (`crate::mobs::spawner`).
|
||||
Spawner(Box<Spawner>),
|
||||
/// `TrialSpawnerBlockEntity` (`crate::mobs::trial_spawner`).
|
||||
TrialSpawner(Box<TrialSpawner>),
|
||||
/// `VaultBlockEntity` (`crate::vault`).
|
||||
Vault(Box<Vault>),
|
||||
/// `BeehiveBlockEntity` of beehives and bee nests (`crate::beehive`).
|
||||
Beehive(Box<Beehive>),
|
||||
/// `CommandBlockEntity` of the three command blocks (`crate::command_block`).
|
||||
CommandBlock(Box<CommandBlock>),
|
||||
/// `CampfireBlockEntity` of campfires and soul campfires.
|
||||
Campfire(Box<Campfire>),
|
||||
/// `SkullBlockEntity` of every skull and head block.
|
||||
Skull(Skull),
|
||||
/// `TheEndGatewayBlockEntity`: its age and configured destination.
|
||||
EndGateway(EndGateway),
|
||||
/// `DaylightDetectorBlockEntity`: no data, only the ticker
|
||||
/// (`DaylightDetectorBlock.tickEntity`).
|
||||
DaylightDetector,
|
||||
/// `ChiseledBookShelfBlockEntity`: a `Container` of six one-book slots.
|
||||
ChiseledBookshelf(Box<ChiseledBookshelf>),
|
||||
}
|
||||
/// `ChiseledBookShelfBlockEntity`: six slots and `lastInteractedSlot`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ChiseledBookshelf {
|
||||
pub items: [Option<ItemStack>; 6],
|
||||
pub last_slot: i32,
|
||||
}
|
||||
impl Default for ChiseledBookshelf {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
items: Default::default(),
|
||||
last_slot: -1,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `TheEndGatewayBlockEntity`.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct EndGateway {
|
||||
pub age: i64,
|
||||
pub exit_portal: Option<[i32; 3]>,
|
||||
pub exact_teleport: bool,
|
||||
}
|
||||
/// `CampfireBlockEntity`: four cooking slots, each with its
|
||||
/// `cookingProgress` and `cookingTime` (the recipe's).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Campfire {
|
||||
pub items: [Option<ItemStack>; 4],
|
||||
pub progress: [i32; 4],
|
||||
pub total: [i32; 4],
|
||||
}
|
||||
/// `SkullBlockEntity`: the player profile the head shows (kept as loaded:
|
||||
/// a compound, or a string when it names a player only), the note block
|
||||
/// sound and `custom_name`.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Skull {
|
||||
pub profile: Option<horizon_world::nbt::Tag>,
|
||||
pub note_block_sound: Option<String>,
|
||||
pub custom_name: Option<horizon_world::nbt::Tag>,
|
||||
/// What `profile` draws, read from it once rather than every frame.
|
||||
pub skin: Option<SkullSkin>,
|
||||
}
|
||||
/// `SkullBlockRenderer.getPlayerSkin` of a head's profile: its skin's URL
|
||||
/// with the `skins/<sha1>` sprite it downloads to, and the
|
||||
/// `DefaultPlayerSkin` drawn until that sprite is there (steve for a
|
||||
/// profile with no id).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SkullSkin {
|
||||
pub download: Option<(String, String)>,
|
||||
/// `ResolvableProfile.nameOrId` when the profile is `Dynamic` (a name or
|
||||
/// an id only): the key `skins::Resolver` resolves it by.
|
||||
pub dynamic: Option<NameOrId>,
|
||||
pub fallback: &'static str,
|
||||
}
|
||||
/// `Either<String, UUID>`: the key a `Dynamic` `ResolvableProfile` resolves
|
||||
/// by (`ResolvableProfile.nameOrId`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum NameOrId {
|
||||
Name(String),
|
||||
Id(u128),
|
||||
}
|
||||
/// `CrafterBlockEntity`: nine slots that can be disabled, the powered flag
|
||||
/// and the ticks left in the crafting animation.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Crafter {
|
||||
pub items: [Option<ItemStack>; 9],
|
||||
pub disabled: [bool; 9],
|
||||
pub triggered: bool,
|
||||
pub crafting_ticks: i32,
|
||||
/// The block state's `triggered` when last ticked (not saved): the block
|
||||
/// entity flag follows it only when it changes (`CrafterBlock.neighborChanged`).
|
||||
pub state_triggered: Option<bool>,
|
||||
}
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Banner {
|
||||
pub layers: Vec<BannerLayer>,
|
||||
pub name: Option<String>,
|
||||
/// Components not consumed by BannerBlockEntity's implicit fields.
|
||||
pub components: horizon_world::nbt::Compound,
|
||||
}
|
||||
/// `CreakingHeartBlockEntity` (the logic is in `game/creaking.rs`).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct CreakingHeart {
|
||||
/// The protector: its entity id once resolved, and its UUID (the saved form).
|
||||
pub creaking: Option<(Option<u32>, [i32; 4])>,
|
||||
pub ticks_existed: i64,
|
||||
pub ticker: i32,
|
||||
/// Ticks left of the hurt effect (`emitter`) and where it heads.
|
||||
pub emitter: i32,
|
||||
pub emitter_target: Option<[f64; 3]>,
|
||||
pub output_signal: i32,
|
||||
}
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Lectern {
|
||||
pub book: Option<ItemStack>,
|
||||
pub page: i32,
|
||||
}
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Beacon {
|
||||
pub levels: i32,
|
||||
/// Effect ids (`minecraft:speed`).
|
||||
pub primary: Option<String>,
|
||||
pub secondary: Option<String>,
|
||||
/// Beam sections (RGB colour, height) and the ones being rebuilt.
|
||||
pub sections: Vec<(u32, i32)>,
|
||||
pub checking: Vec<(u32, i32)>,
|
||||
pub last_check_y: i32,
|
||||
}
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Jukebox {
|
||||
pub record: Option<ItemStack>,
|
||||
/// `ticks_since_song_started`, while a song plays.
|
||||
pub ticks: i64,
|
||||
pub playing: bool,
|
||||
}
|
||||
/// `BrewingStandBlockEntity` state. Slots: 0-2 bottles, 3 ingredient, 4 fuel.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct BrewingStand {
|
||||
pub items: [Option<ItemStack>; 5],
|
||||
pub brew_time: i32,
|
||||
pub total_brew_time: i32,
|
||||
pub fuel: i32,
|
||||
pub total_fuel: i32,
|
||||
pub speed: f32,
|
||||
pub ingredient: Option<Arc<str>>,
|
||||
pub last_bottles: Option<[bool; 3]>,
|
||||
}
|
||||
/// One sign side (`SignText`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SignText {
|
||||
pub lines: [String; 4],
|
||||
/// `DyeColor` id.
|
||||
pub color: u8,
|
||||
pub glowing: bool,
|
||||
}
|
||||
/// `SignBlockEntity` / `HangingSignBlockEntity` data.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct SignData {
|
||||
pub hanging: bool,
|
||||
pub front: SignText,
|
||||
pub back: SignText,
|
||||
pub waxed: bool,
|
||||
}
|
||||
/// `VibrationInfo`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Info {
|
||||
pub event: &'static str,
|
||||
pub distance: f32,
|
||||
pub pos: [f64; 3],
|
||||
pub source: Option<Who>,
|
||||
pub owner: Option<Who>,
|
||||
}
|
||||
/// `VibrationSelector`: the closest, then highest-frequency candidate of a tick.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Selector {
|
||||
pub current: Option<(Info, i64)>,
|
||||
}
|
||||
/// `VibrationSystem.Data`.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Data {
|
||||
pub current: Option<Info>,
|
||||
pub travel: i32,
|
||||
pub selector: Selector,
|
||||
}
|
||||
/// Which sculk block entity this is.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SculkKind {
|
||||
Sensor,
|
||||
Calibrated,
|
||||
Shrieker,
|
||||
Catalyst,
|
||||
}
|
||||
/// `SculkSensorBlockEntity`, `CalibratedSculkSensorBlockEntity`,
|
||||
/// `SculkShriekerBlockEntity` and `SculkCatalystBlockEntity`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SculkEntity {
|
||||
pub kind: SculkKind,
|
||||
pub vibration: Data,
|
||||
/// `last_vibration_frequency` (sensors).
|
||||
pub last_frequency: i32,
|
||||
/// `warning_level` (shriekers).
|
||||
pub warning_level: i32,
|
||||
/// The catalyst's charge cursors.
|
||||
pub spreader: SculkSpreader,
|
||||
}
|
||||
/// `SpawnData.CustomSpawnRules`: the block and sky light the spawn block
|
||||
/// must have (inclusive ranges within 0..=15).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct CustomSpawnRules {
|
||||
pub block: (i32, i32),
|
||||
pub sky: (i32, i32),
|
||||
}
|
||||
/// `SpawnData`: the entity to spawn, optional light rules and an equipment
|
||||
/// table (kept as loaded).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct SpawnData {
|
||||
pub entity: Compound,
|
||||
pub custom_spawn_rules: Option<CustomSpawnRules>,
|
||||
pub equipment: Option<Tag>,
|
||||
}
|
||||
/// Which `SpawnData` is next: one of the potentials (the same object in
|
||||
/// vanilla, so changing it changes the potential) or its own.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Next {
|
||||
Potential(usize),
|
||||
Own(SpawnData),
|
||||
}
|
||||
/// `BaseSpawner`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Spawner {
|
||||
pub spawn_delay: i32,
|
||||
/// `SpawnPotentials`: data and weight.
|
||||
pub potentials: Vec<(SpawnData, i32)>,
|
||||
pub next: Option<Next>,
|
||||
pub min_spawn_delay: i32,
|
||||
pub max_spawn_delay: i32,
|
||||
pub spawn_count: i32,
|
||||
pub max_nearby_entities: i32,
|
||||
pub required_player_range: i32,
|
||||
pub spawn_range: i32,
|
||||
/// The client copy's delay (`spawnDelay` on the client level), which
|
||||
/// speeds the spin up as the next spawn nears.
|
||||
pub client_delay: i32,
|
||||
pub spin: f64,
|
||||
pub o_spin: f64,
|
||||
}
|
||||
impl Default for Spawner {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
spawn_delay: 20,
|
||||
potentials: Vec::new(),
|
||||
next: None,
|
||||
min_spawn_delay: 200,
|
||||
max_spawn_delay: 800,
|
||||
spawn_count: 4,
|
||||
max_nearby_entities: 6,
|
||||
required_player_range: 16,
|
||||
spawn_range: 4,
|
||||
client_delay: 20,
|
||||
spin: 0.0,
|
||||
o_spin: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `TrialSpawnerConfig`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TrialConfig {
|
||||
pub spawn_range: i32,
|
||||
pub total_mobs: f32,
|
||||
pub simultaneous_mobs: f32,
|
||||
pub total_mobs_added_per_player: f32,
|
||||
pub simultaneous_mobs_added_per_player: f32,
|
||||
pub ticks_between_spawn: i32,
|
||||
pub spawn_potentials: Vec<(SpawnData, i32)>,
|
||||
pub loot_tables_to_eject: Vec<(String, i32)>,
|
||||
pub items_to_drop_when_ominous: String,
|
||||
}
|
||||
impl Default for TrialConfig {
|
||||
/// `TrialSpawnerConfig.Builder`'s defaults.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
spawn_range: 4,
|
||||
total_mobs: 6.0,
|
||||
simultaneous_mobs: 2.0,
|
||||
total_mobs_added_per_player: 2.0,
|
||||
simultaneous_mobs_added_per_player: 1.0,
|
||||
ticks_between_spawn: 40,
|
||||
spawn_potentials: Vec::new(),
|
||||
loot_tables_to_eject: vec![
|
||||
("minecraft:spawners/trial_chamber/consumables".into(), 1),
|
||||
("minecraft:spawners/trial_chamber/key".into(), 1),
|
||||
],
|
||||
items_to_drop_when_ominous:
|
||||
"minecraft:spawners/trial_chamber/items_to_drop_when_ominous".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A `Holder<TrialSpawnerConfig>` as saved: a registry id
|
||||
/// (`minecraft:trial_chamber/breeze/normal`) or an inline config.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ConfigSlot {
|
||||
/// The value as loaded, written back as is; None for the default.
|
||||
pub saved: Option<Tag>,
|
||||
pub config: TrialConfig,
|
||||
}
|
||||
/// `TrialSpawner` with its `TrialSpawnerStateData`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TrialSpawner {
|
||||
pub normal: ConfigSlot,
|
||||
pub ominous: ConfigSlot,
|
||||
pub target_cooldown_length: i32,
|
||||
pub required_player_range: i32,
|
||||
pub detected_players: Vec<Uuid>,
|
||||
pub current_mobs: Vec<Uuid>,
|
||||
pub cooldown_ends_at: i64,
|
||||
pub next_mob_spawns_at: i64,
|
||||
pub total_mobs_spawned: i32,
|
||||
pub next_spawn_data: Option<SpawnData>,
|
||||
pub ejecting_loot_table: Option<String>,
|
||||
/// `isOminous`, set from the block on every server tick.
|
||||
pub is_ominous: bool,
|
||||
/// The server's display entity exists (`getOrCreateDisplayEntity`): it is
|
||||
/// made once per loaded block entity and kept.
|
||||
pub has_display: bool,
|
||||
pub spin: f64,
|
||||
pub o_spin: f64,
|
||||
/// `getDispensingItems`, kept once rolled (not saved): the ominous items
|
||||
/// and their weights (the rolled counts).
|
||||
pub dispensing: Option<Vec<ItemStack>>,
|
||||
}
|
||||
impl Default for TrialSpawner {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
normal: ConfigSlot::default(),
|
||||
ominous: ConfigSlot::default(),
|
||||
target_cooldown_length: 36000,
|
||||
required_player_range: 14,
|
||||
detected_players: Vec::new(),
|
||||
current_mobs: Vec::new(),
|
||||
cooldown_ends_at: 0,
|
||||
next_mob_spawns_at: 0,
|
||||
total_mobs_spawned: 0,
|
||||
next_spawn_data: None,
|
||||
ejecting_loot_table: None,
|
||||
is_ominous: false,
|
||||
has_display: false,
|
||||
spin: 0.0,
|
||||
o_spin: 0.0,
|
||||
dispensing: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `VaultConfig`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct VaultConfig {
|
||||
pub loot_table: String,
|
||||
pub activation_range: f64,
|
||||
pub deactivation_range: f64,
|
||||
/// None when the config has no key (`ItemStack.EMPTY`): the vault never opens.
|
||||
pub key_item: Option<ItemStack>,
|
||||
pub override_loot_table_to_display: Option<String>,
|
||||
}
|
||||
/// `VaultBlockEntity`: its config, server data and shared data.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Vault {
|
||||
pub config: VaultConfig,
|
||||
/// `VaultServerData`, oldest rewarded player first.
|
||||
pub rewarded_players: Vec<Uuid>,
|
||||
pub state_updating_resumes_at: i64,
|
||||
/// Popped from the end.
|
||||
pub items_to_eject: Vec<ItemStack>,
|
||||
pub total_ejections_needed: i32,
|
||||
pub last_insert_fail_timestamp: i64,
|
||||
/// `VaultSharedData`.
|
||||
pub display_item: Option<ItemStack>,
|
||||
pub connected_players: Vec<Uuid>,
|
||||
pub connected_particles_range: f64,
|
||||
/// The shared data changed since the last tick sent it.
|
||||
pub shared_dirty: bool,
|
||||
/// `VaultClientData`: the display item's spin and the previous tick's, in degrees.
|
||||
pub spin: f32,
|
||||
pub o_spin: f32,
|
||||
}
|
||||
/// `BeehiveBlockEntity`: the bees inside and the flower one of them knew.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Beehive {
|
||||
pub bees: Vec<Occupant>,
|
||||
pub flower: Option<[i32; 3]>,
|
||||
}
|
||||
/// `BeehiveBlockEntity.Occupant`: a bee's tag (with its `id`), its time in
|
||||
/// the hive and the time it stays at least.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Occupant {
|
||||
pub entity_data: Tag,
|
||||
pub ticks_in_hive: i32,
|
||||
pub min_ticks_in_hive: i32,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CommandBlock {
|
||||
pub command: String,
|
||||
pub success_count: i32,
|
||||
/// `CustomName` as saved (a text component).
|
||||
pub custom_name: Option<Tag>,
|
||||
pub track_output: bool,
|
||||
pub last_output: Option<Text>,
|
||||
pub update_last_execution: bool,
|
||||
/// The game time of the last run, -1 for none.
|
||||
pub last_execution: i64,
|
||||
pub powered: bool,
|
||||
pub auto: bool,
|
||||
pub condition_met: bool,
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+6
-20
@@ -1,8 +1,10 @@
|
||||
//! `CommandBlockEntity` with its `BaseCommandBlock`: the command, the success
|
||||
//! count comparators read, the output it keeps, and the powered, automatic
|
||||
//! and condition flags `CommandBlock.tick` runs on.
|
||||
use crate::{
|
||||
save::{number, text},
|
||||
pub use crate::chunk::block_entity::CommandBlock;
|
||||
use horizon_client_core::{
|
||||
nbt_value::{number, text},
|
||||
scoreboard,
|
||||
text::Text,
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
@@ -76,22 +78,6 @@ pub fn automatic_block(name: &str) -> bool {
|
||||
name == "minecraft:chain_command_block"
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CommandBlock {
|
||||
pub command: String,
|
||||
pub success_count: i32,
|
||||
/// `CustomName` as saved (a text component).
|
||||
pub custom_name: Option<Tag>,
|
||||
pub track_output: bool,
|
||||
pub last_output: Option<Text>,
|
||||
pub update_last_execution: bool,
|
||||
/// The game time of the last run, -1 for none.
|
||||
pub last_execution: i64,
|
||||
pub powered: bool,
|
||||
pub auto: bool,
|
||||
pub condition_met: bool,
|
||||
}
|
||||
|
||||
impl Default for CommandBlock {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -123,7 +109,7 @@ impl CommandBlock {
|
||||
last_output: track_output
|
||||
.then(|| tag.get("LastOutput"))
|
||||
.flatten()
|
||||
.map(|t| crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t))),
|
||||
.map(|t| scoreboard::comp_text(&scoreboard::tag_to_json(t))),
|
||||
update_last_execution,
|
||||
last_execution: if update_last_execution {
|
||||
number(tag.get("LastExecution")).unwrap_or(-1)
|
||||
@@ -179,7 +165,7 @@ impl CommandBlock {
|
||||
pub fn name(&self) -> Text {
|
||||
self.custom_name.as_ref().map_or_else(
|
||||
|| Text::literal("@"),
|
||||
|t| crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t)),
|
||||
|t| scoreboard::comp_text(&scoreboard::tag_to_json(t)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
//! Container block entities: chests/barrels (27 slots) and the three furnace
|
||||
//! 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,
|
||||
};
|
||||
use crate::items::ItemStack;
|
||||
use std::collections::HashMap;
|
||||
|
||||
impl FurnaceKind {
|
||||
pub fn recipe_type(self) -> &'static str {
|
||||
match self {
|
||||
Self::Furnace => "minecraft:smelting",
|
||||
Self::BlastFurnace => "minecraft:blasting",
|
||||
Self::Smoker => "minecraft:smoking",
|
||||
}
|
||||
}
|
||||
pub fn fast(self) -> bool {
|
||||
self != Self::Furnace
|
||||
}
|
||||
pub fn from_block(name: &str) -> Option<Self> {
|
||||
match name {
|
||||
"minecraft:furnace" => Some(Self::Furnace),
|
||||
"minecraft:blast_furnace" => Some(Self::BlastFurnace),
|
||||
"minecraft:smoker" => Some(Self::Smoker),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The key `recipes_used` keeps a recipe under: its full id, as vanilla's `ResourceKey<Recipe>`
|
||||
/// writes it in `RecipesUsed`. Horizon's own recipe ids are file names without the namespace, so
|
||||
/// keeping both forms would write a key twice, and vanilla refuses such a furnace
|
||||
/// ("Duplicate entry for key").
|
||||
pub fn recipe_used_key(id: &str) -> String {
|
||||
if id.contains(':') {
|
||||
id.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
}
|
||||
}
|
||||
/// A hopper's slots: the only plain container with five.
|
||||
pub const HOPPER_SLOTS: usize = 5;
|
||||
|
||||
/// `getDefaultName` of the `BaseContainerBlockEntity` that `block` holds: the
|
||||
/// translation key of its menu's title when it has no custom name. Copper
|
||||
/// chests hold a `ChestBlockEntity`, shulker boxes of every colour a
|
||||
/// `ShulkerBoxBlockEntity`.
|
||||
pub fn default_name_key(block: &str) -> Option<&'static str> {
|
||||
Some(match block {
|
||||
"minecraft:chest" | "minecraft:trapped_chest" => "container.chest",
|
||||
n if n.ends_with("copper_chest") => "container.chest",
|
||||
"minecraft:barrel" => "container.barrel",
|
||||
n if n.ends_with("shulker_box") => "container.shulkerBox",
|
||||
"minecraft:dispenser" => "container.dispenser",
|
||||
"minecraft:dropper" => "container.dropper",
|
||||
"minecraft:hopper" => "container.hopper",
|
||||
"minecraft:furnace" => "container.furnace",
|
||||
"minecraft:smoker" => "container.smoker",
|
||||
"minecraft:blast_furnace" => "container.blast_furnace",
|
||||
"minecraft:brewing_stand" => "container.brewing",
|
||||
"minecraft:crafter" => "container.crafter",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `BaseContainerBlockEntity.getDisplayName` (`getName`), the title of the
|
||||
/// `ClientboundOpenScreenPacket` that opens it: the block entity's custom
|
||||
/// name (`CustomName`, a literal), else its default name.
|
||||
pub fn display_name(custom: Option<&str>, default_key: &str) -> horizon_world::nbt::Tag {
|
||||
match custom {
|
||||
Some(name) => horizon_protocol::text::literal(name),
|
||||
None => horizon_protocol::text::translatable(default_key, &[]),
|
||||
}
|
||||
}
|
||||
|
||||
/// `ChestBlock.MENU_PROVIDER_COMBINER`'s `getDisplayName` for a double chest:
|
||||
/// the first half's custom name (the right half, `DoubleBlockCombiner`'s
|
||||
/// `FIRST`), else the second's, else `container.chestDouble`.
|
||||
pub fn double_chest_name(first: Option<&str>, second: Option<&str>) -> horizon_world::nbt::Tag {
|
||||
display_name(first.or(second), "container.chestDouble")
|
||||
}
|
||||
|
||||
impl Campfire {
|
||||
/// `placeFood`'s slot: the first empty one, given the item's
|
||||
/// `campfire_cooking` time; it takes one item of `held`.
|
||||
pub fn place_food(&mut self, held: &mut ItemStack, cooking_time: i32) -> bool {
|
||||
let Some(slot) = self.items.iter().position(Option::is_none) else {
|
||||
return false;
|
||||
};
|
||||
self.total[slot] = cooking_time;
|
||||
self.progress[slot] = 0;
|
||||
self.items[slot] = Some(ItemStack {
|
||||
count: 1,
|
||||
..held.clone()
|
||||
});
|
||||
held.count = held.count.saturating_sub(1);
|
||||
true
|
||||
}
|
||||
/// `cookTick` (lit): each item's progress rises by one; the slots whose
|
||||
/// progress reached their cooking time give up their item (the caller
|
||||
/// drops its result).
|
||||
pub fn cook_tick(&mut self) -> Vec<(usize, ItemStack)> {
|
||||
let mut done = Vec::new();
|
||||
for i in 0..4 {
|
||||
if self.items[i].is_none() {
|
||||
continue;
|
||||
}
|
||||
self.progress[i] += 1;
|
||||
if self.progress[i] >= self.total[i]
|
||||
&& let Some(item) = self.items[i].take()
|
||||
{
|
||||
done.push((i, item));
|
||||
}
|
||||
}
|
||||
done
|
||||
}
|
||||
/// `cooldownTick` (unlit): the progress falls by two, no lower than 0.
|
||||
/// Whether anything changed (`setChanged`).
|
||||
pub fn cooldown_tick(&mut self) -> bool {
|
||||
let mut changed = false;
|
||||
for i in 0..4 {
|
||||
if self.progress[i] > 0 {
|
||||
changed = true;
|
||||
self.progress[i] = (self.progress[i] - 2).clamp(0, self.total[i]);
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
}
|
||||
impl Skull {
|
||||
pub fn new(
|
||||
profile: Option<horizon_world::nbt::Tag>,
|
||||
note_block_sound: Option<String>,
|
||||
custom_name: Option<horizon_world::nbt::Tag>,
|
||||
) -> Self {
|
||||
let skin = profile.as_ref().map(SkullSkin::of);
|
||||
Self {
|
||||
profile,
|
||||
note_block_sound,
|
||||
custom_name,
|
||||
skin,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl SkullSkin {
|
||||
pub fn of(profile: &horizon_world::nbt::Tag) -> Self {
|
||||
let download = crate::skins::profile_skin_url_nbt(profile).and_then(|url| {
|
||||
let location = crate::skins::skin_location(&crate::skins::texture_hash(&url)?);
|
||||
Some((url, location))
|
||||
});
|
||||
// A `Dynamic` profile (a name or id only) resolves through the
|
||||
// services when the skull is drawn.
|
||||
let dynamic = crate::skins::dynamic_nbt(profile);
|
||||
// DefaultPlayerSkin.get(id), or getDefaultSkin (slim steve) without an id.
|
||||
let fallback = crate::skins::profile_uuid_nbt(profile)
|
||||
.or_else(|| {
|
||||
crate::skins::nbt_text(profile.get("name"))
|
||||
.map(|name| horizon_protocol::crypto::offline_uuid(&name))
|
||||
})
|
||||
.map_or("entity/player/slim/steve", crate::tab_list::default_skin);
|
||||
Self {
|
||||
download,
|
||||
dynamic,
|
||||
fallback,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The skull and head blocks (`SkullBlock`, `WallSkullBlock`): every
|
||||
/// `*_skull` and `*_head` but the piston's head.
|
||||
fn is_skull(name: &str) -> bool {
|
||||
name.ends_with("_skull") || (name.ends_with("_head") && name != "minecraft:piston_head")
|
||||
}
|
||||
impl Crafter {
|
||||
/// `slotCanBeDisabled`: only empty slots.
|
||||
pub fn slot_can_be_disabled(&self, slot: usize) -> bool {
|
||||
slot < 9 && self.items[slot].is_none()
|
||||
}
|
||||
/// `setSlotState`.
|
||||
pub fn set_slot_state(&mut self, slot: usize, enabled: bool) {
|
||||
if self.slot_can_be_disabled(slot) {
|
||||
self.disabled[slot] = !enabled;
|
||||
}
|
||||
}
|
||||
/// `getRedstoneSignal`: filled or disabled slots.
|
||||
pub fn redstone_signal(&self) -> i32 {
|
||||
(0..9)
|
||||
.filter(|&i| self.items[i].is_some() || self.disabled[i])
|
||||
.count() as i32
|
||||
}
|
||||
/// `canPlaceItem`: a hopper fills the smallest stacks first.
|
||||
pub fn can_place(&self, slot: usize, _stack: &ItemStack, max_stack: u32) -> bool {
|
||||
if slot >= 9 || self.disabled[slot] {
|
||||
return false;
|
||||
}
|
||||
let Some(current) = &self.items[slot] else {
|
||||
return true;
|
||||
};
|
||||
if current.count >= max_stack {
|
||||
return false;
|
||||
}
|
||||
!(slot + 1..9).any(|i| {
|
||||
!self.disabled[i]
|
||||
&& match &self.items[i] {
|
||||
None => true,
|
||||
Some(other) => {
|
||||
other.count < current.count && other.same_item_same_components(current)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
impl BlockEntity {
|
||||
pub fn for_block(name: &str) -> Option<Self> {
|
||||
if let Some(hanging) = crate::signs::sign_kind(name) {
|
||||
return Some(Self::Sign(crate::signs::SignData {
|
||||
hanging,
|
||||
..Default::default()
|
||||
}));
|
||||
}
|
||||
if let Some(kind) = FurnaceKind::from_block(name) {
|
||||
return Some(Self::Furnace(Furnace {
|
||||
kind,
|
||||
items: [None, None, None],
|
||||
lit_remaining: 0,
|
||||
lit_total: 0,
|
||||
cooking_timer: 0,
|
||||
cooking_total: 0,
|
||||
speed: 1.0,
|
||||
recipes_used: HashMap::new(),
|
||||
}));
|
||||
}
|
||||
if name == "minecraft:creaking_heart" {
|
||||
return Some(Self::CreakingHeart(Box::default()));
|
||||
}
|
||||
if name == "minecraft:spawner" {
|
||||
return Some(Self::Spawner(Box::default()));
|
||||
}
|
||||
if name == "minecraft:trial_spawner" {
|
||||
return Some(Self::TrialSpawner(Box::default()));
|
||||
}
|
||||
if name == "minecraft:vault" {
|
||||
return Some(Self::Vault(Box::default()));
|
||||
}
|
||||
if name == "minecraft:beehive" || name == "minecraft:bee_nest" {
|
||||
return Some(Self::Beehive(Box::default()));
|
||||
}
|
||||
if name == "minecraft:lectern" {
|
||||
return Some(Self::Lectern(Lectern::default()));
|
||||
}
|
||||
if crate::command_block::Mode::of_block(name).is_some() {
|
||||
// CommandBlock.newBlockEntity: setAutomatic(this.automatic).
|
||||
return Some(Self::CommandBlock(Box::new(
|
||||
crate::command_block::CommandBlock {
|
||||
auto: crate::command_block::automatic_block(name),
|
||||
..Default::default()
|
||||
},
|
||||
)));
|
||||
}
|
||||
if let Some(sculk) = crate::sculk::SculkEntity::for_block(name) {
|
||||
return Some(Self::Sculk(Box::new(sculk)));
|
||||
}
|
||||
if name == "minecraft:crafter" {
|
||||
// CrafterBlock.newBlockEntity copies the placed state's flag: the
|
||||
// first tick sees it as a change from untriggered.
|
||||
return Some(Self::Crafter(Crafter {
|
||||
state_triggered: Some(false),
|
||||
..Crafter::default()
|
||||
}));
|
||||
}
|
||||
if name.ends_with("_banner") {
|
||||
return Some(Self::Banner(Banner::default()));
|
||||
}
|
||||
if is_skull(name) {
|
||||
return Some(Self::Skull(Skull::default()));
|
||||
}
|
||||
if name == "minecraft:end_gateway" {
|
||||
return Some(Self::EndGateway(EndGateway::default()));
|
||||
}
|
||||
if name == "minecraft:daylight_detector" {
|
||||
return Some(Self::DaylightDetector);
|
||||
}
|
||||
if name == "minecraft:beacon" {
|
||||
return Some(Self::Beacon(Box::new(Beacon {
|
||||
last_check_y: i32::MIN / 2,
|
||||
..Default::default()
|
||||
})));
|
||||
}
|
||||
if name == "minecraft:brewing_stand" {
|
||||
return Some(Self::Brewing(Box::new(crate::brewing::BrewingStand::new())));
|
||||
}
|
||||
if name == "minecraft:campfire" || name == "minecraft:soul_campfire" {
|
||||
return Some(Self::Campfire(Box::default()));
|
||||
}
|
||||
if name == "minecraft:chiseled_bookshelf" {
|
||||
return Some(Self::ChiseledBookshelf(Box::default()));
|
||||
}
|
||||
let size = match name {
|
||||
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => 27,
|
||||
n if n.ends_with("copper_chest") => 27,
|
||||
"minecraft:hopper" => HOPPER_SLOTS,
|
||||
"minecraft:dispenser" | "minecraft:dropper" => 9,
|
||||
n if n.ends_with("shulker_box") => 27,
|
||||
_ => return None,
|
||||
};
|
||||
Some(Self::Chest(vec![None; size]))
|
||||
}
|
||||
pub fn id(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Chest(items) => match items.len() {
|
||||
HOPPER_SLOTS => "minecraft:hopper",
|
||||
9 => "minecraft:dispenser",
|
||||
_ => "minecraft:chest",
|
||||
},
|
||||
Self::Furnace(f) => match f.kind {
|
||||
FurnaceKind::Furnace => "minecraft:furnace",
|
||||
FurnaceKind::BlastFurnace => "minecraft:blast_furnace",
|
||||
FurnaceKind::Smoker => "minecraft:smoker",
|
||||
},
|
||||
Self::Brewing(_) => "minecraft:brewing_stand",
|
||||
Self::Sign(s) => s.id(),
|
||||
Self::Jukebox(_) => "minecraft:jukebox",
|
||||
Self::Beacon(_) => "minecraft:beacon",
|
||||
Self::Lectern(_) => "minecraft:lectern",
|
||||
Self::Sculk(s) => s.id(),
|
||||
Self::Banner(_) => "minecraft:banner",
|
||||
Self::Crafter(_) => "minecraft:crafter",
|
||||
Self::CreakingHeart(_) => "minecraft:creaking_heart",
|
||||
Self::Spawner(_) => "minecraft:mob_spawner",
|
||||
Self::TrialSpawner(_) => "minecraft:trial_spawner",
|
||||
Self::Vault(_) => "minecraft:vault",
|
||||
Self::Beehive(_) => "minecraft:beehive",
|
||||
Self::CommandBlock(_) => "minecraft:command_block",
|
||||
Self::Skull(_) => "minecraft:skull",
|
||||
Self::EndGateway(_) => "minecraft:end_gateway",
|
||||
Self::DaylightDetector => "minecraft:daylight_detector",
|
||||
Self::Campfire(_) => "minecraft:campfire",
|
||||
Self::ChiseledBookshelf(_) => "minecraft:chiseled_bookshelf",
|
||||
}
|
||||
}
|
||||
pub fn contents(&self) -> Vec<ItemStack> {
|
||||
match self {
|
||||
Self::Chest(items) => items.iter().flatten().cloned().collect(),
|
||||
Self::Furnace(f) => f.items.iter().flatten().cloned().collect(),
|
||||
Self::Brewing(b) => b.items.iter().flatten().cloned().collect(),
|
||||
Self::Sign(_) => Vec::new(),
|
||||
Self::Jukebox(j) => j.record.iter().cloned().collect(),
|
||||
Self::Beacon(_)
|
||||
| Self::CreakingHeart(_)
|
||||
| Self::Spawner(_)
|
||||
| Self::TrialSpawner(_)
|
||||
| Self::Vault(_)
|
||||
| Self::Beehive(_)
|
||||
| Self::CommandBlock(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::DaylightDetector => 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(),
|
||||
}
|
||||
}
|
||||
pub fn slots(&self) -> &[Option<ItemStack>] {
|
||||
match self {
|
||||
Self::Chest(items) => items,
|
||||
Self::Furnace(f) => &f.items,
|
||||
Self::Brewing(b) => &b.items,
|
||||
Self::Sign(_)
|
||||
| Self::Jukebox(_)
|
||||
| Self::Beacon(_)
|
||||
| Self::Lectern(_)
|
||||
| Self::Sculk(_)
|
||||
| Self::Banner(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::DaylightDetector => &[],
|
||||
Self::Crafter(c) => &c.items,
|
||||
Self::ChiseledBookshelf(b) => &b.items,
|
||||
Self::CreakingHeart(_)
|
||||
| Self::Spawner(_)
|
||||
| Self::TrialSpawner(_)
|
||||
| Self::Vault(_)
|
||||
| Self::Beehive(_)
|
||||
| Self::CommandBlock(_)
|
||||
// Not a Container (only Clearable): hoppers don't reach in.
|
||||
| Self::Campfire(_) => &[],
|
||||
}
|
||||
}
|
||||
pub fn slots_mut(&mut self) -> &mut [Option<ItemStack>] {
|
||||
match self {
|
||||
Self::Chest(items) => items,
|
||||
Self::Furnace(f) => &mut f.items,
|
||||
Self::Brewing(b) => &mut b.items,
|
||||
Self::Sign(_)
|
||||
| Self::Jukebox(_)
|
||||
| Self::Beacon(_)
|
||||
| Self::Lectern(_)
|
||||
| Self::Sculk(_)
|
||||
| Self::Banner(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::DaylightDetector => &mut [],
|
||||
Self::Crafter(c) => &mut c.items,
|
||||
Self::ChiseledBookshelf(b) => &mut b.items,
|
||||
Self::CreakingHeart(_)
|
||||
| Self::Spawner(_)
|
||||
| Self::TrialSpawner(_)
|
||||
| Self::Vault(_)
|
||||
| Self::Beehive(_)
|
||||
| Self::CommandBlock(_)
|
||||
| Self::Campfire(_) => &mut [],
|
||||
}
|
||||
}
|
||||
}
|
||||
pub use crate::providers::Providers;
|
||||
|
||||
/// A `ChestBlock`, which `ChestBlock.combine` can join into a double chest:
|
||||
/// chests, trapped chests and copper chests in every weathering state, waxed
|
||||
/// or not (`CopperChestBlock` extends `ChestBlock`). Ender chests are
|
||||
/// `EnderChestBlock`s and always single.
|
||||
pub fn is_chest_block(name: &str) -> bool {
|
||||
matches!(name, "minecraft:chest" | "minecraft:trapped_chest") || name.ends_with("copper_chest")
|
||||
}
|
||||
|
||||
/// A chest block's name with its `type` and `facing` properties.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ChestHalf<'a> {
|
||||
pub block: &'a str,
|
||||
pub kind: &'a str,
|
||||
pub facing: &'a str,
|
||||
}
|
||||
|
||||
impl ChestHalf<'_> {
|
||||
/// `ChestBlock.getConnectedDirection` as an (x, z) step to the other
|
||||
/// half: clockwise from the facing for a left half, counter-clockwise for
|
||||
/// a right one. None for a single chest.
|
||||
pub fn connected_step(&self) -> Option<(i32, i32)> {
|
||||
let step = |facing: &str| match facing {
|
||||
"north" => Some((0, -1)),
|
||||
"south" => Some((0, 1)),
|
||||
"west" => Some((-1, 0)),
|
||||
"east" => Some((1, 0)),
|
||||
_ => None,
|
||||
};
|
||||
let (x, z) = step(self.facing)?;
|
||||
// Turning clockwise seen from above takes (x, z) to (-z, x).
|
||||
match self.kind {
|
||||
"left" => Some((-z, x)),
|
||||
"right" => Some((z, -x)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `DoubleBlockCombiner.combineWithNeigbour` as `ChestBlock.combine` calls
|
||||
/// it: `other`, the block in this half's connected direction, is its other
|
||||
/// half when it is the same block, a half of the other side and faces the
|
||||
/// same way. Copper chests in different weathering states never join
|
||||
/// (`CopperChestBlock` keeps both halves in one state instead).
|
||||
pub fn pairs_with(&self, other: &ChestHalf) -> bool {
|
||||
is_chest_block(self.block)
|
||||
&& self.kind != "single"
|
||||
&& other.block == self.block
|
||||
&& other.kind != "single"
|
||||
&& other.kind != self.kind
|
||||
&& other.facing == self.facing
|
||||
}
|
||||
}
|
||||
|
||||
/// `AbstractFurnaceBlockEntity.createExperience`: `count * experience`, floored, and one
|
||||
/// more with the chance of what is left over (a level random draw only when there is some).
|
||||
pub fn create_experience(
|
||||
count: u32,
|
||||
experience: f32,
|
||||
random: &mut impl horizon_core::random::RandomSource,
|
||||
) -> u32 {
|
||||
let total = count as f32 * experience;
|
||||
let whole = total.floor();
|
||||
let fraction = total - whole;
|
||||
let mut xp = whole.max(0.0) as u32;
|
||||
if fraction != 0.0 && random.next_float() < fraction {
|
||||
xp += 1;
|
||||
}
|
||||
xp
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
|
||||
#[test]
|
||||
fn campfire_slots_take_food_cook_it_and_cool_down() {
|
||||
let mut c = Campfire::default();
|
||||
let mut beef = ItemStack::new("minecraft:beef", 2);
|
||||
// placeFood: the first free slot, one item, its recipe's time.
|
||||
assert!(c.place_food(&mut beef, 600));
|
||||
assert_eq!(beef.count, 1);
|
||||
assert_eq!(c.items[0].as_ref().map(|s| s.count), Some(1));
|
||||
assert_eq!((c.progress[0], c.total[0]), (0, 600));
|
||||
let mut fish = ItemStack::new("minecraft:cod", 1);
|
||||
assert!(c.place_food(&mut fish, 3));
|
||||
assert_eq!(c.items[1].as_ref().map(|s| &*s.item), Some("minecraft:cod"));
|
||||
// cookTick: the cod is done on its third tick, the beef keeps cooking.
|
||||
assert!(c.cook_tick().is_empty());
|
||||
assert!(c.cook_tick().is_empty());
|
||||
let done = c.cook_tick();
|
||||
assert_eq!(done.len(), 1);
|
||||
assert_eq!((done[0].0, &*done[0].1.item), (1, "minecraft:cod"));
|
||||
assert!(c.items[1].is_none());
|
||||
assert_eq!(c.progress[0], 3);
|
||||
// cooldownTick: two a tick, never below zero.
|
||||
assert!(c.cooldown_tick());
|
||||
assert_eq!(c.progress[0], 1);
|
||||
assert!(c.cooldown_tick());
|
||||
assert_eq!(c.progress[0], 0);
|
||||
// Full: four slots, no fifth.
|
||||
for _ in 0..3 {
|
||||
let mut more = ItemStack::new("minecraft:potato", 1);
|
||||
assert!(c.place_food(&mut more, 600));
|
||||
}
|
||||
let mut fifth = ItemStack::new("minecraft:potato", 1);
|
||||
assert!(!c.place_food(&mut fifth, 600));
|
||||
assert_eq!(fifth.count, 1);
|
||||
}
|
||||
|
||||
/// A whole amount pays itself without a draw; a fraction is a chance of one more.
|
||||
#[test]
|
||||
fn create_experience_rounds_the_fraction_by_chance() {
|
||||
let mut random = LegacyRandom::new(7);
|
||||
let mut untouched = LegacyRandom::new(7);
|
||||
assert_eq!(create_experience(4, 0.5, &mut random), 2);
|
||||
assert_eq!(create_experience(3, 0.0, &mut random), 0);
|
||||
assert_eq!(random.next_long(), untouched.next_long());
|
||||
let paid: u32 = (0..1000)
|
||||
.map(|_| create_experience(1, 0.35, &mut random))
|
||||
.sum();
|
||||
assert!((250..450).contains(&paid), "{paid}");
|
||||
assert!((0..100).all(|_| (1..=2).contains(&create_experience(1, 1.7, &mut random))));
|
||||
}
|
||||
}
|
||||
@@ -326,16 +326,18 @@ fn panic_message(info: &std::panic::PanicHookInfo) -> String {
|
||||
/// frames go. Paths inside the workspace lose the build machine's prefix.
|
||||
pub fn stack_lines(backtrace: &str, from_panic: bool) -> String {
|
||||
// The workspace as it was on the machine that built this binary (it need not exist here).
|
||||
let manifest = env!("CARGO_MANIFEST_DIR");
|
||||
// A report names workspace files the same way on every host, whatever
|
||||
// separator the build machine used.
|
||||
let manifest = env!("CARGO_MANIFEST_DIR").replace('\\', "/");
|
||||
let root = manifest
|
||||
.strip_suffix("crates/horizon-client")
|
||||
.unwrap_or(manifest);
|
||||
.strip_suffix("crates/horizon-client-render")
|
||||
.unwrap_or(&manifest);
|
||||
let mut frames: Vec<(String, Option<String>)> = Vec::new();
|
||||
for line in backtrace.lines() {
|
||||
let line = line.trim();
|
||||
if let Some(at) = line.strip_prefix("at ") {
|
||||
if let Some(frame) = frames.last_mut() {
|
||||
frame.1 = Some(short_path(at, root).to_owned());
|
||||
frame.1 = Some(short_path(&at.replace('\\', "/"), root).to_owned());
|
||||
}
|
||||
} else if let Some((n, name)) = line.split_once(": ")
|
||||
&& n.chars().all(|c| c.is_ascii_digit())
|
||||
@@ -603,7 +605,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
stack_lines(&bt, true),
|
||||
"horizon_client::crash::debug_crash_tick (crates/horizon-client/src/crash.rs:359:9)\nwinit::dispatch (winit-0.30.13/src/event_loop.rs:642:56)\nmain\n"
|
||||
"horizon_client::crash::debug_crash_tick (crates/horizon-client-render/src/crash.rs:359:9)\nwinit::dispatch (winit-0.30.13/src/event_loop.rs:642:56)\nmain\n"
|
||||
);
|
||||
}
|
||||
|
||||
File renamed without changes.
File renamed without changes.
@@ -29,6 +29,10 @@ impl EffectInstance {
|
||||
fn infinite(&self) -> bool {
|
||||
self.duration == -1
|
||||
}
|
||||
/// `MobEffectInstance.endsWithin`.
|
||||
pub fn ends_within(&self, ticks: i32) -> bool {
|
||||
!self.infinite() && self.duration <= ticks
|
||||
}
|
||||
fn shorter_than(&self, other: &Self) -> bool {
|
||||
!self.infinite() && (self.duration < other.duration || other.infinite())
|
||||
}
|
||||
@@ -129,15 +133,16 @@ fn periodic(id: &str, tick: i32, amp: i32) -> Option<EffectAction> {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// `InstantenousMobEffect.applyInstantenousEffect` (potions apply these directly).
|
||||
/// `HealOrHarmMobEffect.applyInstantaneousEffect` (potions apply these directly;
|
||||
/// the amount rounds to the nearest whole point).
|
||||
pub fn instant(id: &str, amp: i32, scale: f64) -> Option<EffectAction> {
|
||||
match id {
|
||||
"instant_health" => Some(EffectAction::Heal(
|
||||
(scale * f64::from(4 << amp.min(30))) as i32 as f32,
|
||||
(scale * f64::from(4 << amp.min(30)) + 0.5) as i32 as f32,
|
||||
)),
|
||||
"instant_damage" => Some(EffectAction::Hurt(
|
||||
"minecraft:magic",
|
||||
(scale * f64::from(6 << amp.min(30))) as i32 as f32,
|
||||
(scale * f64::from(6 << amp.min(30)) + 0.5) as i32 as f32,
|
||||
)),
|
||||
"saturation" => Some(EffectAction::Eat(amp + 1, 1.0)),
|
||||
_ => None,
|
||||
+18
-2
@@ -22,6 +22,7 @@ struct Globals {
|
||||
outline_params: vec4<f32>,
|
||||
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
|
||||
dynamic_lights: array<vec4<f32>, 16>,
|
||||
sunrise_fan: array<vec4<f32>, 32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> globals: Globals;
|
||||
@group(0) @binding(1) var atlas: texture_2d<f32>;
|
||||
@@ -54,6 +55,8 @@ struct Out {
|
||||
// The `GLINT_TEXTURING` sample for overlay 7 (item foil), or the model UV
|
||||
// `ARMOR_ENTITY_GLINT_TEXTURING` scrolls for overlay 13 (armour).
|
||||
@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,
|
||||
}
|
||||
// Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed.
|
||||
fn entity_light(normal: vec3<f32>, nether: bool) -> f32 {
|
||||
@@ -102,7 +105,10 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
|
||||
// minecraft_mix_light: the vertex colour times the cardinal light only.
|
||||
var c = color;
|
||||
var back = c;
|
||||
if normal.w != 0.0 {
|
||||
// Normal w: 0 keeps the baked colour, +-127 shades by the normal, and a small positive 1
|
||||
// keeps the baked colour but culls back faces (entitySolid models such as the decorated pot).
|
||||
out.cull = select(0u, 1u, normal.w > 0.0 && normal.w < 0.5);
|
||||
if abs(normal.w) >= 0.5 {
|
||||
c = vec4(c.rgb * entity_light(normal.xyz, globals.time.y != 0.0), c.a);
|
||||
back = select(c, vec4(back.rgb * entity_light(-normal.xyz, globals.time.y != 0.0), back.a), normal.w < 0.0);
|
||||
}
|
||||
@@ -234,7 +240,7 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
|
||||
// clouds.fsh: alpha fades out towards the cloud fog end.
|
||||
return vec4(color.rgb, color.a * (1.0 - linear_fog(in.spherical, 0.0, globals.fog_ends.y)));
|
||||
}
|
||||
if color.a < 0.1 { discard; }
|
||||
if color.a < 0.1 || (in.cull != 0u && !front) { discard; }
|
||||
// entity.fsh calculateFinalColor: the overlay (hurt red, TNT white) over the
|
||||
// lit texture, then the lightmap, then the glint and fog.
|
||||
color = vec4(mix(in.overlay_color.rgb, color.rgb, in.overlay_color.a), color.a);
|
||||
@@ -258,6 +264,16 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
|
||||
|
||||
// rendertype_outline.fsh: the vertex colour (the team colour; outline vertices carry no
|
||||
// normal, so no cardinal light) wherever the texture is not fully clear.
|
||||
// rendertype_entity_shadow.fsh: the shadow texture times the vertex colour,
|
||||
// then fog, with no alpha cutout, so a faint shadow in dim light still draws.
|
||||
@fragment fn fs_entity_shadow(in: Out) -> @location(0) vec4<f32> {
|
||||
var color = textureSample(atlas, atlas_sampler, in.uv) * in.color;
|
||||
color *= in.light_color;
|
||||
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
|
||||
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
|
||||
}
|
||||
|
||||
@fragment fn fs_outline(in: Out) -> @location(0) vec4<f32> {
|
||||
if textureSample(atlas, atlas_sampler, in.uv).a == 0.0 { discard; }
|
||||
return vec4(in.color.rgb, 1.0);
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -203,6 +203,13 @@ pub struct FogInputs<'a> {
|
||||
/// `BossHealthOverlay.shouldCreateWorldFog`: a boss bar with
|
||||
/// `createWorldFog` is shown (the Ender Dragon's).
|
||||
pub boss_world_fog: bool,
|
||||
/// The camera entity is a spectator (`LavaFogEnvironment`).
|
||||
pub spectator: bool,
|
||||
/// The camera entity has Fire Resistance (`LavaFogEnvironment`).
|
||||
pub fire_resistance: bool,
|
||||
/// `GameRenderer.nightVisionScale` while Night Vision is on without
|
||||
/// Darkness, else 0 (`FogRenderer.computeFogColor`'s brightenFactor).
|
||||
pub night_vision: f32,
|
||||
}
|
||||
|
||||
/// `FogRenderer.setupFog` + `computeFogColor`.
|
||||
@@ -272,7 +279,11 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
|
||||
color[1] += (color[1] * 0.6 - color[1]) * t;
|
||||
color[2] += (color[2] * 0.6 - color[2]) * t;
|
||||
}
|
||||
let brighten = if eye_in_water { water_vision } else { 0.0 };
|
||||
let brighten = if eye_in_water {
|
||||
water_vision
|
||||
} else {
|
||||
input.night_vision
|
||||
};
|
||||
if color.iter().all(|&c| c != 0.0) {
|
||||
let scale = 1.0 / color[0].max(color[1]).max(color[2]);
|
||||
color = color.map(|c| c + (c * scale - c) * brighten);
|
||||
@@ -288,10 +299,16 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
|
||||
};
|
||||
match input.fog_type {
|
||||
FogType::Lava => {
|
||||
fog.environmental_start = 0.25;
|
||||
fog.environmental_end = 1.0;
|
||||
fog.sky_end = 1.0;
|
||||
fog.cloud_end = 1.0;
|
||||
// LavaFogEnvironment.setupFog.
|
||||
(fog.environmental_start, fog.environmental_end) = if input.spectator {
|
||||
(-8.0, rd * 0.5)
|
||||
} else if input.fire_resistance {
|
||||
(0.0, 5.0)
|
||||
} else {
|
||||
(0.25, 1.0)
|
||||
};
|
||||
fog.sky_end = fog.environmental_end;
|
||||
fog.cloud_end = fog.environmental_end;
|
||||
}
|
||||
FogType::PowderSnow => {
|
||||
fog.environmental_start = 0.0;
|
||||
@@ -405,6 +422,9 @@ mod tests {
|
||||
darkness: None,
|
||||
darken_world: 0.0,
|
||||
boss_world_fog: false,
|
||||
spectator: false,
|
||||
fire_resistance: false,
|
||||
night_vision: 0.0,
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
@@ -8,7 +8,7 @@
|
||||
//! offsets), styled drawing and StringSplitter-style line wrapping.
|
||||
use crate::{
|
||||
hud::Rect,
|
||||
text::{Style, Text},
|
||||
text::{Span, Style, Text, legacy_format},
|
||||
zip::Zip,
|
||||
};
|
||||
use ab_glyph::{Font as _, ScaleFont as _};
|
||||
@@ -130,6 +130,8 @@ pub struct Font {
|
||||
atlas: (u32, u32),
|
||||
region: (u32, u32),
|
||||
cache: RefCell<Cache>,
|
||||
/// Set anew for each font and each `reset_cache` (see `epoch`).
|
||||
generation: std::cell::Cell<u64>,
|
||||
/// "Force Unicode Font": the `uniform` filter option.
|
||||
pub uniform: bool,
|
||||
/// "Japanese Glyph Variants": the `jp` filter option.
|
||||
@@ -559,6 +561,11 @@ impl Region {
|
||||
Some((ax, ay))
|
||||
}
|
||||
}
|
||||
/// A value no font has had yet (`Font::epoch`).
|
||||
fn next_generation() -> u64 {
|
||||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
impl Font {
|
||||
/// Build from the atlas holding the bitmap font sheets (`sprites`, of
|
||||
/// `atlas` size) with `region` the reserved rows.
|
||||
@@ -603,6 +610,7 @@ impl Font {
|
||||
.map_or(0, |d| d.as_nanos() as i64),
|
||||
),
|
||||
}),
|
||||
generation: std::cell::Cell::new(next_generation()),
|
||||
uniform: false,
|
||||
japanese: false,
|
||||
};
|
||||
@@ -661,6 +669,13 @@ impl Font {
|
||||
}
|
||||
cache.region.cursor = (0, 0);
|
||||
cache.region.row = 0;
|
||||
self.generation.set(next_generation());
|
||||
}
|
||||
/// Changes whenever a glyph may come out different: another font, a
|
||||
/// cache reset (glyphs move in the atlas) or other font options. Text laid
|
||||
/// out under one epoch can be kept until it changes.
|
||||
pub fn epoch(&self) -> u64 {
|
||||
self.generation.get() << 2 | u64::from(self.uniform) << 1 | u64::from(self.japanese)
|
||||
}
|
||||
/// The glyph for a character in the default font (FontSet.getGlyph).
|
||||
pub fn glyph(&self, c: char) -> Glyph {
|
||||
@@ -951,6 +966,83 @@ impl Font {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `Font.split`: `StringSplitter.splitLines(text, width, Style.EMPTY)` over
|
||||
/// the text's parts with their `§` codes in. A code styles what follows
|
||||
/// (`§r` back to no style while measuring), and the part a line breaks in
|
||||
/// goes on in the style at the break. Lines keep their codes for drawing.
|
||||
pub fn split_formatted(&self, t: &Text, max_width: f32) -> Vec<Text> {
|
||||
let max_width = max_width.max(1.0);
|
||||
let mut parts: Vec<(Vec<char>, Style)> = t
|
||||
.spans
|
||||
.iter()
|
||||
.filter(|s| !s.text.is_empty())
|
||||
.map(|s| (s.text.chars().collect(), s.style.clone()))
|
||||
.collect();
|
||||
let mut flat: Vec<char> = parts.iter().flat_map(|p| p.0.iter().copied()).collect();
|
||||
let mut out = Vec::new();
|
||||
let mut force_new_line = false;
|
||||
// One LineBreakFinder per line, over the parts left.
|
||||
while let Some((at, split_style)) = self.line_break(&parts, max_width) {
|
||||
let tail = flat[at];
|
||||
let new_line = tail == '\n';
|
||||
let skip = usize::from(new_line || tail == ' ');
|
||||
force_new_line = new_line;
|
||||
out.push(split_at(&mut parts, at, skip, &split_style));
|
||||
flat.drain(..at + skip);
|
||||
}
|
||||
if !parts.is_empty() {
|
||||
let mut rest = Text::default();
|
||||
rest.spans = parts
|
||||
.into_iter()
|
||||
.map(|(text, style)| Span {
|
||||
text: text.into_iter().collect(),
|
||||
style,
|
||||
})
|
||||
.collect();
|
||||
out.push(rest);
|
||||
} else if force_new_line {
|
||||
out.push(Text::empty());
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `StringSplitter.LineBreakFinder` fed by `StringDecomposer.iterateFormatted`:
|
||||
/// where the parts break (a character index) and the style there.
|
||||
fn line_break(&self, parts: &[(Vec<char>, Style)], max_width: f32) -> Option<(usize, Style)> {
|
||||
let (mut width, mut had_visible, mut last_space) = (0.0f32, false, None);
|
||||
let mut offset = 0;
|
||||
for (contents, part_style) in parts {
|
||||
let mut style = part_style.clone();
|
||||
let mut i = 0;
|
||||
while i < contents.len() {
|
||||
let c = contents[i];
|
||||
if c == '§' {
|
||||
// A `§` that ends its part ends only that part.
|
||||
let Some(&code) = contents.get(i + 1) else {
|
||||
break;
|
||||
};
|
||||
style = legacy_format(&style, code, &Style::default());
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
let at = offset + i;
|
||||
if c == '\n' {
|
||||
return Some((at, style));
|
||||
}
|
||||
if c == ' ' {
|
||||
last_space = Some((at, style.clone()));
|
||||
}
|
||||
let advance = self.advance(c, &style);
|
||||
width += advance;
|
||||
if had_visible && width > max_width {
|
||||
return Some(last_space.unwrap_or((at, style)));
|
||||
}
|
||||
had_visible |= advance != 0.0;
|
||||
i += 1;
|
||||
}
|
||||
offset += contents.len();
|
||||
}
|
||||
None
|
||||
}
|
||||
/// `ComponentRenderUtils.wrapComponents`: the split lines, wrapped ones
|
||||
/// indented by an unstyled space; one empty line for empty text.
|
||||
pub fn wrap_components(&self, t: &Text, max_width: i32) -> Vec<Text> {
|
||||
@@ -1044,6 +1136,47 @@ impl Font {
|
||||
}
|
||||
}
|
||||
|
||||
/// `StringSplitter.FlatComponents.splitAt`: the parts before `at` as a line,
|
||||
/// `skip` characters dropped, and the part it ends in restyled as `style`.
|
||||
fn split_at(parts: &mut Vec<(Vec<char>, Style)>, at: usize, skip: usize, style: &Style) -> Text {
|
||||
let mut line = Text::default();
|
||||
let mut push = |text: &[char], style: &Style| {
|
||||
line.spans.push(Span {
|
||||
text: text.iter().collect(),
|
||||
style: style.clone(),
|
||||
})
|
||||
};
|
||||
// Each part done is removed, so the one at hand is always the first.
|
||||
let (mut position, mut in_skip) = (at, false);
|
||||
while let Some((text, part_style)) = parts.first() {
|
||||
let size = text.len();
|
||||
if !in_skip {
|
||||
if position > size {
|
||||
push(text, part_style);
|
||||
parts.remove(0);
|
||||
position -= size;
|
||||
continue;
|
||||
}
|
||||
if position > 0 {
|
||||
push(&text[..position], part_style);
|
||||
}
|
||||
position += skip;
|
||||
in_skip = true;
|
||||
}
|
||||
if position <= size {
|
||||
if position == size {
|
||||
parts.remove(0);
|
||||
} else {
|
||||
parts[0] = (text[position..].to_vec(), style.clone());
|
||||
}
|
||||
break;
|
||||
}
|
||||
parts.remove(0);
|
||||
position -= size;
|
||||
}
|
||||
line
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1206,6 +1339,37 @@ mod tests {
|
||||
assert!(!font.take_patches().is_empty());
|
||||
}
|
||||
|
||||
/// StringDecomposer.iterateFormatted: a `§` that ends a part stops that
|
||||
/// part only; the next part is still measured and can break.
|
||||
#[test]
|
||||
fn a_section_sign_ending_a_part_still_breaks_the_next() {
|
||||
let sheet = png(16, 8, |x, _| x < 2);
|
||||
let default = br#"{"providers":[{"type":"space","advances":{" ":4}},{"type":"bitmap","file":"minecraft:font/a.png","ascent":7,"chars":["ab"]}]}"#;
|
||||
let root = root(
|
||||
"section-sign",
|
||||
&[
|
||||
("assets/minecraft/font/default.json", default),
|
||||
("assets/minecraft/textures/font/a.png", &sheet),
|
||||
],
|
||||
);
|
||||
let font = load(&root);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let mut text = Text::default();
|
||||
text.spans = ["a§", "a a"]
|
||||
.map(|part| Span {
|
||||
text: part.into(),
|
||||
style: Style::default(),
|
||||
})
|
||||
.into();
|
||||
// 3 + 3 + 4 + 3 is past 12: the line breaks at the space.
|
||||
let lines: Vec<String> = font
|
||||
.split_formatted(&text, 12.0)
|
||||
.iter()
|
||||
.map(Text::plain)
|
||||
.collect();
|
||||
assert_eq!(lines, ["a§a", "a"]);
|
||||
}
|
||||
|
||||
/// A renderer rebuilt over the same font (the loading screen's handoff)
|
||||
/// makes its atlas texture from the textures alone, so the glyphs it had
|
||||
/// rasterized into the previous texture must be placed into the new one.
|
||||
@@ -39,8 +39,9 @@ mod entity_outline;
|
||||
mod weather;
|
||||
use entity_batch::EntityBatch;
|
||||
pub use entity_batch::{
|
||||
ENERGY_SWIRL_OVERLAY, SHADOW_OVERLAY, TRANSLUCENT_GLINT_ITEM_OVERLAY, TRANSLUCENT_ITEM_OVERLAY,
|
||||
TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY,
|
||||
ENERGY_SWIRL_OVERLAY, SHADOW_OVERLAY, TEXT_POLYGON_OFFSET_OVERLAY,
|
||||
TRANSLUCENT_GLINT_ITEM_OVERLAY, TRANSLUCENT_ITEM_OVERLAY, TRANSLUCENT_PARTICLE_OVERLAY,
|
||||
WATER_MASK_OVERLAY,
|
||||
};
|
||||
|
||||
/// Window.getAppropriateLineWidth, in framebuffer pixels.
|
||||
@@ -93,6 +94,7 @@ mod page_runs;
|
||||
pub mod panorama;
|
||||
mod resident;
|
||||
mod stars;
|
||||
mod sunrise_fan;
|
||||
mod timing;
|
||||
pub mod translucency;
|
||||
pub(crate) mod upload;
|
||||
@@ -105,12 +107,9 @@ const COMPLETE_COLUMN: u32 = (1 << (crate::chunk::HEIGHT / 16)) - 1;
|
||||
/// its fixed-point UVs (`terrain.wgsl`'s `terrain_uv`).
|
||||
/// The world pass again after occlusion culling's test: it loads both
|
||||
/// targets (phase 2 draws over phase 1, reading the test's results).
|
||||
/// LevelRenderer: no sky inside lava or powder snow (sky fog ends within 2 blocks).
|
||||
/// `LevelRenderer.addSkyPass`: fluids and camera effects block the sky pass.
|
||||
fn sky_open(frame: &Frame) -> bool {
|
||||
match frame.fog {
|
||||
Some(f) => f.sky_end > 2.0,
|
||||
None => !frame.underwater,
|
||||
}
|
||||
!frame.sky_blocked
|
||||
}
|
||||
|
||||
/// The Overworld-style sky (disc, sunset, stars, void) draws this frame.
|
||||
@@ -191,6 +190,8 @@ struct Globals {
|
||||
outline_params: [f32; 4],
|
||||
/// Horizon Extras' dynamic lights: camera-relative position and level (`dynamic_light`).
|
||||
dynamic_lights: [[f32; 4]; crate::dynamic_light::MAX],
|
||||
/// The sunrise fan's triangles as the sky shader tests them (`sunrise_fan::normals`).
|
||||
sunrise_fan: [[f32; 4]; 2 * sunrise_fan::TRIANGLES],
|
||||
}
|
||||
pub type SectionKey = (i32, i32, i32);
|
||||
struct SectionGpu {
|
||||
@@ -222,6 +223,9 @@ pub struct Frame<'a> {
|
||||
/// The `GameRenderer.bobHurt`/`bobView` pose multiplied into the
|
||||
/// projection (`crate::camera::bob_matrix`).
|
||||
pub bob: Mat4,
|
||||
/// The spinning effect multiplied into the level's projection only
|
||||
/// (`crate::camera::spinning_effect_matrix`), not the hand's.
|
||||
pub spin: Mat4,
|
||||
pub sky: Sky,
|
||||
/// Render distance in blocks (fog end).
|
||||
pub distance: f32,
|
||||
@@ -235,6 +239,8 @@ pub struct Frame<'a> {
|
||||
pub cave_culling: bool,
|
||||
/// Title screen: only the dedicated panorama cube map and the HUD.
|
||||
pub title: bool,
|
||||
/// Lava, powder snow, blindness or darkness blocks the sky pass.
|
||||
pub sky_blocked: bool,
|
||||
/// Dimensions without a skybox (the Nether) clear to the fog colour.
|
||||
pub skybox: bool,
|
||||
/// The End: clear to black and draw the resident sky box behind the world.
|
||||
@@ -423,6 +429,7 @@ pub struct Renderer {
|
||||
lightning_pipeline: wgpu::RenderPipeline,
|
||||
lightning: lightning::Renderer,
|
||||
see_through_pipeline: wgpu::RenderPipeline,
|
||||
text_polygon_offset_pipeline: wgpu::RenderPipeline,
|
||||
celestial_pipeline: wgpu::RenderPipeline,
|
||||
celestial_buffer: wgpu::Buffer,
|
||||
end_sky: end_sky::EndSky,
|
||||
@@ -531,12 +538,15 @@ pub struct Renderer {
|
||||
block_entity_revision: u64,
|
||||
/// VisGraph face connectivity per meshed section (cave culling).
|
||||
occlusion: occlusion::Occlusion,
|
||||
/// Resolution scale and FXAA (Horizon settings).
|
||||
/// Resolution scale, FXAA and TAA (Horizon settings).
|
||||
world_post: world_post::Post,
|
||||
/// HDR output (Horizon setting): `Some` while the swapchain is HDR.
|
||||
hdr: Option<hdr::Hdr>,
|
||||
/// The HDR output the window's surface can present.
|
||||
hdr_output: Option<hdr::Output>,
|
||||
/// The marker file of an HDR output that has not presented a frame yet
|
||||
/// (`graphics::HDR_MARKER`).
|
||||
hdr_marker: Option<std::path::PathBuf>,
|
||||
/// The shader pack's post-processing, and the dimension (legacy id) it was loaded for.
|
||||
pack_post: Option<(i32, Option<pack_post::PackPost>)>,
|
||||
/// The post passes of one of Horizon's own shader packs; never beside `pack_post`'s.
|
||||
@@ -760,8 +770,14 @@ fn mip_chain(
|
||||
/// pass is also what passes each draw's first instance to shaders reading
|
||||
/// `instance_index`, and DirectX 12 is the fallback, where working matters
|
||||
/// more than CPU time.
|
||||
fn instance_descriptor(backends: wgpu::Backends) -> wgpu::InstanceDescriptor {
|
||||
fn instance_descriptor(
|
||||
backends: wgpu::Backends,
|
||||
window: Option<&Arc<Window>>,
|
||||
) -> wgpu::InstanceDescriptor {
|
||||
let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle();
|
||||
if let Some(window) = window {
|
||||
descriptor = descriptor.with_display_handle(Box::new(WindowDisplay(window.clone())));
|
||||
}
|
||||
descriptor.backends = backends.with_env();
|
||||
let dx12 = cfg!(windows) && descriptor.backends.contains(wgpu::Backends::DX12);
|
||||
if !cfg!(debug_assertions) && !dx12 {
|
||||
@@ -771,6 +787,21 @@ fn instance_descriptor(backends: wgpu::Backends) -> wgpu::InstanceDescriptor {
|
||||
}
|
||||
descriptor.with_env()
|
||||
}
|
||||
/// The window's display connection, so the OpenGL backend creates its EGL
|
||||
/// display from the X11 or Wayland connection the window lives on. Without
|
||||
/// one, wgpu-hal falls back to `EGL_MESA_platform_surfaceless`, whose configs
|
||||
/// cannot present to a window; the backend then reports the surface as
|
||||
/// incompatible and the game has no OpenGL to fall back to (`gl not
|
||||
/// compatible with provided surface`).
|
||||
#[derive(Debug, Clone)]
|
||||
struct WindowDisplay(Arc<Window>);
|
||||
impl wgpu::rwh::HasDisplayHandle for WindowDisplay {
|
||||
fn display_handle(
|
||||
&self,
|
||||
) -> std::result::Result<wgpu::rwh::DisplayHandle<'_>, wgpu::rwh::HandleError> {
|
||||
wgpu::rwh::HasDisplayHandle::display_handle(&*self.0)
|
||||
}
|
||||
}
|
||||
/// The backends to open a device on, each tried in turn until one gives a
|
||||
/// device. `api` is the player's choice (`GraphicsApi::with_env` applied);
|
||||
/// `os` is `std::env::consts::OS`; `wgpu_backend`: whether `WGPU_BACKEND` is set,
|
||||
@@ -885,7 +916,7 @@ async fn open_device(
|
||||
if attempt > 0 {
|
||||
log::warn!("Horizon renderer: trying {name} ({})", failures.join("; "));
|
||||
}
|
||||
let instance = wgpu::Instance::new(instance_descriptor(backends));
|
||||
let instance = wgpu::Instance::new(instance_descriptor(backends, Some(window)));
|
||||
let surface = if offscreen {
|
||||
None
|
||||
} else {
|
||||
@@ -1123,7 +1154,14 @@ impl Renderer {
|
||||
));
|
||||
}
|
||||
surface.configure(&device, &config);
|
||||
(config, caps.present_modes.clone(), hdr::output(&caps))
|
||||
let hdr_output = hdr::output(&caps);
|
||||
if previous.is_none() {
|
||||
log::info!(
|
||||
"Horizon renderer: HDR output {}",
|
||||
hdr_output.map_or("not offered by this display", hdr::Output::name)
|
||||
);
|
||||
}
|
||||
(config, caps.present_modes.clone(), hdr_output)
|
||||
} else {
|
||||
let format = wgpu::TextureFormat::Bgra8Unorm;
|
||||
let config = wgpu::SurfaceConfiguration {
|
||||
@@ -1964,7 +2002,7 @@ impl Renderer {
|
||||
// ulps covers that spread at any resolution while staying far below
|
||||
// the 0.001 a model's feet sit higher, so shadows never bleed onto
|
||||
// the feet.
|
||||
let shadow_blob_pipeline = entity_pipeline(
|
||||
let shadow_blob_pipeline = entity_pipeline_with(
|
||||
"Entity shadows",
|
||||
None,
|
||||
false,
|
||||
@@ -1976,6 +2014,8 @@ impl Renderer {
|
||||
},
|
||||
alpha_blend,
|
||||
false,
|
||||
"fs_entity_shadow",
|
||||
wgpu::ColorWrites::ALL,
|
||||
);
|
||||
// RenderPipelines.ITEM_TRANSLUCENT: blended, depth tested. Drawn before
|
||||
// translucent terrain, it cannot write depth: the water behind a
|
||||
@@ -2009,6 +2049,21 @@ impl Renderer {
|
||||
alpha_blend,
|
||||
false,
|
||||
);
|
||||
// RenderPipelines.TEXT_POLYGON_OFFSET: a text display's text and
|
||||
// background win against the surface they lie on (bias 1, 10).
|
||||
let text_polygon_offset_pipeline = entity_pipeline(
|
||||
"Polygon offset text",
|
||||
None,
|
||||
true,
|
||||
wgpu::CompareFunction::GreaterEqual,
|
||||
wgpu::DepthBiasState {
|
||||
constant: 10,
|
||||
slope_scale: 1.0,
|
||||
clamp: 0.0,
|
||||
},
|
||||
alpha_blend,
|
||||
false,
|
||||
);
|
||||
let entity_pipeline = entity_pipeline(
|
||||
"Entities",
|
||||
None,
|
||||
@@ -2566,6 +2621,7 @@ impl Renderer {
|
||||
lightning_pipeline,
|
||||
lightning: lightning::Renderer::new(),
|
||||
see_through_pipeline,
|
||||
text_polygon_offset_pipeline,
|
||||
celestial_pipeline,
|
||||
celestial_buffer,
|
||||
end_sky: end_sky::EndSky::default(),
|
||||
@@ -2642,6 +2698,7 @@ impl Renderer {
|
||||
world_post,
|
||||
hdr: None,
|
||||
hdr_output,
|
||||
hdr_marker: None,
|
||||
pack_post: None,
|
||||
native: None,
|
||||
pass_timing: pack_timing::PassTiming::logging(),
|
||||
@@ -3198,14 +3255,43 @@ impl Renderer {
|
||||
}
|
||||
config
|
||||
}
|
||||
/// The F3 screen's HDR line while the setting is on: the swapchain HDR presents to with
|
||||
/// its paper white and peak, or that the surface offers none.
|
||||
pub fn hdr_line(&self) -> String {
|
||||
match &self.hdr {
|
||||
Some(hdr) => format!(
|
||||
"HDR: {}, {}/{} nits",
|
||||
hdr.output.name(),
|
||||
hdr.paper_white.round(),
|
||||
hdr.peak.round()
|
||||
),
|
||||
None => "HDR: not offered by this display".into(),
|
||||
}
|
||||
}
|
||||
/// 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) {
|
||||
pub fn set_hdr(&mut self, on: bool, paper_white: f32, peak: 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
|
||||
// the next start, which then comes up with HDR off.
|
||||
if output.is_some() {
|
||||
let _ = std::fs::write(marker, "HDR output was being switched on\n");
|
||||
self.hdr_marker = Some(marker.to_owned());
|
||||
} else if let Some(marker) = self.hdr_marker.take() {
|
||||
let _ = std::fs::remove_file(marker);
|
||||
}
|
||||
let format = self.config.format.remove_srgb_suffix();
|
||||
self.hdr = output.map(|output| hdr::Hdr::new(&self.device, format, output));
|
||||
self.configure_surface();
|
||||
match output {
|
||||
Some(output) => log::info!(
|
||||
"Horizon renderer: HDR on: {} ({:?}), {paper_white}/{peak} nits",
|
||||
output.name(),
|
||||
output.format()
|
||||
),
|
||||
None => log::info!("Horizon renderer: HDR off"),
|
||||
}
|
||||
}
|
||||
if let Some(hdr) = &mut self.hdr
|
||||
&& (hdr.paper_white, hdr.peak) != (paper_white, peak)
|
||||
@@ -3215,9 +3301,12 @@ impl Renderer {
|
||||
self.send_hdr_metadata();
|
||||
}
|
||||
}
|
||||
pub fn set_world_post(&mut self, scale: f32, antialias: bool) {
|
||||
/// `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) {
|
||||
self.world_post.scale = scale.clamp(0.25, 2.0);
|
||||
self.world_post.antialias = antialias;
|
||||
self.world_post.antialias = antialias && !taa;
|
||||
self.world_post.taa = taa;
|
||||
self.world_post.upscale = if taa { upscale.clamp(1.0, 4.0) } else { 1.0 };
|
||||
}
|
||||
/// Runs `post` over the world from now on (`None`: no pack), for the dimension `dimension`;
|
||||
/// `block_ids` gives each block sprite layer's `mc_Entity` (id, fluid) pair.
|
||||
@@ -3418,7 +3507,7 @@ impl Renderer {
|
||||
frame.fov_degrees,
|
||||
frame.yaw,
|
||||
frame.pitch,
|
||||
frame.bob,
|
||||
frame.bob * frame.spin,
|
||||
frame.depth_far,
|
||||
)
|
||||
}
|
||||
@@ -3587,7 +3676,7 @@ impl Renderer {
|
||||
if self.animated_textures {
|
||||
self.animate(&mut encoder, frame.ticks);
|
||||
self.hud
|
||||
.animate_fire(&mut self.uploads, &mut encoder, frame.ticks);
|
||||
.animate_sprites(&mut self.uploads, &mut encoder, frame.ticks);
|
||||
}
|
||||
self.update_lightmap(
|
||||
&mut encoder,
|
||||
@@ -3599,6 +3688,16 @@ impl Renderer {
|
||||
frame.boss_darken,
|
||||
);
|
||||
let view_proj = self.view_projection(frame);
|
||||
// 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())
|
||||
&& self.native.as_ref().is_none_or(|n| n.post.is_none());
|
||||
let jittered = self.world_post.jitter(
|
||||
[self.config.width, self.config.height],
|
||||
taa,
|
||||
view_proj,
|
||||
frame.camera,
|
||||
) * view_proj;
|
||||
if frame.title {
|
||||
self.panorama.prepare(
|
||||
&self.queue,
|
||||
@@ -3650,7 +3749,7 @@ impl Renderer {
|
||||
)
|
||||
};
|
||||
let globals = Globals {
|
||||
view_proj: view_proj.to_cols_array_2d(),
|
||||
view_proj: jittered.to_cols_array_2d(),
|
||||
fog_color,
|
||||
fog,
|
||||
sky_color: [
|
||||
@@ -3690,6 +3789,7 @@ impl Renderer {
|
||||
}
|
||||
lights
|
||||
},
|
||||
sunrise_fan: sunrise_fan::normals(frame.sky.sunrise_color[3], sun),
|
||||
};
|
||||
self.uploads
|
||||
.buffer(&mut encoder, &self.globals, 0, bytemuck::bytes_of(&globals));
|
||||
@@ -4192,6 +4292,7 @@ impl Renderer {
|
||||
}
|
||||
let [
|
||||
entity_quads,
|
||||
text_quads,
|
||||
crumbling_quads,
|
||||
translucent_crumbling_quads,
|
||||
water_mask_quads,
|
||||
@@ -4204,6 +4305,7 @@ impl Renderer {
|
||||
.update(entities, frame.additive, frame.hand, frame.see_through);
|
||||
let weather_quads = frame.weather.len() as u32 / 4;
|
||||
let world_quads = entity_quads
|
||||
+ text_quads
|
||||
+ crumbling_quads
|
||||
+ translucent_crumbling_quads
|
||||
+ water_mask_quads
|
||||
@@ -4282,7 +4384,7 @@ impl Renderer {
|
||||
self.stars.prepare(
|
||||
&mut self.uploads,
|
||||
&mut encoder,
|
||||
view_proj * rotation,
|
||||
jittered * rotation,
|
||||
brightness,
|
||||
);
|
||||
}
|
||||
@@ -4455,6 +4557,8 @@ impl Renderer {
|
||||
.flatten();
|
||||
}
|
||||
let pack_reads_world = pack_post || native_post;
|
||||
// Phase 2 of occlusion culling draws over the depth, and a shader pack or TAA reads it.
|
||||
let keep_depth = pack_reads_world || (taa && self.world_post.taa);
|
||||
// The pack's program reads the vertices this frame's draws bind.
|
||||
let pack_draws_terrain = pack_terrain && flat_frame == self.pack_terrain_flat;
|
||||
// The shadow camera's lists, drawn by the pack's shadow programs.
|
||||
@@ -4508,7 +4612,7 @@ impl Renderer {
|
||||
cull.bind_depth(&self.device, world_depth);
|
||||
}
|
||||
let cull = self.cull.as_ref().filter(|_| culling && solid_draws > 0);
|
||||
let clear = if frame.title || (frame.end_sky && !frame.underwater) {
|
||||
let clear = if frame.title || (frame.end_sky && !frame.underwater && sky_open(frame)) {
|
||||
wgpu::Color::BLACK
|
||||
} else {
|
||||
wgpu::Color {
|
||||
@@ -4606,8 +4710,7 @@ impl Renderer {
|
||||
view: world_depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(0.0),
|
||||
// Phase 2 of occlusion culling draws over it; a shader pack reads it.
|
||||
store: if cull.is_some() || pack_reads_world {
|
||||
store: if cull.is_some() || keep_depth {
|
||||
wgpu::StoreOp::Store
|
||||
} else {
|
||||
wgpu::StoreOp::Discard
|
||||
@@ -4682,7 +4785,7 @@ impl Renderer {
|
||||
&mut encoder,
|
||||
world_view,
|
||||
world_depth,
|
||||
pack_reads_world,
|
||||
keep_depth,
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pack_frame(&mut pass);
|
||||
@@ -4712,7 +4815,7 @@ impl Renderer {
|
||||
&mut encoder,
|
||||
world_view,
|
||||
world_depth,
|
||||
pack_reads_world,
|
||||
keep_depth,
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pack_frame(&mut pass);
|
||||
@@ -4741,6 +4844,13 @@ impl Renderer {
|
||||
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if text_quads > 0 {
|
||||
pass.set_pipeline(&self.text_polygon_offset_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(0..text_quads * 6, (entity_quads * 4) as i32, 0..1);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if translucent_item_quads > 0 {
|
||||
pass.set_pipeline(&self.translucent_item_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
@@ -4760,7 +4870,11 @@ impl Renderer {
|
||||
pass.set_pipeline(&self.crumbling_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(0..crumbling_quads * 6, (entity_quads * 4) as i32, 0..1);
|
||||
pass.draw_indexed(
|
||||
0..crumbling_quads * 6,
|
||||
((entity_quads + text_quads) * 4) as i32,
|
||||
0..1,
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if shadow_quads > 0 {
|
||||
@@ -4786,7 +4900,8 @@ impl Renderer {
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(
|
||||
0..water_mask_quads * 6,
|
||||
((entity_quads + crumbling_quads + translucent_crumbling_quads) * 4) as i32,
|
||||
((entity_quads + text_quads + crumbling_quads + translucent_crumbling_quads)
|
||||
* 4) as i32,
|
||||
0..1,
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
@@ -4804,6 +4919,7 @@ impl Renderer {
|
||||
pass.draw_indexed(
|
||||
0..swirl_quads * 6,
|
||||
((entity_quads
|
||||
+ text_quads
|
||||
+ crumbling_quads
|
||||
+ translucent_crumbling_quads
|
||||
+ water_mask_quads)
|
||||
@@ -4897,7 +5013,7 @@ impl Renderer {
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(
|
||||
0..translucent_crumbling_quads * 6,
|
||||
((entity_quads + crumbling_quads) * 4) as i32,
|
||||
((entity_quads + text_quads + crumbling_quads) * 4) as i32,
|
||||
0..1,
|
||||
);
|
||||
}
|
||||
@@ -5025,7 +5141,8 @@ impl Renderer {
|
||||
native.timing.as_mut(),
|
||||
);
|
||||
} else if scaled.is_some() {
|
||||
self.world_post.finish(&mut encoder, &view_target);
|
||||
self.world_post
|
||||
.finish(&self.queue, &mut encoder, &view_target);
|
||||
}
|
||||
if let Some(hdr) = &self.hdr {
|
||||
hdr.keep_world(&mut encoder);
|
||||
@@ -5229,6 +5346,12 @@ impl Renderer {
|
||||
startup_step(format_args!("presenting the first frame"));
|
||||
}
|
||||
self.queue.present(surface_frame);
|
||||
if self.hdr.is_some()
|
||||
&& let Some(marker) = self.hdr_marker.take()
|
||||
{
|
||||
let _ = std::fs::remove_file(marker);
|
||||
log::info!("Horizon renderer: HDR output presented its first frame");
|
||||
}
|
||||
if reconfigure && let Some(surface) = &self.surface {
|
||||
Self::configure(
|
||||
&self.device,
|
||||
@@ -5494,6 +5617,7 @@ mod tests {
|
||||
(11, 2),
|
||||
(15, 10),
|
||||
(7, 3),
|
||||
(35, 11),
|
||||
(4, 4),
|
||||
(12, 7),
|
||||
]
|
||||
@@ -5503,7 +5627,7 @@ mod tests {
|
||||
let mut batch = super::EntityBatch::default();
|
||||
let counts = batch.update(&entities, &quad(3, 5), &quad(0, 6), &quad(0, 8));
|
||||
let vertices = &batch.combined;
|
||||
assert_eq!(counts, [4, 1, 1, 0, 0, 1, 1, 0]);
|
||||
assert_eq!(counts, [4, 1, 1, 1, 0, 0, 1, 1, 0]);
|
||||
assert_eq!(
|
||||
vertices
|
||||
.as_chunks::<4>()
|
||||
@@ -5511,7 +5635,7 @@ mod tests {
|
||||
.iter()
|
||||
.map(|q| q[0].light)
|
||||
.collect::<Vec<_>>(),
|
||||
[0, 1, 3, 4, 2, 7, 9, 10, 5, 6, 8]
|
||||
[0, 1, 3, 4, 11, 2, 7, 9, 10, 5, 6, 8]
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
@@ -5682,7 +5806,7 @@ mod tests {
|
||||
#[test]
|
||||
fn indirect_draws_are_validated_only_in_debug_builds_and_on_directx_12() {
|
||||
let validated = |backends| {
|
||||
super::instance_descriptor(backends)
|
||||
super::instance_descriptor(backends, None)
|
||||
.flags
|
||||
.contains(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL)
|
||||
};
|
||||
+122
-13
@@ -103,10 +103,11 @@ impl SpriteAnimator {
|
||||
let mut counts: Vec<usize> = Vec::new();
|
||||
let mut sprites = Vec::with_capacity(animations.len());
|
||||
for animation in animations {
|
||||
let chains: Vec<_> = (0..animation.frame_count())
|
||||
.map(|frame| source_chain(animation.pixels(frame), size, settings, animation.layer))
|
||||
let pixels: Vec<_> = (0..animation.frame_count())
|
||||
.map(|frame| animation.pixels(frame))
|
||||
.collect();
|
||||
let bytes: u64 = chains.iter().map(|(chain, _)| chain.len() as u64).sum();
|
||||
let (chains, edge) = source_chains(&pixels, size, settings, animation.layer);
|
||||
let bytes: u64 = chains.iter().map(|chain| chain.len() as u64).sum();
|
||||
if bytes > limit {
|
||||
log::warn!(
|
||||
"Horizon: layer {}'s animation ({bytes} bytes) does not fit in a storage buffer; it stays still",
|
||||
@@ -124,7 +125,7 @@ impl SpriteAnimator {
|
||||
let data = &mut frames[buffer];
|
||||
let offsets = chains
|
||||
.iter()
|
||||
.map(|(chain, _)| {
|
||||
.map(|chain| {
|
||||
let offset = (data.len() / 4) as u32;
|
||||
data.extend_from_slice(chain);
|
||||
offset
|
||||
@@ -142,7 +143,7 @@ impl SpriteAnimator {
|
||||
shown: None,
|
||||
layer: animation.layer % split,
|
||||
frames: offsets,
|
||||
edge: chains[0].1,
|
||||
edge,
|
||||
group,
|
||||
});
|
||||
}
|
||||
@@ -424,15 +425,63 @@ fn mix_level(frames: &[u8], [_, current, next, millis, edge]: [u32; 5], width: u
|
||||
/// array's size from its own size's chain, its first level repeated into the
|
||||
/// larger levels: that chain, which the shader repeats. Any other sprite: its
|
||||
/// chain at the array's size.
|
||||
#[cfg(test)]
|
||||
fn source_chain(pixels: &[u8], size: u32, settings: &[Settings], layer: u32) -> (Vec<u8>, u32) {
|
||||
let native = settings.get(layer as usize).map_or(0, |s| s.native_size);
|
||||
let edge = if native.is_power_of_two() && native < size {
|
||||
native
|
||||
let (mut chains, edge) = source_chains(&[pixels], size, settings, layer);
|
||||
(chains.swap_remove(0), edge)
|
||||
}
|
||||
|
||||
/// Every frame's chain, laid out as one frame's `source_chain`, but mipmapped as
|
||||
/// vanilla's `SpriteContents.increaseMipLevel` does: over the whole animation
|
||||
/// sheet (the frames stacked), so `solidify` and the alpha-to-coverage scale
|
||||
/// of each level are the sheet's, not each frame's. A frame alone has a
|
||||
/// single texel square at its 2x2 level, and its coverage scale could round
|
||||
/// a whole far frame transparent (kelp blinking out at distance).
|
||||
fn source_chains(
|
||||
frames: &[&[u8]],
|
||||
size: u32,
|
||||
settings: &[Settings],
|
||||
layer: u32,
|
||||
) -> (Vec<Vec<u8>>, u32) {
|
||||
let s = settings.get(layer as usize).copied().unwrap_or_default();
|
||||
let edge = if s.native_size.is_power_of_two() && s.native_size < size {
|
||||
s.native_size
|
||||
} else {
|
||||
size
|
||||
};
|
||||
let frame = crate::sprites::resample(pixels, size, edge);
|
||||
(super::mip_chain(&frame, edge, settings, layer, 1).0, edge)
|
||||
let mut sheet = crate::mipmap::Image {
|
||||
width: edge,
|
||||
height: edge * frames.len() as u32,
|
||||
pixels: Vec::with_capacity((edge * edge) as usize * frames.len()),
|
||||
};
|
||||
for frame in frames {
|
||||
let frame = crate::sprites::resample(frame, size, edge);
|
||||
sheet.pixels.extend_from_slice(frame.as_chunks::<4>().0);
|
||||
}
|
||||
let has_transparent = s.has_transparent || sheet.pixels.iter().any(|p| p[3] == 0);
|
||||
let levels = crate::mipmap::generate(
|
||||
sheet,
|
||||
edge.trailing_zeros(),
|
||||
Settings {
|
||||
has_transparent,
|
||||
..s
|
||||
},
|
||||
);
|
||||
let chains = (0..frames.len())
|
||||
.map(|frame| {
|
||||
let mut chain = Vec::new();
|
||||
for level in &levels {
|
||||
let texels = (level.width * level.width) as usize;
|
||||
chain.extend(
|
||||
level.pixels[frame * texels..(frame + 1) * texels]
|
||||
.iter()
|
||||
.flatten(),
|
||||
);
|
||||
}
|
||||
chain
|
||||
})
|
||||
.collect();
|
||||
(chains, edge)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -572,6 +621,63 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kelp_frames_share_the_sheet_mips() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root
|
||||
.join("assets/minecraft/textures/block/kelp.png")
|
||||
.exists()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let mut sprites = crate::sprites::Sprites::default();
|
||||
for name in ["kelp", "kelp_plant", "seagrass", "tall_seagrass_top"] {
|
||||
sprites
|
||||
.insert(&root, &format!("minecraft:block/{name}"))
|
||||
.unwrap();
|
||||
}
|
||||
sprites.finish();
|
||||
// A level drawn nowhere: no texel passes the cutout's alpha test.
|
||||
let blank = |chain: &[u8], edge: u32| {
|
||||
let mut start = 0;
|
||||
let mut level = edge;
|
||||
let mut blank = Vec::new();
|
||||
while level > 0 {
|
||||
let bytes = (level * level * 4) as usize;
|
||||
let texels = &chain[start..start + bytes];
|
||||
blank.push(texels.as_chunks::<4>().0.iter().all(|p| p[3] < 128));
|
||||
start += bytes;
|
||||
level >>= 1;
|
||||
}
|
||||
blank
|
||||
};
|
||||
let mut blinked = 0;
|
||||
for animation in &sprites.animations {
|
||||
let size = sprites.size;
|
||||
let pixels: Vec<_> = (0..animation.frame_count())
|
||||
.map(|f| animation.pixels(f))
|
||||
.collect();
|
||||
let (chains, edge) = source_chains(&pixels, size, &sprites.mips, animation.layer);
|
||||
for (frame, chain) in chains.iter().enumerate() {
|
||||
// The 1x1 level has no texel square to measure coverage on
|
||||
// (vanilla leaves it unscaled); every larger level shows.
|
||||
let levels = blank(chain, edge);
|
||||
assert!(
|
||||
!levels[..levels.len() - 1].contains(&true),
|
||||
"layer {} frame {frame}: {levels:?}",
|
||||
animation.layer
|
||||
);
|
||||
let (alone, _) = source_chain(pixels[frame], size, &sprites.mips, animation.layer);
|
||||
let alone = blank(&alone, edge);
|
||||
blinked += usize::from(alone[..alone.len() - 1].contains(&true));
|
||||
}
|
||||
}
|
||||
// Mipmapped frame by frame, some kelp frames vanished at the 2x2 level.
|
||||
assert!(blinked > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounding_matches_f32_mix_but_for_exact_halves() {
|
||||
// The CPU blend this replaced: f32 mix, then rounded. They differ
|
||||
@@ -730,8 +836,11 @@ mod tests {
|
||||
queue.submit([encoder.finish()]);
|
||||
for animation in &animations {
|
||||
let blend = animation.blend(tick);
|
||||
let chain = |f| source_chain(animation.pixels(f), size, &mips, animation.layer);
|
||||
let ((current, edge), (next, _)) = (chain(blend.frame), chain(blend.next));
|
||||
let pixels: Vec<_> = (0..animation.frame_count())
|
||||
.map(|f| animation.pixels(f))
|
||||
.collect();
|
||||
let (chains, edge) = source_chains(&pixels, size, &mips, animation.layer);
|
||||
let (current, next) = (&chains[blend.frame], &chains[blend.next]);
|
||||
let layers = read_back(
|
||||
&device,
|
||||
&queue,
|
||||
@@ -739,7 +848,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
layers[(animation.layer % split) as usize],
|
||||
reference(¤t, &next, blend.millis, edge, size),
|
||||
reference(current, next, blend.millis, edge, size),
|
||||
"layer {} at tick {tick}",
|
||||
animation.layer
|
||||
);
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+54
-55
@@ -2,13 +2,14 @@
|
||||
use super::Vertex;
|
||||
|
||||
/// Entity materials by a quad's overlay (`Vertex::layer`), in draw order:
|
||||
/// ordinary entities, breaking cracks (11), translucent breaking cracks (12),
|
||||
/// the boat water mask (13, `RenderPipelines.WATER_MASK`), energy swirls
|
||||
/// (34, `RenderPipelines.ENERGY_SWIRL`), entity shadows (14,
|
||||
/// ordinary entities, world text drawn with a depth bias (35,
|
||||
/// `RenderPipelines.TEXT_POLYGON_OFFSET`), breaking cracks (11), translucent
|
||||
/// breaking cracks (12), the boat water mask (13, `RenderPipelines.WATER_MASK`),
|
||||
/// energy swirls (34, `RenderPipelines.ENERGY_SWIRL`), entity shadows (14,
|
||||
/// `RenderPipelines.ENTITY_SHADOW`), translucent-layer particles (15, drawn
|
||||
/// after translucent terrain: `SubmitNodeCollection.submitQuadParticleGroup`)
|
||||
/// and translucent item quads (32, drawn without depth writes).
|
||||
const MATERIALS: usize = 8;
|
||||
const MATERIALS: usize = 9;
|
||||
/// The overlay of depth-only quads that hide water (`RenderTypes.waterMask`).
|
||||
pub const WATER_MASK_OVERLAY: u32 = 13;
|
||||
/// The overlay of energy swirl quads (`RenderTypes.energySwirl`: the charged creeper's and the
|
||||
@@ -24,13 +25,31 @@ pub const TRANSLUCENT_PARTICLE_OVERLAY: u32 = 15;
|
||||
pub const TRANSLUCENT_ITEM_OVERLAY: u32 = 32;
|
||||
/// A translucent item with an enchantment glint.
|
||||
pub const TRANSLUCENT_GLINT_ITEM_OVERLAY: u32 = 33;
|
||||
/// The overlay of text in `Font.DisplayMode.POLYGON_OFFSET` (text displays):
|
||||
/// drawn like an ordinary entity, pulled toward the camera.
|
||||
pub const TEXT_POLYGON_OFFSET_OVERLAY: u32 = 35;
|
||||
|
||||
/// The material of a quad by its overlay (`MATERIALS` order).
|
||||
fn material(layer: u32) -> usize {
|
||||
match layer {
|
||||
TEXT_POLYGON_OFFSET_OVERLAY => 1,
|
||||
11 => 2,
|
||||
12 => 3,
|
||||
WATER_MASK_OVERLAY => 4,
|
||||
ENERGY_SWIRL_OVERLAY => 5,
|
||||
SHADOW_OVERLAY => 6,
|
||||
TRANSLUCENT_PARTICLE_OVERLAY => 7,
|
||||
TRANSLUCENT_ITEM_OVERLAY | TRANSLUCENT_GLINT_ITEM_OVERLAY => 8,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct EntityBatch {
|
||||
// Quad indices avoid copying vertices twice while grouping materials.
|
||||
// Keep initialized high-water storage; local counts delimit active entries.
|
||||
// Each bucket can hold all quads, removing per-quad Vec growth checks.
|
||||
buckets: [Vec<usize>; MATERIALS],
|
||||
// Runs of consecutive quads of one material (first quad, quad count): a
|
||||
// model's quads share one, so whole runs are copied at once. Cleared each
|
||||
// frame, keeping their storage.
|
||||
runs: [Vec<(usize, usize)>; MATERIALS],
|
||||
pub(super) combined: Vec<Vertex>,
|
||||
}
|
||||
|
||||
@@ -43,50 +62,29 @@ impl EntityBatch {
|
||||
see_through: &[Vertex],
|
||||
) -> [u32; MATERIALS] {
|
||||
let quads = entities.as_chunks::<4>().0;
|
||||
for bucket in &mut self.buckets {
|
||||
if bucket.len() < quads.len() {
|
||||
bucket.resize(quads.len(), 0);
|
||||
}
|
||||
for runs in &mut self.runs {
|
||||
runs.clear();
|
||||
}
|
||||
let mut counts = [0usize; MATERIALS];
|
||||
for (index, quad) in quads.iter().enumerate() {
|
||||
let material = match quad[0].layer {
|
||||
11 => 1,
|
||||
12 => 2,
|
||||
WATER_MASK_OVERLAY => 3,
|
||||
ENERGY_SWIRL_OVERLAY => 4,
|
||||
SHADOW_OVERLAY => 5,
|
||||
TRANSLUCENT_PARTICLE_OVERLAY => 6,
|
||||
TRANSLUCENT_ITEM_OVERLAY | TRANSLUCENT_GLINT_ITEM_OVERLAY => 7,
|
||||
_ => 0,
|
||||
};
|
||||
self.buckets[material][counts[material]] = index;
|
||||
counts[material] += 1;
|
||||
let mut start = 0;
|
||||
while start < quads.len() {
|
||||
let kind = material(quads[start][0].layer);
|
||||
let end = quads[start + 1..]
|
||||
.iter()
|
||||
.position(|quad| material(quad[0].layer) != kind)
|
||||
.map_or(quads.len(), |n| start + 1 + n);
|
||||
self.runs[kind].push((start, end - start));
|
||||
counts[kind] += end - start;
|
||||
start = end;
|
||||
}
|
||||
let entity_vertices = quads.len() * 4;
|
||||
self.combined.reserve(
|
||||
(entity_vertices + additive.len() + hand.len() + see_through.len())
|
||||
.saturating_sub(self.combined.len()),
|
||||
);
|
||||
self.combined.clear();
|
||||
self.combined
|
||||
.resize(entity_vertices, bytemuck::Zeroable::zeroed());
|
||||
if counts.contains(&quads.len()) {
|
||||
// Uniform materials are already ordered; copy the whole batch.
|
||||
self.combined.copy_from_slice(&entities[..entity_vertices]);
|
||||
} else {
|
||||
let mut offset = 0;
|
||||
for (bucket, &count) in self.buckets.iter().zip(&counts) {
|
||||
let bucket = &bucket[..count];
|
||||
let end = offset + count * 4;
|
||||
for (output, &index) in self.combined[offset..end]
|
||||
.as_chunks_mut::<4>()
|
||||
.0
|
||||
.iter_mut()
|
||||
.zip(bucket)
|
||||
{
|
||||
*output = quads[index];
|
||||
}
|
||||
offset = end;
|
||||
.reserve(entity_vertices + additive.len() + hand.len() + see_through.len());
|
||||
for runs in &self.runs {
|
||||
for &(first, count) in runs {
|
||||
self.combined
|
||||
.extend_from_slice(&entities[first * 4..(first + count) * 4]);
|
||||
}
|
||||
}
|
||||
self.combined.extend_from_slice(additive);
|
||||
@@ -109,9 +107,10 @@ mod tests {
|
||||
) -> (Vec<Vertex>, [u32; MATERIALS]) {
|
||||
let mut combined = Vec::with_capacity(entities.len() + additive.len() + hand.len());
|
||||
let mut counts = [0; MATERIALS];
|
||||
for (i, layer) in [0, 11, 12, 13, 34, 14, 15].into_iter().enumerate() {
|
||||
for (i, layer) in [0, 35, 11, 12, 13, 34, 14, 15].into_iter().enumerate() {
|
||||
for quad in entities.as_chunks::<4>().0 {
|
||||
let material = match quad[0].layer {
|
||||
35 => 35,
|
||||
11 => 11,
|
||||
12 => 12,
|
||||
13 => 13,
|
||||
@@ -137,7 +136,7 @@ mod tests {
|
||||
pos: [index as f32, 2.0, 3.0],
|
||||
uv: [index as f32, 5.0],
|
||||
layer: if mixed {
|
||||
[7, 11, 14, 12, 4, 13, 15, 0][index / 4 % 8]
|
||||
[7, 11, 14, 35, 12, 4, 13, 15, 0][index / 4 % 9]
|
||||
} else {
|
||||
0
|
||||
},
|
||||
@@ -174,12 +173,12 @@ mod tests {
|
||||
let hand = geometry(2, false);
|
||||
// Warm every bucket and the combined buffer to their maximum sizes.
|
||||
equivalent(&mut batch, &entities, &additive, &hand);
|
||||
let capacities = batch.buckets.each_ref().map(|b| b.capacity());
|
||||
let capacities = batch.runs.each_ref().map(|b| b.capacity());
|
||||
let capacity = batch.combined.capacity();
|
||||
let pointer = batch.combined.as_ptr();
|
||||
for length in [0, 1, 3, 4, 511, entities.len(), 17, 0, entities.len()] {
|
||||
equivalent(&mut batch, &entities[..length], &additive[..5], &hand[..3]);
|
||||
assert_eq!(batch.buckets.each_ref().map(|b| b.capacity()), capacities);
|
||||
assert_eq!(batch.runs.each_ref().map(|b| b.capacity()), capacities);
|
||||
assert_eq!(batch.combined.capacity(), capacity);
|
||||
assert_eq!(batch.combined.as_ptr(), pointer);
|
||||
}
|
||||
@@ -188,11 +187,11 @@ mod tests {
|
||||
// Grow again, then confirm empty/small frames retain the new storage.
|
||||
let larger = geometry(8192, true);
|
||||
equivalent(&mut batch, &larger, &additive, &hand);
|
||||
let capacities = batch.buckets.each_ref().map(|b| b.capacity());
|
||||
let capacities = batch.runs.each_ref().map(|b| b.capacity());
|
||||
let capacity = batch.combined.capacity();
|
||||
for entities in [&larger[..7], &larger[..], &[]] {
|
||||
equivalent(&mut batch, entities, &[], &[]);
|
||||
assert_eq!(batch.buckets.each_ref().map(|b| b.capacity()), capacities);
|
||||
assert_eq!(batch.runs.each_ref().map(|b| b.capacity()), capacities);
|
||||
assert_eq!(batch.combined.capacity(), capacity);
|
||||
}
|
||||
}
|
||||
@@ -255,9 +254,9 @@ mod tests {
|
||||
before[7] as f64 / after[7] as f64,
|
||||
batch.combined.capacity() * std::mem::size_of::<Vertex>()
|
||||
+ batch
|
||||
.buckets
|
||||
.runs
|
||||
.iter()
|
||||
.map(|b| b.capacity() * std::mem::size_of::<usize>())
|
||||
.map(|b| b.capacity() * std::mem::size_of::<(usize, usize)>())
|
||||
.sum::<usize>()
|
||||
);
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -14,6 +14,12 @@ pub enum Output {
|
||||
}
|
||||
|
||||
impl Output {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Pq => "HDR10 (PQ)",
|
||||
Self::ScRgb => "scRGB",
|
||||
}
|
||||
}
|
||||
pub fn format(self) -> wgpu::TextureFormat {
|
||||
match self {
|
||||
Self::Pq => wgpu::TextureFormat::Rgb10a2Unorm,
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+3
-2
@@ -361,8 +361,9 @@ impl NativePost {
|
||||
pub fn frame_inputs(frame: &Frame, size: [u32; 2], seconds: f32, index: u32) -> FrameInputs {
|
||||
let aspect = size[0] as f32 / size[1].max(1) as f32;
|
||||
let view = crate::camera::view_matrix(frame.yaw, frame.pitch);
|
||||
let projection =
|
||||
super::world_projection(frame.fov_degrees, aspect, frame.depth_far) * frame.bob;
|
||||
let projection = super::world_projection(frame.fov_degrees, aspect, frame.depth_far)
|
||||
* frame.bob
|
||||
* frame.spin;
|
||||
// A celestial body's direction at `angle` degrees (SkyRenderer: Y -90°, then X by the
|
||||
// angle, of straight up): overhead at 0, rising in the east (+x).
|
||||
let body = |angle: f32| {
|
||||
File renamed without changes.
File renamed without changes.
Loaded 100 of 810 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user