mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-13 20:13:13 -10:00
Compare commits
646
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aaaa7021e8 | ||
|
|
faeeb26eb7 | ||
|
|
04976d95eb | ||
|
|
3132240f21 | ||
|
|
352d59f617 | ||
|
|
ce517e179a | ||
|
|
3e2bcb00bc | ||
|
|
c27711cf2e | ||
|
|
0056f3445c | ||
|
|
51b2104c1d | ||
|
|
e2f04717c4 | ||
|
|
b8988e03b4 | ||
|
|
9382a17fed | ||
|
|
15df84b505 | ||
|
|
bdcc1d9f39 | ||
|
|
3caf55ae8a | ||
|
|
96824f711d | ||
|
|
6daaf0449a | ||
|
|
8fbe40df70 | ||
|
|
02dc9e2e30 | ||
|
|
f15c9098b6 | ||
|
|
bdbcbf2970 | ||
|
|
7a5bbcd7c8 | ||
|
|
ed0d4931b0 | ||
|
|
8f022a0b27 | ||
|
|
e07b94681b | ||
|
|
c679ef53ea | ||
|
|
7bc7f9e783 | ||
|
|
19327a55b0 | ||
|
|
df1874cced | ||
|
|
726f588668 | ||
|
|
dbc84adbd3 | ||
|
|
dcc393acec | ||
|
|
bb6985ecb7 | ||
|
|
574d031d14 | ||
|
|
5edb5defbb | ||
|
|
91dc405a0f | ||
|
|
587f81f9c6 | ||
|
|
73be91512c | ||
|
|
d10f82a7cb | ||
|
|
17dd36e93a | ||
|
|
81d38acadc | ||
|
|
62ddcb7909 | ||
|
|
c3db4a63d5 | ||
|
|
ffae67874b | ||
|
|
a52ce752a0 | ||
|
|
17bd76282b | ||
|
|
174df925ca | ||
|
|
cb77d4cb54 | ||
|
|
cba5d53333 | ||
|
|
50f2ca1636 | ||
|
|
79efd6a2dc | ||
|
|
22115dfb11 | ||
|
|
34abe889db | ||
|
|
22ad79ba01 | ||
|
|
235e3e97c6 | ||
|
|
a827346dd5 | ||
|
|
b853fd4599 | ||
|
|
f10edd4f40 | ||
|
|
52ff5ebdc6 | ||
|
|
4e9ddb17d9 | ||
|
|
726d9e86ef | ||
|
|
b086d4298b | ||
|
|
d2186eb9aa | ||
|
|
4ca814d896 | ||
|
|
41672b1221 | ||
|
|
44471a42a8 | ||
|
|
e262d68fcb | ||
|
|
941c4498a2 | ||
|
|
16ae712381 | ||
|
|
69937e5495 | ||
|
|
486e3001d7 | ||
|
|
edeecb4971 | ||
|
|
5e2d9e758b | ||
|
|
f26519bb53 | ||
|
|
c7cc6e1256 | ||
|
|
e2479b2272 | ||
|
|
d5f484c89f | ||
|
|
66f4bfbaae | ||
|
|
e4472a7423 | ||
|
|
0cbb5bae35 | ||
|
|
c81de0f908 | ||
|
|
5a42da4fe1 | ||
|
|
2b78aa1735 | ||
|
|
95825f818c | ||
|
|
5c34936e1b | ||
|
|
267e7063d5 | ||
|
|
b32541cb88 | ||
|
|
1df4c50f81 | ||
|
|
7fb48c7154 | ||
|
|
ca7e8bed1a | ||
|
|
416678ba17 | ||
|
|
bbc9c46c17 | ||
|
|
fe89da2a2c | ||
|
|
0a341cc353 | ||
|
|
da4db61ba9 | ||
|
|
f0651e7540 | ||
|
|
47a3b39fa9 | ||
|
|
2867be0d53 | ||
|
|
8a378f441c | ||
|
|
9a0e718599 | ||
|
|
d24853c0a0 | ||
|
|
3da531255a | ||
|
|
e84a47c069 | ||
|
|
ba6344a59c | ||
|
|
483d832a31 | ||
|
|
9465fb9cb7 | ||
|
|
45982faa5c | ||
|
|
c361096ab0 | ||
|
|
fb8e62bc7e | ||
|
|
5d80bf5b89 | ||
|
|
fb8321177b | ||
|
|
3bff52e242 | ||
|
|
95d9303204 | ||
|
|
1e8f93a692 | ||
|
|
034191391b | ||
|
|
d225a5fdf7 | ||
|
|
03c8dd0d98 | ||
|
|
bb64067197 | ||
|
|
cfab69e790 | ||
|
|
2c526aa767 | ||
|
|
912b671416 | ||
|
|
3a11501e23 | ||
|
|
3c6999d700 | ||
|
|
d129fb7cfe | ||
|
|
92e956be80 | ||
|
|
956bd64c5d | ||
|
|
7a1a5e8e88 | ||
|
|
186540eb84 | ||
|
|
4b14acc1f6 | ||
|
|
16356bdc57 | ||
|
|
6be3dad37b | ||
|
|
ef024a082c | ||
|
|
e9eda298fd | ||
|
|
c8ee23f2b8 | ||
|
|
958522ce67 | ||
|
|
b1dfde280b | ||
|
|
84ac601f3a | ||
|
|
aff1d9864f | ||
|
|
1bd934d3fb | ||
|
|
2d41c9d432 | ||
|
|
03498ad1c7 | ||
|
|
fbd7288a1d | ||
|
|
6966a69d36 | ||
|
|
e4fb44e067 | ||
|
|
0c441c9009 | ||
|
|
c48d6773c5 | ||
|
|
2160cd016a | ||
|
|
a99ef86dc6 | ||
|
|
699fae6296 | ||
|
|
9bdb5ec7fd | ||
|
|
c3601b74f3 | ||
|
|
265bb903d7 | ||
|
|
3891dd9410 | ||
|
|
68f036059e | ||
|
|
0b73217d3d | ||
|
|
1dd92e5f84 | ||
|
|
99998b9f3c | ||
|
|
c2db12a1a0 | ||
|
|
dcd86510f5 | ||
|
|
b87f65b1b6 | ||
|
|
0c149c7d1c | ||
|
|
df1a26d2bd | ||
|
|
05275d7682 | ||
|
|
907b5729b1 | ||
|
|
c2670e7bd2 | ||
|
|
2a6ae514f8 | ||
|
|
5fb90b440c | ||
|
|
e445e0edf6 | ||
|
|
4f03c8a873 | ||
|
|
bd9b325872 | ||
|
|
b51637616b | ||
|
|
5a017c9006 | ||
|
|
c18e893143 | ||
|
|
b9f8ea2f27 | ||
|
|
536c3b90fd | ||
|
|
4abef77bba | ||
|
|
bc3383d1bf | ||
|
|
b76ae35f5b | ||
|
|
1e5aaec352 | ||
|
|
43777b9d16 | ||
|
|
ef917292ed | ||
|
|
c6bf75b3b1 | ||
|
|
08e7c00806 | ||
|
|
a6aa6a071a | ||
|
|
c9a87963ef | ||
|
|
6edf1ff467 | ||
|
|
33f417fb32 | ||
|
|
ee883db4b9 | ||
|
|
7e6da998fa | ||
|
|
f11bbea3b1 | ||
|
|
dbce6420c6 | ||
|
|
bb0bf9e1c8 | ||
|
|
a6a734da85 | ||
|
|
3b82d4770f | ||
|
|
fa50f136fe | ||
|
|
eb3f03d7f3 | ||
|
|
061751a023 | ||
|
|
5851a80ce0 | ||
|
|
bf6c0b8a24 | ||
|
|
05f5c2197b | ||
|
|
3ffa45a5cf | ||
|
|
05114a3673 | ||
|
|
9ee6b293d4 | ||
|
|
d322e068e2 | ||
|
|
df583d34af | ||
|
|
737657443d | ||
|
|
101d3b0bec | ||
|
|
0359138d94 | ||
|
|
b5ec91342e | ||
|
|
f1eedfcb40 | ||
|
|
6883b74274 | ||
|
|
d9e5234fa3 | ||
|
|
9aeb985305 | ||
|
|
0ec337f17e | ||
|
|
a93ffc2f36 | ||
|
|
6e0605ad33 | ||
|
|
3bd99f79ae | ||
|
|
655b331b1e | ||
|
|
a46ce6898b | ||
|
|
8a7cd0deaf | ||
|
|
6269b7a21e | ||
|
|
c5410785c0 | ||
|
|
ada669266f | ||
|
|
85ff723581 | ||
|
|
5b695d5a26 | ||
|
|
800e7cef33 | ||
|
|
b9c54ecd50 | ||
|
|
24bea8540d | ||
|
|
633cd1dbce | ||
|
|
f03c1771af | ||
|
|
25f9cd3a99 | ||
|
|
f257391453 | ||
|
|
e497995b44 | ||
|
|
03ba7662d5 | ||
|
|
2c91574bbb | ||
|
|
f0ac52bf7f | ||
|
|
1f3ccb0fc3 | ||
|
|
fcbb0d627a | ||
|
|
30666b33b6 | ||
|
|
ada67586d0 | ||
|
|
6ca9531468 | ||
|
|
972719f088 | ||
|
|
b548c70594 | ||
|
|
4fef8a8682 | ||
|
|
a80678b890 | ||
|
|
be8a82a40b | ||
|
|
e54e8003e5 | ||
|
|
bc31cef0a7 | ||
|
|
2b2a8bedd4 | ||
|
|
6db70a1ae4 | ||
|
|
0d5cd6a591 | ||
|
|
6d67bf43bb | ||
|
|
6d59158973 | ||
|
|
647050d7aa | ||
|
|
1c333aa336 | ||
|
|
41d772380a | ||
|
|
df00ca8693 | ||
|
|
589164c071 | ||
|
|
ed196ffd66 | ||
|
|
00480831ce | ||
|
|
2fa595676e | ||
|
|
edefd8ef0c | ||
|
|
d3338640bc | ||
|
|
ea10a17842 | ||
|
|
fcb14be442 | ||
|
|
f1c0ab982a | ||
|
|
91bd434281 | ||
|
|
173d00dadb | ||
|
|
ea7e58787d | ||
|
|
7ce7388582 | ||
|
|
6fea98f668 | ||
|
|
3ee203767a | ||
|
|
a013e649c9 | ||
|
|
b78e407c12 | ||
|
|
9858aec68b | ||
|
|
7852c700f1 | ||
|
|
14fad0786e | ||
|
|
0dfa513424 | ||
|
|
735cdab711 | ||
|
|
ce91a02d84 | ||
|
|
51c10dd31d | ||
|
|
0d9399ead9 | ||
|
|
923f425f19 | ||
|
|
2be6cda974 | ||
|
|
9ad9d087ef | ||
|
|
79593d80b6 | ||
|
|
58853fdf50 | ||
|
|
834819ddf4 | ||
|
|
7835010339 | ||
|
|
590542c5ee | ||
|
|
8f7c959679 | ||
|
|
4cb754474d | ||
|
|
0855cc910f | ||
|
|
793337b0ed | ||
|
|
a603c95eba | ||
|
|
564b8a4b9e | ||
|
|
a16097dd96 | ||
|
|
4c802c1342 | ||
|
|
304a3ca76e | ||
|
|
4ef3c1f54a | ||
|
|
d163f38b6a | ||
|
|
e2fa940581 | ||
|
|
a32ab2c5e4 | ||
|
|
03b464ec0e | ||
|
|
186095dd37 | ||
|
|
a4f0482bd9 | ||
|
|
2ba6caa289 | ||
|
|
5f5d0434cb | ||
|
|
11159a1ed5 | ||
|
|
bf8d58facd | ||
|
|
a8e3cebc5f | ||
|
|
30fcf2c30c | ||
|
|
9d72c1ddcd | ||
|
|
62caef810a | ||
|
|
1c16f98098 | ||
|
|
69d2086e7d | ||
|
|
0829657bcc | ||
|
|
88e79c1119 | ||
|
|
1dde830baf | ||
|
|
588ca0cd22 | ||
|
|
4488cda84f | ||
|
|
2fd01bd8bd | ||
|
|
ba5585da84 | ||
|
|
d409f691dc | ||
|
|
4d1f533af6 | ||
|
|
cc683c75f0 | ||
|
|
57e49b068e | ||
|
|
89f40134b2 | ||
|
|
490a33aea9 | ||
|
|
7cce0bf15b | ||
|
|
a536b00c30 | ||
|
|
4289f22f00 | ||
|
|
e4d8570fbc | ||
|
|
4e3a161ac6 | ||
|
|
6cd8cc4987 | ||
|
|
30da32b83c | ||
|
|
f84b88b2e9 | ||
|
|
173e278206 | ||
|
|
6b8e3b8f5f | ||
|
|
31ad980443 | ||
|
|
084394a849 | ||
|
|
e8d5059b6a | ||
|
|
413e6e856e | ||
|
|
23c5335910 | ||
|
|
ae0512e299 | ||
|
|
8c3d85eb58 | ||
|
|
8bbfc5cbf0 | ||
|
|
4592a7e799 | ||
|
|
5dc59b0f6a | ||
|
|
e93427276f | ||
|
|
5550a6c327 | ||
|
|
70e83d9f65 | ||
|
|
db38f50b12 | ||
|
|
8a773bca20 | ||
|
|
d4a973f65f | ||
|
|
0e4f8e2799 | ||
|
|
4464c5d64a | ||
|
|
b0c2f375bb | ||
|
|
183e772727 | ||
|
|
c41e95bf53 | ||
|
|
3887ceefec | ||
|
|
0d322f4fca | ||
|
|
3c8a73f86f | ||
|
|
f58660a875 | ||
|
|
c744e51084 | ||
|
|
7f1edb9263 | ||
|
|
396ba2e5e7 | ||
|
|
10e715be15 | ||
|
|
5b3b108515 | ||
|
|
dff6db1e86 | ||
|
|
71127f982f | ||
|
|
a72876f0c0 | ||
|
|
39314175e9 | ||
|
|
88b9342ffc | ||
|
|
21a033cea0 | ||
|
|
1761eb0842 | ||
|
|
a18c9206d9 | ||
|
|
ad9f9a66f6 | ||
|
|
ba59ac2ccf | ||
|
|
7ca3671df3 | ||
|
|
b495d4931e | ||
|
|
0ce65d34c6 | ||
|
|
379ec36d52 | ||
|
|
7f70737138 | ||
|
|
f7b77fbc88 | ||
|
|
b80c1a9eef | ||
|
|
682f8a20fa | ||
|
|
5aa2da4960 | ||
|
|
c1de70edd4 | ||
|
|
149c4b87f8 | ||
|
|
a6b3066f85 | ||
|
|
08918a8ab0 | ||
|
|
fe08d02d5d | ||
|
|
359e83b338 | ||
|
|
8b17c0383e | ||
|
|
407dd201f8 | ||
|
|
889931c0b9 | ||
|
|
509536179d | ||
|
|
a9d2ae39d9 | ||
|
|
9d44aa4ba0 | ||
|
|
0c34e3f36c | ||
|
|
4049220715 | ||
|
|
1139057d3e | ||
|
|
9f3cba308c | ||
|
|
80b68bae0c | ||
|
|
48efd53e00 | ||
|
|
c7440b78d5 | ||
|
|
0703ae6bd4 | ||
|
|
ec6ea46499 | ||
|
|
62c4d91aa5 | ||
|
|
d5d42c8a5f | ||
|
|
765ed5d0c2 | ||
|
|
a63b69c300 | ||
|
|
126f997703 | ||
|
|
6befa8622d | ||
|
|
4fe17c268c | ||
|
|
1474b118b4 | ||
|
|
4fcaff9935 | ||
|
|
0554db3906 | ||
|
|
e94f1917a7 | ||
|
|
713b2774aa | ||
|
|
882e3a3005 | ||
|
|
d684cafca1 | ||
|
|
20ad245e02 | ||
|
|
6840b57201 | ||
|
|
f31e30a081 | ||
|
|
913ad15462 | ||
|
|
1976c27870 | ||
|
|
7af0a21f2d | ||
|
|
7a2121d84f | ||
|
|
3d5407ab11 | ||
|
|
ef890e667f | ||
|
|
b5bcf031e5 | ||
|
|
1e3f05fe8c | ||
|
|
45e1d44be2 | ||
|
|
131458ee0f | ||
|
|
d21886b2f3 | ||
|
|
56b327bd94 | ||
|
|
f6df8b8339 | ||
|
|
6529206304 | ||
|
|
88dc2c5f8d | ||
|
|
bb7b1b27ad | ||
|
|
a68a897af5 | ||
|
|
296fd3d839 | ||
|
|
33f3444a49 | ||
|
|
2711a4a005 | ||
|
|
c37cab6b6a | ||
|
|
cbf57eb6ee | ||
|
|
e1c7a297bc | ||
|
|
bc3161c619 | ||
|
|
dd649971c8 | ||
|
|
50a1911cea | ||
|
|
b82ebb2261 | ||
|
|
8880e0d373 | ||
|
|
b9ddeb1820 | ||
|
|
50121630ce | ||
|
|
edd7a9cb61 | ||
|
|
5270924ba9 | ||
|
|
97eda2b7f5 | ||
|
|
86c1795ec6 | ||
|
|
b469038d45 | ||
|
|
d17c1bc0e9 | ||
|
|
ec77633cfe | ||
|
|
5909dc68b6 | ||
|
|
bb7adf1207 | ||
|
|
97294e3838 | ||
|
|
01cf25687f | ||
|
|
2151ef21f2 | ||
|
|
d686f4b9d7 | ||
|
|
2b90acbc0d | ||
|
|
2e49c68051 | ||
|
|
aa30c8e9d2 | ||
|
|
4dcb669759 | ||
|
|
40b0467e20 | ||
|
|
ada08437e5 | ||
|
|
997bbef785 | ||
|
|
753660c0b4 | ||
|
|
eba7cb0409 | ||
|
|
2d477df4fd | ||
|
|
71092caa14 | ||
|
|
5968cd0f09 | ||
|
|
8ce62d3aaa | ||
|
|
2153681d47 | ||
|
|
349127673c | ||
|
|
32e16761bd | ||
|
|
57a2512025 | ||
|
|
25d15d2a0a | ||
|
|
a1a45472c8 | ||
|
|
37f9481bb7 | ||
|
|
04fb868e99 | ||
|
|
7ed6012c79 | ||
|
|
a8be1d9ee0 | ||
|
|
b6f3d91b09 | ||
|
|
26f46141c7 | ||
|
|
4f5a70ee53 | ||
|
|
d0761d3c50 | ||
|
|
caca2ef2d4 | ||
|
|
8874140947 | ||
|
|
a369faeb32 | ||
|
|
6dc2f77e99 | ||
|
|
bb4da395a3 | ||
|
|
2555b46e7c | ||
|
|
65a23ca98e | ||
|
|
24101477da | ||
|
|
e04428b60c | ||
|
|
79ed5ba529 | ||
|
|
a569559196 | ||
|
|
ac74644dc9 | ||
|
|
c419845102 | ||
|
|
a6e91967a1 | ||
|
|
af8751173f | ||
|
|
af0bc10c34 | ||
|
|
7fd82c846b | ||
|
|
f27a098c2d | ||
|
|
08e206c459 | ||
|
|
5bc9606d99 | ||
|
|
c4f0c49c0e | ||
|
|
ff70b164cc | ||
|
|
66c20c246d | ||
|
|
32f2156b13 | ||
|
|
810a0152d3 | ||
|
|
c124b94c8b | ||
|
|
845343e019 | ||
|
|
89ecd6cd9d | ||
|
|
e314b5c6e1 | ||
|
|
be2963b1d6 | ||
|
|
d68d59f6b6 | ||
|
|
7a98019450 | ||
|
|
c40d0112a6 | ||
|
|
89cbb09961 | ||
|
|
d43b957afc | ||
|
|
93dd53e1c6 | ||
|
|
1a371c3e20 | ||
|
|
f1a0963f2a | ||
|
|
2947c2d71f | ||
|
|
952fc5a1f5 | ||
|
|
ba7f106d53 | ||
|
|
9191728d12 | ||
|
|
bd89ae3df7 | ||
|
|
5ea674c6e6 | ||
|
|
d8ac144995 | ||
|
|
a7f03f52fd | ||
|
|
fabb6d78ec | ||
|
|
3082b36894 | ||
|
|
c9053670cd | ||
|
|
4269899778 | ||
|
|
6eb26eb046 | ||
|
|
d1129a9fce | ||
|
|
0cfb0400c8 | ||
|
|
230d89f7ec | ||
|
|
be48b7a4a9 | ||
|
|
ef3163c244 | ||
|
|
96877dcf48 | ||
|
|
7d227270a2 | ||
|
|
3931a46585 | ||
|
|
bcfeeeeb2a | ||
|
|
e28ed6d877 | ||
|
|
34c77f9eac | ||
|
|
20ffb882bd | ||
|
|
38a315331d | ||
|
|
e09cbf2b9e | ||
|
|
88e10db8e5 | ||
|
|
1c4b029b95 | ||
|
|
31c0c3dfe4 | ||
|
|
fea840e0d4 | ||
|
|
582331c3ea | ||
|
|
8b65dc4516 | ||
|
|
eed02f0cda | ||
|
|
dacde01da2 | ||
|
|
26c139f901 | ||
|
|
d4f466623d | ||
|
|
c9999e3336 | ||
|
|
3a37c32d8d | ||
|
|
91591e4dcf | ||
|
|
6180a78195 | ||
|
|
731896f222 | ||
|
|
7703ebcf95 | ||
|
|
8ca96ad967 | ||
|
|
c11d57d1f8 | ||
|
|
0b6c48be77 | ||
|
|
815beda680 | ||
|
|
8e0939f3e8 | ||
|
|
df96b6787d | ||
|
|
0c79bae87d | ||
|
|
5071edb248 | ||
|
|
51005638e0 | ||
|
|
94eff29428 | ||
|
|
136349ae95 | ||
|
|
5cdf9a9151 | ||
|
|
4812518327 | ||
|
|
7daca92c4a | ||
|
|
fa77b35140 | ||
|
|
845dbae91b | ||
|
|
a167a89215 | ||
|
|
953a5a84ee | ||
|
|
37a2cc0057 | ||
|
|
029870bb90 | ||
|
|
d27a7cbce3 | ||
|
|
1afbdca0f0 | ||
|
|
5601fea75e | ||
|
|
e83ef39df4 | ||
|
|
158075a8e8 | ||
|
|
e8c1a797c8 | ||
|
|
9fa7dddbe4 | ||
|
|
3c9b2599d8 | ||
|
|
ce2b51090f | ||
|
|
30d33e077f | ||
|
|
35345be102 | ||
|
|
6e2ac2522d | ||
|
|
e72a8c5c6e | ||
|
|
c74d170fbc | ||
|
|
21773ca7f5 | ||
|
|
ce45e065c3 | ||
|
|
861be0b075 | ||
|
|
cbd1b1c93e | ||
|
|
c075240fa3 | ||
|
|
7d43179850 | ||
|
|
64ff13890a | ||
|
|
708f0b48bf | ||
|
|
7a082c2176 | ||
|
|
c4e54ad254 | ||
|
|
9e91278794 | ||
|
|
fdb4e917c0 | ||
|
|
c2b55479f1 | ||
|
|
a6d8409466 | ||
|
|
46c62bdf2a | ||
|
|
0c639904f1 | ||
|
|
2ac85c50eb | ||
|
|
9c18b4e524 | ||
|
|
43f2497f1f | ||
|
|
c7b02f8568 | ||
|
|
8401cde519 | ||
|
|
9bb3fc077e | ||
|
|
2aa9056259 | ||
|
|
8117e2767f | ||
|
|
bc5ec6fb46 | ||
|
|
e5ebeddb90 | ||
|
|
d0b42fb88e | ||
|
|
bca77d1ac4 | ||
|
|
815c09b1f8 | ||
|
|
0104eeaf33 | ||
|
|
729a224eef | ||
|
|
22054eee90 | ||
|
|
52f0e38d41 | ||
|
|
1a80673d50 |
No files matched your search
@@ -22,7 +22,9 @@ jobs:
|
||||
if: >-
|
||||
github.event_name == 'issue_comment' && github.event.comment.user.type != 'Bot' && (
|
||||
startsWith(github.event.comment.body, '/claim') ||
|
||||
startsWith(github.event.comment.body, '/unclaim'))
|
||||
startsWith(github.event.comment.body, '/unclaim') ||
|
||||
startsWith(github.event.comment.body, '🤖 Agent: /claim') ||
|
||||
startsWith(github.event.comment.body, '🤖 Agent: /unclaim'))
|
||||
# One command per issue at a time, so two `/claim`s can't both find it free. On the job, not the workflow:
|
||||
# the skipped runs of ordinary comments must not cancel a waiting one.
|
||||
concurrency:
|
||||
|
||||
@@ -53,7 +53,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
| `horizon-server` | `horizon-server`: `server.toml`, CLI, supervisor of the headless game ([docs/hosting.md](docs/hosting.md)) |
|
||||
| `horizon-server-files` | `server.properties`, eula, ops/whitelist/bans, usercache |
|
||||
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
|
||||
| `horizon-mods`, `horizon-mod-sdk`, `cargo-horizon-mod` | Sandboxed WebAssembly mods: runtime, SDK + WIT, `cargo horizon-mod` ([docs/mods.md](docs/mods.md)) |
|
||||
| `horizon-mods`, `cargo-horizon-mod` | Content mods (`.hmod`: manifest, content fence, no code), the registry index and `.hpack`, and `cargo horizon-mod` (`new`, `build`, `run`, `check`, `registry`) ([docs/mods.md](docs/mods.md)). Code mods are native, built by the launcher ([docs/native-mods.md](docs/native-mods.md)) |
|
||||
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
|
||||
| `horizon-shader-api` | Horizon's own WGSL shader packs ([docs/api/shaders.md](docs/api/shaders.md)): `horizon-shaders.toml`, the `.hshader` package; shared by the game, the launcher and the registry |
|
||||
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
|
||||
@@ -73,7 +73,7 @@ reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` an
|
||||
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
|
||||
python3 tools/pr_ready.py --quick # same without harnesses
|
||||
cargo test -p horizon-client --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
|
||||
cargo test -p horizon-client --features horizon-client/mods --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
|
||||
cargo test -p horizon-client --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
|
||||
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
|
||||
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
|
||||
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
|
||||
@@ -93,9 +93,6 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
- What a change reruns (of 329 harnesses, measured 2026-10-23): a file of the game binary (`game.rs`, `game/`)
|
||||
~25, a file of the client lib ~103, of `horizon-world` ~103–109, of `horizon-core` ~119. Plan for it.
|
||||
- Rust integration tests: `crates/horizon-client/tests/` (`lan_bot.rs`, `generation_saves.rs`, `streaming_meshes.rs`).
|
||||
- **macOS:** `cargo build/test --workspace` may fail to link the two wasm guest cdylibs (undefined
|
||||
`horizon:mods/events` symbols) until #1446 lands: add `--exclude coords-hud --exclude predator-missile --exclude horizon-mods-test-guest --exclude horizon-mods-test-ui-guest`.
|
||||
Their real check is `cargo test -p horizon-mods` (builds them for `wasm32-unknown-unknown`).
|
||||
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio and controllers (the server build).
|
||||
- Run and drive the game (starts the `horizon-client` beside `horizon-ctl`; other arguments go to the game):
|
||||
`target/fast/horizon-ctl --socket $PWD/ctl.sock launch --world W --creative --offscreen --wait --resources $PWD/.local/horizon-client/26.3`,
|
||||
|
||||
Generated
+27
@@ -561,6 +561,12 @@ dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cty"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b365fabc795046672053e29c954733ec3b05e4be654ab130fe8f1f94d7051f35"
|
||||
|
||||
[[package]]
|
||||
name = "cursor-icon"
|
||||
version = "1.2.0"
|
||||
@@ -1412,6 +1418,8 @@ name = "horizon-threads"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"libmimalloc-sys",
|
||||
"mimalloc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -1787,6 +1795,16 @@ version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "libmimalloc-sys"
|
||||
version = "0.1.49"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cty",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.25"
|
||||
@@ -1878,6 +1896,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mimalloc"
|
||||
version = "0.1.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862"
|
||||
dependencies = [
|
||||
"libmimalloc-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.3.0"
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<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%">
|
||||
<a href="#project-status"><img src="https://img.shields.io/badge/player--visible%20gaps-833%20open-F0795A" alt="Player-visible gaps open"></a>
|
||||
<img src="https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20Windows-2A2470" alt="Linux, macOS and Windows">
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-3B2F7E" alt="MIT license"></a>
|
||||
</p>
|
||||
@@ -48,7 +49,7 @@ The [documentation](https://playhorizon.dev/docs/) covers each of these.
|
||||
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
|
||||
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates, item modifiers and world generation (vanilla's heights and biomes for now).
|
||||
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining servers, online-mode ones with the Microsoft account the launcher signs in with. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
|
||||
- **Mods:** Horizon has its own mods, installed from the launcher. The official ones (Zoom, FPS Counter, Keystrokes and Minimap) are native Rust that the launcher builds into your game, with no Rust to install; older WebAssembly mods still run while they move. Java mod jars do not run in Horizon. See [Mods and data packs](https://playhorizon.dev/docs/mods).
|
||||
- **Mods:** content mods add blocks, items and data, and native mods change the game's own Rust code, built by the launcher on your copy, with no Rust to install. Horizon's own (Zoom, FPS Counter, Keystrokes, Minimap, Recipes and Coordinates HUD) are native mods. Java mod jars do not run in Horizon. See [Mods and data packs](https://playhorizon.dev/docs/mods).
|
||||
|
||||
Screenshots, captured in the Rust client itself with vanilla assets and no mods or shaders:
|
||||
|
||||
@@ -127,6 +128,8 @@ It runs rustfmt, clippy with warnings denied, the tests of the crates you touche
|
||||
|
||||
## Project status
|
||||
|
||||
**Player-visible gaps: 833 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Screens, HUD and client packets [197](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [129](https://github.com/horizon-rust/horizon/issues/3519); Animals [110](https://github.com/horizon-rust/horizon/issues/3512); Server and players on the server [106](https://github.com/horizon-rust/horizon/issues/3520); Blocks, redstone and fluids [86](https://github.com/horizon-rust/horizon/issues/3516); Entities, projectiles, vehicles and decorations [64](https://github.com/horizon-rust/horizon/issues/3514); Monsters and bosses [53](https://github.com/horizon-rust/horizon/issues/3513); Mob AI, villagers and raids [35](https://github.com/horizon-rust/horizon/issues/3521); World, world generation and saves [32](https://github.com/horizon-rust/horizon/issues/3517); Items, enchantments and inventories [21](https://github.com/horizon-rust/horizon/issues/3515). This number goes down as gaps close (`tools/parity_gaps.py`); the harness results below say that what is tested matches.
|
||||
|
||||
The table below is the project's own audit of each subsystem. Every subsystem now matches vanilla in every harness that compares it. 100% means 100% of what the harnesses compare, not proof that nothing else differs. The remaining known differences are listed below.
|
||||
|
||||
### Java parity: 100%
|
||||
@@ -163,7 +166,7 @@ lists every method of vanilla 26.3 next to the Rust functions that port it, with
|
||||
|
||||
### Known issues
|
||||
|
||||
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; item tooltips lack attribute lines.
|
||||
|
||||
## Licence
|
||||
|
||||
|
||||
+9
-9
@@ -76,7 +76,7 @@ Owner: [levzzz5154](https://github.com/levzzz5154) · Tracking: [#3911](https://
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
One item for all modding work ([#3911](https://github.com/horizon-rust/horizon/issues/3911)), led by levzzz5154 with vladislav_hagen; the order between the parts is theirs. Native mods on stable hook points (game/native_hooks.rs, [#3345](https://github.com/horizon-rust/horizon/issues/3345), [#3346](https://github.com/horizon-rust/horizon/issues/3346), [#3434](https://github.com/horizon-rust/horizon/issues/3434), [#3435](https://github.com/horizon-rust/horizon/issues/3435)) built by the launcher ([#3388](https://github.com/horizon-rust/horizon/issues/3388), [#3855](https://github.com/horizon-rust/horizon/issues/3855)); server-side hooks ([#3878](https://github.com/horizon-rust/horizon/issues/3878), [#3706](https://github.com/horizon-rust/horizon/issues/3706)); mod-owned screens, input and drawing; tools for mod makers (templates, `native.py check`); the registry ([#1634](https://github.com/horizon-rust/horizon/issues/1634), [#1642](https://github.com/horizon-rust/horizon/issues/1642), [#1659](https://github.com/horizon-rust/horizon/issues/1659)); machines with inventories and menus ([#1189](https://github.com/horizon-rust/horizon/issues/1189)); mods that read and change the world through the server. The WebAssembly runtime is being removed ([#3351](https://github.com/horizon-rust/horizon/issues/3351), [#3353](https://github.com/horizon-rust/horizon/issues/3353)). [docs/native-mods.md](docs/native-mods.md).
|
||||
One item for all modding work ([#3911](https://github.com/horizon-rust/horizon/issues/3911)), led by levzzz5154 with vladislav_hagen; the order between the parts is theirs. Native mods on stable hook points (game/native_hooks.rs, [#3345](https://github.com/horizon-rust/horizon/issues/3345), [#3346](https://github.com/horizon-rust/horizon/issues/3346), [#3434](https://github.com/horizon-rust/horizon/issues/3434), [#3435](https://github.com/horizon-rust/horizon/issues/3435)) built by the launcher ([#3388](https://github.com/horizon-rust/horizon/issues/3388), [#3855](https://github.com/horizon-rust/horizon/issues/3855)); server-side hooks ([#3878](https://github.com/horizon-rust/horizon/issues/3878), [#3706](https://github.com/horizon-rust/horizon/issues/3706)); mod-owned screens, input and drawing; tools for mod makers (templates, `native.py check`); the registry ([#1634](https://github.com/horizon-rust/horizon/issues/1634), [#1642](https://github.com/horizon-rust/horizon/issues/1642), [#1659](https://github.com/horizon-rust/horizon/issues/1659)); machines with inventories and menus ([#1189](https://github.com/horizon-rust/horizon/issues/1189)); mods that read and change the world through the server. The WebAssembly runtime is 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)). [docs/native-mods.md](docs/native-mods.md).
|
||||
|
||||
More: [#3360](https://github.com/horizon-rust/horizon/issues/3360) · [#1596](https://github.com/horizon-rust/horizon/issues/1596) · [#1635](https://github.com/horizon-rust/horizon/issues/1635)
|
||||
|
||||
@@ -350,7 +350,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/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.
|
||||
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. Option to look at again: a bridge for simple Fabric mods.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -370,7 +370,7 @@ When a mod, shader pack or modpack hasn't been rewritten for Horizon, one button
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Generalise the mod browser's Port step (`horizon-port-pack`: the data, assets, worldgen and block and item definitions of Java mods, .mrpack and CurseForge zips) to every kind of content: code mods where the API allows it (see 'Mods with code') and shader packs, converting an Iris/OptiFine pack into Horizon's format. The browser says what works before the install. Java code itself does not run ([docs/modpacks.md](docs/modpacks.md), [docs/mod-browser.md](docs/mod-browser.md)).
|
||||
Generalise the mod browser's Port step (`horizon-port-pack`: the data, assets, worldgen and block and item definitions of Java mods, .mrpack and CurseForge zips) to every kind of content: code mods as native mods (see 'Mods with code') and shader packs, converting an Iris/OptiFine pack into Horizon's format. The browser says what works before the install. Java code itself does not run ([docs/modpacks.md](docs/modpacks.md), [docs/mod-browser.md](docs/mod-browser.md)).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -380,7 +380,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/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)).
|
||||
333 of the 496 mods in a big pack (ATM10) contain code. Issue [#1854](https://github.com/horizon-rust/horizon/issues/1854) ranks the gaps (ticking block entities, networking, rendering, menus, inventories) that unlock them in tiers, with machines first as the recommended path. Code mods are native mods now ([docs/native-mods.md](docs/native-mods.md)): each port is a horizon-mixins bundle. The path isn't started yet ([docs/modpacks.md](docs/modpacks.md)).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -490,7 +490,7 @@ 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.
|
||||
Waits for native mods to settle: the earlier plan ran Lua inside the WebAssembly sandbox, which is removed. A script layer over the native hook points is to be designed.
|
||||
|
||||
More: [docs/native-mods.md](docs/native-mods.md)
|
||||
|
||||
@@ -498,11 +498,11 @@ More: [docs/native-mods.md](docs/native-mods.md)
|
||||
|
||||
#### 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.
|
||||
The community mod Orbital Laser, made for a fork of Horizon, running on the normal game as a native mod and listed in the registry.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A pull request on [https://github.com/Stink-O/orbital-laser](https://github.com/Stink-O/orbital-laser) (MIT) once the input, ui, render and world capabilities exist (see 'Screens, input and drawing for mods'): today the mod needs them through its author's fork. Tested in the game, and listed in the registry.
|
||||
A native port (a horizon-mixins bundle, docs/native-mods.md) of [https://github.com/Stink-O/orbital-laser](https://github.com/Stink-O/orbital-laser) (MIT), which today runs through its author's fork. Tested in the game, and listed in the registry.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -524,7 +524,7 @@ Contraptions and physics like the Create and Aeronautics mods, as a mod for Hori
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -534,7 +534,7 @@ Hear nearby players talk, as with the voice chat mods of other Minecraft version
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Needs server mods, mod networking and an audio interface in the mod API before anything can be built ([docs/api/mods.md](docs/api/mods.md)).
|
||||
A native mod ([docs/native-mods.md](docs/native-mods.md)). It needs a way to carry voice between players and an audio path in the game before anything can be built.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+383
-14
@@ -3,8 +3,9 @@
|
||||
//! does, answers keep-alives and pings, accepts teleports, acknowledges chunk batches, and moves at 20 Hz.
|
||||
//!
|
||||
//! Bots fly a little above the ground, so the server must give them creative mode (`gamemode=creative`
|
||||
//! and `force-gamemode=true`): they never touch blocks, so they need no world of their own, and no server
|
||||
//! can refuse their moves for walking into terrain. Every behaviour is deterministic from `--seed`.
|
||||
//! and `force-gamemode=true`): they never walk on blocks, so they need no world of their own, and no server
|
||||
//! can refuse their moves for walking into terrain (the `server` crowd places and breaks stone in the air
|
||||
//! below itself). Every behaviour is deterministic from `--seed`.
|
||||
//!
|
||||
//! horizon-bots --server 127.0.0.1:25565 --bots 25 --behaviour mixed --seconds 360 --report bots.json
|
||||
//!
|
||||
@@ -21,18 +22,19 @@ use horizon_protocol::{
|
||||
connection::{ConnWriter, Connection},
|
||||
crypto::offline_uuid,
|
||||
ids,
|
||||
item::Stack,
|
||||
packets::{
|
||||
Empty, configuration,
|
||||
handshake::{self, Intention},
|
||||
login::{self, ServerHello},
|
||||
play,
|
||||
play, recipes,
|
||||
},
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
Arc, LazyLock, Mutex,
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
},
|
||||
thread,
|
||||
@@ -44,7 +46,27 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
|
||||
|
||||
--server HOST:PORT default 127.0.0.1:25565
|
||||
--bots N default 10
|
||||
--behaviour B idle, walk, explore or mixed (60 % walk, 20 % explore, 20 % idle); default mixed
|
||||
--behaviour B idle, walk, explore or mixed (60 % walk, 20 % explore, 20 % idle); default mixed.
|
||||
server: a public server's crowd. Of every ten bots, four travel 150 to 1500
|
||||
blocks out at 10 blocks a second and live there, five live around spawn, one
|
||||
explores at 10 blocks a second. The settled ones walk around their home and
|
||||
place or break stone near themselves every few seconds; the ones at spawn
|
||||
also chat about every 30 s and drop an item about every 20 s.
|
||||
One abusive client, to test a server's limits (run it beside a normal crowd):
|
||||
flood-move (--flood moves a tick, each 64 to 256 blocks away, ignoring chunks),
|
||||
flood-acks (--flood chunk batch acks asking for 64 chunks a tick, while exploring),
|
||||
flood-packets (--flood rotations a tick),
|
||||
flood-chunks (one move a tick, 9.9 blocks straight ahead: as fast as the
|
||||
moved-too-quickly check allows, never waiting for chunks),
|
||||
and --flood a tick of packets that cost the server work:
|
||||
flood-use (place and break stone around itself), flood-click (inventory
|
||||
clicks with a stale state), flood-suggest (command suggestions, every
|
||||
other one 30 000 characters long), flood-book (100 pages of 1024 characters
|
||||
into a writable book), flood-sign (four 384-character lines on the block
|
||||
below), flood-recipe (recipe book placements)
|
||||
--flood N packets a tick for the flood behaviours; default 100
|
||||
--no-read stop reading from the server once spawned (a client that lets the
|
||||
server's replies pile up)
|
||||
--join-every SECONDS default 1
|
||||
--seconds N how long to run after the first bot joins; default 60
|
||||
--view-distance N what the bots ask for; default 10
|
||||
@@ -59,6 +81,18 @@ enum Behaviour {
|
||||
Idle,
|
||||
Walk,
|
||||
Explore,
|
||||
Settle,
|
||||
Town,
|
||||
FloodMove,
|
||||
FloodAcks,
|
||||
FloodPackets,
|
||||
FloodChunks,
|
||||
FloodUse,
|
||||
FloodClick,
|
||||
FloodSuggest,
|
||||
FloodBook,
|
||||
FloodSign,
|
||||
FloodRecipe,
|
||||
}
|
||||
|
||||
impl Behaviour {
|
||||
@@ -67,6 +101,18 @@ impl Behaviour {
|
||||
Self::Idle => "idle",
|
||||
Self::Walk => "walk",
|
||||
Self::Explore => "explore",
|
||||
Self::Settle => "settle",
|
||||
Self::Town => "town",
|
||||
Self::FloodMove => "flood-move",
|
||||
Self::FloodAcks => "flood-acks",
|
||||
Self::FloodPackets => "flood-packets",
|
||||
Self::FloodChunks => "flood-chunks",
|
||||
Self::FloodUse => "flood-use",
|
||||
Self::FloodClick => "flood-click",
|
||||
Self::FloodSuggest => "flood-suggest",
|
||||
Self::FloodBook => "flood-book",
|
||||
Self::FloodSign => "flood-sign",
|
||||
Self::FloodRecipe => "flood-recipe",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,6 +127,8 @@ struct Options {
|
||||
view_distance: i8,
|
||||
seed: u64,
|
||||
explore_speed: f64,
|
||||
flood: usize,
|
||||
no_read: bool,
|
||||
name: String,
|
||||
report: Option<String>,
|
||||
}
|
||||
@@ -96,6 +144,8 @@ fn options() -> Result<Options, String> {
|
||||
view_distance: 10,
|
||||
seed: 12345,
|
||||
explore_speed: 22.0,
|
||||
flood: 100,
|
||||
no_read: false,
|
||||
name: "Bot".into(),
|
||||
report: None,
|
||||
};
|
||||
@@ -105,6 +155,10 @@ fn options() -> Result<Options, String> {
|
||||
print!("{HELP}");
|
||||
std::process::exit(0);
|
||||
}
|
||||
if arg == "--no-read" {
|
||||
o.no_read = true;
|
||||
continue;
|
||||
}
|
||||
let value = args.next().ok_or(format!("{arg} needs a value"))?;
|
||||
let number = |what: &str| format!("{arg} needs {what}, not {value:?}");
|
||||
match arg.as_str() {
|
||||
@@ -122,14 +176,32 @@ fn options() -> Result<Options, String> {
|
||||
"--explore-speed" => {
|
||||
o.explore_speed = value.parse().map_err(|_| number("blocks a second"))?;
|
||||
}
|
||||
"--flood" => o.flood = value.parse().map_err(|_| number("packets a tick"))?,
|
||||
"--name" => o.name = value,
|
||||
"--report" => o.report = Some(value),
|
||||
_ => return Err(format!("unknown option {arg}; --help lists them")),
|
||||
}
|
||||
}
|
||||
if !["idle", "walk", "explore", "mixed"].contains(&o.behaviour.as_str()) {
|
||||
const BEHAVIOURS: [&str; 15] = [
|
||||
"idle",
|
||||
"walk",
|
||||
"explore",
|
||||
"mixed",
|
||||
"server",
|
||||
"flood-move",
|
||||
"flood-acks",
|
||||
"flood-packets",
|
||||
"flood-chunks",
|
||||
"flood-use",
|
||||
"flood-click",
|
||||
"flood-suggest",
|
||||
"flood-book",
|
||||
"flood-sign",
|
||||
"flood-recipe",
|
||||
];
|
||||
if !BEHAVIOURS.contains(&o.behaviour.as_str()) {
|
||||
return Err(format!(
|
||||
"--behaviour {}: use idle, walk, explore or mixed",
|
||||
"--behaviour {}: use idle, walk, explore, mixed, server or a flood behaviour (--help lists them)",
|
||||
o.behaviour
|
||||
));
|
||||
}
|
||||
@@ -156,6 +228,8 @@ struct Bot {
|
||||
teleport: Mutex<Option<[f64; 3]>>,
|
||||
error: Mutex<Option<String>>,
|
||||
joined_after_ms: AtomicU64,
|
||||
/// The bot stopped reading (--no-read).
|
||||
deaf: AtomicBool,
|
||||
}
|
||||
|
||||
/// A small deterministic generator (SplitMix64), so every run moves the bots the same way.
|
||||
@@ -185,6 +259,89 @@ fn raw(id: i32, write: impl FnOnce(&mut Writer)) -> Vec<u8> {
|
||||
w.data
|
||||
}
|
||||
|
||||
/// Item ids of the pinned version (26.3's item registry).
|
||||
const STONE: i32 = 1;
|
||||
const WRITABLE_BOOK: i32 = 1371;
|
||||
|
||||
/// The `i`th packet of a costly flood this tick, the bot at `pos`; none for other behaviours.
|
||||
fn costly(
|
||||
behaviour: Behaviour,
|
||||
i: usize,
|
||||
sequence: i32,
|
||||
pos: [f64; 3],
|
||||
random: &mut Random,
|
||||
) -> Option<Vec<u8>> {
|
||||
use ids::play::serverbound as sb;
|
||||
static LONG_COMMAND: LazyLock<String> =
|
||||
LazyLock::new(|| format!("/msg {}", "x".repeat(30_000)));
|
||||
static BOOK: LazyLock<Vec<u8>> = LazyLock::new(|| {
|
||||
play::Serverbound::EditBook(play::EditBook {
|
||||
slot: 0,
|
||||
pages: vec!["a".repeat(1024); 100],
|
||||
title: None,
|
||||
})
|
||||
.encode()
|
||||
});
|
||||
let block = pos.map(|v| v.floor() as i32);
|
||||
let packet = match behaviour {
|
||||
Behaviour::FloodUse => {
|
||||
// A block within reach: place stone there, or break what is there.
|
||||
let at = [0, 1, 2].map(|k| block[k] + (random.unit() * 7.0) as i32 - 3);
|
||||
if i % 2 == 0 {
|
||||
play::Serverbound::UseItemOn(play::UseItemOn {
|
||||
hand: 0,
|
||||
pos: at,
|
||||
face: 1,
|
||||
cursor: [0.5, 1.0, 0.5],
|
||||
inside: false,
|
||||
world_border: false,
|
||||
sequence,
|
||||
})
|
||||
} else {
|
||||
play::Serverbound::PlayerAction(play::PlayerAction {
|
||||
action: 0,
|
||||
pos: at,
|
||||
direction: 1,
|
||||
sequence,
|
||||
})
|
||||
}
|
||||
}
|
||||
Behaviour::FloodClick => play::Serverbound::ContainerClick(play::ContainerClick {
|
||||
container: 0,
|
||||
state: 0,
|
||||
slot: 36 + (i % 9) as i16,
|
||||
button: 0,
|
||||
input: 0,
|
||||
changed: Vec::new(),
|
||||
carried: None,
|
||||
}),
|
||||
Behaviour::FloodSuggest => {
|
||||
let text = if i % 2 == 0 {
|
||||
"/"
|
||||
} else {
|
||||
LONG_COMMAND.as_str()
|
||||
};
|
||||
return Some(raw(sb::COMMAND_SUGGESTION, |w| {
|
||||
w.varint(sequence);
|
||||
w.string(text);
|
||||
}));
|
||||
}
|
||||
Behaviour::FloodBook => return Some(BOOK.clone()),
|
||||
Behaviour::FloodSign => play::Serverbound::SignUpdate(play::SignUpdate {
|
||||
pos: [block[0], block[1] - 1, block[2]],
|
||||
lines: std::array::from_fn(|_| "a".repeat(384)),
|
||||
front: true,
|
||||
}),
|
||||
Behaviour::FloodRecipe => play::Serverbound::PlaceRecipe(recipes::PlaceRecipe {
|
||||
container: 0,
|
||||
recipe: (i % 2042) as i32,
|
||||
use_max: true,
|
||||
}),
|
||||
_ => return None,
|
||||
};
|
||||
Some(packet.encode())
|
||||
}
|
||||
|
||||
fn err(e: impl std::fmt::Display) -> String {
|
||||
e.to_string()
|
||||
}
|
||||
@@ -339,6 +496,12 @@ fn read(
|
||||
let mut batch = BatchSize::new();
|
||||
let mut teleports = 0u64;
|
||||
loop {
|
||||
if bot.deaf.load(Ordering::Relaxed) {
|
||||
while bot.online.load(Ordering::SeqCst) {
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let (id, body) = match reader.recv() {
|
||||
Ok(packet) => packet,
|
||||
Err(e) => {
|
||||
@@ -489,11 +652,47 @@ fn run_bot(
|
||||
.store(asked.elapsed().as_millis() as u64, Ordering::SeqCst);
|
||||
let send = |body: Vec<u8>| lock(&writer).send(&body).is_ok();
|
||||
send(raw(sb::PLAYER_LOADED, |_| {}));
|
||||
bot.deaf.store(o.no_read, Ordering::Relaxed);
|
||||
// What the costly floods need in hand (creative: the server takes the bot's word for it).
|
||||
let tool = match behaviour {
|
||||
Behaviour::FloodUse | Behaviour::Settle | Behaviour::Town => Some(Stack::new(STONE, 64)),
|
||||
Behaviour::FloodBook => Some(Stack::new(WRITABLE_BOOK, 1)),
|
||||
_ => None,
|
||||
};
|
||||
if tool.is_some() {
|
||||
send(
|
||||
play::Serverbound::SetCreativeModeSlot(play::SetCreativeModeSlot {
|
||||
slot: 36,
|
||||
item: tool,
|
||||
})
|
||||
.encode(),
|
||||
);
|
||||
send(play::Serverbound::SetCarriedItem(play::SetCarriedItem { slot: 0 }).encode());
|
||||
}
|
||||
let mut sequence = 0;
|
||||
let mut random = Random(o.seed ^ (index as u64).wrapping_mul(0x2545_F491_4F6C_DD1D));
|
||||
let mut pos = spawn;
|
||||
let cruise = spawn[1] + 24.0;
|
||||
let heading = random.unit() * std::f64::consts::TAU;
|
||||
let mut target = pos;
|
||||
// Where a settler lives: far out on its heading. The others live at spawn.
|
||||
let mut home = spawn;
|
||||
let mut travel = match behaviour {
|
||||
Behaviour::Settle => 150.0 + random.unit() * 1350.0,
|
||||
_ => 0.0,
|
||||
};
|
||||
if travel > 0.0 {
|
||||
home = [
|
||||
spawn[0] + heading.cos() * travel,
|
||||
cruise,
|
||||
spawn[2] + heading.sin() * travel,
|
||||
];
|
||||
}
|
||||
// The ticks until the next block placed or broken, chat line and dropped item.
|
||||
let mut build_in = 20 + (random.unit() * 60.0) as u32;
|
||||
let mut chat_in = 100 + (random.unit() * 600.0) as u32;
|
||||
let mut drop_in = 100 + (random.unit() * 400.0) as u32;
|
||||
let mut placed: Option<[i32; 3]> = None;
|
||||
let mut next = Instant::now();
|
||||
loop {
|
||||
if lock(&until).is_some_and(|u| Instant::now() >= u) || !bot.online.load(Ordering::SeqCst) {
|
||||
@@ -504,23 +703,61 @@ fn run_bot(
|
||||
pos = at;
|
||||
}
|
||||
let here = ((pos[0].floor() as i32) >> 4, (pos[2].floor() as i32) >> 4);
|
||||
if !lock(&bot.held).contains(&here) {
|
||||
// The abusive client doesn't wait for its chunk, as a real client must.
|
||||
if behaviour == Behaviour::FloodMove {
|
||||
// The last of these moves goes out below, with the tick's end.
|
||||
pos[1] = cruise;
|
||||
for _ in 1..o.flood.max(1) {
|
||||
let angle = random.unit() * std::f64::consts::TAU;
|
||||
let reach = 64.0 + random.unit() * 192.0;
|
||||
pos[0] += angle.cos() * reach;
|
||||
pos[2] += angle.sin() * reach;
|
||||
send(
|
||||
play::Serverbound::MovePlayerPos(play::MovePlayerPos {
|
||||
x: pos[0],
|
||||
y: pos[1],
|
||||
z: pos[2],
|
||||
flags: 0,
|
||||
})
|
||||
.encode(),
|
||||
);
|
||||
}
|
||||
let angle = random.unit() * std::f64::consts::TAU;
|
||||
pos[0] += angle.cos() * 128.0;
|
||||
pos[2] += angle.sin() * 128.0;
|
||||
} else if behaviour == Behaviour::FloodChunks {
|
||||
// Up to cruising height first, then just under the server's 10 blocks a tick:
|
||||
// every tick asks for new chunks.
|
||||
if pos[1] < cruise {
|
||||
pos[1] = (pos[1] + 0.5).min(cruise);
|
||||
} else {
|
||||
pos[0] += heading.cos() * 9.9;
|
||||
pos[2] += heading.sin() * 9.9;
|
||||
}
|
||||
} else if !lock(&bot.held).contains(&here) {
|
||||
bot.waited.fetch_add(1, Ordering::Relaxed);
|
||||
} else if pos[1] < cruise {
|
||||
pos[1] = (pos[1] + 0.5).min(cruise);
|
||||
} else {
|
||||
match behaviour {
|
||||
Behaviour::Idle => {}
|
||||
Behaviour::Walk => {
|
||||
Behaviour::Settle if travel > 0.0 => {
|
||||
// On the way home: 10 blocks a second, about a horse's gallop.
|
||||
let step = travel.min(0.5);
|
||||
travel -= step;
|
||||
pos[0] += heading.cos() * step;
|
||||
pos[2] += heading.sin() * step;
|
||||
}
|
||||
Behaviour::Walk | Behaviour::Settle | Behaviour::Town => {
|
||||
let (dx, dz) = (target[0] - pos[0], target[2] - pos[2]);
|
||||
let distance = dx.hypot(dz);
|
||||
if distance < 1.0 {
|
||||
let angle = random.unit() * std::f64::consts::TAU;
|
||||
let reach = 8.0 + random.unit() * 40.0;
|
||||
target = [
|
||||
spawn[0] + angle.cos() * reach,
|
||||
home[0] + angle.cos() * reach,
|
||||
cruise,
|
||||
spawn[2] + angle.sin() * reach,
|
||||
home[2] + angle.sin() * reach,
|
||||
];
|
||||
} else {
|
||||
// Sprinting: 5.6 blocks a second.
|
||||
@@ -529,15 +766,132 @@ fn run_bot(
|
||||
pos[2] += dz / distance * step;
|
||||
}
|
||||
}
|
||||
Behaviour::Explore => {
|
||||
Behaviour::FloodMove
|
||||
| Behaviour::FloodChunks
|
||||
| Behaviour::FloodUse
|
||||
| Behaviour::FloodClick
|
||||
| Behaviour::FloodSuggest
|
||||
| Behaviour::FloodBook
|
||||
| Behaviour::FloodSign
|
||||
| Behaviour::FloodRecipe => {}
|
||||
Behaviour::FloodPackets => {
|
||||
for i in 0..o.flood {
|
||||
send(
|
||||
play::Serverbound::MovePlayerRot(play::MovePlayerRot {
|
||||
yaw: (i % 360) as f32,
|
||||
pitch: 0.0,
|
||||
flags: 0,
|
||||
})
|
||||
.encode(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Behaviour::FloodAcks | Behaviour::Explore => {
|
||||
if behaviour == Behaviour::FloodAcks {
|
||||
for _ in 0..o.flood {
|
||||
send(
|
||||
play::Serverbound::ChunkBatchReceived(play::ChunkBatchReceived {
|
||||
chunks_per_tick: 64.0,
|
||||
})
|
||||
.encode(),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Flying in creative, by default about 22 blocks a second, which keeps new
|
||||
// chunks coming as fast as the server sends them.
|
||||
let step = o.explore_speed / 20.0;
|
||||
// chunks coming as fast as the server sends them (a server crowd's explorers: 10 at most).
|
||||
let speed = if o.behaviour == "server" {
|
||||
o.explore_speed.min(10.0)
|
||||
} else {
|
||||
o.explore_speed
|
||||
};
|
||||
let step = speed / 20.0;
|
||||
pos[0] += heading.cos() * step;
|
||||
pos[2] += heading.sin() * step;
|
||||
}
|
||||
}
|
||||
}
|
||||
// The costly floods go out wherever the bot is, chunks or not (--no-read gets none).
|
||||
for i in 0..o.flood {
|
||||
sequence += 1;
|
||||
if let Some(body) = costly(behaviour, i, sequence, pos, &mut random) {
|
||||
send(body);
|
||||
}
|
||||
}
|
||||
// A server crowd's player at home builds, chats and drops things now and then.
|
||||
let at_home =
|
||||
behaviour == Behaviour::Town || (behaviour == Behaviour::Settle && travel <= 0.0);
|
||||
if at_home && pos[1] >= cruise && lock(&bot.held).contains(&here) {
|
||||
build_in -= 1;
|
||||
if build_in == 0 {
|
||||
build_in = 40 + (random.unit() * 60.0) as u32;
|
||||
sequence += 1;
|
||||
// Break what it placed last time, or place stone in the air below it (air is
|
||||
// replaceable, so the clicked block itself becomes stone).
|
||||
let body = if let Some(at) = placed.take() {
|
||||
play::Serverbound::PlayerAction(play::PlayerAction {
|
||||
action: 0,
|
||||
pos: at,
|
||||
direction: 1,
|
||||
sequence,
|
||||
})
|
||||
} else {
|
||||
let block = pos.map(|v| v.floor() as i32);
|
||||
let at = [
|
||||
block[0] + (random.unit() * 5.0) as i32 - 2,
|
||||
block[1] - 2,
|
||||
block[2] + (random.unit() * 5.0) as i32 - 2,
|
||||
];
|
||||
placed = Some(at);
|
||||
play::Serverbound::UseItemOn(play::UseItemOn {
|
||||
hand: 0,
|
||||
pos: at,
|
||||
face: 1,
|
||||
cursor: [0.5, 1.0, 0.5],
|
||||
inside: false,
|
||||
world_border: false,
|
||||
sequence,
|
||||
})
|
||||
};
|
||||
send(body.encode());
|
||||
}
|
||||
if behaviour == Behaviour::Town {
|
||||
chat_in -= 1;
|
||||
if chat_in == 0 {
|
||||
chat_in = 400 + (random.unit() * 400.0) as u32;
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_millis() as i64);
|
||||
send(
|
||||
play::Serverbound::Chat(play::Chat {
|
||||
message: format!("{name}: anyone near spawn? ({sequence})"),
|
||||
timestamp: now,
|
||||
salt: random.next() as i64,
|
||||
signature: None,
|
||||
last_seen: play::LastSeenUpdate {
|
||||
offset: 0,
|
||||
acknowledged: [0; 3],
|
||||
checksum: 0,
|
||||
},
|
||||
})
|
||||
.encode(),
|
||||
);
|
||||
}
|
||||
drop_in -= 1;
|
||||
if drop_in == 0 {
|
||||
drop_in = 300 + (random.unit() * 200.0) as u32;
|
||||
// ServerboundPlayerActionPacket DROP_ITEM: one stone from the hand.
|
||||
send(
|
||||
play::Serverbound::PlayerAction(play::PlayerAction {
|
||||
action: 4,
|
||||
pos: [0, 0, 0],
|
||||
direction: 0,
|
||||
sequence: 0,
|
||||
})
|
||||
.encode(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let ok = send(
|
||||
play::Serverbound::MovePlayerPos(play::MovePlayerPos {
|
||||
x: pos[0],
|
||||
@@ -569,6 +923,21 @@ fn behaviour_of(mode: &str, index: usize) -> Behaviour {
|
||||
"idle" => Behaviour::Idle,
|
||||
"walk" => Behaviour::Walk,
|
||||
"explore" => Behaviour::Explore,
|
||||
"flood-move" => Behaviour::FloodMove,
|
||||
"flood-acks" => Behaviour::FloodAcks,
|
||||
"flood-packets" => Behaviour::FloodPackets,
|
||||
"flood-chunks" => Behaviour::FloodChunks,
|
||||
"flood-use" => Behaviour::FloodUse,
|
||||
"flood-click" => Behaviour::FloodClick,
|
||||
"flood-suggest" => Behaviour::FloodSuggest,
|
||||
"flood-book" => Behaviour::FloodBook,
|
||||
"flood-sign" => Behaviour::FloodSign,
|
||||
"flood-recipe" => Behaviour::FloodRecipe,
|
||||
"server" => match index % 10 {
|
||||
0..=3 => Behaviour::Settle,
|
||||
9 => Behaviour::Explore,
|
||||
_ => Behaviour::Town,
|
||||
},
|
||||
// mixed: of every five bots, three walk, one explores, one stands.
|
||||
_ => match index % 5 {
|
||||
0 | 2 | 4 => Behaviour::Walk,
|
||||
|
||||
@@ -55,6 +55,12 @@ particle_types! {
|
||||
Rain = "rain",
|
||||
Bubble = "bubble",
|
||||
SulfurBubbles = "sulfur_bubbles",
|
||||
NoxiousGas = "noxious_gas",
|
||||
NoxiousGasCloud = "noxious_gas_cloud",
|
||||
Geyser = "geyser",
|
||||
GeyserBase = "geyser_base",
|
||||
GeyserPoof = "geyser_poof",
|
||||
GeyserPlume = "geyser_plume",
|
||||
BubblePop = "bubble_pop",
|
||||
BubbleColumnUp = "bubble_column_up",
|
||||
CurrentDown = "current_down",
|
||||
@@ -149,6 +155,10 @@ impl ParticleType {
|
||||
matches!(
|
||||
self,
|
||||
Self::BlockMarker
|
||||
| Self::Geyser
|
||||
| Self::GeyserBase
|
||||
| Self::GeyserPoof
|
||||
| Self::GeyserPlume
|
||||
| Self::DamageIndicator
|
||||
| Self::ExplosionEmitter
|
||||
| Self::Explosion
|
||||
@@ -191,6 +201,8 @@ impl ParticleType {
|
||||
// SpellParticle.
|
||||
| Self::Effect | Self::InstantEffect | Self::EntityEffect
|
||||
| Self::Witch | Self::Infested | Self::TrialOmen | Self::RaidOmen
|
||||
// NoxiousGasParticle.
|
||||
| Self::NoxiousGas
|
||||
// EmissiveRisingParticle.
|
||||
| Self::Soul | Self::SculkSoul
|
||||
// SculkChargeParticle, SculkChargePopParticle, ShriekParticle,
|
||||
|
||||
@@ -28,6 +28,10 @@ pub struct Registries {
|
||||
/// Game rule name -> integer-valued.
|
||||
pub game_rules: Vec<(String, bool)>,
|
||||
pub players: Vec<String>,
|
||||
/// `ClientSuggestionProvider.customCompletionSuggestions`: the extra chat
|
||||
/// completions the server sent (ClientboundCustomChatCompletionsPacket),
|
||||
/// each once.
|
||||
pub custom_completions: std::collections::BTreeSet<String>,
|
||||
/// Scoreboard objective names, team names and tracked score holders.
|
||||
pub objectives: Vec<String>,
|
||||
pub teams: Vec<String>,
|
||||
@@ -87,7 +91,55 @@ pub struct RegistryIds {
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
const EMPTY: &[String] = &[];
|
||||
/// ClientboundCustomChatCompletionsPacket.Action.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CustomCompletionAction {
|
||||
Add,
|
||||
Remove,
|
||||
Set,
|
||||
}
|
||||
impl CustomCompletionAction {
|
||||
/// `Action.BY_ID`: `ByIdMap.continuous` with `OutOfBoundsStrategy.ZERO`,
|
||||
/// so an unknown id is `ADD`.
|
||||
pub fn from_id(id: i32) -> Self {
|
||||
match id {
|
||||
1 => Self::Remove,
|
||||
2 => Self::Set,
|
||||
_ => Self::Add,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ClientSuggestionProvider.modifyCustomCompletions`: the server adds,
|
||||
/// removes or replaces the extra chat completions.
|
||||
pub fn modify_custom_completions(
|
||||
set: &mut std::collections::BTreeSet<String>,
|
||||
action: CustomCompletionAction,
|
||||
entries: &[String],
|
||||
) {
|
||||
match action {
|
||||
CustomCompletionAction::Add => set.extend(entries.iter().cloned()),
|
||||
CustomCompletionAction::Remove => {
|
||||
for e in entries {
|
||||
set.remove(e);
|
||||
}
|
||||
}
|
||||
CustomCompletionAction::Set => {
|
||||
set.clear();
|
||||
set.extend(entries.iter().cloned());
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Registries {
|
||||
/// `ClientSuggestionProvider.getCustomTabSuggestions`: what chat (not a
|
||||
/// command) completes, the online players' names with the server's custom
|
||||
/// completions, each once.
|
||||
pub fn custom_tab_suggestions(&self) -> std::collections::BTreeSet<&str> {
|
||||
self.players
|
||||
.iter()
|
||||
.chain(&self.custom_completions)
|
||||
.map(String::as_str)
|
||||
.collect()
|
||||
}
|
||||
/// Fill what the game did not build itself from vanilla's exported
|
||||
/// registries (`command-registries.json`, written by
|
||||
/// `VanillaCommandParseOracle registries`): built-in registries such as
|
||||
|
||||
@@ -13,6 +13,12 @@ pub enum Button {
|
||||
}
|
||||
|
||||
pub trait InteractionWorld {
|
||||
/// Minecraft.pick before handleKeybinds: the tick's `hitResult`, which
|
||||
/// every click of the tick reads, even after one of them changed the
|
||||
/// block it names (a creative break, then a use that replaces it).
|
||||
fn pick_hit_result(&mut self);
|
||||
/// The tick's clicks are done: later picks follow the crosshair again.
|
||||
fn release_hit_result(&mut self);
|
||||
fn using_item(&self) -> bool;
|
||||
fn release_using(&mut self);
|
||||
fn start_attack(&mut self) -> bool;
|
||||
@@ -48,6 +54,7 @@ impl InteractionInput {
|
||||
|
||||
pub fn tick(&mut self, world: &mut impl InteractionWorld, attack_down: bool, use_down: bool) {
|
||||
let [attack, use_item, pick] = std::mem::take(&mut self.clicks);
|
||||
world.pick_hit_result();
|
||||
let mut instant = false;
|
||||
if world.using_item() {
|
||||
if !use_down {
|
||||
@@ -70,6 +77,7 @@ impl InteractionInput {
|
||||
world.start_use();
|
||||
}
|
||||
world.continue_attack(!instant && attack_down);
|
||||
world.release_hit_result();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +94,8 @@ mod tests {
|
||||
events: Vec<&'static str>,
|
||||
}
|
||||
impl InteractionWorld for World {
|
||||
fn pick_hit_result(&mut self) {}
|
||||
fn release_hit_result(&mut self) {}
|
||||
fn using_item(&self) -> bool {
|
||||
self.using
|
||||
}
|
||||
@@ -183,4 +193,67 @@ mod tests {
|
||||
input.tick(&mut world, false, false);
|
||||
assert_eq!(world.events, ["release", "continue"]);
|
||||
}
|
||||
|
||||
/// A floating block in front of the crosshair, nothing behind it, and a
|
||||
/// block item in the hand of a creative player.
|
||||
#[derive(Default)]
|
||||
struct Column {
|
||||
block: bool,
|
||||
hit: Option<Option<()>>,
|
||||
placed: bool,
|
||||
}
|
||||
impl Column {
|
||||
/// Minecraft.hitResult: the tick's pick, or a fresh one outside it.
|
||||
fn target(&self) -> Option<()> {
|
||||
self.hit.unwrap_or(self.block.then_some(()))
|
||||
}
|
||||
}
|
||||
impl InteractionWorld for Column {
|
||||
fn pick_hit_result(&mut self) {
|
||||
self.hit = Some(self.target());
|
||||
}
|
||||
fn release_hit_result(&mut self) {
|
||||
self.hit = None;
|
||||
}
|
||||
fn using_item(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn release_using(&mut self) {}
|
||||
fn start_attack(&mut self) -> bool {
|
||||
// MultiPlayerGameMode.startDestroyBlock in creative: the
|
||||
// predicted break leaves air.
|
||||
if self.target().is_some() {
|
||||
self.block = false;
|
||||
}
|
||||
!self.block
|
||||
}
|
||||
fn start_use(&mut self) {
|
||||
// BlockItem.place: the clicked block, now air, can be replaced.
|
||||
if self.target().is_some() && !self.block {
|
||||
self.block = true;
|
||||
self.placed = true;
|
||||
}
|
||||
}
|
||||
fn pick(&mut self) {}
|
||||
fn use_delay(&self) -> u32 {
|
||||
0
|
||||
}
|
||||
fn continue_attack(&mut self, _: bool) {}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attack_and_use_in_one_tick_share_the_hit_result() {
|
||||
// #3006: both buttons at once replace the block the crosshair is on.
|
||||
let mut input = InteractionInput::default();
|
||||
let mut world = Column {
|
||||
block: true,
|
||||
..Default::default()
|
||||
};
|
||||
input.press(Button::Attack);
|
||||
input.press(Button::Use);
|
||||
input.tick(&mut world, false, false);
|
||||
assert!(world.placed, "the use acts on the block the attack broke");
|
||||
assert!(world.block);
|
||||
assert!(world.hit.is_none(), "the next pick follows the crosshair");
|
||||
}
|
||||
}
|
||||
@@ -192,16 +192,30 @@ impl Assets {
|
||||
pub fn reload(&self, root: &Path) -> Result<RenderAssets> {
|
||||
RenderAssets::load(root, self)
|
||||
}
|
||||
/// [`Assets::reload`] for a server, which draws nothing: the item models
|
||||
/// and the sprites' layers, without the block models or the textures'
|
||||
/// pixels.
|
||||
pub fn reload_for_server(&self, root: &Path) -> Result<RenderAssets> {
|
||||
RenderAssets::load_with(root, self, false)
|
||||
}
|
||||
}
|
||||
impl RenderAssets {
|
||||
pub fn load(root: &Path, assets: &Assets) -> Result<Self> {
|
||||
Self::load_with(root, assets, true)
|
||||
}
|
||||
fn load_with(root: &Path, assets: &Assets, draws: bool) -> Result<Self> {
|
||||
let registry = assets.registry;
|
||||
let mut sprites = crate::sprites::Sprites::with_disk_cache(root);
|
||||
for name in MATERIALS {
|
||||
sprites.insert(root, &format!("minecraft:block/{name}"))?;
|
||||
}
|
||||
let parsed = crate::model_library::ParsedModels::load(root);
|
||||
let models = crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?;
|
||||
// A server draws no block: its models (about 24 MB baked) stay empty.
|
||||
let models = if draws {
|
||||
crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?
|
||||
} else {
|
||||
crate::models::PreparedModels::default()
|
||||
};
|
||||
let model_library = parsed.into_item_library();
|
||||
// Fluids are drawn by the fluid renderer, not block models; breaking
|
||||
// progress uses the destroy-stage overlays.
|
||||
@@ -218,7 +232,11 @@ impl RenderAssets {
|
||||
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
|
||||
}
|
||||
sprites.save_disk_cache();
|
||||
sprites.finish();
|
||||
if draws {
|
||||
sprites.finish();
|
||||
} else {
|
||||
sprites.finish_without_pixels();
|
||||
}
|
||||
// `finish` grows the buffer by doubling: keep only the bytes in use.
|
||||
sprites.pixels.shrink_to_fit();
|
||||
Ok(Self {
|
||||
@@ -241,6 +259,7 @@ impl RenderAssets {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::items::ConsumeEffect;
|
||||
use horizon_world::loot::PackOverlay;
|
||||
|
||||
#[test]
|
||||
fn always_edible_and_consume_effects_loaded() {
|
||||
@@ -251,7 +270,8 @@ mod tests {
|
||||
}
|
||||
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 items =
|
||||
crate::items::Items::load(&root, assets.registry, &facts, &PackOverlay::NONE).unwrap();
|
||||
|
||||
let apple = items.get("minecraft:golden_apple").expect("golden apple");
|
||||
assert!(apple.can_always_eat);
|
||||
|
||||
@@ -165,6 +165,8 @@ pub struct Audio {
|
||||
/// How many times each selection was stopped ("record *", "* minecraft:…", `*` for
|
||||
/// any), with or without an audio device, as `heard`.
|
||||
pub stopped: std::collections::BTreeMap<String, u32>,
|
||||
/// The volume sliders' `SoundPreviewHandler`.
|
||||
preview: crate::sound_preview::SoundPreview,
|
||||
}
|
||||
fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
|
||||
use symphonia::core::{
|
||||
@@ -634,6 +636,7 @@ impl Audio {
|
||||
capture: None,
|
||||
heard: Default::default(),
|
||||
stopped: Default::default(),
|
||||
preview: Default::default(),
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "audio"))]
|
||||
@@ -817,6 +820,13 @@ impl Audio {
|
||||
false,
|
||||
);
|
||||
}
|
||||
/// `SoundPreviewHandler.preview`: a category volume slider moved to
|
||||
/// `volume` on the title screen's options.
|
||||
pub fn preview(&mut self, source: usize, volume: f32) {
|
||||
let mut preview = std::mem::take(&mut self.preview);
|
||||
preview.preview(self, source, volume);
|
||||
self.preview = preview;
|
||||
}
|
||||
/// `play` with the seed a sound packet carries.
|
||||
pub fn play_seeded(
|
||||
&mut self,
|
||||
|
||||
@@ -52,20 +52,20 @@ impl AuthSession {
|
||||
/// The session file's JSON: {"name", "uuid", "access_token", "user_type": "msa", "xuid"}.
|
||||
pub fn parse(text: &str) -> Result<Self, String> {
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(text).map_err(|_| "the session file is not valid JSON")?;
|
||||
serde_json::from_str(text).map_err(|_| "the session is not valid JSON")?;
|
||||
let field = |k: &str| v.get(k).and_then(|s| s.as_str());
|
||||
let name = field("name").ok_or("the session file has no name")?;
|
||||
let name = field("name").ok_or("the session has no name")?;
|
||||
if name.is_empty() || name.chars().count() > 16 {
|
||||
return Err("the session file's name is not a valid player name".into());
|
||||
return Err("the session's name is not a valid player name".into());
|
||||
}
|
||||
let uuid = field("uuid")
|
||||
.and_then(parse_uuid)
|
||||
.ok_or("the session file has no valid uuid")?;
|
||||
.ok_or("the session has no valid uuid")?;
|
||||
let access_token = field("access_token")
|
||||
.filter(|t| !t.is_empty())
|
||||
.ok_or("the session file has no access token")?;
|
||||
.ok_or("the session has no access token")?;
|
||||
if field("user_type").is_some_and(|t| t != "msa") {
|
||||
return Err("the session file is not a Microsoft account session".into());
|
||||
return Err("the session is not a Microsoft account session".into());
|
||||
}
|
||||
Ok(Self {
|
||||
name: name.to_owned(),
|
||||
|
||||
@@ -865,13 +865,23 @@ const VIEW_DISTANCE: f64 = 64.0;
|
||||
|
||||
impl BlockEntityRenderer {
|
||||
pub fn load(root: &Path, registry: &BlockRegistry) -> crate::Result<Self> {
|
||||
let visibility = crate::models::load_visibility(root, registry)?;
|
||||
Self::load_with_visibility(root, registry, visibility)
|
||||
}
|
||||
/// [`BlockEntityRenderer::load`] over the face visibility the meshes use
|
||||
/// (`MeshTables::visibility`): a clone shares its shapes and their faces.
|
||||
pub fn load_with_visibility(
|
||||
root: &Path,
|
||||
registry: &BlockRegistry,
|
||||
visibility: FaceVisibility,
|
||||
) -> crate::Result<Self> {
|
||||
let all: Value = serde_json::from_slice(&std::fs::read(root.join("entity-models.json"))?)?;
|
||||
let models = MODELS
|
||||
.iter()
|
||||
.filter_map(|(key, layer)| all.get(*layer).map(|v| (*key, ModelPart::parse(v))))
|
||||
.collect();
|
||||
let mut renderer = Self::new(models, registry);
|
||||
renderer.visibility = Some(Mutex::new(crate::models::load_visibility(root, registry)?));
|
||||
renderer.visibility = Some(Mutex::new(visibility));
|
||||
// Sherd items: provides_pottery_pattern -> decorated_pot_pattern asset id.
|
||||
if let Ok(bytes) = std::fs::read(root.join("items.json")) {
|
||||
let items: Value = serde_json::from_slice(&bytes)?;
|
||||
@@ -2363,6 +2373,7 @@ mod tests {
|
||||
lines: [line.into(), "two".into(), String::new(), "four".into()],
|
||||
color: 3,
|
||||
glowing,
|
||||
..Default::default()
|
||||
};
|
||||
let first_text = crate::signs::SignData {
|
||||
front: text("one", false),
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::{
|
||||
items::{ItemStack, Items},
|
||||
providers::Providers,
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashSet, fs, path::Path};
|
||||
|
||||
@@ -142,20 +143,41 @@ pub struct Brewing {
|
||||
pub inputs: HashSet<String>,
|
||||
}
|
||||
impl Brewing {
|
||||
pub fn load(root: &Path, items: &Items) -> Self {
|
||||
let mut files: Vec<_> = fs::read_dir(root.join("data/minecraft/recipe/brewing"))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.collect();
|
||||
files.sort();
|
||||
let mut recipes = Vec::new();
|
||||
for p in files {
|
||||
let Ok(bytes) = fs::read(&p) else { continue };
|
||||
let Ok(v) = serde_json::from_slice::<Value>(&bytes) else {
|
||||
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Self {
|
||||
let mut sources: Vec<(String, Value)> =
|
||||
fs::read_dir(root.join("data/minecraft/recipe/brewing"))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let bytes = fs::read(e.path()).ok()?;
|
||||
let v = serde_json::from_slice::<Value>(&bytes).ok()?;
|
||||
Some((
|
||||
format!(
|
||||
"minecraft:brewing/{}",
|
||||
e.file_name().to_string_lossy().strip_suffix(".json")?
|
||||
),
|
||||
v,
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
// The world's packs' brewing recipes over the game's own: the top
|
||||
// pack's file for each id replaces it (`SimpleJsonResourceReloadListener`).
|
||||
for (id, files) in overlay.kind("recipe") {
|
||||
if !id.starts_with("minecraft:brewing/") {
|
||||
continue;
|
||||
}
|
||||
let Ok(v) = serde_json::from_slice::<Value>(PackOverlay::top(files)) else {
|
||||
continue;
|
||||
};
|
||||
match sources.iter_mut().find(|(n, _)| n == id) {
|
||||
Some(slot) => slot.1 = v,
|
||||
None => sources.push((id.to_owned(), v)),
|
||||
}
|
||||
}
|
||||
sources.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
let mut recipes = Vec::new();
|
||||
for (id, v) in sources {
|
||||
if v["type"] != "minecraft:brewing" {
|
||||
continue;
|
||||
}
|
||||
@@ -169,10 +191,7 @@ impl Brewing {
|
||||
output = output.with_potion(potion);
|
||||
}
|
||||
recipes.push(BrewingRecipe {
|
||||
id: format!(
|
||||
"minecraft:brewing/{}",
|
||||
p.file_stem().and_then(|s| s.to_str()).unwrap_or("")
|
||||
),
|
||||
id,
|
||||
input: PotionIngredient::parse(&v["input"], items),
|
||||
reagent: PotionIngredient::parse(&v["reagent"], items),
|
||||
output,
|
||||
|
||||
@@ -929,7 +929,9 @@ impl ChatScreen {
|
||||
} else if !command.chars().all(char::is_whitespace) {
|
||||
let partial = &command[..cursor_b];
|
||||
let mut builder = SuggestionsBuilder::new(partial, last_word_index(partial));
|
||||
builder.suggest_matching(c.registries.players.iter().map(String::as_str));
|
||||
// ClientSuggestionProvider.getCustomTabSuggestions: the players
|
||||
// and the server's custom chat completions.
|
||||
builder.suggest_matching(c.registries.custom_tab_suggestions());
|
||||
let (range, list) = merge_suggestions(partial, builder.out);
|
||||
self.awaiting = None;
|
||||
self.pending = Some((range.0, list));
|
||||
|
||||
@@ -244,6 +244,58 @@ fn mod_commands_are_coloured_and_complete_like_vanilla_ones() {
|
||||
assert_eq!(screen.value(), "/coords");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_completes_the_servers_custom_completions_with_the_players() {
|
||||
// CommandSuggestions.updateCommandInfo completes chat from
|
||||
// ClientSuggestionProvider.getCustomTabSuggestions: the online players
|
||||
// and what ClientboundCustomChatCompletionsPacket added.
|
||||
use crate::command::{CustomCompletionAction as Action, modify_custom_completions};
|
||||
let font = font();
|
||||
let m = Measure(&font);
|
||||
let mut registries = Registries {
|
||||
players: vec!["Steve".into()],
|
||||
..Registries::default()
|
||||
};
|
||||
let entries = |e: &[&str]| e.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
let set = &mut registries.custom_completions;
|
||||
modify_custom_completions(set, Action::from_id(0), &entries(&["Alex", "#trade"]));
|
||||
modify_custom_completions(set, Action::Add, &entries(&["Notch", "Steve"]));
|
||||
modify_custom_completions(set, Action::Remove, &entries(&["Alex"]));
|
||||
// An id past the last action is ADD (OutOfBoundsStrategy.ZERO).
|
||||
modify_custom_completions(set, Action::from_id(7), &entries(&["Zuri"]));
|
||||
assert_eq!(
|
||||
registries
|
||||
.custom_tab_suggestions()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>(),
|
||||
["#trade", "Notch", "Steve", "Zuri"]
|
||||
);
|
||||
let completed = |registries: &Registries, line: &str| {
|
||||
let dispatcher = crate::command::vanilla::game_dispatcher();
|
||||
let c = Commands {
|
||||
dispatcher,
|
||||
registries,
|
||||
};
|
||||
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
|
||||
screen.type_text(&m, &c, line);
|
||||
// Chat shows no list by itself: Tab shows it, Tab again uses it.
|
||||
for _ in 0..2 {
|
||||
screen.key(&m, &c, &[], winit::keyboard::KeyCode::Tab, false, false);
|
||||
}
|
||||
screen.value().to_owned()
|
||||
};
|
||||
assert_eq!(completed(®istries, "hi no"), "hi Notch");
|
||||
assert_eq!(completed(®istries, "sell #tr"), "sell #trade");
|
||||
assert_eq!(completed(®istries, "hi st"), "hi Steve");
|
||||
assert_eq!(completed(®istries, "hi al"), "hi al");
|
||||
// SET replaces them all; the players stay.
|
||||
let set = &mut registries.custom_completions;
|
||||
modify_custom_completions(set, Action::Set, &entries(&["Alex"]));
|
||||
assert_eq!(completed(®istries, "hi no"), "hi no");
|
||||
assert_eq!(completed(®istries, "hi al"), "hi Alex");
|
||||
assert_eq!(completed(®istries, "hi st"), "hi Steve");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ask_server_nodes_wait_for_the_servers_completions() {
|
||||
// ClientSuggestionProvider.customSuggestion: an `ask_server` node at the
|
||||
|
||||
@@ -8,10 +8,171 @@ use std::sync::{Arc, OnceLock};
|
||||
|
||||
pub mod block_entity;
|
||||
|
||||
/// The vanilla Overworld's column, which every dimension's chunks hold for
|
||||
/// now (`LevelShape::column`).
|
||||
pub const MIN_Y: i32 = -64;
|
||||
pub const HEIGHT: i32 = 384;
|
||||
pub const SECTIONS: usize = (HEIGHT / 16) as usize;
|
||||
|
||||
/// A level's build range and skylight, from its dimension type (`min_y`,
|
||||
/// `height`, `logical_height`, `has_skylight`), as `LevelHeightAccessor` and
|
||||
/// `SerializableChunkData.write` read them.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct LevelShape {
|
||||
pub sky_light: bool,
|
||||
pub min_y: i32,
|
||||
pub height: i32,
|
||||
/// `DimensionType.logicalHeight`: how high portals and chorus fruit reach.
|
||||
pub logical_height: i32,
|
||||
}
|
||||
impl LevelShape {
|
||||
pub const OVERWORLD: Self = Self {
|
||||
sky_light: true,
|
||||
min_y: MIN_Y,
|
||||
height: HEIGHT,
|
||||
logical_height: HEIGHT,
|
||||
};
|
||||
pub const NETHER: Self = Self {
|
||||
sky_light: false,
|
||||
min_y: 0,
|
||||
height: 256,
|
||||
logical_height: 128,
|
||||
};
|
||||
pub const END: Self = Self {
|
||||
sky_light: true,
|
||||
min_y: 0,
|
||||
height: 256,
|
||||
logical_height: 256,
|
||||
};
|
||||
/// A dimension type's shape, checked as its codec checks it.
|
||||
pub fn new(
|
||||
min_y: i32,
|
||||
height: i32,
|
||||
logical_height: i32,
|
||||
sky_light: bool,
|
||||
) -> Result<Self, String> {
|
||||
ColumnLayout::new(min_y, height)?;
|
||||
if !(0..=height).contains(&logical_height) {
|
||||
return Err(format!(
|
||||
"logical_height {logical_height} is outside 0..={height}"
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
sky_light,
|
||||
min_y,
|
||||
height,
|
||||
logical_height,
|
||||
})
|
||||
}
|
||||
/// One above the highest block (`getMaxY() + 1`).
|
||||
pub fn max_y(self) -> i32 {
|
||||
self.min_y + self.height
|
||||
}
|
||||
pub fn min_section(self) -> i32 {
|
||||
self.min_y >> 4
|
||||
}
|
||||
pub fn section_count(self) -> usize {
|
||||
(self.height >> 4) as usize
|
||||
}
|
||||
/// Whether section `y` (a section coordinate) is inside the level.
|
||||
pub fn has_section(self, y: i32) -> bool {
|
||||
(self.min_section()..self.min_section() + self.section_count() as i32).contains(&y)
|
||||
}
|
||||
/// The sections a chunk of this level holds: the level's, widened to the
|
||||
/// vanilla Overworld's `-64..320` so that vanilla's Nether and End keep
|
||||
/// the 24 sections they have always had.
|
||||
pub fn column(self) -> ColumnLayout {
|
||||
ColumnLayout::of_level(self.min_y, self.height)
|
||||
}
|
||||
}
|
||||
|
||||
/// The span of block Y a chunk holds: from `min_y`, `sections` sections up
|
||||
/// (`LevelShape::column`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ColumnLayout {
|
||||
min_y: i32,
|
||||
sections: usize,
|
||||
}
|
||||
impl ColumnLayout {
|
||||
pub const VANILLA: Self = Self {
|
||||
min_y: MIN_Y,
|
||||
sections: SECTIONS,
|
||||
};
|
||||
/// The column of a level from `min_y`, `height` high
|
||||
/// (`LevelShape::column`).
|
||||
pub fn of_level(min_y: i32, height: i32) -> Self {
|
||||
let low = min_y.min(MIN_Y);
|
||||
let high = (min_y + height).max(MIN_Y + HEIGHT);
|
||||
Self {
|
||||
min_y: low,
|
||||
sections: ((high - low) / 16) as usize,
|
||||
}
|
||||
}
|
||||
/// `DimensionType.MIN_Y`, `MAX_Y`: 12 bits of `BlockPos` Y, less 32.
|
||||
pub const LOWEST_Y: i32 = -2032;
|
||||
pub const HIGHEST_Y: i32 = 2031;
|
||||
/// A dimension type's `min_y` and `height`, as its codec accepts them:
|
||||
/// multiples of 16, at least 16 high, within `LOWEST_Y..=HIGHEST_Y`.
|
||||
pub fn new(min_y: i32, height: i32) -> Result<Self, String> {
|
||||
if min_y % 16 != 0 || height % 16 != 0 {
|
||||
return Err(format!(
|
||||
"min_y {min_y} and height {height} must be multiples of 16"
|
||||
));
|
||||
}
|
||||
if height < 16 {
|
||||
return Err(format!("height {height} is below 16"));
|
||||
}
|
||||
if min_y < Self::LOWEST_Y
|
||||
|| i64::from(min_y) + i64::from(height) > i64::from(Self::HIGHEST_Y) + 1
|
||||
{
|
||||
return Err(format!(
|
||||
"min_y {min_y} with height {height} leaves {}..={}",
|
||||
Self::LOWEST_Y,
|
||||
Self::HIGHEST_Y
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
min_y,
|
||||
sections: (height / 16) as usize,
|
||||
})
|
||||
}
|
||||
pub fn min_y(self) -> i32 {
|
||||
self.min_y
|
||||
}
|
||||
/// One above the highest block.
|
||||
pub fn max_y(self) -> i32 {
|
||||
self.min_y + self.height()
|
||||
}
|
||||
pub fn height(self) -> i32 {
|
||||
self.sections as i32 * 16
|
||||
}
|
||||
pub fn sections(self) -> usize {
|
||||
self.sections
|
||||
}
|
||||
/// The index of the section holding block `y`, if the column holds it.
|
||||
pub fn section_index(self, y: i32) -> Option<usize> {
|
||||
usize::try_from((y - self.min_y) >> 4)
|
||||
.ok()
|
||||
.filter(|&s| s < self.sections)
|
||||
}
|
||||
/// The minimum block Y of section `index`.
|
||||
pub fn section_y(self, index: usize) -> i32 {
|
||||
self.min_y + index as i32 * 16
|
||||
}
|
||||
}
|
||||
/// `true` for each section of `layout`: what callers that keep no
|
||||
/// per-section record (of fluids, say) pass as "every section may have it".
|
||||
pub fn every_section(layout: ColumnLayout) -> &'static [bool] {
|
||||
static ALL: [bool; crate::section_mask::SectionMask::CAPACITY] =
|
||||
[true; crate::section_mask::SectionMask::CAPACITY];
|
||||
&ALL[..layout.sections()]
|
||||
}
|
||||
impl Default for ColumnLayout {
|
||||
fn default() -> Self {
|
||||
Self::VANILLA
|
||||
}
|
||||
}
|
||||
|
||||
/// Biomes are 4x4x4 containers over the biome table (67 entries: a 7-bit
|
||||
/// global palette).
|
||||
pub const BIOME_STRATEGY: Strategy = Strategy::biomes(7);
|
||||
@@ -138,9 +299,11 @@ impl Section {
|
||||
pub struct Chunk {
|
||||
pub x: i32,
|
||||
pub z: i32,
|
||||
/// The minimum block Y of `sections[0]` (the layout's when it was made).
|
||||
min_y: i32,
|
||||
pub sections: Vec<Arc<Section>>,
|
||||
/// `Heightmap.Types.WORLD_SURFACE` per column (`z << 4 | x`): one above
|
||||
/// the highest non-air block, or `MIN_Y`. Built on the first read; a
|
||||
/// the highest non-air block, or the column's minimum Y. Built on the first read; a
|
||||
/// `set` that can move a column's surface drops it.
|
||||
surface: OnceLock<Box<[i16; 256]>>,
|
||||
/// `Heightmap.Types.MOTION_BLOCKING` per column, kept like `surface`.
|
||||
@@ -169,11 +332,16 @@ pub struct Chunk {
|
||||
pub structure_starts: Vec<Arc<horizon_world::worldgen::structure::start::StructureStart>>,
|
||||
}
|
||||
impl Chunk {
|
||||
/// An all-air chunk in the current `layout`.
|
||||
pub fn empty(x: i32, z: i32) -> Self {
|
||||
Self::with_layout(x, z, ColumnLayout::VANILLA)
|
||||
}
|
||||
pub fn with_layout(x: i32, z: i32, layout: ColumnLayout) -> Self {
|
||||
Self {
|
||||
x,
|
||||
z,
|
||||
sections: (0..SECTIONS)
|
||||
min_y: layout.min_y(),
|
||||
sections: (0..layout.sections())
|
||||
.map(|_| Arc::new(Section::default()))
|
||||
.collect(),
|
||||
surface: OnceLock::new(),
|
||||
@@ -190,13 +358,36 @@ impl Chunk {
|
||||
entities: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// The minimum block Y of the column.
|
||||
#[inline]
|
||||
pub fn min_y(&self) -> i32 {
|
||||
self.min_y
|
||||
}
|
||||
/// The column the chunk holds.
|
||||
pub fn layout(&self) -> ColumnLayout {
|
||||
ColumnLayout {
|
||||
min_y: self.min_y,
|
||||
sections: self.sections.len(),
|
||||
}
|
||||
}
|
||||
/// One above the column's highest block.
|
||||
#[inline]
|
||||
pub fn max_y(&self) -> i32 {
|
||||
self.min_y + self.sections.len() as i32 * 16
|
||||
}
|
||||
/// The index of the section holding block `y`, if the column holds it.
|
||||
#[inline]
|
||||
pub fn section_index(&self, y: i32) -> Option<usize> {
|
||||
usize::try_from((y - self.min_y) >> 4)
|
||||
.ok()
|
||||
.filter(|&s| s < self.sections.len())
|
||||
}
|
||||
#[inline]
|
||||
pub fn get(&self, x: i32, y: i32, z: i32) -> u32 {
|
||||
let s = (y - MIN_Y) >> 4;
|
||||
if !(0..SECTIONS as i32).contains(&s) {
|
||||
return 0;
|
||||
match self.section_index(y) {
|
||||
Some(s) => self.sections[s].get(index(x, y, z)),
|
||||
None => 0,
|
||||
}
|
||||
self.sections[s as usize].get(index(x, y, z))
|
||||
}
|
||||
#[inline]
|
||||
pub fn block(&self, x: i32, y: i32, z: i32) -> BlockStateId {
|
||||
@@ -211,16 +402,15 @@ impl Chunk {
|
||||
.rev()
|
||||
.filter(|(_, section)| !section.is_uniform(skip))
|
||||
.find_map(|(s, _)| {
|
||||
let base = MIN_Y + s as i32 * 16;
|
||||
let base = self.min_y + s as i32 * 16;
|
||||
(base..base + 16).rev().find(|&y| matches(y))
|
||||
})
|
||||
}
|
||||
pub fn set(&mut self, x: i32, y: i32, z: i32, v: u32) -> u32 {
|
||||
let s = (y - MIN_Y) >> 4;
|
||||
if !(0..SECTIONS as i32).contains(&s) {
|
||||
let Some(s) = self.section_index(y) else {
|
||||
return v;
|
||||
}
|
||||
let previous = Arc::make_mut(&mut self.sections[s as usize]).set(index(x, y, z), v);
|
||||
};
|
||||
let previous = Arc::make_mut(&mut self.sections[s]).set(index(x, y, z), v);
|
||||
// `Heightmap.update`: a write two or more below a map's height can't
|
||||
// move it. Any other change rebuilds that map on the next read.
|
||||
if previous != v {
|
||||
@@ -236,7 +426,8 @@ impl Chunk {
|
||||
previous
|
||||
}
|
||||
/// `getHeight(Heightmap.Types.WORLD_SURFACE, x, z)` (`x`, `z` within the
|
||||
/// chunk): one above the highest block that isn't `air`, or `MIN_Y`.
|
||||
/// chunk): one above the highest block that isn't `air`, or the column's
|
||||
/// minimum Y.
|
||||
/// Sections must change only through `set` once this has been read.
|
||||
pub fn world_surface(&self, x: i32, z: i32, air: impl Fn(u32) -> bool) -> i32 {
|
||||
let surface = self.surface.get_or_init(|| self.prime(|v| !air(v)));
|
||||
@@ -244,7 +435,8 @@ impl Chunk {
|
||||
}
|
||||
/// `getHeight(Heightmap.Types.MOTION_BLOCKING, x, z)` (`x`, `z` within
|
||||
/// the chunk): one above the highest state where `blocks` (a motion
|
||||
/// blocking block or a fluid), or `MIN_Y`. Kept as `world_surface` is.
|
||||
/// blocking block or a fluid), or the column's minimum Y. Kept as
|
||||
/// `world_surface` is.
|
||||
pub fn motion_blocking(&self, x: i32, z: i32, blocks: impl Fn(u32) -> bool) -> i32 {
|
||||
let heights = self.motion_blocking.get_or_init(|| self.prime(blocks));
|
||||
i32::from(heights[column(x, z)])
|
||||
@@ -252,7 +444,7 @@ impl Chunk {
|
||||
/// `Heightmap.primeHeightmaps`: each section from the top, skipping
|
||||
/// those of one state that doesn't count, until every column is found.
|
||||
fn prime(&self, counts: impl Fn(u32) -> bool) -> Box<[i16; 256]> {
|
||||
let mut surface = Box::new([MIN_Y as i16; 256]);
|
||||
let mut surface = Box::new([self.min_y as i16; 256]);
|
||||
let mut found = [false; 256];
|
||||
let mut left = 256;
|
||||
let mut values = [0u32; 4096];
|
||||
@@ -264,7 +456,7 @@ impl Chunk {
|
||||
continue;
|
||||
}
|
||||
section.unpack(&mut values);
|
||||
let base = MIN_Y + s as i32 * 16;
|
||||
let base = self.min_y + s as i32 * 16;
|
||||
for i in 0..256 {
|
||||
if found[i] {
|
||||
continue;
|
||||
@@ -280,11 +472,11 @@ impl Chunk {
|
||||
}
|
||||
#[inline]
|
||||
pub fn light(&self, x: i32, y: i32, z: i32) -> u8 {
|
||||
let s = (y - MIN_Y) >> 4;
|
||||
let s = (y - self.min_y) >> 4;
|
||||
if s < 0 {
|
||||
return 0;
|
||||
}
|
||||
if s >= SECTIONS as i32 {
|
||||
if s >= self.sections.len() as i32 {
|
||||
return 0xF0;
|
||||
}
|
||||
self.sections[s as usize].light(index(x, y, z))
|
||||
@@ -297,7 +489,7 @@ impl Chunk {
|
||||
/// the quart y clamped to the column).
|
||||
#[inline]
|
||||
pub fn biome(&self, x: i32, y: i32, z: i32) -> u16 {
|
||||
let qy = ((y - MIN_Y) >> 2).clamp(0, HEIGHT / 4 - 1);
|
||||
let qy = ((y - self.min_y) >> 2).clamp(0, self.sections.len() as i32 * 4 - 1);
|
||||
self.sections[(qy >> 2) as usize]
|
||||
.biomes
|
||||
.get(((qy & 3) << 4 | ((z & 15) >> 2) << 2 | (x & 15) >> 2) as usize) as u16
|
||||
@@ -504,4 +696,69 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shapes_follow_the_dimension_type_codec() {
|
||||
assert_eq!(ColumnLayout::new(-64, 384), Ok(ColumnLayout::VANILLA));
|
||||
assert_eq!(ColumnLayout::default(), ColumnLayout::VANILLA);
|
||||
// To The Max's Overworld: the highest top vanilla allows.
|
||||
let tall = ColumnLayout::new(-64, 2096).unwrap();
|
||||
assert_eq!((tall.sections(), tall.max_y()), (131, 2032));
|
||||
assert!(ColumnLayout::new(-2032, 4064).is_ok());
|
||||
for (min_y, height) in [(-64, 2112), (-2048, 64), (-60, 384), (0, 250), (0, 0)] {
|
||||
assert!(
|
||||
ColumnLayout::new(min_y, height).is_err(),
|
||||
"{min_y} {height}"
|
||||
);
|
||||
}
|
||||
assert_eq!(tall.section_index(-65), None);
|
||||
assert_eq!(tall.section_index(-64), Some(0));
|
||||
assert_eq!(tall.section_index(2031), Some(130));
|
||||
assert_eq!(tall.section_index(2032), None);
|
||||
assert_eq!(tall.section_y(130), 2016);
|
||||
assert_eq!(
|
||||
LevelShape::new(-64, 384, 384, true),
|
||||
Ok(LevelShape::OVERWORLD)
|
||||
);
|
||||
assert_eq!(LevelShape::new(0, 256, 128, false), Ok(LevelShape::NETHER));
|
||||
assert!(LevelShape::new(0, 256, 257, true).is_err());
|
||||
assert!(LevelShape::new(-60, 384, 384, true).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vanilla_levels_keep_the_overworld_column() {
|
||||
for shape in [LevelShape::OVERWORLD, LevelShape::NETHER, LevelShape::END] {
|
||||
assert_eq!(shape.column(), ColumnLayout::VANILLA, "{shape:?}");
|
||||
}
|
||||
let tall = LevelShape::new(-64, 2096, 2096, true).unwrap();
|
||||
assert_eq!(tall.column(), ColumnLayout::new(-64, 2096).unwrap());
|
||||
let deep = LevelShape::new(-512, 256, 256, false).unwrap();
|
||||
assert_eq!(deep.column(), ColumnLayout::new(-512, 832).unwrap());
|
||||
assert!(LevelShape::NETHER.has_section(15) && !LevelShape::NETHER.has_section(16));
|
||||
assert!(!LevelShape::NETHER.has_section(-1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tall_column_stores_its_whole_height() {
|
||||
let layout = ColumnLayout::new(-64, 2096).unwrap();
|
||||
let mut chunk = Chunk::with_layout(0, 0, layout);
|
||||
assert_eq!(
|
||||
(chunk.min_y(), chunk.max_y(), chunk.sections.len()),
|
||||
(-64, 2032, 131)
|
||||
);
|
||||
let stone = 1;
|
||||
assert_eq!(chunk.set(5, 2031, 6, stone), 0);
|
||||
assert_eq!(chunk.set(5, 2032, 6, stone), stone, "above the column");
|
||||
assert_eq!(chunk.get(5, 2031, 6), stone);
|
||||
assert_eq!(chunk.get(5, 2032, 6), 0);
|
||||
assert_eq!(chunk.world_surface(5, 6, |v| v == 0), 2032);
|
||||
assert_eq!(chunk.world_surface(0, 0, |v| v == 0), -64);
|
||||
assert_eq!(chunk.highest(0, |y| y < 1000), None);
|
||||
chunk.set(5, 999, 6, stone);
|
||||
assert_eq!(chunk.highest(0, |y| y < 1000), Some(999));
|
||||
assert_eq!(chunk.light(0, 2100, 0), 0xF0);
|
||||
assert_eq!(chunk.light(0, -65, 0), 0);
|
||||
chunk.set_biome_cell(0, 130 * 4 + 3, 0, 9);
|
||||
assert_eq!(chunk.biome(0, 5000, 0), 9);
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,24 @@ pub enum BlockEntity {
|
||||
Shelf(Box<Shelf>),
|
||||
/// `ChiseledBookShelfBlockEntity`: a `Container` of six one-book slots.
|
||||
ChiseledBookshelf(Box<ChiseledBookshelf>),
|
||||
/// `PotentSulfurBlockEntity` (`game/potent_sulfur.rs`).
|
||||
PotentSulfur(PotentSulfur),
|
||||
}
|
||||
/// `PotentSulfurBlockEntity`: the seconds left to the geyser's next change of
|
||||
/// state (`waitingCountdown`, saved as `countdown`) and the game time of its
|
||||
/// last eruption block event, which the client's plume counts from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct PotentSulfur {
|
||||
pub waiting_countdown: i32,
|
||||
pub eruption_tick: i64,
|
||||
}
|
||||
impl Default for PotentSulfur {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
waiting_countdown: -1,
|
||||
eruption_tick: -1,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `BrushableBlockEntity`: the loot table is resolved on the first brush.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -228,6 +246,8 @@ pub struct SignText {
|
||||
/// `DyeColor` id.
|
||||
pub color: u8,
|
||||
pub glowing: bool,
|
||||
/// The `click_event` in each line's component style, as saved.
|
||||
pub click: [Option<Tag>; 4],
|
||||
}
|
||||
/// `SignBlockEntity` / `HangingSignBlockEntity` data.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
@@ -236,6 +256,8 @@ pub struct SignData {
|
||||
pub front: SignText,
|
||||
pub back: SignText,
|
||||
pub waxed: bool,
|
||||
/// `allow_op_features`: the sign's click events may run commands.
|
||||
pub allow_op_features: bool,
|
||||
}
|
||||
/// `VibrationInfo`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
|
||||
@@ -82,6 +82,9 @@ impl crate::light::LightWorld for ChunkMap {
|
||||
fn set_section_light(&mut self, cx: i32, s: usize, cz: i32, light: crate::chunk::Light) {
|
||||
self.columns.set_section_light(cx, s, cz, light);
|
||||
}
|
||||
fn layout(&self) -> crate::chunk::ColumnLayout {
|
||||
self.columns.layout()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -305,7 +305,7 @@ impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
|
||||
/// From the section's palette, without reading its blocks; a palette
|
||||
/// of the whole registry reads every cell.
|
||||
fn may_reach_out(&mut self, chunk: Self::Chunk, section_y: i32) -> io::Result<bool> {
|
||||
let blocks = usize::try_from(section_y - (crate::chunk::MIN_Y >> 4))
|
||||
let blocks = usize::try_from(section_y - (chunk.min_y() >> 4))
|
||||
.ok()
|
||||
.and_then(|s| chunk.sections.get(s))
|
||||
.map(|section| §ion.blocks);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! from the data-driven `context_int_provider`/`context_float_provider` files.
|
||||
pub use crate::chunk::block_entity::{
|
||||
Banner, Beacon, BlockEntity, Brushable, Campfire, ChiseledBookshelf, Crafter, CreakingHeart,
|
||||
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, Shelf, Skull, SkullSkin,
|
||||
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, PotentSulfur, Shelf, Skull, SkullSkin,
|
||||
};
|
||||
use crate::items::ItemStack;
|
||||
use std::collections::HashMap;
|
||||
@@ -306,6 +306,9 @@ impl BlockEntity {
|
||||
if name == "minecraft:chiseled_bookshelf" {
|
||||
return Some(Self::ChiseledBookshelf(Box::default()));
|
||||
}
|
||||
if name == "minecraft:potent_sulfur" {
|
||||
return Some(Self::PotentSulfur(PotentSulfur::default()));
|
||||
}
|
||||
let size = match name {
|
||||
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => 27,
|
||||
n if n.ends_with("copper_chest") => 27,
|
||||
@@ -349,6 +352,7 @@ impl BlockEntity {
|
||||
Self::Campfire(_) => "minecraft:campfire",
|
||||
Self::ChiseledBookshelf(_) => "minecraft:chiseled_bookshelf",
|
||||
Self::Shelf(_) => "minecraft:shelf",
|
||||
Self::PotentSulfur(_) => "minecraft:potent_sulfur",
|
||||
}
|
||||
}
|
||||
pub fn contents(&self) -> Vec<ItemStack> {
|
||||
@@ -367,6 +371,7 @@ impl BlockEntity {
|
||||
| Self::CommandBlock(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::PotentSulfur(_)
|
||||
| Self::DaylightDetector
|
||||
| Self::Brushable(_) => Vec::new(),
|
||||
Self::Lectern(l) => l.book.iter().cloned().collect(),
|
||||
@@ -391,6 +396,7 @@ impl BlockEntity {
|
||||
| Self::Banner(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::PotentSulfur(_)
|
||||
| Self::DaylightDetector
|
||||
| Self::Brushable(_) => &[],
|
||||
Self::Crafter(c) => &c.items,
|
||||
@@ -419,6 +425,7 @@ impl BlockEntity {
|
||||
| Self::Banner(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::PotentSulfur(_)
|
||||
| Self::DaylightDetector
|
||||
| Self::Brushable(_) => &mut [],
|
||||
Self::Crafter(c) => &mut c.items,
|
||||
|
||||
@@ -320,6 +320,10 @@ fn thread_name() -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// What starts the stderr line naming a saved report, the path follows: the Launch API's
|
||||
/// promise to launchers (docs/api/launch.md), so it never changes within a line of that API.
|
||||
pub const SAVED_LINE: &str = "Horizon: crash report saved to ";
|
||||
|
||||
/// Writes the report and tells where it is; the log gets the error too.
|
||||
fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
|
||||
if REPORTED.swap(true, Ordering::SeqCst) {
|
||||
@@ -343,7 +347,7 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
|
||||
crate::telemetry::keep_crash(home, &report);
|
||||
match report.save(home) {
|
||||
Ok(path) => {
|
||||
eprintln!("Horizon: crash report saved to {}", path.display());
|
||||
eprintln!("{SAVED_LINE}{}", path.display());
|
||||
Some(path)
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
@@ -423,9 +423,31 @@ pub struct DataStore {
|
||||
pub predicates: BTreeMap<String, serde_json::Value>,
|
||||
/// `item_modifier` files, for `/item modify`.
|
||||
pub item_modifiers: BTreeMap<String, serde_json::Value>,
|
||||
/// The content tables' raw files from the packs after the built-in one:
|
||||
/// `recipe/`, `loot_table/`, `advancement/` and `tags/…/` files, by
|
||||
/// `<kind-dir>/<ns>:<path>`, each id's files in pack order. The tables
|
||||
/// merge them over what they read from the resource root.
|
||||
pub files: BTreeMap<String, Vec<Vec<u8>>>,
|
||||
/// Functions that failed to parse (id, message), for logging.
|
||||
pub errors: Vec<(String, String)>,
|
||||
}
|
||||
/// `<kind-dir>/<ns>:<path>` for a pack file the content tables read:
|
||||
/// `recipe/`, `loot_table/`, `advancement/` or `tags/<sub>/`, where the id
|
||||
/// keeps the sub path so a nested tag kind stays addressable.
|
||||
fn content_id(file: &str) -> Option<String> {
|
||||
let rest = file.strip_prefix("data/")?;
|
||||
let (ns, rest) = rest.split_once('/')?;
|
||||
let (kind, rest) = rest.split_once('/')?;
|
||||
let kind = match kind {
|
||||
"recipe" | "loot_table" | "advancement" => kind,
|
||||
"tags" => {
|
||||
let (sub, name) = rest.rsplit_once('/')?;
|
||||
return Some(format!("tags/{sub}/{ns}:{}", name.strip_suffix(".json")?));
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(format!("{kind}/{ns}:{}", rest.strip_suffix(".json")?))
|
||||
}
|
||||
/// `ns:path` for a file `data/<ns>/<kind>/<path>.<ext>`.
|
||||
fn resource_id(file: &str, kind: &str, ext: &str) -> Option<String> {
|
||||
let rest = file.strip_prefix("data/")?;
|
||||
@@ -440,7 +462,22 @@ impl DataStore {
|
||||
pub fn load(packs: &[&DataPack]) -> Self {
|
||||
let mut s = Self::default();
|
||||
for pack in packs {
|
||||
for f in pack.list_kinds(&["function", "item_modifier", "predicate", "tags/function"]) {
|
||||
// `tags` walks the whole tags tree, so a `tags/function` file is
|
||||
// listed by both kinds: handle every file once.
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let builtin = pack.id == "vanilla";
|
||||
for f in pack.list_kinds(&[
|
||||
"advancement",
|
||||
"function",
|
||||
"item_modifier",
|
||||
"loot_table",
|
||||
"predicate",
|
||||
"recipe",
|
||||
"tags",
|
||||
]) {
|
||||
if !seen.insert(f.clone()) {
|
||||
continue;
|
||||
}
|
||||
if let Some(id) = resource_id(&f, "function", ".mcfunction")
|
||||
&& let Some(bytes) = pack.read(&f)
|
||||
{
|
||||
@@ -470,6 +507,14 @@ impl DataStore {
|
||||
&& let Ok(v) = serde_json::from_slice(&bytes)
|
||||
{
|
||||
s.item_modifiers.insert(id, v);
|
||||
} else if !builtin
|
||||
&& let Some(id) = content_id(&f)
|
||||
&& let Some(bytes) = pack.read(&f)
|
||||
{
|
||||
// The built-in pack's content files are the resource
|
||||
// root's own, which the tables read from disk; only the
|
||||
// world's packs come through here.
|
||||
s.files.entry(id).or_default().push(bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ struct Out {
|
||||
@location(9) light_color: vec4<f32>,
|
||||
@location(10) @interpolate(flat) weather_rect: vec4<f32>,
|
||||
// The `GLINT_TEXTURING` sample for overlay 7 (item foil), or the model UV
|
||||
// `ARMOR_ENTITY_GLINT_TEXTURING` scrolls for overlay 13 (armour).
|
||||
// `ARMOR_ENTITY_GLINT_TEXTURING` scrolls for overlay 36 (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,
|
||||
@@ -254,8 +254,8 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
|
||||
// Enchantment glint: item.fsh's GLINT path, sampled from
|
||||
// enchanted_glint_item at the `GLINT_TEXTURING` UV.
|
||||
color = glint_over(color, textureSample(glint_tex, glint_sampler, in.glint_uv));
|
||||
} else if in.overlay == 13u {
|
||||
// Enchanted armour: enchanted_glint_armor at the scrolling model UV.
|
||||
} else if in.overlay == 36u {
|
||||
// Enchanted armour (mobs::render::ARMOR_GLINT): enchanted_glint_armor at the scrolling model UV.
|
||||
color = glint_over(
|
||||
color,
|
||||
textureSample(glint_armor_tex, glint_sampler, armor_glint_uv(in.glint_uv)),
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::{
|
||||
graphics::GraphicsApi,
|
||||
hud::{HudBatch, HudRenderer},
|
||||
mesher::{SectionMesh, Sides, TerrainVertex, Vertex},
|
||||
section_mask::SectionMask,
|
||||
sprites::SpriteAnimation,
|
||||
};
|
||||
use glam::{DVec3, Mat4, Vec3, Vec4};
|
||||
@@ -105,8 +106,6 @@ mod view;
|
||||
mod wgsl_prefix;
|
||||
mod world_post;
|
||||
|
||||
/// A column whose every section has its mesh (`Renderer::lod_detail_columns`).
|
||||
const COMPLETE_COLUMN: u32 = (1 << (crate::chunk::HEIGHT / 16)) - 1;
|
||||
/// A section's instance: its camera-relative origin and, in `w`, the step of
|
||||
/// its fixed-point UVs (`terrain.wgsl`'s `terrain_uv`).
|
||||
/// The world pass again after occlusion culling's test: it loads both
|
||||
@@ -517,7 +516,7 @@ pub struct Renderer {
|
||||
lod_retiring: crate::fast_hash::FxHashSet<SectionKey>,
|
||||
/// Finished tile meshes `upload_lods` has yet to upload.
|
||||
lod_pending: Option<lod::Pending>,
|
||||
detail_columns: crate::fast_hash::FxHashMap<(i32, i32), u32>,
|
||||
detail_columns: crate::fast_hash::FxHashMap<(i32, i32), SectionMask>,
|
||||
/// Bumped whenever a column becomes or stops being complete.
|
||||
detail_revision: u64,
|
||||
arena: arena::Arena,
|
||||
@@ -556,6 +555,9 @@ pub struct Renderer {
|
||||
block_entity_revision: u64,
|
||||
/// VisGraph face connectivity per meshed section (cave culling).
|
||||
occlusion: occlusion::Occlusion,
|
||||
/// The column `occlusion`, `sections` and `detail_columns` count in: the
|
||||
/// level's (`clear_sections`).
|
||||
layout: crate::chunk::ColumnLayout,
|
||||
/// Resolution scale, FXAA and TAA (Horizon settings).
|
||||
world_post: world_post::Post,
|
||||
/// HDR output (Horizon setting): `Some` while the swapchain is HDR.
|
||||
@@ -588,6 +590,10 @@ pub struct Renderer {
|
||||
readback: readback::Readback,
|
||||
pub info: wgpu::AdapterInfo,
|
||||
pub suspended: bool,
|
||||
/// Consecutive frames whose surface acquisition failed validation. The
|
||||
/// first gets the swapchain a fresh configuration; later ones reattach
|
||||
/// the window, as a lost surface does.
|
||||
acquire_validation_failures: u32,
|
||||
pub drawn_sections: usize,
|
||||
/// Last frame's section draws (face ranges and translucent sections),
|
||||
/// and the draw calls the backend issued for them.
|
||||
@@ -790,9 +796,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.
|
||||
/// `allow_nonconformant` offers the adapters wgpu hides for reporting no Vulkan
|
||||
/// conformance version: Mesa's hasvk, which drives Ivy Bridge and Haswell,
|
||||
/// reports 0.0.0.0 while it works. `open_device` asks for them only on a second
|
||||
/// try at a backend that had no adapter at all.
|
||||
fn instance_descriptor(
|
||||
backends: wgpu::Backends,
|
||||
window: Option<&Arc<Window>>,
|
||||
allow_nonconformant: bool,
|
||||
) -> wgpu::InstanceDescriptor {
|
||||
let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle();
|
||||
if let Some(window) = window {
|
||||
@@ -805,7 +816,13 @@ fn instance_descriptor(
|
||||
.flags
|
||||
.remove(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL);
|
||||
}
|
||||
descriptor.with_env()
|
||||
let mut descriptor = descriptor.with_env();
|
||||
if allow_nonconformant {
|
||||
descriptor
|
||||
.flags
|
||||
.insert(wgpu::InstanceFlags::ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER);
|
||||
}
|
||||
descriptor
|
||||
}
|
||||
/// The window's display connection, so the OpenGL backend creates its EGL
|
||||
/// display from the X11 or Wayland connection the window lives on. Without
|
||||
@@ -944,96 +961,147 @@ async fn open_device(
|
||||
if attempt > 0 {
|
||||
log::warn!("Horizon renderer: trying {name} ({})", failures.join("; "));
|
||||
}
|
||||
let instance = wgpu::Instance::new(instance_descriptor(backends, Some(window)));
|
||||
let surface = if offscreen {
|
||||
None
|
||||
} else {
|
||||
match instance.create_surface(window.clone()) {
|
||||
Ok(surface) => Some(surface),
|
||||
Err(e) => {
|
||||
failures.push(format!("{name} surface: {e}"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
let adapter = match instance
|
||||
.request_adapter(&wgpu::RequestAdapterOptions {
|
||||
power_preference: wgpu::PowerPreference::HighPerformance,
|
||||
compatible_surface: surface.as_ref(),
|
||||
force_fallback_adapter: false,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(adapter) => adapter,
|
||||
Err(e) => {
|
||||
failures.push(format!("{name}: {e}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
describe_adapter(&adapter);
|
||||
let info = adapter.get_info();
|
||||
let limits = adapter.limits();
|
||||
if let Err(e) = check_layers(&limits, layers) {
|
||||
failures.push(format!("{name}: {e}"));
|
||||
continue;
|
||||
}
|
||||
// Mappable primary buffers: section pages that no longer fit in
|
||||
// video memory go to system memory. Shader packs' 16-bit normalized buffers (render
|
||||
// targets with the adapter's own format features) and R11F_G11F_B10F: a float
|
||||
// stand-in neither clamps nor drops NaN.
|
||||
let features = timing::GpuTimer::features(&adapter)
|
||||
| cull::Cull::features(&adapter)
|
||||
| (adapter.features()
|
||||
& (wgpu::Features::INDIRECT_FIRST_INSTANCE
|
||||
| wgpu::Features::MAPPABLE_PRIMARY_BUFFERS
|
||||
| wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
|
||||
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES
|
||||
| wgpu::Features::RG11B10UFLOAT_RENDERABLE));
|
||||
let driver = format!("{} {}", info.driver, info.driver_info);
|
||||
startup_step(format_args!(
|
||||
"requesting the device (driver {}) with features [{features}] and the adapter's \
|
||||
limits (buffers up to {} bytes, storage bindings up to {} bytes, {} texture layers)",
|
||||
match driver.trim() {
|
||||
"" => "unnamed",
|
||||
driver => driver,
|
||||
},
|
||||
limits.max_buffer_size,
|
||||
limits.max_storage_buffer_binding_size,
|
||||
limits.max_texture_array_layers,
|
||||
));
|
||||
let desc = wgpu::DeviceDescriptor {
|
||||
label: Some("Horizon device"),
|
||||
required_features: features,
|
||||
required_limits: limits,
|
||||
..Default::default()
|
||||
};
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let opened = match info.backend {
|
||||
wgpu::Backend::Vulkan => horizon_hdr::open_vulkan_device(&adapter, &desc),
|
||||
_ => None,
|
||||
};
|
||||
#[cfg(target_os = "macos")]
|
||||
let opened = None;
|
||||
let opened = match opened {
|
||||
Some(opened) => opened,
|
||||
None => adapter.request_device(&desc).await,
|
||||
};
|
||||
match opened {
|
||||
Ok((device, queue)) => {
|
||||
return Ok(Opened {
|
||||
instance,
|
||||
surface,
|
||||
adapter,
|
||||
device,
|
||||
queue,
|
||||
});
|
||||
}
|
||||
Err(e) => failures.push(format!("{name} device: {e}")),
|
||||
match open_with_retry(backends, window, offscreen, layers).await {
|
||||
Ok(opened) => return Ok(opened),
|
||||
Err(e) => failures.push(e),
|
||||
}
|
||||
}
|
||||
Err(format!("{NO_GPU_DRIVER} ({})", failures.join("; ")).into())
|
||||
}
|
||||
/// Why a backend gave no device: no adapter at all, which a second try with the
|
||||
/// adapters wgpu hides can still find, or a failure after an adapter.
|
||||
enum NoDevice {
|
||||
NoAdapter(String),
|
||||
Failed(String),
|
||||
}
|
||||
/// Opens a device on one backend, and on a backend whose adapters wgpu all hid
|
||||
/// for reporting no Vulkan conformance version tries it again with them shown:
|
||||
/// Mesa's hasvk drives Ivy Bridge and Haswell, works, and reports 0.0.0.0, so
|
||||
/// without this the player is told they have no graphics driver while Vulkan
|
||||
/// runs (a `gl not compatible with provided surface` fallback, or nothing, is
|
||||
/// all they get). Returns why the backend gave no device.
|
||||
async fn open_with_retry(
|
||||
backends: wgpu::Backends,
|
||||
window: &Arc<Window>,
|
||||
offscreen: bool,
|
||||
layers: u32,
|
||||
) -> std::result::Result<Opened, String> {
|
||||
match open_backend(backends, window, offscreen, false, layers).await {
|
||||
Err(NoDevice::NoAdapter(hidden)) => {
|
||||
log::warn!(
|
||||
"Horizon renderer: {} has no conformant adapter ({hidden}); asking again for the \
|
||||
ones wgpu hides as non-conformant",
|
||||
backends_name(backends)
|
||||
);
|
||||
match open_backend(backends, window, offscreen, true, layers).await {
|
||||
Ok(opened) => {
|
||||
log::warn!(
|
||||
"Horizon renderer: using {} although its driver reports no Vulkan \
|
||||
conformance version",
|
||||
opened.adapter.get_info().name
|
||||
);
|
||||
Ok(opened)
|
||||
}
|
||||
Err(NoDevice::NoAdapter(e)) | Err(NoDevice::Failed(e)) => Err(e),
|
||||
}
|
||||
}
|
||||
Err(NoDevice::Failed(e)) => Err(e),
|
||||
Ok(opened) => Ok(opened),
|
||||
}
|
||||
}
|
||||
/// Opens a device on `backends` alone: its surface, adapter and device, or what
|
||||
/// stopped it. `allow_nonconformant` is `open_with_retry`'s second try.
|
||||
async fn open_backend(
|
||||
backends: wgpu::Backends,
|
||||
window: &Arc<Window>,
|
||||
offscreen: bool,
|
||||
allow_nonconformant: bool,
|
||||
layers: u32,
|
||||
) -> std::result::Result<Opened, NoDevice> {
|
||||
let name = backends_name(backends);
|
||||
let instance = wgpu::Instance::new(instance_descriptor(
|
||||
backends,
|
||||
Some(window),
|
||||
allow_nonconformant,
|
||||
));
|
||||
let surface = if offscreen {
|
||||
None
|
||||
} else {
|
||||
match instance.create_surface(window.clone()) {
|
||||
Ok(surface) => Some(surface),
|
||||
Err(e) => return Err(NoDevice::Failed(format!("{name} surface: {e}"))),
|
||||
}
|
||||
};
|
||||
let adapter = match instance
|
||||
.request_adapter(&wgpu::RequestAdapterOptions {
|
||||
power_preference: wgpu::PowerPreference::HighPerformance,
|
||||
compatible_surface: surface.as_ref(),
|
||||
force_fallback_adapter: false,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(adapter) => adapter,
|
||||
Err(e) => return Err(NoDevice::NoAdapter(format!("{name}: {e}"))),
|
||||
};
|
||||
describe_adapter(&adapter);
|
||||
let info = adapter.get_info();
|
||||
let limits = adapter.limits();
|
||||
if let Err(e) = check_layers(&limits, layers) {
|
||||
return Err(NoDevice::Failed(format!("{name}: {e}")));
|
||||
}
|
||||
// Mappable primary buffers: section pages that no longer fit in
|
||||
// video memory go to system memory. Shader packs' 16-bit normalized buffers (render
|
||||
// targets with the adapter's own format features) and R11F_G11F_B10F: a float
|
||||
// stand-in neither clamps nor drops NaN.
|
||||
let features = timing::GpuTimer::features(&adapter)
|
||||
| cull::Cull::features(&adapter)
|
||||
| (adapter.features()
|
||||
& (wgpu::Features::INDIRECT_FIRST_INSTANCE
|
||||
| wgpu::Features::MAPPABLE_PRIMARY_BUFFERS
|
||||
| wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
|
||||
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES
|
||||
| wgpu::Features::RG11B10UFLOAT_RENDERABLE));
|
||||
let driver = format!("{} {}", info.driver, info.driver_info);
|
||||
startup_step(format_args!(
|
||||
"requesting the device (driver {}) with features [{features}] and the adapter's \
|
||||
limits (buffers up to {} bytes, storage bindings up to {} bytes, {} texture layers)",
|
||||
match driver.trim() {
|
||||
"" => "unnamed",
|
||||
driver => driver,
|
||||
},
|
||||
limits.max_buffer_size,
|
||||
limits.max_storage_buffer_binding_size,
|
||||
limits.max_texture_array_layers,
|
||||
));
|
||||
let desc = wgpu::DeviceDescriptor {
|
||||
label: Some("Horizon device"),
|
||||
required_features: features,
|
||||
required_limits: limits,
|
||||
..Default::default()
|
||||
};
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let opened = match info.backend {
|
||||
wgpu::Backend::Vulkan => horizon_hdr::open_vulkan_device(&adapter, &desc),
|
||||
_ => None,
|
||||
};
|
||||
#[cfg(target_os = "macos")]
|
||||
let opened = None;
|
||||
let opened = match opened {
|
||||
Some(opened) => opened,
|
||||
None => adapter.request_device(&desc).await,
|
||||
};
|
||||
match opened {
|
||||
Ok((device, queue)) => Ok(Opened {
|
||||
instance,
|
||||
surface,
|
||||
adapter,
|
||||
device,
|
||||
queue,
|
||||
}),
|
||||
Err(e) => Err(NoDevice::Failed(format!("{name} device: {e}"))),
|
||||
}
|
||||
}
|
||||
/// What to tell a player whose system offers no usable graphics adapter.
|
||||
/// Vulkan is loaded at run time (the game links no graphics library), so a
|
||||
/// missing driver shows here rather than as a missing-DLL dialog.
|
||||
@@ -2528,10 +2596,19 @@ impl Renderer {
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &TERRAIN_ATTRIBUTES,
|
||||
})];
|
||||
let info = adapter.get_info();
|
||||
let min_sections = if flat::is_broken_intel_vulkan(&info)
|
||||
|| std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1")
|
||||
{
|
||||
0
|
||||
} else {
|
||||
flat::MIN_SECTIONS
|
||||
};
|
||||
flat::Flat::new(
|
||||
&device,
|
||||
&bindings,
|
||||
arena.page_vertices(),
|
||||
min_sections,
|
||||
|layout, label, fragment, blend| {
|
||||
pipeline_in(
|
||||
layout,
|
||||
@@ -2744,6 +2821,7 @@ impl Renderer {
|
||||
block_entities: Default::default(),
|
||||
block_entity_revision: next_block_entity_revision(),
|
||||
occlusion: occlusion::Occlusion::new(crate::chunk::MIN_Y, crate::chunk::HEIGHT / 16),
|
||||
layout: crate::chunk::ColumnLayout::VANILLA,
|
||||
world_post,
|
||||
hdr: None,
|
||||
hdr_output,
|
||||
@@ -2761,6 +2839,7 @@ impl Renderer {
|
||||
readback: readback::Readback::default(),
|
||||
info,
|
||||
suspended: !drawable_size(size.width, size.height, max_side),
|
||||
acquire_validation_failures: 0,
|
||||
drawn_sections: 0,
|
||||
section_draws: 0,
|
||||
section_draw_calls: 0,
|
||||
@@ -3180,7 +3259,8 @@ impl Renderer {
|
||||
pub fn block_entity_revision(&self) -> u64 {
|
||||
self.block_entity_revision
|
||||
}
|
||||
pub fn clear_sections(&mut self) {
|
||||
/// Drops every section, for a level whose chunks hold `layout`.
|
||||
pub fn clear_sections(&mut self, layout: crate::chunk::ColumnLayout) {
|
||||
self.block_entities.clear();
|
||||
self.block_entity_revision = next_block_entity_revision();
|
||||
self.sections.clear();
|
||||
@@ -3196,12 +3276,23 @@ impl Renderer {
|
||||
}
|
||||
self.sorted_indices.clear();
|
||||
self.translucency_cursor = 0;
|
||||
self.occlusion.clear();
|
||||
if layout == self.layout {
|
||||
self.occlusion.clear();
|
||||
} else {
|
||||
self.layout = layout;
|
||||
self.occlusion = occlusion::Occlusion::new(layout.min_y(), layout.sections() as i32);
|
||||
self.sections.set_min_y(layout.min_y());
|
||||
}
|
||||
}
|
||||
/// A column whose every section has its mesh (`lod_detail_columns`).
|
||||
fn complete_column(&self) -> SectionMask {
|
||||
SectionMask::below(self.layout.sections())
|
||||
}
|
||||
pub fn lod_detail_columns(&self) -> impl Iterator<Item = (i32, i32)> + '_ {
|
||||
let complete = self.complete_column();
|
||||
self.detail_columns
|
||||
.iter()
|
||||
.filter_map(move |(&column, &mask)| (mask == COMPLETE_COLUMN).then_some(column))
|
||||
.filter_map(move |(&column, &mask)| (mask == complete).then_some(column))
|
||||
}
|
||||
/// Changes whenever `lod_detail_columns` does.
|
||||
pub fn lod_detail_revision(&self) -> u64 {
|
||||
@@ -3210,16 +3301,22 @@ impl Renderer {
|
||||
/// Whether the section at `key` has its mesh, for `lod_detail_columns`.
|
||||
fn set_detail(&mut self, key: SectionKey, meshed: bool) {
|
||||
let column = (key.0, key.2);
|
||||
let mask = self.detail_columns.get(&column).copied().unwrap_or(0);
|
||||
let next = if meshed {
|
||||
mask | 1 << key.1
|
||||
let mask = self
|
||||
.detail_columns
|
||||
.get(&column)
|
||||
.copied()
|
||||
.unwrap_or_default();
|
||||
let mut next = mask;
|
||||
if meshed {
|
||||
next.insert(key.1 as usize);
|
||||
} else {
|
||||
mask & !(1 << key.1)
|
||||
};
|
||||
if (mask == COMPLETE_COLUMN) != (next == COMPLETE_COLUMN) {
|
||||
next.remove(key.1 as usize);
|
||||
}
|
||||
let complete = self.complete_column();
|
||||
if (mask == complete) != (next == complete) {
|
||||
self.detail_revision += 1;
|
||||
}
|
||||
if next == 0 {
|
||||
if next.is_empty() {
|
||||
self.detail_columns.remove(&column);
|
||||
} else {
|
||||
self.detail_columns.insert(column, next);
|
||||
@@ -3232,12 +3329,12 @@ impl Renderer {
|
||||
let mut released = Vec::new();
|
||||
let mut block_entities = false;
|
||||
for (x, z) in columns {
|
||||
if self.detail_columns.remove(&(x, z)) == Some(COMPLETE_COLUMN) {
|
||||
if self.detail_columns.remove(&(x, z)) == Some(self.complete_column()) {
|
||||
self.detail_revision += 1;
|
||||
}
|
||||
self.occlusion.remove_column(x, z);
|
||||
let retiring = !self.lod_cutout.is_empty() && self.lod_cutout.contains(&(x, z));
|
||||
for sy in 0..crate::chunk::SECTIONS as i32 {
|
||||
for sy in 0..self.layout.sections() as i32 {
|
||||
let key = (x, sy, z);
|
||||
block_entities |= self.block_entities.remove(&key).is_some();
|
||||
if retiring {
|
||||
@@ -3744,9 +3841,32 @@ impl Renderer {
|
||||
return Ok(false);
|
||||
}
|
||||
Some(wgpu::CurrentSurfaceTexture::Validation) => {
|
||||
return Err("surface acquisition failed validation".into());
|
||||
// Acquire caught a validation error: the swapchain cannot be
|
||||
// used as configured (wgpu reports a configuration raced with
|
||||
// a submission this way). Give it a fresh configuration like
|
||||
// an Outdated one, and once that has not helped, reattach the
|
||||
// window like a Lost one — a fullscreen loading screen then
|
||||
// skips frames instead of failing every one of them.
|
||||
if self.acquire_validation_failures == 0 {
|
||||
if let Some(surface) = &self.surface {
|
||||
Self::configure(
|
||||
&self.device,
|
||||
surface,
|
||||
&self.surface_config(),
|
||||
self.hdr.as_ref(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
self.recreate_surface()?;
|
||||
}
|
||||
self.acquire_validation_failures =
|
||||
self.acquire_validation_failures.saturating_add(1);
|
||||
return Ok(false);
|
||||
}
|
||||
};
|
||||
if surface_frame.is_some() {
|
||||
self.acquire_validation_failures = 0;
|
||||
}
|
||||
let size = [self.config.width, self.config.height];
|
||||
let target_texture = match (&surface_frame, &mut self.hdr) {
|
||||
(Some(_), Some(hdr)) => hdr.frame(&self.device, size),
|
||||
@@ -3927,7 +4047,7 @@ impl Renderer {
|
||||
planes: frustum(cull_proj),
|
||||
};
|
||||
let camera = frame.camera;
|
||||
let camera_section = camera_section(camera);
|
||||
let camera_section = camera_section(camera, self.layout.min_y());
|
||||
// Solid quads facing away from the eye are left out: the bob moves
|
||||
// the eye off `camera`, so it is found from the projection.
|
||||
let eye = projection_center(cull_proj).filter(|_| self.sides_on);
|
||||
@@ -4141,7 +4261,7 @@ impl Renderer {
|
||||
let scheduled = (i + count - self.translucency_cursor) % count < budget;
|
||||
let origin = [
|
||||
v.key.0 * 16,
|
||||
crate::chunk::MIN_Y + v.key.1 * 16,
|
||||
self.layout.min_y() + v.key.1 * 16,
|
||||
v.key.2 * 16,
|
||||
];
|
||||
if (!s.sort.is_sorted() || nearby || scheduled)
|
||||
@@ -4244,7 +4364,7 @@ impl Renderer {
|
||||
{
|
||||
let origin = [
|
||||
v.key.0 * 16,
|
||||
crate::chunk::MIN_Y + v.key.1 * 16,
|
||||
self.layout.min_y() + v.key.1 * 16,
|
||||
v.key.2 * 16,
|
||||
];
|
||||
if let Some(indices) =
|
||||
@@ -5420,6 +5540,16 @@ impl Renderer {
|
||||
if let (Some(hdr), Some(f)) = (&self.hdr, &surface_frame) {
|
||||
hdr.present(&self.queue, &mut encoder, &f.texture, !frame.title);
|
||||
}
|
||||
// With HDR on, a full-frame screenshot gets an HDR10 copy beside it.
|
||||
let hdr_readback = readback
|
||||
.as_ref()
|
||||
.filter(|(request, _)| request.size.is_none())
|
||||
.zip(self.hdr.as_ref())
|
||||
.and_then(|(_, hdr)| {
|
||||
let texture = hdr.capture(&self.device, &self.queue, &mut encoder, !frame.title)?;
|
||||
let size = [self.config.width, self.config.height];
|
||||
Some(copy_frame(&self.device, &mut encoder, &texture, size))
|
||||
});
|
||||
let commands = crate::spans::time("encode", || encoder.finish());
|
||||
let first_frame = self.first_frame == FirstFrame::Submit;
|
||||
if first_frame {
|
||||
@@ -5438,6 +5568,16 @@ impl Renderer {
|
||||
timer.submitted(&self.queue);
|
||||
}
|
||||
if let Some((request, buffer)) = readback {
|
||||
if let Some(hdr_buffer) = hdr_readback {
|
||||
let hdr_path = hdr_screenshot_path(&request.path);
|
||||
match self.write_hdr_screenshot(&hdr_path, &hdr_buffer) {
|
||||
Ok(()) => log::info!("Saved HDR screenshot to {}", hdr_path.display()),
|
||||
Err(e) => log::warn!(
|
||||
"Horizon renderer: HDR screenshot {}: {e}",
|
||||
hdr_path.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
self.screenshot_result = Some(
|
||||
self.write_screenshot(&request, &buffer)
|
||||
.map_err(|e| e.to_string()),
|
||||
@@ -5546,11 +5686,8 @@ impl Renderer {
|
||||
None
|
||||
}
|
||||
}
|
||||
fn write_screenshot(
|
||||
&self,
|
||||
request: &ScreenshotRequest,
|
||||
buffer: &wgpu::Buffer,
|
||||
) -> Result<[u32; 2]> {
|
||||
/// The pixels `copy_frame` read back into `buffer`, 4 bytes each, row after row.
|
||||
fn read_frame(&self, buffer: &wgpu::Buffer) -> Result<Vec<[u8; 4]>> {
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
buffer.map_async(wgpu::MapMode::Read, .., move |r| {
|
||||
let _ = sender.send(r);
|
||||
@@ -5563,28 +5700,34 @@ impl Renderer {
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())?;
|
||||
let (width, height, row) = (
|
||||
self.config.width,
|
||||
self.config.height,
|
||||
self.config.width as usize,
|
||||
self.config.height as usize,
|
||||
padded_row(self.config.width) as usize,
|
||||
);
|
||||
let mut pixels = Vec::with_capacity(width * height);
|
||||
{
|
||||
let data = buffer.get_mapped_range(..).map_err(|e| e.to_string())?;
|
||||
for y in 0..height {
|
||||
pixels.extend_from_slice(data[y * row..y * row + width * 4].as_chunks::<4>().0);
|
||||
}
|
||||
}
|
||||
buffer.unmap();
|
||||
Ok(pixels)
|
||||
}
|
||||
fn write_screenshot(
|
||||
&self,
|
||||
request: &ScreenshotRequest,
|
||||
buffer: &wgpu::Buffer,
|
||||
) -> Result<[u32; 2]> {
|
||||
let bgra = matches!(
|
||||
self.config.format.remove_srgb_suffix(),
|
||||
wgpu::TextureFormat::Bgra8Unorm
|
||||
);
|
||||
let mut pixels = Vec::with_capacity((width * height * 4) as usize);
|
||||
{
|
||||
let data = buffer.get_mapped_range(..).map_err(|e| e.to_string())?;
|
||||
for y in 0..height as usize {
|
||||
for px in data[y * row..y * row + width as usize * 4]
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
{
|
||||
let (r, b) = if bgra { (px[2], px[0]) } else { (px[0], px[2]) };
|
||||
pixels.extend_from_slice(&[r, px[1], b, 255]);
|
||||
}
|
||||
}
|
||||
let mut pixels = Vec::with_capacity((self.config.width * self.config.height * 4) as usize);
|
||||
for px in self.read_frame(buffer)? {
|
||||
let (r, b) = if bgra { (px[2], px[0]) } else { (px[0], px[2]) };
|
||||
pixels.extend_from_slice(&[r, px[1], b, 255]);
|
||||
}
|
||||
buffer.unmap();
|
||||
// NativeImage.resizeSubRectTo on takeAutoScreenshot's grab: the icon
|
||||
// is the frame's centre square averaged down to its size.
|
||||
let (width, height, pixels) = match request.size {
|
||||
@@ -5605,6 +5748,50 @@ impl Renderer {
|
||||
encoder.write_header()?.write_image_data(&pixels)?;
|
||||
Ok([width, height])
|
||||
}
|
||||
/// The HDR10 copy (`Hdr::capture`) as a 16-bit PNG tagged BT.2020 with PQ (cICP, PNG's
|
||||
/// third edition), with the peak and paper white as its content light levels (cLLI).
|
||||
fn write_hdr_screenshot(&self, path: &std::path::Path, buffer: &wgpu::Buffer) -> Result<()> {
|
||||
let Some(hdr) = &self.hdr else {
|
||||
return Ok(());
|
||||
};
|
||||
let pixels = hdr10_to_rgb16(&self.read_frame(buffer)?);
|
||||
let mut encoder = png::Encoder::new(
|
||||
std::io::BufWriter::new(std::fs::File::create(path)?),
|
||||
self.config.width,
|
||||
self.config.height,
|
||||
);
|
||||
encoder.set_color(png::ColorType::Rgb);
|
||||
encoder.set_depth(png::BitDepth::Sixteen);
|
||||
let mut writer = encoder.write_header()?;
|
||||
// BT.2020 primaries, PQ, RGB, full range.
|
||||
writer.write_chunk(png::chunk::cICP, &[9, 16, 0, 1])?;
|
||||
let nits = |n: f32| ((n * 10_000.0) as u32).to_be_bytes();
|
||||
let peak = hdr.peak.max(hdr.paper_white);
|
||||
writer.write_chunk(
|
||||
png::chunk::cLLI,
|
||||
&[nits(peak), nits(hdr.paper_white.min(peak))].concat(),
|
||||
)?;
|
||||
writer.write_image_data(&pixels)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
/// `screenshots/2026-10-28_12.00.00.png` -> `screenshots/2026-10-28_12.00.00-hdr.png`.
|
||||
fn hdr_screenshot_path(path: &std::path::Path) -> PathBuf {
|
||||
let stem = path.file_stem().unwrap_or_default().to_string_lossy();
|
||||
path.with_file_name(format!("{stem}-hdr.png"))
|
||||
}
|
||||
/// `Rgb10a2Unorm` pixels as 16-bit RGB, big-endian as PNG stores it: each 10-bit value
|
||||
/// with its top bits repeated below, so 0 stays 0 and 1023 becomes 65535.
|
||||
fn hdr10_to_rgb16(pixels: &[[u8; 4]]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(pixels.len() * 6);
|
||||
for &px in pixels {
|
||||
let v = u32::from_le_bytes(px);
|
||||
for shift in [0, 10, 20] {
|
||||
let c = (v >> shift & 0x3ff) as u16;
|
||||
out.extend_from_slice(&(c << 6 | c >> 4).to_be_bytes());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Centre-crop the frame to a square and average it down to `size`.
|
||||
pub(crate) fn square_downscale(pixels: &[u8], width: u32, height: u32, size: [u32; 2]) -> Vec<u8> {
|
||||
@@ -5678,10 +5865,10 @@ impl Frustum {
|
||||
/// are GREATER_THAN_OR_EQUAL), which keeps far faces a hair apart, like
|
||||
/// fire_side's 0.01 pixel in front of its block, from z-fighting.
|
||||
/// The section the camera is in.
|
||||
fn camera_section(camera: DVec3) -> SectionKey {
|
||||
fn camera_section(camera: DVec3, min_y: i32) -> SectionKey {
|
||||
(
|
||||
(camera.x.floor() as i32) >> 4,
|
||||
(camera.y.floor() as i32 - crate::chunk::MIN_Y) >> 4,
|
||||
(camera.y.floor() as i32 - min_y) >> 4,
|
||||
(camera.z.floor() as i32) >> 4,
|
||||
)
|
||||
}
|
||||
@@ -5859,6 +6046,23 @@ mod tests {
|
||||
assert_eq!(order(api, "macos"), [Backends::default()]);
|
||||
}
|
||||
}
|
||||
/// The second try at a backend shows the adapters wgpu hides for reporting
|
||||
/// no Vulkan conformance version (Mesa's hasvk, which drives Ivy Bridge and
|
||||
/// Haswell, reports 0.0.0.0 and works).
|
||||
#[test]
|
||||
fn the_second_try_at_a_backend_shows_nonconformant_adapters() {
|
||||
let shown = |allow| {
|
||||
super::instance_descriptor(wgpu::Backends::VULKAN, None, allow)
|
||||
.flags
|
||||
.contains(wgpu::InstanceFlags::ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER)
|
||||
};
|
||||
assert!(shown(true));
|
||||
// The first try keeps wgpu's own filtering, unless `with_env` turns the
|
||||
// flag on from the environment (WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER).
|
||||
if std::env::var_os("WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER").is_none() {
|
||||
assert!(!shown(false));
|
||||
}
|
||||
}
|
||||
/// Every WGSL shader file translates to HLSL as wgpu's DirectX 12 backend
|
||||
/// writes it for FXC (shader model 5.1), entry point by entry point.
|
||||
#[test]
|
||||
@@ -5958,6 +6162,17 @@ mod tests {
|
||||
assert_eq!(blend.alpha.dst_factor, wgpu::BlendFactor::Zero);
|
||||
}
|
||||
#[test]
|
||||
fn hdr_screenshots_keep_every_10_bit_step() {
|
||||
// Rgb10a2Unorm: red in the low 10 bits, then green, then blue.
|
||||
let px = |r: u32, g: u32, b: u32| (r | g << 10 | b << 20 | 3 << 30).to_le_bytes();
|
||||
let out = super::hdr10_to_rgb16(&[px(0, 1023, 512)]);
|
||||
assert_eq!(out, [0, 0, 0xff, 0xff, 0x80, 0x20]);
|
||||
assert_eq!(
|
||||
super::hdr_screenshot_path(std::path::Path::new("screenshots/2026-10-28_12.00.00.png")),
|
||||
std::path::Path::new("screenshots/2026-10-28_12.00.00-hdr.png")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn shader_globals_match_the_rust_layout() {
|
||||
// terrain.wgsl and entity.wgsl bind the same buffer: same fields, same order.
|
||||
let fields = |source: &str| -> Vec<String> {
|
||||
@@ -6003,7 +6218,7 @@ mod tests {
|
||||
#[test]
|
||||
fn indirect_draws_are_validated_only_in_debug_builds_and_on_directx_12() {
|
||||
let validated = |backends| {
|
||||
super::instance_descriptor(backends, None)
|
||||
super::instance_descriptor(backends, None, false)
|
||||
.flags
|
||||
.contains(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL)
|
||||
};
|
||||
|
||||
@@ -147,7 +147,7 @@ impl SpriteAnimator {
|
||||
group,
|
||||
});
|
||||
}
|
||||
let Some(pipeline) = pipeline else {
|
||||
let Some((pipeline, layout)) = pipeline else {
|
||||
let groups = keys
|
||||
.iter()
|
||||
.zip(&counts)
|
||||
@@ -193,7 +193,6 @@ impl SpriteAnimator {
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let layout = pipeline.get_bind_group_layout(0);
|
||||
let groups = keys
|
||||
.iter()
|
||||
.zip(&counts)
|
||||
@@ -364,11 +363,15 @@ impl SpriteAnimator {
|
||||
}
|
||||
}
|
||||
|
||||
/// `animate.wgsl`'s pipeline, unless the device fails to make it: an OpenGL
|
||||
/// driver can list compute shaders among its extensions while wgpu writes
|
||||
/// the GLSL version of its core version, which has none (Mesa's
|
||||
/// `MESA_GL_VERSION_OVERRIDE`, older drivers).
|
||||
fn pipeline(device: &wgpu::Device) -> Option<wgpu::ComputePipeline> {
|
||||
/// `animate.wgsl`'s pipeline and the bind group layout of its group 0,
|
||||
/// unless the device fails to make the pipeline: an OpenGL driver can list
|
||||
/// compute shaders among its extensions while wgpu writes the GLSL version
|
||||
/// of its core version, which has none (Mesa's `MESA_GL_VERSION_OVERRIDE`,
|
||||
/// older drivers). A device lost between the scopes' pushes answers pipeline
|
||||
/// creation with a silent placeholder and reports its invalidity only when
|
||||
/// the layout is asked for — inside the scopes that error is caught, outside
|
||||
/// them it is uncaptured and panics (#3866).
|
||||
fn pipeline(device: &wgpu::Device) -> Option<(wgpu::ComputePipeline, wgpu::BindGroupLayout)> {
|
||||
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
@@ -383,9 +386,10 @@ fn pipeline(device: &wgpu::Device) -> Option<wgpu::ComputePipeline> {
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let layout = pipeline.get_bind_group_layout(0);
|
||||
let internal = pollster::block_on(internal.pop());
|
||||
match internal.or(pollster::block_on(validation.pop())) {
|
||||
None => Some(pipeline),
|
||||
None => Some((pipeline, layout)),
|
||||
Some(e) => {
|
||||
log::warn!("Horizon renderer: animated sprites drawn on the CPU ({e})");
|
||||
None
|
||||
|
||||
@@ -115,7 +115,7 @@ pub struct Flat {
|
||||
/// (9,300 draws) the CPU saves 1.3 ms. The bar was 4,096 draws when they
|
||||
/// were 2.2 to 2.5 per section; a section's draw count now depends on the
|
||||
/// sides it shows the eye (`mesher::Sides`), so it counts sections.
|
||||
const MIN_SECTIONS: usize = 1600;
|
||||
pub const MIN_SECTIONS: usize = 1600;
|
||||
|
||||
/// Whether flat draws apply: the backend emulates multi-draw, and vertex
|
||||
/// shaders can read storage buffers large enough for an index page.
|
||||
@@ -126,7 +126,20 @@ pub fn wanted(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
|
||||
Ok("1") => return supported(adapter, device),
|
||||
_ => {}
|
||||
}
|
||||
adapter.get_info().backend == wgpu::Backend::Metal && supported(adapter, device)
|
||||
let info = adapter.get_info();
|
||||
(info.backend == wgpu::Backend::Metal || is_broken_intel_vulkan(&info))
|
||||
&& supported(adapter, device)
|
||||
}
|
||||
|
||||
/// Intel Gen11 (Ice Lake) ANV Vulkan driver has broken `first_instance` for instance-step
|
||||
/// vertex attributes (#3529), causing terrain sections to be drawn with another section's origin
|
||||
/// and flicker.
|
||||
pub fn is_broken_intel_vulkan(info: &wgpu::AdapterInfo) -> bool {
|
||||
info.backend == wgpu::Backend::Vulkan
|
||||
&& info.vendor == 0x8086
|
||||
&& (info.name.contains("ICL")
|
||||
|| info.name.contains("Ice Lake")
|
||||
|| (info.device >= 0x8a50 && info.device <= 0x8a71))
|
||||
}
|
||||
|
||||
fn supported(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
|
||||
@@ -254,6 +267,7 @@ impl Flat {
|
||||
device: &wgpu::Device,
|
||||
terrain_layout: &wgpu::BindGroupLayout,
|
||||
page_vertices: u64,
|
||||
min_sections: usize,
|
||||
pipeline: impl Fn(
|
||||
&wgpu::PipelineLayout,
|
||||
&str,
|
||||
@@ -357,11 +371,7 @@ impl Flat {
|
||||
phase1_calls: 0,
|
||||
solid_calls: 0,
|
||||
shadow_calls: [0; 2],
|
||||
min_sections: if std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1") {
|
||||
0
|
||||
} else {
|
||||
MIN_SECTIONS
|
||||
},
|
||||
min_sections,
|
||||
range_align: (u64::from(device.limits().min_storage_buffer_offset_alignment) / 16)
|
||||
.max(1),
|
||||
}
|
||||
|
||||
@@ -102,6 +102,10 @@ pub struct Hdr {
|
||||
/// output keeps the hand at the world's brightness.
|
||||
hand_mark: wgpu::RenderPipeline,
|
||||
params: wgpu::Buffer,
|
||||
/// The same pass drawing HDR10 for a screenshot (`capture`), with its own parameters,
|
||||
/// so it can differ from the swapchain's in one frame.
|
||||
capture_pipeline: wgpu::RenderPipeline,
|
||||
capture_params: wgpu::Buffer,
|
||||
pub output: Output,
|
||||
/// The frame's format: what every other pipeline draws in.
|
||||
format: wgpu::TextureFormat,
|
||||
@@ -156,31 +160,33 @@ impl Hdr {
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Horizon HDR"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_hdr"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_hdr"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: output.format(),
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let pipeline_for = |format| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Horizon HDR"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_hdr"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_hdr"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let alpha_pass = |label, blend| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
@@ -223,18 +229,22 @@ impl Hdr {
|
||||
alpha: scale_alpha,
|
||||
}),
|
||||
);
|
||||
let params = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon HDR parameters"),
|
||||
size: 80,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let params = || {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon HDR parameters"),
|
||||
size: 80,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
};
|
||||
Self {
|
||||
layout,
|
||||
pipeline,
|
||||
pipeline: pipeline_for(output.format()),
|
||||
clear_alpha,
|
||||
hand_mark,
|
||||
params,
|
||||
params: params(),
|
||||
capture_pipeline: pipeline_for(Output::Pq.format()),
|
||||
capture_params: params(),
|
||||
output,
|
||||
format,
|
||||
targets: None,
|
||||
@@ -375,6 +385,64 @@ impl Hdr {
|
||||
let Some(t) = &self.targets else {
|
||||
return;
|
||||
};
|
||||
queue.write_buffer(
|
||||
&self.params,
|
||||
0,
|
||||
bytemuck::cast_slice(&self.params(world, self.output == Output::Pq)),
|
||||
);
|
||||
Self::draw(encoder, &self.pipeline, &t.group, target);
|
||||
}
|
||||
|
||||
/// The frame `present` writes, as HDR10 (BT.2020, PQ, 10 bits a channel) whatever
|
||||
/// the swapchain is, in a texture of its own for a screenshot (`Rgb10a2Unorm`).
|
||||
pub fn capture(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
world: bool,
|
||||
) -> Option<wgpu::Texture> {
|
||||
let t = self.targets.as_ref()?;
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Horizon HDR screenshot"),
|
||||
size: t.frame.size(),
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: Output::Pq.format(),
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Horizon HDR screenshot bindings"),
|
||||
layout: &self.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(&view(&t.frame)),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(&view(&t.world)),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: self.capture_params.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
queue.write_buffer(
|
||||
&self.capture_params,
|
||||
0,
|
||||
bytemuck::cast_slice(&self.params(world, true)),
|
||||
);
|
||||
Self::draw(encoder, &self.capture_pipeline, &group, &texture);
|
||||
Some(texture)
|
||||
}
|
||||
|
||||
/// hdr.wgsl's `Params`.
|
||||
fn params(&self, world: bool, pq: bool) -> [f32; 20] {
|
||||
let paper = self.paper_white.max(1.0);
|
||||
let mut params = [0.0; 20];
|
||||
params[..5].copy_from_slice(&[
|
||||
@@ -382,7 +450,7 @@ impl Hdr {
|
||||
(self.peak / paper).max(1.0),
|
||||
f32::from(u8::from(world)),
|
||||
f32::from(u8::from(self.debug)),
|
||||
f32::from(u8::from(self.output == Output::Pq)),
|
||||
f32::from(u8::from(pq)),
|
||||
]);
|
||||
params[5] = self.black.clamp(0.0, paper);
|
||||
params[6] = self.ui.clamp(0.25, 1.0);
|
||||
@@ -391,7 +459,15 @@ impl Hdr {
|
||||
params[12 + i] = t.start;
|
||||
params[16 + i] = t.full;
|
||||
}
|
||||
queue.write_buffer(&self.params, 0, bytemuck::cast_slice(¶ms));
|
||||
params
|
||||
}
|
||||
|
||||
fn draw(
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
pipeline: &wgpu::RenderPipeline,
|
||||
group: &wgpu::BindGroup,
|
||||
target: &wgpu::Texture,
|
||||
) {
|
||||
let view = target.create_view(&Default::default());
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
@@ -407,8 +483,8 @@ impl Hdr {
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &t.group, &[]);
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ impl Column {
|
||||
/// Values of type `T` per section key, in a dense list with stable keys and
|
||||
/// slots that move only when a section is removed.
|
||||
pub struct Resident<T> {
|
||||
/// The minimum Y of section index 0 (the level's column).
|
||||
min_y: i32,
|
||||
items: Vec<(SectionKey, T)>,
|
||||
slots: FxHashMap<SectionKey, u32>,
|
||||
columns: Vec<Column>,
|
||||
@@ -36,6 +38,7 @@ pub struct Resident<T> {
|
||||
impl<T> Default for Resident<T> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
min_y: crate::chunk::MIN_Y,
|
||||
items: Vec::new(),
|
||||
slots: FxHashMap::default(),
|
||||
columns: Vec::new(),
|
||||
@@ -61,6 +64,10 @@ impl<T> Resident<T> {
|
||||
pub fn get_mut(&mut self, slot: u32) -> &mut T {
|
||||
&mut self.items[slot as usize].1
|
||||
}
|
||||
/// Sections from now on count from `min_y` (a level of another column).
|
||||
pub fn set_min_y(&mut self, min_y: i32) {
|
||||
self.min_y = min_y;
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.items.clear();
|
||||
self.slots.clear();
|
||||
@@ -144,10 +151,10 @@ pub struct Visible {
|
||||
}
|
||||
|
||||
/// The camera-relative origin of the section at `key`.
|
||||
fn origin(key: SectionKey, camera: DVec3) -> DVec3 {
|
||||
fn origin(key: SectionKey, min_y: i32, camera: DVec3) -> DVec3 {
|
||||
DVec3::new(
|
||||
f64::from(key.0 * 16),
|
||||
f64::from(crate::chunk::MIN_Y + key.1 * 16),
|
||||
f64::from(min_y + key.1 * 16),
|
||||
f64::from(key.2 * 16),
|
||||
) - camera
|
||||
}
|
||||
@@ -169,18 +176,19 @@ fn in_view(key: SectionKey, camera: DVec3, radius: i32, frustum: &Frustum) -> Op
|
||||
if !in_view_distance(camera_column(camera), radius, key.0, key.2) {
|
||||
return None;
|
||||
}
|
||||
in_frustum(key, camera, frustum, false)
|
||||
in_frustum(key, crate::chunk::MIN_Y, camera, frustum, false)
|
||||
}
|
||||
|
||||
/// `in_view` without the distance test; `inside`: the section's column is
|
||||
/// entirely inside the frustum, so the section is too.
|
||||
fn in_frustum(
|
||||
key: SectionKey,
|
||||
min_y: i32,
|
||||
camera: DVec3,
|
||||
frustum: &Frustum,
|
||||
inside: bool,
|
||||
) -> Option<(f64, Vec3)> {
|
||||
let origin = origin(key, camera);
|
||||
let origin = origin(key, min_y, camera);
|
||||
let min = origin.as_vec3();
|
||||
if !inside && !frustum.intersects(min, min + Vec3::splat(16.0)) {
|
||||
return None;
|
||||
@@ -216,8 +224,8 @@ impl<T> Resident<T> {
|
||||
if !near && !beyond(column.x, column.z) {
|
||||
continue;
|
||||
}
|
||||
let min = origin((column.x, column.low, column.z), camera).as_vec3();
|
||||
let top = origin((column.x, column.high, column.z), camera).as_vec3();
|
||||
let min = origin((column.x, column.low, column.z), self.min_y, camera).as_vec3();
|
||||
let top = origin((column.x, column.high, column.z), self.min_y, camera).as_vec3();
|
||||
let max = top + Vec3::splat(16.0);
|
||||
let inside = frustum.contains(min, max);
|
||||
if !inside && !frustum.intersects(min, max) {
|
||||
@@ -228,7 +236,7 @@ impl<T> Resident<T> {
|
||||
if near && !reachable(key) {
|
||||
continue;
|
||||
}
|
||||
if let Some((d, o)) = in_frustum(key, camera, frustum, inside) {
|
||||
if let Some((d, o)) = in_frustum(key, self.min_y, camera, frustum, inside) {
|
||||
out.push(Visible {
|
||||
distance: d.to_bits(),
|
||||
key,
|
||||
@@ -286,7 +294,7 @@ mod tests {
|
||||
if near {
|
||||
in_view(k, camera, radius, &frustum).filter(|_| reachable(k))
|
||||
} else if beyond(k.0, k.2) {
|
||||
in_frustum(k, camera, &frustum, false)
|
||||
in_frustum(k, crate::chunk::MIN_Y, camera, &frustum, false)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -329,8 +337,8 @@ mod tests {
|
||||
.columns
|
||||
.iter()
|
||||
.filter(|c| {
|
||||
let min = origin((c.x, c.low, c.z), camera).as_vec3();
|
||||
let top = origin((c.x, c.high, c.z), camera).as_vec3();
|
||||
let min = origin((c.x, c.low, c.z), crate::chunk::MIN_Y, camera).as_vec3();
|
||||
let top = origin((c.x, c.high, c.z), crate::chunk::MIN_Y, camera).as_vec3();
|
||||
frustum.contains(min, top + Vec3::splat(16.0))
|
||||
})
|
||||
.count();
|
||||
|
||||
@@ -10,19 +10,36 @@ const CHUNK: u64 = 1 << 20;
|
||||
|
||||
pub struct Uploads {
|
||||
belt: wgpu::util::StagingBelt,
|
||||
chunk: u64,
|
||||
}
|
||||
|
||||
impl Uploads {
|
||||
pub fn new(device: &wgpu::Device) -> Self {
|
||||
Self::with_chunk(device, CHUNK)
|
||||
}
|
||||
/// A belt whose staging buffers hold `chunk` bytes (larger writes get
|
||||
/// a buffer of their own).
|
||||
/// A belt whose staging buffers hold `chunk` bytes (a larger write gets
|
||||
/// a buffer of its own, rounded up to a power of two).
|
||||
pub fn with_chunk(device: &wgpu::Device, chunk: u64) -> Self {
|
||||
Self {
|
||||
belt: wgpu::util::StagingBelt::new(device.clone(), chunk),
|
||||
chunk,
|
||||
}
|
||||
}
|
||||
/// A mapped staging slice of at least `size` bytes. The belt gives a
|
||||
/// write larger than its chunk a buffer of exactly that size, keeps it for
|
||||
/// good and only reuses it for writes that fit: an upload that grows a
|
||||
/// little every frame (the entity batch while mobs appear) took a new
|
||||
/// buffer each frame until the game ran out of memory (#2826). Rounded up
|
||||
/// to a power of two, the large buffers it keeps are reused, one per
|
||||
/// doubling.
|
||||
fn allocate(&mut self, size: NonZeroU64, alignment: NonZeroU64) -> wgpu::BufferSlice<'_> {
|
||||
let size = if size.get() > self.chunk {
|
||||
size.checked_next_power_of_two().unwrap_or(size)
|
||||
} else {
|
||||
size
|
||||
};
|
||||
self.belt.allocate(size, alignment)
|
||||
}
|
||||
/// Copy tightly packed RGBA8 `pixels` (`width` by `height`) into one mip
|
||||
/// level of one array layer, padding rows to the copy alignment.
|
||||
pub fn texture(
|
||||
@@ -41,7 +58,7 @@ impl Uploads {
|
||||
};
|
||||
let alignment = NonZeroU64::new(u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT))
|
||||
.expect("non-zero alignment");
|
||||
let slice = self.belt.allocate(size, alignment);
|
||||
let slice = self.allocate(size, alignment);
|
||||
{
|
||||
let mut view = slice
|
||||
.get_mapped_range_mut()
|
||||
@@ -80,8 +97,20 @@ impl Uploads {
|
||||
let Some(size) = NonZeroU64::new(bytes.len() as u64) else {
|
||||
return;
|
||||
};
|
||||
self.belt
|
||||
.write_buffer(encoder, target, offset, size)
|
||||
// The belt's own `write_buffer` checks, kept now that a large write's
|
||||
// slice is rounded up past them.
|
||||
assert!(
|
||||
offset.is_multiple_of(wgpu::COPY_BUFFER_ALIGNMENT)
|
||||
&& size.get().is_multiple_of(wgpu::COPY_BUFFER_ALIGNMENT),
|
||||
"buffer upload at {offset} of {size} bytes must be 4-byte aligned"
|
||||
);
|
||||
let alignment = NonZeroU64::new(wgpu::COPY_BUFFER_ALIGNMENT).expect("non-zero alignment");
|
||||
let slice = self.allocate(size, alignment).slice(..size.get());
|
||||
encoder.copy_buffer_to_buffer(slice.buffer(), slice.offset(), target, offset, size.get());
|
||||
slice
|
||||
.get_mapped_range_mut()
|
||||
.expect("staging belt slices are mapped")
|
||||
.slice(..)
|
||||
.copy_from_slice(bytes);
|
||||
}
|
||||
/// Close this frame's staging buffers: they come back for reuse once the
|
||||
@@ -217,6 +246,80 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Staging buffers the belt holds, once every submitted frame is done
|
||||
/// with them. The belt only reports its buffers through `Debug`.
|
||||
fn kept_buffers(device: &wgpu::Device, uploads: &mut Uploads) -> usize {
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
// Finishing an empty frame takes back every recalled buffer.
|
||||
uploads.finish(&device.create_command_encoder(&Default::default()));
|
||||
let belt = format!("{:?}", uploads.belt);
|
||||
let count = |field: &str| -> usize {
|
||||
let at = belt.find(&format!("{field}: ")).expect(&belt) + field.len() + 2;
|
||||
let digits = belt[at..].split(|c: char| !c.is_ascii_digit()).next();
|
||||
digits.and_then(|d| d.parse().ok()).expect(&belt)
|
||||
};
|
||||
count("active_chunks") + count("closed_chunks") + count("free_chunks")
|
||||
}
|
||||
|
||||
/// #2826: a write larger than the chunk that grows a little every frame
|
||||
/// (the entity batch while mobs appear) got a new exact-size buffer each
|
||||
/// frame, all of them kept, until the game ran out of memory.
|
||||
#[test]
|
||||
fn growing_large_writes_reuse_the_belt_buffers() {
|
||||
let Some((device, queue)) = device() else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
};
|
||||
const CHUNK: u64 = 1 << 16;
|
||||
const TARGET: usize = 4 * CHUNK as usize;
|
||||
let target = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: TARGET as u64,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let readback = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: TARGET as u64,
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut uploads = Uploads::with_chunk(&device, CHUNK);
|
||||
let mut expected = vec![0u8; TARGET];
|
||||
let frames = 64;
|
||||
for frame in 0..frames {
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
// One small write, then the large one, 1.5 to 3.5 chunks long.
|
||||
uploads.buffer(&mut encoder, &target, TARGET as u64 - 4, &[9; 4]);
|
||||
expected[TARGET - 4..].fill(9);
|
||||
let len = (3 * CHUNK as usize / 2 + frame * 2048).next_multiple_of(4);
|
||||
let bytes: Vec<u8> = (0..len).map(|i| (i + frame) as u8).collect();
|
||||
uploads.buffer(&mut encoder, &target, 0, &bytes);
|
||||
expected[..len].copy_from_slice(&bytes);
|
||||
encoder.copy_buffer_to_buffer(&target, 0, &readback, 0, TARGET as u64);
|
||||
uploads.finish(&encoder);
|
||||
queue.submit([encoder.finish()]);
|
||||
readback
|
||||
.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
// Only the written bytes land: the rest of the target is untouched.
|
||||
assert!(
|
||||
*readback.slice(..).get_mapped_range().unwrap() == expected[..],
|
||||
"frame {frame}"
|
||||
);
|
||||
readback.unmap();
|
||||
}
|
||||
// A chunk for the small writes and a buffer per doubling of the large
|
||||
// one (2 and 4 chunks), maybe one more where a small write took the
|
||||
// start of a buffer the large one needed whole: not one per frame.
|
||||
let kept = kept_buffers(&device, &mut uploads);
|
||||
assert!(
|
||||
kept <= 4,
|
||||
"the belt kept {kept} buffers over {frames} frames"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn belt_buffer_writes_land_in_order_at_their_offsets() {
|
||||
let Some((device, queue)) = device() else {
|
||||
|
||||
@@ -59,16 +59,19 @@ pub mod packs;
|
||||
pub mod par;
|
||||
pub mod particles;
|
||||
pub mod poi_sections;
|
||||
pub mod potent_sulfur;
|
||||
pub mod providers;
|
||||
pub mod screen_draw;
|
||||
pub mod screen_effects;
|
||||
pub mod sculk;
|
||||
pub mod section_mask;
|
||||
pub mod settings;
|
||||
pub mod shader_pack_list;
|
||||
pub mod signs;
|
||||
pub mod skins;
|
||||
pub mod sound_engine;
|
||||
pub mod sound_events;
|
||||
pub mod sound_preview;
|
||||
pub mod spans;
|
||||
pub mod split_layout;
|
||||
mod sprite_cache;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
use crate::fast_hash::FxHashMap;
|
||||
use crate::{
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, HEIGHT, Light, MIN_Y, SECTIONS, Section, index},
|
||||
chunk::{Chunk, ColumnLayout, Light, Section, index},
|
||||
};
|
||||
use horizon_core::geometry::{Direction, ShapeOp, VoxelShape};
|
||||
use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
@@ -273,7 +273,7 @@ pub fn light_chunk(
|
||||
tables: &LightTables,
|
||||
sky_light: bool,
|
||||
) -> Vec<Light> {
|
||||
region[1][1].expect("centre chunk");
|
||||
let sections = region[1][1].expect("centre chunk").sections.len();
|
||||
// Highest occupied section in the region bounds the work.
|
||||
let mut top_section = 0;
|
||||
for chunk in region.iter().flatten().flatten() {
|
||||
@@ -286,7 +286,7 @@ pub fn light_chunk(
|
||||
}
|
||||
// One extra section above the highest occupied one: block light rises
|
||||
// up to 14 blocks into it (sky light there is always 15).
|
||||
let h = ((top_section + 1) * 16).min(HEIGHT as usize);
|
||||
let h = ((top_section + 1) * 16).min(sections * 16);
|
||||
let at = |x: usize, y: usize, z: usize| (y * W + z) * W + x;
|
||||
// Packed cells (`type << 4 | dampening`), not state ids: the flood fill
|
||||
// then never touches the large `LightTables::cells` table.
|
||||
@@ -422,14 +422,21 @@ pub fn light_chunk(
|
||||
};
|
||||
spread(&mut sky, &mut sky_queue);
|
||||
spread(&mut block, &mut queue);
|
||||
pack_lighting(h, &sky, &block, sky_light)
|
||||
pack_lighting(sections, h, &sky, &block, sky_light)
|
||||
}
|
||||
|
||||
/// Extract just the centre column's light; never copy worker snapshot sections.
|
||||
fn pack_lighting(h: usize, sky: &[u8], block: &[u8], sky_light: bool) -> Vec<Light> {
|
||||
/// Extract just the centre column's light (`sections` of it); never copy
|
||||
/// worker snapshot sections.
|
||||
fn pack_lighting(
|
||||
sections: usize,
|
||||
h: usize,
|
||||
sky: &[u8],
|
||||
block: &[u8],
|
||||
sky_light: bool,
|
||||
) -> Vec<Light> {
|
||||
let at = |x: usize, y: usize, z: usize| (y * W + z) * W + x;
|
||||
let open = if sky_light { 0xF0 } else { 0x00 };
|
||||
(0..SECTIONS)
|
||||
(0..sections)
|
||||
.map(|si| {
|
||||
if si * 16 >= h {
|
||||
return Light::Uniform(open);
|
||||
@@ -467,6 +474,7 @@ pub fn apply_lighting(chunk: &mut Chunk, lights: Vec<Light>) {
|
||||
/// `apply_lighting` for a chunk lit before: writes only the sections whose
|
||||
/// light changed and adds the cells that changed to `changed`.
|
||||
pub fn apply_relighting(chunk: &mut Chunk, lights: Vec<Light>, changed: &mut LightChanges) {
|
||||
changed.keyed(chunk.layout());
|
||||
for (sy, (section, light)) in chunk.sections.iter_mut().zip(lights).enumerate() {
|
||||
let old = §ion.light;
|
||||
if let (Light::Uniform(a), Light::Uniform(b)) = (old, &light)
|
||||
@@ -506,7 +514,8 @@ pub fn light_from_layers<L: AsRef<[u8]>>(
|
||||
mut above: [u8; 256],
|
||||
sky_light: bool,
|
||||
) {
|
||||
for (si, (sky, block)) in layers.iter().enumerate().take(SECTIONS).rev() {
|
||||
let sections = chunk.sections.len();
|
||||
for (si, (sky, block)) in layers.iter().enumerate().take(sections).rev() {
|
||||
let sky = sky.as_ref().filter(|_| sky_light);
|
||||
let section = Arc::make_mut(&mut chunk.sections[si]);
|
||||
if sky.is_none() && block.is_none() && above.iter().all(|&v| v == above[0]) {
|
||||
@@ -539,7 +548,8 @@ pub fn update_light_layers<L: AsRef<[u8]>>(
|
||||
sky_light: bool,
|
||||
) -> Vec<usize> {
|
||||
let mut changed = Vec::new();
|
||||
for (si, (sky, block)) in layers.iter().enumerate().take(SECTIONS) {
|
||||
let sections = chunk.sections.len();
|
||||
for (si, (sky, block)) in layers.iter().enumerate().take(sections) {
|
||||
let sky = sky.as_ref().filter(|_| sky_light);
|
||||
if sky.is_none() && block.is_none() {
|
||||
continue;
|
||||
@@ -569,8 +579,15 @@ pub trait LightWorld {
|
||||
fn section(&self, cx: i32, s: usize, cz: i32) -> Option<&Arc<Section>>;
|
||||
/// Replace the light of a section `section` returned.
|
||||
fn set_section_light(&mut self, cx: i32, s: usize, cz: i32, light: Light);
|
||||
/// The column the world's chunks hold (its level's).
|
||||
fn layout(&self) -> ColumnLayout;
|
||||
}
|
||||
impl<S: std::hash::BuildHasher> LightWorld for HashMap<(i32, i32), Arc<Chunk>, S> {
|
||||
fn layout(&self) -> ColumnLayout {
|
||||
self.values()
|
||||
.next()
|
||||
.map_or(ColumnLayout::VANILLA, |c| c.layout())
|
||||
}
|
||||
fn section(&self, cx: i32, s: usize, cz: i32) -> Option<&Arc<Section>> {
|
||||
self.get(&(cx, cz))
|
||||
.filter(|c| c.lit)
|
||||
@@ -587,6 +604,7 @@ impl<S: std::hash::BuildHasher> LightWorld for HashMap<(i32, i32), Arc<Chunk>, S
|
||||
/// gets written is copied out on the first write and stored back by `finish`.
|
||||
struct Region<'w> {
|
||||
world: &'w mut dyn LightWorld,
|
||||
layout: ColumnLayout,
|
||||
slots: Vec<Slot>,
|
||||
/// Section key (cx, s, cz) to slot, `NONE` where the chunk isn't lit.
|
||||
index: FxHashMap<Pos, u32>,
|
||||
@@ -602,6 +620,7 @@ const NONE: u32 = u32::MAX;
|
||||
impl<'w> Region<'w> {
|
||||
fn new(world: &'w mut dyn LightWorld) -> Self {
|
||||
Self {
|
||||
layout: world.layout(),
|
||||
world,
|
||||
slots: Vec::new(),
|
||||
index: FxHashMap::default(),
|
||||
@@ -610,10 +629,8 @@ impl<'w> Region<'w> {
|
||||
}
|
||||
#[inline]
|
||||
fn slot(&mut self, x: i32, y: i32, z: i32) -> Option<usize> {
|
||||
if !(MIN_Y..MIN_Y + HEIGHT).contains(&y) {
|
||||
return None;
|
||||
}
|
||||
let key = (x >> 4, (y - MIN_Y) >> 4, z >> 4);
|
||||
let s = self.layout.section_index(y)?;
|
||||
let key = (x >> 4, s as i32, z >> 4);
|
||||
if self.last.0 != key {
|
||||
self.last = (key, self.load(key));
|
||||
}
|
||||
@@ -702,12 +719,20 @@ type Pos = (i32, i32, i32);
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct LightChanges {
|
||||
sections: FxHashMap<Pos, u32>,
|
||||
/// The column the section indexes count in.
|
||||
layout: ColumnLayout,
|
||||
}
|
||||
impl LightChanges {
|
||||
/// Changes in chunks of `layout`: what the relighter or a light job adds
|
||||
/// after this (they all come from one level).
|
||||
fn keyed(&mut self, layout: ColumnLayout) {
|
||||
debug_assert!(self.sections.is_empty() || self.layout == layout);
|
||||
self.layout = layout;
|
||||
}
|
||||
/// A cell whose stored light changed.
|
||||
#[inline]
|
||||
pub fn push(&mut self, (x, y, z): Pos) {
|
||||
let key = (x >> 4, (y - MIN_Y) >> 4, z >> 4);
|
||||
let key = (x >> 4, (y - self.layout.min_y()) >> 4, z >> 4);
|
||||
*self.sections.entry(key).or_default() |= 1 << Self::class(x, y, z);
|
||||
}
|
||||
/// Cells of section `key` (x, section index, z) in the given classes.
|
||||
@@ -732,7 +757,7 @@ impl LightChanges {
|
||||
self.sections.keys().copied()
|
||||
}
|
||||
/// Every section (x, section index, z) whose cells lie within one block
|
||||
/// of a changed cell, section indexes outside `0..SECTIONS` left out (a
|
||||
/// of a changed cell, section indexes outside the column left out (a
|
||||
/// section next to several changed ones comes once for each).
|
||||
pub fn dirty_sections(&self, mut out: impl FnMut(Pos)) {
|
||||
// Per class, the bit set of neighbour offsets (dx, dy, dz in -1..=1,
|
||||
@@ -759,6 +784,7 @@ impl LightChanges {
|
||||
}
|
||||
reach
|
||||
};
|
||||
let sections = self.layout.sections() as i32;
|
||||
for (&(x, sy, z), &classes) in &self.sections {
|
||||
let mut offsets = 0;
|
||||
let mut rest = classes;
|
||||
@@ -770,7 +796,7 @@ impl LightChanges {
|
||||
let o = offsets.trailing_zeros() as i32;
|
||||
offsets &= offsets - 1;
|
||||
let s = sy + o / 3 % 3 - 1;
|
||||
if (0..SECTIONS as i32).contains(&s) {
|
||||
if (0..sections).contains(&s) {
|
||||
out((x + o % 3 - 1, s, z + o / 9 - 1));
|
||||
}
|
||||
}
|
||||
@@ -794,11 +820,12 @@ fn set_level(world: &mut Region, sky: bool, p: Pos, value: u8) {
|
||||
/// `ChunkSkyLightSources`: the Y of the upper block of the highest occluded
|
||||
/// edge in the column (every block at or above it is a sky source).
|
||||
fn lowest_sky_source(world: &mut Region, tables: &LightTables, x: i32, z: i32) -> i32 {
|
||||
let mut y = MIN_Y + HEIGHT - 1;
|
||||
let (min_y, max_y) = (world.layout.min_y(), world.layout.max_y());
|
||||
let mut y = max_y - 1;
|
||||
let Some(mut top) = world.state(x, y, z) else {
|
||||
return MIN_Y;
|
||||
return min_y;
|
||||
};
|
||||
while y > MIN_Y {
|
||||
while y > min_y {
|
||||
let Some(bottom) = world.state(x, y - 1, z) else {
|
||||
break;
|
||||
};
|
||||
@@ -808,7 +835,7 @@ fn lowest_sky_source(world: &mut Region, tables: &LightTables, x: i32, z: i32) -
|
||||
top = bottom;
|
||||
y -= 1;
|
||||
}
|
||||
MIN_Y
|
||||
min_y
|
||||
}
|
||||
/// Vanilla's `checkBlock` + `runLightUpdates` for changed positions on
|
||||
/// resident, lit chunks: clear the light that may have come through or from
|
||||
@@ -825,9 +852,27 @@ pub fn relight(
|
||||
changed: &mut LightChanges,
|
||||
) {
|
||||
let mut region = Region::new(world);
|
||||
changed.keyed(region.layout);
|
||||
relight_region(&mut region, tables, sky_light, positions, force, changed);
|
||||
region.finish();
|
||||
}
|
||||
/// The columns `changed` touched that border a loaded column not lit yet
|
||||
/// (`unlit`). `relight` reads and writes only lit columns, so an edit's
|
||||
/// darkening can take away light such a neighbour sends in and never put it
|
||||
/// back, and nothing relights the column when that neighbour's own light
|
||||
/// lands: an air block at 0 beside one at 6 (#2786). Vanilla's light engine
|
||||
/// keeps one light storage for every chunk, so its edits and its chunk
|
||||
/// lighting see each other. These columns need a full light job, which
|
||||
/// lights them from the blocks of their whole 3x3, that neighbour's included.
|
||||
pub fn beside_unlit(changed: &LightChanges, unlit: impl Fn(i32, i32) -> bool) -> Vec<(i32, i32)> {
|
||||
let mut columns: Vec<(i32, i32)> = changed.sections().map(|(x, _, z)| (x, z)).collect();
|
||||
columns.sort_unstable();
|
||||
columns.dedup();
|
||||
columns.retain(|&(x, z)| {
|
||||
(-1..=1).any(|dz| (-1..=1).any(|dx| (dx, dz) != (0, 0) && unlit(x + dx, z + dz)))
|
||||
});
|
||||
columns
|
||||
}
|
||||
fn relight_region(
|
||||
world: &mut Region,
|
||||
tables: &LightTables,
|
||||
@@ -840,6 +885,7 @@ fn relight_region(
|
||||
relight_layer(world, tables, false, positions.to_vec(), force, changed)
|
||||
});
|
||||
if sky_light {
|
||||
let (min_y, max_y) = (world.layout.min_y(), world.layout.max_y());
|
||||
let mut seeds = positions.to_vec();
|
||||
let mut columns: FxHashMap<(i32, i32), i32> = FxHashMap::default();
|
||||
crate::spans::time("lt_columns", || {
|
||||
@@ -851,13 +897,13 @@ fn relight_region(
|
||||
columns.insert((x, z), source);
|
||||
// Cells whose source status changed: non-15 at or above the new
|
||||
// lowest source, and old 15s (sources) below it.
|
||||
for y in source..MIN_Y + HEIGHT {
|
||||
for y in source..max_y {
|
||||
if level(world, true, (x, y, z)).is_some_and(|l| l != 15) {
|
||||
seeds.push((x, y, z));
|
||||
}
|
||||
}
|
||||
let mut y = source - 1;
|
||||
while y >= MIN_Y && level(world, true, (x, y, z)) == Some(15) {
|
||||
while y >= min_y && level(world, true, (x, y, z)) == Some(15) {
|
||||
seeds.push((x, y, z));
|
||||
y -= 1;
|
||||
}
|
||||
@@ -977,6 +1023,7 @@ fn relight_layer(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::chunk::{HEIGHT, MIN_Y, SECTIONS};
|
||||
|
||||
const AIR: u32 = 0;
|
||||
const STONE: u32 = 1;
|
||||
@@ -1004,11 +1051,18 @@ mod tests {
|
||||
)
|
||||
}
|
||||
fn world(edit: impl Fn(&mut Chunk)) -> HashMap<(i32, i32), Arc<Chunk>> {
|
||||
world_of(crate::chunk::ColumnLayout::VANILLA, edit)
|
||||
}
|
||||
/// Lit chunks around (0, 0) of `layout`, stone up to y 0.
|
||||
fn world_of(
|
||||
layout: crate::chunk::ColumnLayout,
|
||||
edit: impl Fn(&mut Chunk),
|
||||
) -> HashMap<(i32, i32), Arc<Chunk>> {
|
||||
let mut chunks = HashMap::new();
|
||||
for z in -1..=1 {
|
||||
for x in -1..=1 {
|
||||
let mut c = Chunk::empty(x, z);
|
||||
for y in MIN_Y..0 {
|
||||
let mut c = Chunk::with_layout(x, z, layout);
|
||||
for y in layout.min_y()..0 {
|
||||
for i in 0..256 {
|
||||
c.set(i & 15, y, i >> 4, STONE);
|
||||
}
|
||||
@@ -1072,6 +1126,70 @@ mod tests {
|
||||
// Without the shape test the room would be lit (slabs have no dampening).
|
||||
assert_eq!(sky(&w, 8, 2, 8), 0, "the full bottom face stops sky light");
|
||||
}
|
||||
/// #2786: (1, 0) is loaded but its own light job hasn't landed; (0, 0)
|
||||
/// was lit from the 3x3's blocks, its torch's light and the far torch's
|
||||
/// in (1, 0) both. Taking its torch away darkens everything the torch
|
||||
/// lit, the far torch's light at the border too, and `relight` cannot
|
||||
/// bring that back from a column it doesn't read. Columns like that are
|
||||
/// lit again whole; without it the border stays at 0 for good once
|
||||
/// (1, 0)'s light lands beside it.
|
||||
#[test]
|
||||
fn an_edit_beside_a_column_not_lit_yet_lights_the_column_again() {
|
||||
let tables = tables();
|
||||
let mut w: HashMap<(i32, i32), Arc<Chunk>> = HashMap::new();
|
||||
for z in -1..=1 {
|
||||
for x in -1..=1 {
|
||||
let mut c = Chunk::empty(x, z);
|
||||
for y in MIN_Y..0 {
|
||||
for i in 0..256 {
|
||||
c.set(i & 15, y, i >> 4, STONE);
|
||||
}
|
||||
}
|
||||
if (x, z) == (0, 0) {
|
||||
c.set(14, 0, 8, TORCH);
|
||||
} else if (x, z) == (1, 0) {
|
||||
c.set(6, 0, 8, TORCH);
|
||||
}
|
||||
w.insert((x, z), Arc::new(c));
|
||||
}
|
||||
}
|
||||
let light = |w: &HashMap<(i32, i32), Arc<Chunk>>, (x, z): (i32, i32)| {
|
||||
let region: [[Option<&Chunk>; 3]; 3] = std::array::from_fn(|dz| {
|
||||
std::array::from_fn(|dx| {
|
||||
w.get(&(x + dx as i32 - 1, z + dz as i32 - 1)).map(|c| &**c)
|
||||
})
|
||||
});
|
||||
light_chunk(®ion, &tables, true)
|
||||
};
|
||||
let keys: Vec<_> = w.keys().copied().collect();
|
||||
for key in &keys {
|
||||
let lights = light(&w, *key);
|
||||
let c = Arc::make_mut(w.get_mut(key).unwrap());
|
||||
apply_lighting(c, lights);
|
||||
c.lit = *key != (1, 0);
|
||||
}
|
||||
// The far torch at x 22 gives 7 at x 15; (0, 0)'s own torch gives 13.
|
||||
assert_eq!(block(&w, 15, 0, 8), 13);
|
||||
|
||||
Arc::make_mut(w.get_mut(&(0, 0)).unwrap()).set(14, 0, 8, AIR);
|
||||
let mut changed = LightChanges::default();
|
||||
relight(&mut w, &tables, true, &[(14, 0, 8)], false, &mut changed);
|
||||
let again = beside_unlit(&changed, |x, z| w.get(&(x, z)).is_some_and(|c| !c.lit));
|
||||
for &key in &again {
|
||||
let lights = light(&w, key);
|
||||
let c = Arc::make_mut(w.get_mut(&key).unwrap());
|
||||
apply_relighting(c, lights, &mut LightChanges::default());
|
||||
}
|
||||
// (1, 0)'s own job lands.
|
||||
let lights = light(&w, (1, 0));
|
||||
let c = Arc::make_mut(w.get_mut(&(1, 0)).unwrap());
|
||||
apply_lighting(c, lights);
|
||||
c.lit = true;
|
||||
assert_eq!(block(&w, 16, 0, 8), 8);
|
||||
assert_eq!(block(&w, 15, 0, 8), 7, "air beside 8 across the border");
|
||||
assert_eq!(block(&w, 14, 0, 8), 6);
|
||||
assert!(again.contains(&(0, 0)), "{again:?}");
|
||||
}
|
||||
#[test]
|
||||
fn relight_keeps_unchanged_and_unlit_sections() {
|
||||
let mut w = world(|_| {});
|
||||
@@ -1218,6 +1336,66 @@ mod tests {
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn a_tall_column_is_lit_to_its_top() {
|
||||
let layout = crate::chunk::ColumnLayout::new(-64, 2096).unwrap();
|
||||
let mut chunk = Chunk::with_layout(0, 0, layout);
|
||||
for i in 0..256 {
|
||||
chunk.set(i & 15, 1600, i >> 4, STONE);
|
||||
}
|
||||
chunk.set(8, 1500, 8, TORCH);
|
||||
let region = [[None; 3], [None, Some(&chunk), None], [None; 3]];
|
||||
let lights = light_chunk(®ion, &tables(), true);
|
||||
assert_eq!(lights.len(), 131);
|
||||
apply_lighting(&mut chunk, lights);
|
||||
assert_eq!(chunk.light(8, 1500, 8) & 15, 14);
|
||||
assert_eq!(chunk.light(8, 1501, 8) & 15, 13);
|
||||
assert_eq!(chunk.light(8, 1601, 8) >> 4, 15);
|
||||
assert_eq!(chunk.light(8, 1599, 8) >> 4, 0, "under the roof");
|
||||
assert_eq!(chunk.light(8, 2031, 8), 0xF0);
|
||||
}
|
||||
#[test]
|
||||
fn a_tall_column_relights_as_its_full_light_and_keys_its_sections() {
|
||||
let layout = crate::chunk::ColumnLayout::new(-64, 2096).unwrap();
|
||||
let mut w = world_of(layout, |c| {
|
||||
for i in 0..256 {
|
||||
c.set(i & 15, 1900, i >> 4, STONE);
|
||||
}
|
||||
});
|
||||
let edits = [
|
||||
((8, 1800, 8), TORCH),
|
||||
((8, 1900, 8), AIR),
|
||||
((3, 2020, 3), TORCH),
|
||||
];
|
||||
let mut changed = LightChanges::default();
|
||||
for ((x, y, z), state) in edits {
|
||||
Arc::make_mut(w.get_mut(&(0, 0)).unwrap()).set(x, y, z, state);
|
||||
relight(&mut w, &tables(), true, &[(x, y, z)], false, &mut changed);
|
||||
}
|
||||
let region: [[Option<&Chunk>; 3]; 3] = std::array::from_fn(|dz| {
|
||||
std::array::from_fn(|dx| w.get(&(dx as i32 - 1, dz as i32 - 1)).map(|c| &**c))
|
||||
});
|
||||
let full = light_chunk(®ion, &tables(), true);
|
||||
assert_eq!(full.len(), 131);
|
||||
for (si, s) in full.iter().enumerate() {
|
||||
for i in 0..4096 {
|
||||
assert_eq!(
|
||||
s.get(i),
|
||||
w[&(0, 0)].sections[si].light(i),
|
||||
"section {si} index {i}"
|
||||
);
|
||||
}
|
||||
}
|
||||
// y 2020 is section 130 of the tall column, the top one.
|
||||
assert!(
|
||||
changed
|
||||
.sections()
|
||||
.any(|(x, sy, z)| (x, sy, z) == (0, 130, 0))
|
||||
);
|
||||
let mut dirty = Vec::new();
|
||||
changed.dirty_sections(|s| dirty.push(s.1));
|
||||
assert!(dirty.iter().all(|&sy| (0..131).contains(&sy)), "{dirty:?}");
|
||||
}
|
||||
#[test]
|
||||
fn incremental_relight_matches_full_relight() {
|
||||
let mut w = world(|c| {
|
||||
c.set(8, 0, 8, TORCH);
|
||||
@@ -1521,7 +1699,7 @@ mod tests {
|
||||
let mut old = fixture.clone();
|
||||
let mut new = fixture.clone();
|
||||
baseline_apply(&mut old, baseline_pack(&fixture, h, &sky, &block));
|
||||
apply_lighting(&mut new, pack_lighting(h, &sky, &block, true));
|
||||
apply_lighting(&mut new, pack_lighting(SECTIONS, h, &sky, &block, true));
|
||||
for (a, b) in old.sections.iter().zip(&new.sections) {
|
||||
for i in 0..4096 {
|
||||
assert_eq!(a.light(i), b.light(i));
|
||||
@@ -1538,14 +1716,20 @@ mod tests {
|
||||
let mut current = fixture.clone();
|
||||
let held = shared.then(|| current.clone());
|
||||
// Warm up and detach for the uniquely-owned scenario.
|
||||
apply_lighting(&mut current, pack_lighting(h, &sky, &block, true));
|
||||
apply_lighting(&mut current, pack_lighting(SECTIONS, h, &sky, &block, true));
|
||||
let started = Instant::now();
|
||||
for _ in 0..500 {
|
||||
let retained = shared.then(|| current.clone());
|
||||
if optimized {
|
||||
apply_lighting(
|
||||
black_box(&mut current),
|
||||
pack_lighting(h, black_box(&sky), black_box(&block), true),
|
||||
pack_lighting(
|
||||
SECTIONS,
|
||||
h,
|
||||
black_box(&sky),
|
||||
black_box(&block),
|
||||
true,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
baseline_apply(
|
||||
|
||||
@@ -161,8 +161,40 @@ impl Column {
|
||||
self.spans.push(span);
|
||||
}
|
||||
}
|
||||
/// `kinds`: `state_kinds`, per block state.
|
||||
fn from_chunk(chunk: &Chunk, kinds: &[u8], x: i32, z: i32) -> Self {
|
||||
/// Open water and its floor lit as open sky, as the seed's far columns
|
||||
/// are: a chunk's own light fades a level per block of water, so the
|
||||
/// chunks a player passed drew a darker band through the seed's sea.
|
||||
/// The water is one span again.
|
||||
fn light_open_water(&mut self, kinds: &[u8]) {
|
||||
// Water; lava glows.
|
||||
let water = |span: &Span| {
|
||||
let kind = kinds.get(span.state.0 as usize).copied().unwrap_or(0);
|
||||
kind & FLUID != 0 && kind >> 4 == 0
|
||||
};
|
||||
if !self.spans.last().is_some_and(water) {
|
||||
return;
|
||||
}
|
||||
let floor = self
|
||||
.spans
|
||||
.iter()
|
||||
.rposition(|span| !water(span))
|
||||
.unwrap_or(0);
|
||||
for (i, mut span) in std::mem::take(&mut self.spans).into_iter().enumerate() {
|
||||
if i >= floor {
|
||||
span.light |= 0xf0;
|
||||
}
|
||||
self.push(span);
|
||||
}
|
||||
}
|
||||
/// `kinds`: `state_kinds`, per block state; `water`: `water_source(kinds)`,
|
||||
/// what blocks holding water (kelp, seagrass, waterlogged ones) are drawn as.
|
||||
fn from_chunk(
|
||||
chunk: &Chunk,
|
||||
kinds: &[u8],
|
||||
water: Option<BlockStateId>,
|
||||
x: i32,
|
||||
z: i32,
|
||||
) -> Self {
|
||||
let kind = |state: BlockStateId| kinds.get(state.0 as usize).copied().unwrap_or(0);
|
||||
let face_light = |y: i32| {
|
||||
let mut sky = 0;
|
||||
@@ -187,7 +219,7 @@ impl Column {
|
||||
};
|
||||
let mut column = Self::default();
|
||||
for (sy, section) in chunk.sections.iter().enumerate() {
|
||||
let min = MIN_Y + sy as i32 * 16;
|
||||
let min = chunk.min_y() + sy as i32 * 16;
|
||||
let uniform = section.blocks.bits() == 0
|
||||
&& section.biomes.bits() == 0
|
||||
&& matches!(section.light, crate::chunk::Light::Uniform(_));
|
||||
@@ -197,11 +229,17 @@ impl Column {
|
||||
let step = if uniform { 16 } else { 1 };
|
||||
for offset in (0..16).step_by(step) {
|
||||
let y = min + offset;
|
||||
let state = BlockStateId(chunk.get(x, y, z));
|
||||
let kind = kind(state);
|
||||
let mut state = BlockStateId(chunk.get(x, y, z));
|
||||
let mut kind = kind(state);
|
||||
if kind & KEPT == 0 {
|
||||
continue;
|
||||
}
|
||||
if kind & WATERY != 0
|
||||
&& let Some(water) = water
|
||||
{
|
||||
state = water;
|
||||
kind = kinds[water.0 as usize];
|
||||
}
|
||||
let light = face_light(y);
|
||||
column.push(Span {
|
||||
min: y as i16,
|
||||
@@ -212,6 +250,7 @@ impl Column {
|
||||
});
|
||||
}
|
||||
}
|
||||
column.light_open_water(kinds);
|
||||
let mut water = None;
|
||||
for span in column.spans.iter().rev() {
|
||||
if kind(span.state) & FLUID != 0 {
|
||||
@@ -230,7 +269,7 @@ impl Column {
|
||||
&& let Some((height, state)) = water
|
||||
{
|
||||
column.surface = Some(Surface {
|
||||
height: MIN_Y,
|
||||
height: chunk.min_y(),
|
||||
state: BlockStateId(
|
||||
kinds.iter().position(|&kind| kind & AIR != 0).unwrap_or(0) as u32
|
||||
),
|
||||
@@ -243,15 +282,28 @@ impl Column {
|
||||
}
|
||||
/// A chunk's 256 columns (`Column::from_chunk`), on the far-terrain worker.
|
||||
fn summarize(chunk: &Chunk, kinds: &[u8]) -> Arc<[Column; 256]> {
|
||||
let water = water_source(kinds);
|
||||
Arc::new(std::array::from_fn(|i| {
|
||||
Column::from_chunk(chunk, kinds, (i & 15) as i32, (i >> 4) as i32)
|
||||
Column::from_chunk(chunk, kinds, water, (i & 15) as i32, (i >> 4) as i32)
|
||||
}))
|
||||
}
|
||||
/// `state_kinds` bits: kept in columns, a fluid, the air state; the light
|
||||
/// emission is in the high nibble.
|
||||
/// The water source state: the first plain kept fluid without light
|
||||
/// (`minecraft:water` comes before lava, and its level 0 first).
|
||||
fn water_source(kinds: &[u8]) -> Option<BlockStateId> {
|
||||
kinds
|
||||
.iter()
|
||||
.position(|&kind| kind == KEPT | FLUID)
|
||||
.map(|i| BlockStateId(i as u32))
|
||||
}
|
||||
/// `state_kinds` bits: kept in columns, a fluid, the air state, a block
|
||||
/// holding water; the light emission is in the high nibble.
|
||||
const KEPT: u8 = 1;
|
||||
const FLUID: u8 = 2;
|
||||
const AIR: u8 = 4;
|
||||
/// A block holding water that draws as none (kelp, seagrass, bubble columns,
|
||||
/// waterlogged blocks that aren't kept): far terrain draws water there, or
|
||||
/// kelp reaching the surface leaves holes in the sea.
|
||||
const WATERY: u8 = 8;
|
||||
/// What `Column::from_chunk` needs of each block state, read from the facts
|
||||
/// once: matching their names for every block made observing a chunk slow.
|
||||
fn state_kinds(facts: &BlockFacts) -> Vec<u8> {
|
||||
@@ -270,14 +322,29 @@ fn state_kinds(facts: &BlockFacts) -> Vec<u8> {
|
||||
|| f.full_collision
|
||||
|| f.name.ends_with("_leaves")
|
||||
|| chorus
|
||||
|| fluid);
|
||||
(u8::from(kept) * KEPT)
|
||||
| (u8::from(fluid) * FLUID)
|
||||
|| fluid
|
||||
|| nearly_full(&f.name));
|
||||
let watery = !kept && !facts.is_air(state) && f.fluid == crate::blocks::Fluid::Water;
|
||||
(u8::from(kept || watery) * KEPT)
|
||||
| (u8::from(fluid || watery) * FLUID)
|
||||
| (u8::from(watery) * WATERY)
|
||||
| (u8::from(state == facts.air) * AIR)
|
||||
| (f.emission.min(15) << 4)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// Blocks that fill their cell but neither draw solid nor collide as a full
|
||||
/// block: far terrain draws them as blocks, or patches of powder snow leave
|
||||
/// holes in mountains (and paths and fields dips).
|
||||
fn nearly_full(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"minecraft:powder_snow"
|
||||
| "minecraft:dirt_path"
|
||||
| "minecraft:farmland"
|
||||
| "minecraft:honey_block"
|
||||
) || name.ends_with("_stairs")
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct TileKey {
|
||||
pub x: i32,
|
||||
@@ -1059,6 +1126,16 @@ impl Lod {
|
||||
})
|
||||
.copied()
|
||||
.collect();
|
||||
// Retiring fine columns are empty stand-ins, excluded from `active`
|
||||
// while their normal meshes still cover the gap. Request their detail
|
||||
// explicitly: neither the active-tile requests nor sampling an already
|
||||
// ready stand-in can fill it, so its GPU sections would never retire.
|
||||
keys.extend(retiring_detail(
|
||||
&self.plan,
|
||||
&self.retiring,
|
||||
&self.saved,
|
||||
&self.detail_masks,
|
||||
));
|
||||
keys.sort_unstable_by_key(|key| (key.distance(camera), *key));
|
||||
let mut chunks = FxHashSet::default();
|
||||
for key in keys {
|
||||
@@ -1563,11 +1640,84 @@ impl Drop for Lod {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Fine retiring stand-ins need actual column detail before `Plan::replaces`
|
||||
/// can release their normal meshes. The caller keeps the usual 32-column
|
||||
/// request budget and waits for the replacement mesh before changing cutout.
|
||||
fn retiring_detail<'a>(
|
||||
plan: &'a Plan,
|
||||
retiring: &'a FxHashSet<(i32, i32)>,
|
||||
saved: &'a Saved,
|
||||
masks: &'a FxHashMap<TileKey, [u64; 4]>,
|
||||
) -> impl Iterator<Item = TileKey> + 'a {
|
||||
retiring.iter().filter_map(move |&(x, z)| {
|
||||
let key = TileKey { x, z, level: 0 };
|
||||
(plan.nodes.contains(&key)
|
||||
&& !saved.contains_key(&(x, z))
|
||||
&& masks.get(&key) != Some(&[u64::MAX; 4]))
|
||||
.then_some(key)
|
||||
})
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mesher::TerrainVertex;
|
||||
#[test]
|
||||
fn retiring_fine_stand_ins_request_detail_before_their_gpu_meshes_can_retire() {
|
||||
let column = (12, 0);
|
||||
let fine = TileKey {
|
||||
x: 12,
|
||||
z: 0,
|
||||
level: 0,
|
||||
};
|
||||
let mut plan = Plan::default();
|
||||
plan.nodes.insert(fine);
|
||||
let ready: FxHashMap<TileKey, ()> = [(fine, ())].into_iter().collect();
|
||||
let retiring = [column].into_iter().collect();
|
||||
let saved = Saved::default();
|
||||
let mut masks = FxHashMap::default();
|
||||
// A covered stand-in is ready, but empty: ordinary sampling skips it,
|
||||
// and selection leaves it out of `active`. It cannot yet replace the
|
||||
// old full-detail column, even though the near world has unloaded it.
|
||||
assert!(!plan.replaces(column, &ready, |key| masks.get(&key)
|
||||
== Some(&[u64::MAX; 4])));
|
||||
assert_eq!(
|
||||
retiring_detail(&plan, &retiring, &saved, &masks).collect::<Vec<_>>(),
|
||||
[fine]
|
||||
);
|
||||
// The existing RequestDetail -> apply_fine path marks complete detail;
|
||||
// only then may selection retire it and publish the new mesh cutout.
|
||||
masks.insert(fine, [u64::MAX; 4]);
|
||||
assert!(
|
||||
retiring_detail(&plan, &retiring, &saved, &masks)
|
||||
.next()
|
||||
.is_none()
|
||||
);
|
||||
assert!(plan.replaces(column, &ready, |key| masks.get(&key)
|
||||
== Some(&[u64::MAX; 4])));
|
||||
}
|
||||
#[test]
|
||||
fn retiring_detail_requests_only_planned_columns_still_missing_detail() {
|
||||
let fine = |x| TileKey { x, z: 0, level: 0 };
|
||||
let mut plan = Plan::default();
|
||||
plan.nodes.extend((0..4).map(fine));
|
||||
plan.nodes.insert(fine(5));
|
||||
let retiring = (0..5).map(|x| (x, 0)).collect();
|
||||
let saved = [((1, 0), Arc::new(std::array::from_fn(|_| Column::default())))]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let masks = [
|
||||
(fine(2), [u64::MAX; 4]),
|
||||
(fine(3), [u64::MAX, u64::MAX, u64::MAX, u64::MAX - 1]),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let mut requested: Vec<_> = retiring_detail(&plan, &retiring, &saved, &masks).collect();
|
||||
requested.sort_unstable();
|
||||
// Saved or complete detail needs no request; an unplanned retiree and
|
||||
// a planned column that is not retiring must not consume the budget.
|
||||
assert_eq!(requested, [fine(0), fine(3)]);
|
||||
}
|
||||
#[test]
|
||||
fn reduced_far_detail_only_merges_tiles_away_from_the_near_terrain() {
|
||||
let leaves = |plan: &Plan| {
|
||||
plan.nodes
|
||||
@@ -1772,6 +1922,82 @@ mod tests {
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn open_water_and_its_floor_are_lit_as_the_seed_lights_them() {
|
||||
let span = |min, max, state, light| Span {
|
||||
min,
|
||||
max,
|
||||
state: BlockStateId(state),
|
||||
biome: 0,
|
||||
light,
|
||||
};
|
||||
// 0 air, 1 stone, 2 water, 3 lava.
|
||||
let kinds = [AIR, KEPT, KEPT | FLUID, KEPT | FLUID | 15 << 4];
|
||||
// A chunk's own light: a cave, the floor, water fading a level a block.
|
||||
let mut sea = Column {
|
||||
surface: None,
|
||||
spans: vec![
|
||||
span(30, 40, 1, 0),
|
||||
span(40, 48, 1, 0x10 | 2 << 12),
|
||||
span(48, 61, 2, 0x20),
|
||||
span(61, 62, 2, 0xe0),
|
||||
span(62, 63, 2, 0xf0),
|
||||
],
|
||||
};
|
||||
sea.light_open_water(&kinds);
|
||||
assert_eq!(
|
||||
sea.spans,
|
||||
[
|
||||
span(30, 40, 1, 0),
|
||||
span(40, 48, 1, 0xf0 | 2 << 12),
|
||||
span(48, 63, 2, 0xf0)
|
||||
]
|
||||
);
|
||||
// Land, and lava, keep their light.
|
||||
for top in [1, 3] {
|
||||
let mut column = Column {
|
||||
surface: None,
|
||||
spans: vec![span(40, 50, 2, 0x20), span(50, 63, top, 0x80)],
|
||||
};
|
||||
let before = column.spans.clone();
|
||||
column.light_open_water(&kinds);
|
||||
assert_eq!(column.spans, before);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn nearly_full_blocks_are_kept() {
|
||||
assert!(nearly_full("minecraft:powder_snow"));
|
||||
assert!(nearly_full("minecraft:dirt_path"));
|
||||
assert!(nearly_full("minecraft:oak_stairs"));
|
||||
assert!(!nearly_full("minecraft:oak_fence"));
|
||||
assert!(!nearly_full("minecraft:wheat"));
|
||||
}
|
||||
#[test]
|
||||
fn kelp_to_the_surface_is_drawn_as_water() {
|
||||
// 0 air, 1 stone, 2 water, 3 kelp (holds water, draws as none).
|
||||
let kinds = [AIR, KEPT, KEPT | FLUID, KEPT | FLUID | WATERY];
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
for x in [3, 4] {
|
||||
for y in MIN_Y..40 {
|
||||
chunk.set(x, y, 3, 1);
|
||||
}
|
||||
}
|
||||
for y in 40..63 {
|
||||
chunk.set(3, y, 3, 3);
|
||||
chunk.set(4, y, 3, 2);
|
||||
}
|
||||
let water = water_source(&kinds);
|
||||
assert_eq!(water, Some(BlockStateId(2)));
|
||||
let kelp = Column::from_chunk(&chunk, &kinds, water, 3, 3);
|
||||
let sea = Column::from_chunk(&chunk, &kinds, water, 4, 3);
|
||||
assert_eq!(kelp.spans, sea.spans);
|
||||
assert_eq!(
|
||||
kelp.spans.last().map(|s| (s.min, s.max, s.state)),
|
||||
Some((40, 63, BlockStateId(2)))
|
||||
);
|
||||
assert_eq!(kelp.surface.unwrap().water, Some((63, BlockStateId(2))));
|
||||
assert_eq!(kelp.surface.unwrap().height, 40);
|
||||
}
|
||||
#[test]
|
||||
fn coarse_voxels_reduce_the_area_instead_of_inflating_one_leaf() {
|
||||
let span = |min, max, state| Span {
|
||||
min,
|
||||
@@ -1967,6 +2193,24 @@ mod tests {
|
||||
chunk.set(8, 71, 8, chorus.0);
|
||||
chunk.set(8, 72, 8, chorus.0);
|
||||
chunk.set(8, 73, 8, flower.0);
|
||||
// Kelp to the surface is water; powder snow and a waterlogged stair
|
||||
// are kept as themselves.
|
||||
let state = |name: &str| assets.registry.default_state(name).unwrap();
|
||||
let (kelp, powder_snow) = (state("minecraft:kelp"), state("minecraft:powder_snow"));
|
||||
let stair = facts
|
||||
.states
|
||||
.iter()
|
||||
.position(|f| &*f.name == "minecraft:oak_stairs" && f.waterlogged)
|
||||
.map(|i| BlockStateId(i as u32))
|
||||
.unwrap();
|
||||
chunk.set(2, 54, 2, stone.0);
|
||||
for y in 55..64 {
|
||||
chunk.set(2, y, 2, kelp.0);
|
||||
}
|
||||
chunk.set(6, 90, 6, stone.0);
|
||||
chunk.set(6, 91, 6, powder_snow.0);
|
||||
chunk.set(10, 54, 10, stone.0);
|
||||
chunk.set(10, 55, 10, stair.0);
|
||||
lod.observe(Arc::new(chunk.clone()), &facts);
|
||||
receive_observed(&mut lod);
|
||||
let observed = &lod.saved[&(-1, -1)];
|
||||
@@ -1984,6 +2228,12 @@ mod tests {
|
||||
.collect::<Vec<_>>(),
|
||||
vec![(70, 71, end_stone), (71, 73, chorus), (73, 74, flower)]
|
||||
);
|
||||
let kelp_column = &observed[2 * 16 + 2];
|
||||
assert_eq!(kelp_column.surface.unwrap().height, 55);
|
||||
assert_eq!(kelp_column.surface.unwrap().water, Some((64, water)));
|
||||
assert_eq!(observed[6 * 16 + 6].surface.unwrap().state, powder_snow);
|
||||
assert_eq!(observed[6 * 16 + 6].surface.unwrap().height, 92);
|
||||
assert_eq!(observed[10 * 16 + 10].surface.unwrap().state, stair);
|
||||
|
||||
let fine = TileKey {
|
||||
x: -1,
|
||||
|
||||
@@ -45,9 +45,20 @@ pub fn data_dir() -> PathBuf {
|
||||
std::env::temp_dir()
|
||||
}
|
||||
|
||||
/// Where the launcher runs the game and keeps its saves: `HORIZON_HOME`, else `horizon` in the
|
||||
/// platform data folder (launcher/src/backend.rs `game_home`).
|
||||
/// `--home DIR`, set once at start; it wins over `HORIZON_HOME`.
|
||||
static HOME: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
|
||||
|
||||
/// Makes `game_home` return `home` for the rest of the run (`--home`).
|
||||
pub fn set_game_home(home: PathBuf) {
|
||||
let _ = HOME.set(home);
|
||||
}
|
||||
|
||||
/// Where the launcher runs the game and keeps its saves: `--home`, else `HORIZON_HOME`, else
|
||||
/// `horizon` in the platform data folder (launcher/src/backend.rs `game_home`).
|
||||
pub fn game_home() -> PathBuf {
|
||||
if let Some(home) = HOME.get() {
|
||||
return home.clone();
|
||||
}
|
||||
match std::env::var_os("HORIZON_HOME").filter(|h| !h.is_empty()) {
|
||||
Some(h) => PathBuf::from(h),
|
||||
None => data_dir().join("horizon"),
|
||||
|
||||
@@ -639,7 +639,7 @@ impl Padded {
|
||||
fn new(region: &[[Option<&Chunk>; 3]; 3], sy: i32) -> Self {
|
||||
let mut blocks = vec![0u32; P * P * P];
|
||||
let mut light = vec![0u8; P * P * P];
|
||||
let base_y = MIN_Y + sy * 16;
|
||||
let base_y = region[1][1].map_or(MIN_Y, Chunk::min_y) + sy * 16;
|
||||
// The section itself is unpacked in one pass; the border cells of the
|
||||
// neighbouring sections are read one by one.
|
||||
let mut own = Box::new([0u32; 4096]);
|
||||
@@ -848,7 +848,7 @@ impl Mesher<'_> {
|
||||
}
|
||||
let pad = Padded::new(region, sy);
|
||||
out.visibility = visibility(&pad, &self.tables.info);
|
||||
let base_y = MIN_Y + sy * 16;
|
||||
let base_y = center.min_y() + sy * 16;
|
||||
let mut tints = Tints::new(
|
||||
region,
|
||||
self.biomes,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//! changed.
|
||||
use crate::{
|
||||
blocks::{BlockFacts, Fluid, MAP_COLORS},
|
||||
chunk::{Chunk, MIN_Y, SECTIONS},
|
||||
chunk::Chunk,
|
||||
fast_hash::FxHashMap,
|
||||
world::World,
|
||||
};
|
||||
@@ -113,7 +113,8 @@ fn scan_column(
|
||||
return (color | WALL, head as i16, 0);
|
||||
}
|
||||
}
|
||||
while y > MIN_Y {
|
||||
let bottom = chunk.min_y();
|
||||
while y > bottom {
|
||||
let (mut color, flags) = palette.get(chunk.get(x, y, z));
|
||||
if color == 0 {
|
||||
y -= 1;
|
||||
@@ -123,7 +124,7 @@ fn scan_column(
|
||||
let fluid = flags & FLUID;
|
||||
if fluid != 0 {
|
||||
let mut below = y - 1;
|
||||
while below > MIN_Y && palette.get(chunk.get(x, below, z)).1 & FLUID != 0 {
|
||||
while below > bottom && palette.get(chunk.get(x, below, z)).1 & FLUID != 0 {
|
||||
depth = depth.saturating_add(1);
|
||||
below -= 1;
|
||||
}
|
||||
@@ -134,11 +135,11 @@ fn scan_column(
|
||||
}
|
||||
return (color, y as i16, depth);
|
||||
}
|
||||
(0, MIN_Y as i16, 0)
|
||||
(0, bottom as i16, 0)
|
||||
}
|
||||
/// The highest block of a chunk worth scanning from.
|
||||
fn top_of(chunk: &Chunk, air: u32, layer: Layer) -> i32 {
|
||||
let surface = (0..SECTIONS)
|
||||
let surface = (0..chunk.sections.len())
|
||||
.rev()
|
||||
.find(|&s| {
|
||||
chunk
|
||||
@@ -146,7 +147,7 @@ fn top_of(chunk: &Chunk, air: u32, layer: Layer) -> i32 {
|
||||
.get(s)
|
||||
.is_some_and(|sec| !sec.is_uniform(air))
|
||||
})
|
||||
.map_or(MIN_Y, |s| MIN_Y + s as i32 * 16 + 15);
|
||||
.map_or(chunk.min_y(), |s| chunk.min_y() + s as i32 * 16 + 15);
|
||||
match layer {
|
||||
Layer::Surface => surface,
|
||||
Layer::Below(head) => surface.min(head),
|
||||
@@ -156,7 +157,7 @@ fn scan_tile(chunk: &Chunk, palette: &Palette, air: u32, layer: Layer, revision:
|
||||
let top = top_of(chunk, air, layer);
|
||||
let mut tile = Tile {
|
||||
color: [0; 256],
|
||||
height: [MIN_Y as i16; 256],
|
||||
height: [chunk.min_y() as i16; 256],
|
||||
depth: [0; 256],
|
||||
revision,
|
||||
layer,
|
||||
|
||||
@@ -45,6 +45,7 @@ pub struct ModelInstance {
|
||||
pub layer: u32,
|
||||
pub shade: f32,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct PreparedModels {
|
||||
pub catalog: StateModels,
|
||||
pub placements: Vec<(BlockPos, BlockStateId)>,
|
||||
|
||||
@@ -42,6 +42,9 @@ pub struct BakedModel {
|
||||
pub cube: Option<[super::cube::CubeStyle; 6]>,
|
||||
}
|
||||
type VariantKey = (String, [u8; 3], bool);
|
||||
/// Empty by default: no state has a model (a dedicated server, which draws
|
||||
/// nothing).
|
||||
#[derive(Default)]
|
||||
pub struct StateModels {
|
||||
/// Each definition's choices, a variant standing as its index in `baked`.
|
||||
choices: Vec<CompiledChoices<u32>>,
|
||||
|
||||
@@ -141,9 +141,7 @@ impl Opt {
|
||||
| Opt::ChunkFade
|
||||
| Opt::ImprovedTransparency
|
||||
| Opt::ChunkBuilder
|
||||
// Saved, but the creative tabs, the quit keys and the mouse
|
||||
// don't read them yet.
|
||||
| Opt::OperatorItemsTab
|
||||
// Saved, but the quit keys and the mouse don't read them yet.
|
||||
| Opt::QuitShortcuts
|
||||
| Opt::CtrlClickEmulatesRightClick
|
||||
)
|
||||
@@ -952,6 +950,24 @@ pub fn set_slider(opt: Opt, s: &mut Settings, v: f32) {
|
||||
apply_preset(s);
|
||||
}
|
||||
}
|
||||
/// A slider pressed or dragged to `v`: apply it live, and tell the game when
|
||||
/// a category volume changed (`Options.createSoundSliderOptionInstance`'s
|
||||
/// callback, which `OptionInstance.set` runs only for a new value).
|
||||
fn drag_slider(opt: Opt, s: &mut Settings, v: f32, events: &mut Vec<Event>) {
|
||||
let volume = |s: &Settings| match opt {
|
||||
Opt::Volume(0) => Some(s.master_volume),
|
||||
Opt::Volume(i) => Some(s.volumes[i]),
|
||||
_ => None,
|
||||
};
|
||||
let before = volume(s);
|
||||
set_slider(opt, s, v);
|
||||
events.push(Event::SliderDrag);
|
||||
if let (Opt::Volume(source), Some(after)) = (opt, volume(s))
|
||||
&& before != Some(after)
|
||||
{
|
||||
events.push(Event::Volume(source, after));
|
||||
}
|
||||
}
|
||||
/// GraphicsPreset.apply: the options Fast, Fancy and Fabulous set (texture
|
||||
/// filtering and anisotropy have no option here yet).
|
||||
pub fn apply_preset(s: &mut Settings) {
|
||||
@@ -1106,6 +1122,9 @@ pub enum Event {
|
||||
Changed,
|
||||
/// A slider is being dragged: apply settings live but defer saving.
|
||||
SliderDrag,
|
||||
/// A category volume slider (`SoundSource` index) moved to a new value,
|
||||
/// after its `SliderDrag` (the sound slider's `onValueUpdate`).
|
||||
Volume(usize, f32),
|
||||
/// The language changed (reload translations).
|
||||
Language(String),
|
||||
/// The pack selection changed (reload resources).
|
||||
@@ -2747,8 +2766,7 @@ impl OptionsUi {
|
||||
{
|
||||
let v = ui::slider_value(wd.x, wd.w, x);
|
||||
self.slider_value = Some((opt, v, Instant::now()));
|
||||
set_slider(opt, s, v);
|
||||
events.push(Event::SliderDrag);
|
||||
drag_slider(opt, s, v, &mut events);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
@@ -2797,26 +2815,7 @@ impl OptionsUi {
|
||||
}
|
||||
Act::Done => events.extend(self.back(s)),
|
||||
Act::Cycle(opt) => {
|
||||
opt.cycle(s, right, framebuffer);
|
||||
if opt == Opt::Preset {
|
||||
apply_preset(s);
|
||||
}
|
||||
if opt == Opt::HighContrast {
|
||||
// Options.highContrast: the built-in pack follows the option.
|
||||
let on = s.resource_packs.iter().any(|id| id == "high_contrast");
|
||||
if on != s.high_contrast {
|
||||
s.resource_packs.retain(|id| id != "high_contrast");
|
||||
if s.high_contrast {
|
||||
s.resource_packs.push("high_contrast".into());
|
||||
}
|
||||
// PackSelectionScreen reads the repository each time it
|
||||
// opens: the next one starts from this selection.
|
||||
self.pack_selection = s.resource_packs.clone();
|
||||
self.pack_screen = None;
|
||||
events.push(Event::Packs(s.resource_packs.clone()));
|
||||
}
|
||||
}
|
||||
events.push(Event::Changed);
|
||||
events.extend(self.cycle_option(s, opt, right, framebuffer));
|
||||
}
|
||||
Act::Extra(item) => {
|
||||
if let Some(name) = item.key_name() {
|
||||
@@ -2880,8 +2879,7 @@ impl OptionsUi {
|
||||
self.dragging = Some(opt);
|
||||
let v = ui::slider_value(wd.x, wd.w, x);
|
||||
self.slider_value = Some((opt, v, Instant::now()));
|
||||
set_slider(opt, s, v);
|
||||
events.push(Event::SliderDrag);
|
||||
drag_slider(opt, s, v, &mut events);
|
||||
}
|
||||
WKind::LanguageRow { code, .. } => {
|
||||
if self.language_choice.as_deref() == Some(code.as_str()) {
|
||||
@@ -2924,6 +2922,77 @@ impl OptionsUi {
|
||||
self.scroll -= amount * 18.0;
|
||||
self.clamp_scroll(top, bottom, content);
|
||||
}
|
||||
/// `CycleButton.cycleValue` on an option, then what the option changes:
|
||||
/// the preset's values, High Contrast's built-in pack.
|
||||
fn cycle_option(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
opt: Opt,
|
||||
back: bool,
|
||||
framebuffer: [i32; 2],
|
||||
) -> Vec<Event> {
|
||||
let mut events = Vec::new();
|
||||
opt.cycle(s, back, framebuffer);
|
||||
if opt == Opt::Preset {
|
||||
apply_preset(s);
|
||||
}
|
||||
if opt == Opt::HighContrast {
|
||||
// Options.highContrast: the built-in pack follows the option.
|
||||
let on = s.resource_packs.iter().any(|id| id == "high_contrast");
|
||||
if on != s.high_contrast {
|
||||
s.resource_packs.retain(|id| id != "high_contrast");
|
||||
if s.high_contrast {
|
||||
s.resource_packs.push("high_contrast".into());
|
||||
}
|
||||
// PackSelectionScreen reads the repository each time it
|
||||
// opens: the next one starts from this selection.
|
||||
self.pack_selection = s.resource_packs.clone();
|
||||
self.pack_screen = None;
|
||||
events.push(Event::Packs(s.resource_packs.clone()));
|
||||
}
|
||||
}
|
||||
events.push(Event::Changed);
|
||||
events
|
||||
}
|
||||
/// The mouse wheel at the cursor. Over a cycle button it takes the step
|
||||
/// (`CycleButton.mouseScrolled`: up goes back) and the list stays put.
|
||||
pub fn wheel(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
size: (f32, f32),
|
||||
cursor: (f32, f32),
|
||||
framebuffer: [i32; 2],
|
||||
amount: f32,
|
||||
) -> Vec<Event> {
|
||||
let plain = self.resource_error.is_none()
|
||||
&& Self::graphics_level(self.page()).is_none()
|
||||
&& !(self.pack_screen.is_some() && self.stack.last() == Some(&Page::Packs));
|
||||
if !plain {
|
||||
self.scroll_at(s, size, cursor, amount);
|
||||
return Vec::new();
|
||||
}
|
||||
let (widgets, top, bottom, _) = self.layout(s, size.0, size.1);
|
||||
let hovered = widgets.iter().find_map(|wd| match &wd.kind {
|
||||
WKind::Button {
|
||||
active: true,
|
||||
act: Act::Cycle(opt),
|
||||
..
|
||||
} if ui::hovered(cursor, wd.x, wd.y, wd.w, wd.h)
|
||||
&& !(wd.list && (cursor.1 < top || cursor.1 >= bottom)) =>
|
||||
{
|
||||
Some(*opt)
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
match hovered {
|
||||
Some(opt) if amount != 0.0 => self.cycle_option(s, opt, amount > 0.0, framebuffer),
|
||||
Some(_) => Vec::new(),
|
||||
None => {
|
||||
self.scroll_at(s, size, cursor, amount);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The mouse wheel at the cursor.
|
||||
pub fn scroll_at(&mut self, s: &Settings, size: (f32, f32), cursor: (f32, f32), amount: f32) {
|
||||
if self.resource_error.is_some() {
|
||||
@@ -2940,6 +3009,33 @@ impl OptionsUi {
|
||||
_ => self.scroll(s, size, amount),
|
||||
}
|
||||
}
|
||||
/// `VideoSettingsScreen.mouseScrolled`: with Ctrl held the wheel steps the GUI scale
|
||||
/// (auto counts as one past the maximum) and the list goes back to the top.
|
||||
pub fn video_wheel_gui_scale(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
framebuffer: [i32; 2],
|
||||
amount: f32,
|
||||
) -> Vec<Event> {
|
||||
let max = i64::from(crate::window::calculate_scale(
|
||||
framebuffer,
|
||||
0,
|
||||
s.force_unicode,
|
||||
));
|
||||
let old = if s.gui_scale == 0 {
|
||||
max + 1
|
||||
} else {
|
||||
i64::from(s.gui_scale)
|
||||
};
|
||||
let step = i64::from(amount > 0.0) - i64::from(amount < 0.0);
|
||||
let new = old + step;
|
||||
if new == 0 || new > max {
|
||||
return Vec::new();
|
||||
}
|
||||
s.gui_scale = new as u32;
|
||||
self.scroll = 0.0;
|
||||
vec![Event::Changed]
|
||||
}
|
||||
/// The pack screen at this GUI size, made on first use.
|
||||
fn packs(&mut self, atlas: &HudAtlas, size: (f32, f32)) -> &mut crate::pack_screen::PackScreen {
|
||||
let m = crate::screen_draw::Measure(&atlas.font);
|
||||
@@ -3336,6 +3432,16 @@ mod tests {
|
||||
assert!(loaded.toggle_attack && loaded.toggle_use);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operator_items_tab_option_can_be_switched() {
|
||||
// The creative screen reads it (CreativeModeInventoryScreen.hasPermissions).
|
||||
assert!(Opt::OperatorItemsTab.available());
|
||||
let mut s = Settings::default();
|
||||
assert!(!s.operator_items_tab);
|
||||
Opt::OperatorItemsTab.cycle(&mut s, false, [854, 480]);
|
||||
assert!(s.operator_items_tab);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_distance_slider_round_trips_through_extended_options_range() {
|
||||
let mut s = Settings::default();
|
||||
@@ -4014,7 +4120,7 @@ mod tests {
|
||||
for cx in [-100.0, 250.0, 1000.0] {
|
||||
assert_eq!(
|
||||
ui.click(&mut s, &atlas, cx, -100.0, size, [854, 480], true, false),
|
||||
vec![Event::SliderDrag]
|
||||
vec![Event::SliderDrag, Event::Volume(0, s.master_volume)]
|
||||
);
|
||||
}
|
||||
assert_eq!(ui.release(), vec![Event::ButtonClick, Event::Changed]);
|
||||
@@ -4024,6 +4130,35 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_moved_volume_slider_reports_its_category_and_value() {
|
||||
let atlas = atlas();
|
||||
let size = (480.0, 320.0);
|
||||
let mut s = Settings::default();
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::Sound);
|
||||
let hostile = SOUND_SOURCES.iter().position(|n| *n == "hostile").unwrap();
|
||||
let (widgets, _, _, _) = ui.layout(&s, size.0, size.1);
|
||||
let slider = widgets
|
||||
.iter()
|
||||
.find(|wd| matches!(wd.kind, WKind::Slider { opt: Opt::Volume(i), .. } if i == hostile))
|
||||
.unwrap();
|
||||
let y = slider.y + 1.0;
|
||||
let x = slider.x + 4.0 + (slider.w - 8.0) * 0.5;
|
||||
let events = ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
|
||||
let volume = s.volumes[hostile];
|
||||
assert!((volume - 0.5).abs() < 1e-3, "{volume}");
|
||||
assert_eq!(
|
||||
events,
|
||||
vec![Event::SliderDrag, Event::Volume(hostile, volume)]
|
||||
);
|
||||
// OptionInstance.set: an unchanged value runs no callback.
|
||||
assert_eq!(
|
||||
ui.click(&mut s, &atlas, x, -50.0, size, [854, 480], true, false),
|
||||
vec![Event::SliderDrag]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_sliders_follow_the_pointer_and_snap_on_release() {
|
||||
let atlas = atlas();
|
||||
|
||||
@@ -44,6 +44,9 @@ pub struct ParticleOptions {
|
||||
/// `ItemParticleOption`: the particle sprite of each layer of the item's
|
||||
/// model as resolved for the ground.
|
||||
pub item: Option<Vec<String>>,
|
||||
/// `GeyserParticleOptions.waterBlocks` and `GeyserBaseParticleOptions`'
|
||||
/// `waterBlocks` and `burstImpulseBase` (0 where the type has none).
|
||||
pub geyser: Option<(i32, f32)>,
|
||||
}
|
||||
impl ParticleOptions {
|
||||
pub fn simple(ty: ParticleType) -> Self {
|
||||
@@ -55,6 +58,21 @@ impl ParticleOptions {
|
||||
to_color: [1.0, 0.0, 0.0],
|
||||
trail: None,
|
||||
item: None,
|
||||
geyser: None,
|
||||
}
|
||||
}
|
||||
/// `GeyserParticleOptions(type, waterBlocks)`.
|
||||
pub fn geyser(ty: ParticleType, water_blocks: i32) -> Self {
|
||||
Self {
|
||||
geyser: Some((water_blocks, 0.0)),
|
||||
..Self::simple(ty)
|
||||
}
|
||||
}
|
||||
/// `GeyserBaseParticleOptions(type, waterBlocks, burstImpulseBase)`.
|
||||
pub fn geyser_base(ty: ParticleType, water_blocks: i32, burst_impulse_base: f32) -> Self {
|
||||
Self {
|
||||
geyser: Some((water_blocks, burst_impulse_base)),
|
||||
..Self::simple(ty)
|
||||
}
|
||||
}
|
||||
/// `ItemParticleOption`: the stack's layer particle sprites (`BreakingItemParticle`).
|
||||
@@ -189,6 +207,29 @@ enum Behaviour {
|
||||
size_start: f32,
|
||||
y_prev: f64,
|
||||
},
|
||||
/// `NoxiousGasParticle`: fades out from `fade_start` ticks of age.
|
||||
NoxiousGas {
|
||||
fade_start: f32,
|
||||
},
|
||||
/// `NoxiousGasCloudParticle`: every second tick, a gas particle where the
|
||||
/// gas reaches.
|
||||
NoxiousGasCloud,
|
||||
/// `GeyserEruptionParticle`: the base puffs, the plume and the poofs of
|
||||
/// one eruption; `aux` is the speed its provider was given.
|
||||
GeyserEruption {
|
||||
water_blocks: i32,
|
||||
aux: [f64; 3],
|
||||
},
|
||||
/// `GeyserPlumeParticle`: shoots up `plumeHeight` blocks, then falls back.
|
||||
GeyserPlume {
|
||||
start_y: f64,
|
||||
max_y: f64,
|
||||
propulsion: f32,
|
||||
spray: [f32; 2],
|
||||
min_size: f32,
|
||||
max_size: f32,
|
||||
done: bool,
|
||||
},
|
||||
BubblePop,
|
||||
BubbleColumnUp,
|
||||
CurrentDown {
|
||||
@@ -869,6 +910,26 @@ impl Particle {
|
||||
(age / self.lifetime as f32).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
/// `NoxiousGasCloudParticle.pickRandomParticleSpawnPoint`: a point up to three
|
||||
/// blocks from the centre of `center_block` in a random horizontal direction,
|
||||
/// a quarter block lower, from the level's random.
|
||||
fn pick_random_particle_spawn_point(random: &mut LegacyRandom, center_block: [i32; 3]) -> [f64; 3] {
|
||||
let dx = f64::from(random.next_float() - 0.5);
|
||||
let dz = f64::from(random.next_float() - 0.5);
|
||||
// Vec3.normalize.
|
||||
let length = (dx * dx + dz * dz).sqrt();
|
||||
let (dx, dz) = if length < f64::from(1.0e-5f32) {
|
||||
(0.0, 0.0)
|
||||
} else {
|
||||
(dx / length, dz / length)
|
||||
};
|
||||
let distance = f64::from(random.next_float() * 3.0);
|
||||
[
|
||||
f64::from(center_block[0]) + 0.5 + dx * distance,
|
||||
f64::from(center_block[1]) + 0.5 - 0.25,
|
||||
f64::from(center_block[2]) + 0.5 + dz * distance,
|
||||
]
|
||||
}
|
||||
/// `FireflyParticle.getFadeAmount`.
|
||||
fn fade_amount(progress: f32, fade_in: f32, fade_out: f32) -> f32 {
|
||||
if progress >= 1.0 - fade_in {
|
||||
@@ -928,7 +989,7 @@ impl ParticleLevel<'_> {
|
||||
Err(_) => d,
|
||||
}
|
||||
}
|
||||
fn block_at(p: [f64; 3]) -> [i32; 3] {
|
||||
pub(crate) fn block_at(p: [f64; 3]) -> [i32; 3] {
|
||||
[
|
||||
p[0].floor() as i32,
|
||||
p[1].floor() as i32,
|
||||
@@ -950,7 +1011,7 @@ impl ParticleLevel<'_> {
|
||||
(s.fluid, height)
|
||||
}
|
||||
/// The fluid at a block and whether it is a source (`FluidState.isSource`).
|
||||
fn fluid_source(&self, b: [i32; 3]) -> (Fluid, bool) {
|
||||
pub(crate) fn fluid_source(&self, b: [i32; 3]) -> (Fluid, bool) {
|
||||
let s = self.facts.get(self.world.block(b[0], b[1], b[2]));
|
||||
(s.fluid, s.fluid_level == 8 && !s.fluid_falling)
|
||||
}
|
||||
@@ -1165,6 +1226,17 @@ impl ParticleEngine {
|
||||
self.create(options, p, v);
|
||||
}
|
||||
}
|
||||
/// `NoxiousGasCloudParticle.spawnNoxiousGasParticle`: the gas, as an
|
||||
/// always visible particle (`ClientLevel.addAlwaysVisibleParticle`).
|
||||
fn spawn_noxious_gas_particle(&mut self, pos: [f64; 3]) {
|
||||
self.add_with(
|
||||
&ParticleOptions::simple(ParticleType::NoxiousGas),
|
||||
pos,
|
||||
[0.0; 3],
|
||||
false,
|
||||
true,
|
||||
);
|
||||
}
|
||||
/// `ClientLevel.calculateParticleLevel`: minimal still shows one in ten
|
||||
/// always-visible particles as decreased, and decreased drops a third.
|
||||
fn calculate_particle_level(&mut self, always_show: bool) -> u8 {
|
||||
@@ -1562,7 +1634,15 @@ impl ParticleEngine {
|
||||
q.set = set;
|
||||
q
|
||||
}
|
||||
T::Smoke | T::LargeSmoke | T::WhiteSmoke | T::Ash | T::WhiteAsh | T::DustPlume => {
|
||||
T::Smoke
|
||||
| T::LargeSmoke
|
||||
| T::WhiteSmoke
|
||||
| T::Ash
|
||||
| T::WhiteAsh
|
||||
| T::DustPlume
|
||||
| T::NoxiousGas
|
||||
| T::GeyserBase
|
||||
| T::GeyserPoof => {
|
||||
// BaseAshSmokeParticle(dir, xa, scale, colorRandom, maxLifetime, gravity, hasPhysics).
|
||||
let (dir, scale, color_random, max_lifetime, gravity, physics, vel) = match ty {
|
||||
T::Smoke | T::WhiteSmoke => ([0.1, 0.1, 0.1], 1.0, 0.3, 8, -0.1, true, v),
|
||||
@@ -1578,6 +1658,22 @@ impl ParticleEngine {
|
||||
false,
|
||||
[v[0], v[1] + f64::from(0.15f32), v[2]],
|
||||
),
|
||||
// NoxiousGasParticle.Provider: scale 3.
|
||||
T::NoxiousGas => ([0.1, 0.1, 0.1], 3.0, 0.3, 5, -0.02, true, v),
|
||||
// GeyserBaseParticle: the burst impulse scales every axis.
|
||||
T::GeyserBase | T::GeyserPoof => {
|
||||
let (water_blocks, burst_impulse_base) = o.geyser?;
|
||||
let burst_impulse = burst_impulse_base + 0.25 * water_blocks as f32;
|
||||
(
|
||||
[f64::from(burst_impulse); 3],
|
||||
3.0 + 0.125 * water_blocks as f32,
|
||||
0.0,
|
||||
0,
|
||||
0.0,
|
||||
true,
|
||||
v,
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
// WhiteAshParticle.Provider draws the motion.
|
||||
let pr = &mut self.random;
|
||||
@@ -1593,6 +1689,18 @@ impl ParticleEngine {
|
||||
([0.1, -0.1, 0.1], 1.0, 0.0, 20, 0.0125, false, [xa, ya, za])
|
||||
}
|
||||
};
|
||||
// GeyserBaseParticle.Provider: a random spot half a block
|
||||
// around the point, a fifth of a block up (the provider's
|
||||
// random, as float arithmetic added to the double position).
|
||||
let p = if matches!(ty, T::GeyserBase | T::GeyserPoof) {
|
||||
let pr = &mut self.random;
|
||||
let x = p[0] + f64::from((pr.next_float() - 0.5) * 0.5);
|
||||
let y = p[1] + f64::from((pr.next_float() - 0.5) * 0.5) + f64::from(0.2f32);
|
||||
let z = p[2] + f64::from((pr.next_float() - 0.5) * 0.5);
|
||||
[x, y, z]
|
||||
} else {
|
||||
p
|
||||
};
|
||||
let mut q = Particle::moving(r, ty, p, [0.0; 3]);
|
||||
q.friction = 0.96;
|
||||
q.gravity = gravity;
|
||||
@@ -1621,6 +1729,87 @@ impl ParticleEngine {
|
||||
);
|
||||
q.behaviour = Behaviour::DustPlume;
|
||||
}
|
||||
match ty {
|
||||
// NoxiousGasParticle: white, living `6 / (0.5 to 1) * 3`
|
||||
// ticks, fading out over its second half.
|
||||
T::NoxiousGas => {
|
||||
q.set_color(1.0, 1.0, 1.0);
|
||||
q.lifetime = (6.0 / (f64::from(r.next_float()) * 0.5 + 0.5)
|
||||
* f64::from(3.0f32)) as i32;
|
||||
q.behaviour = Behaviour::NoxiousGas {
|
||||
fade_start: q.lifetime as f32 / 2.0,
|
||||
};
|
||||
}
|
||||
// GeyserBaseParticle: white, only ever rising, 20 to 25 ticks.
|
||||
T::GeyserBase | T::GeyserPoof => {
|
||||
q.friction = 0.725;
|
||||
q.set_color(1.0, 1.0, 1.0);
|
||||
q.velocity[1] = q.velocity[1].abs();
|
||||
let lifetime_factor = 0.8 + 0.2 * self.level_random.next_float();
|
||||
q.lifetime = (25.0 * lifetime_factor) as i32;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
q.set = set;
|
||||
q
|
||||
}
|
||||
// NoxiousGasCloudParticle: lives 20 ticks, draws nothing itself.
|
||||
T::NoxiousGasCloud => {
|
||||
let mut q = Particle::base_at(r, ty, p);
|
||||
q.lifetime = 20;
|
||||
q.no_render = true;
|
||||
q.behaviour = Behaviour::NoxiousGasCloud;
|
||||
q
|
||||
}
|
||||
// GeyserEruptionParticle: lives 20 ticks, draws nothing itself.
|
||||
T::Geyser => {
|
||||
let (water_blocks, _) = o.geyser?;
|
||||
let mut q = Particle::base_at(r, ty, p);
|
||||
q.lifetime = 20;
|
||||
q.no_render = true;
|
||||
q.behaviour = Behaviour::GeyserEruption {
|
||||
water_blocks,
|
||||
aux: v,
|
||||
};
|
||||
q
|
||||
}
|
||||
// GeyserPlumeParticle, after its provider's random placement.
|
||||
T::GeyserPlume => {
|
||||
let (water_blocks, _) = o.geyser?;
|
||||
let pr = &mut self.random;
|
||||
let at = [
|
||||
p[0] + f64::from((pr.next_float() - 0.5) * 0.2),
|
||||
p[1] + f64::from(pr.next_float()),
|
||||
p[2] + f64::from((pr.next_float() - 0.5) * 0.2),
|
||||
];
|
||||
let plume_height = 5 * water_blocks.max(1);
|
||||
let mut q = Particle::moving(r, ty, at, v);
|
||||
q.has_physics = true;
|
||||
q.speed_up_when_y_blocked = true;
|
||||
q.lifetime = plume_height * 5;
|
||||
q.velocity[1] = 0.0;
|
||||
let level = &mut self.level_random;
|
||||
let spray = [
|
||||
(level.next_float() - 0.5) * 0.2,
|
||||
(level.next_float() - 0.5) * 0.2,
|
||||
];
|
||||
q.friction = 1.0;
|
||||
let propulsion =
|
||||
(if water_blocks == 1 { 1.5 } else { 1.0 }) * plume_height as f32 * 1.45;
|
||||
q.gravity = -propulsion;
|
||||
let initial_size = q.size * 0.75;
|
||||
let min_size = initial_size * (2.0 + plume_height as f32 / 8.0);
|
||||
let max_size = initial_size * (3.0 + plume_height as f32 / 8.0);
|
||||
q.size = min_size;
|
||||
q.behaviour = Behaviour::GeyserPlume {
|
||||
start_y: at[1],
|
||||
max_y: at[1] + f64::from(plume_height) - 1.0,
|
||||
propulsion,
|
||||
spray,
|
||||
min_size,
|
||||
max_size,
|
||||
done: false,
|
||||
};
|
||||
q.set = set;
|
||||
q
|
||||
}
|
||||
@@ -3198,6 +3387,98 @@ impl ParticleEngine {
|
||||
y_prev: p.position[1],
|
||||
};
|
||||
}
|
||||
Behaviour::NoxiousGas { fade_start } => {
|
||||
p.base_tick(level);
|
||||
self.sprite_from_age(p);
|
||||
if p.age as f32 > fade_start {
|
||||
let frames_since_fade_out_start = p.age as f32 - fade_start;
|
||||
p.rgba[3] =
|
||||
(p.lifetime as f32 - frames_since_fade_out_start) / p.lifetime as f32;
|
||||
}
|
||||
}
|
||||
Behaviour::NoxiousGasCloud => {
|
||||
p.base_tick(level);
|
||||
if p.age % 2 == 0 {
|
||||
let source = ParticleLevel::block_at(p.position);
|
||||
let spawn = pick_random_particle_spawn_point(&mut self.level_random, source);
|
||||
if crate::potent_sulfur::can_be_reached_by_noxious_gas(level, source, spawn) {
|
||||
self.spawn_noxious_gas_particle(spawn);
|
||||
}
|
||||
}
|
||||
}
|
||||
Behaviour::GeyserEruption { water_blocks, aux } => {
|
||||
p.base_tick(level);
|
||||
if p.age % 2 == 0 {
|
||||
for _ in 0..2 {
|
||||
self.add(
|
||||
&ParticleOptions::geyser_base(
|
||||
ParticleType::GeyserBase,
|
||||
water_blocks,
|
||||
1.5,
|
||||
),
|
||||
p.position,
|
||||
aux,
|
||||
);
|
||||
}
|
||||
}
|
||||
for _ in 0..water_blocks + 2 {
|
||||
self.add(
|
||||
&ParticleOptions::geyser(ParticleType::GeyserPlume, water_blocks),
|
||||
p.position,
|
||||
aux,
|
||||
);
|
||||
}
|
||||
if p.age % 10 == 0 {
|
||||
for _ in 0..20 {
|
||||
self.add(
|
||||
&ParticleOptions::geyser_base(
|
||||
ParticleType::GeyserPoof,
|
||||
water_blocks,
|
||||
2.0,
|
||||
),
|
||||
p.position,
|
||||
aux,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Behaviour::GeyserPlume {
|
||||
start_y,
|
||||
max_y,
|
||||
propulsion,
|
||||
spray,
|
||||
min_size,
|
||||
max_size,
|
||||
mut done,
|
||||
} => {
|
||||
p.base_tick(level);
|
||||
if !done
|
||||
&& (p.velocity[1] < 0.0
|
||||
|| p.position[1] > max_y
|
||||
|| p.position[1] == p.previous[1])
|
||||
{
|
||||
p.lifetime = p.lifetime.min(p.age + 5);
|
||||
p.friction = 0.0;
|
||||
done = true;
|
||||
}
|
||||
let y_progress_linear =
|
||||
((p.position[1] - start_y) / (max_y - start_y)).clamp(0.0, 1.0);
|
||||
let y_progress_exponential = horizon_core::jmath::pow(y_progress_linear, 3.0);
|
||||
p.gravity = propulsion * y_progress_exponential as f32 * 0.12;
|
||||
p.velocity[0] = y_progress_linear * f64::from(spray[0]);
|
||||
p.velocity[2] = y_progress_linear * f64::from(spray[1]);
|
||||
self.sprite_from_age(p);
|
||||
p.size = min_size + (y_progress_linear * f64::from(max_size - min_size)) as f32;
|
||||
p.behaviour = Behaviour::GeyserPlume {
|
||||
start_y,
|
||||
max_y,
|
||||
propulsion,
|
||||
spray,
|
||||
min_size,
|
||||
max_size,
|
||||
done,
|
||||
};
|
||||
}
|
||||
Behaviour::BubbleColumnUp => {
|
||||
p.base_tick(level);
|
||||
if !p.removed && level.fluid(ParticleLevel::block_at(p.position)).0 != Fluid::Water
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
//! `PotentSulfurBlockEntity`'s reads of the level: where the noxious gas of a
|
||||
//! potent sulfur block comes out, how far a geyser throws, and whether the gas
|
||||
//! reaches a point. The client's particles and the server's nausea and
|
||||
//! launches read the level the same way, through a [`ParticleLevel`].
|
||||
use crate::{blocks::Fluid, collisions::SceneCollisions, particles::ParticleLevel};
|
||||
use horizon_core::{
|
||||
geometry::{Vec3, traverse_blocks},
|
||||
position::BlockPos,
|
||||
};
|
||||
|
||||
/// `PotentSulfurBlock.ALLOWED_WATER_BLOCKS_ABOVE`.
|
||||
const ALLOWED_WATER_BLOCKS_ABOVE: i32 = 4;
|
||||
|
||||
/// `PotentSulfurBlockEntity.isGeyserPassableBlock`: air, water, or a block
|
||||
/// with no collision shape.
|
||||
pub fn is_geyser_passable_block(level: &ParticleLevel, pos: [i32; 3]) -> bool {
|
||||
let state = level.world.block(pos[0], pos[1], pos[2]);
|
||||
if level.facts.is_air(state) || &*level.facts.get(state).name == "minecraft:water" {
|
||||
return true;
|
||||
}
|
||||
let mut scene = SceneCollisions {
|
||||
world: level.world,
|
||||
binding: level.collision,
|
||||
subject: None,
|
||||
};
|
||||
match level.collision.collision_shape(&mut scene, state, pos) {
|
||||
Ok(shape) => shape.is_empty(),
|
||||
Err(_) => !level.facts.get(state).full_collision,
|
||||
}
|
||||
}
|
||||
|
||||
/// `PotentSulfurBlockEntity.isWater`: the block at `pos` holds a water source
|
||||
/// (`FluidState.isSourceOfType`).
|
||||
fn is_water_source(level: &ParticleLevel, pos: [i32; 3]) -> bool {
|
||||
let (fluid, source) = level.fluid_source(pos);
|
||||
fluid == Fluid::Water && source
|
||||
}
|
||||
|
||||
/// `PotentSulfurBlockEntity.findNoxiousGasSourceBlock`: the first block above
|
||||
/// the sulfur, within the water column's reach, that is not water-filled and
|
||||
/// lets gas through; None when something solid or a deeper column stops it.
|
||||
pub fn find_noxious_gas_source_block(level: &ParticleLevel, origin: [i32; 3]) -> Option<[i32; 3]> {
|
||||
let max_y = origin[1] + ALLOWED_WATER_BLOCKS_ABOVE + 1;
|
||||
let mut pos = [origin[0], origin[1] + 1, origin[2]];
|
||||
while pos[1] <= max_y {
|
||||
let state = level.world.block(pos[0], pos[1], pos[2]);
|
||||
let is_water_block = &*level.facts.get(state).name == "minecraft:water";
|
||||
if !is_water_source(level, pos)
|
||||
|| (!is_water_block && !is_geyser_passable_block(level, pos))
|
||||
{
|
||||
if level.facts.is_air(state) || is_geyser_passable_block(level, pos) {
|
||||
return Some(pos);
|
||||
}
|
||||
break;
|
||||
}
|
||||
pos[1] += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `PotentSulfurBlockEntity.getUnobstructedBlockCount`: how many blocks from
|
||||
/// `pos` up, at most six per water block, a geyser's push goes through.
|
||||
pub fn unobstructed_block_count(level: &ParticleLevel, pos: [i32; 3], water_blocks: i32) -> i32 {
|
||||
let geyser_force_height = 6 * water_blocks;
|
||||
for i in 0..geyser_force_height {
|
||||
if !is_geyser_passable_block(level, [pos[0], pos[1] + i, pos[2]]) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
geyser_force_height
|
||||
}
|
||||
|
||||
/// `PotentSulfurBlockEntity.canBeReachedByNoxiousGas`: `pos` is in a block gas
|
||||
/// can enter, within three blocks of the source block's centre, and above
|
||||
/// water the source's water can be seen from.
|
||||
pub fn can_be_reached_by_noxious_gas(
|
||||
level: &ParticleLevel,
|
||||
source_block: [i32; 3],
|
||||
pos: [f64; 3],
|
||||
) -> bool {
|
||||
let block = ParticleLevel::block_at(pos);
|
||||
if !is_geyser_passable_block(level, block) {
|
||||
return false;
|
||||
}
|
||||
let center = source_block.map(|c| f64::from(c) + 0.5);
|
||||
let distance_sqr =
|
||||
(center[0] - pos[0]).powi(2) + (center[1] - pos[1]).powi(2) + (center[2] - pos[2]).powi(2);
|
||||
if distance_sqr > 9.0 {
|
||||
return false;
|
||||
}
|
||||
let below_source = [center[0], f64::from(source_block[1] - 1) + 0.5, center[2]];
|
||||
let below_pos = [pos[0], pos[1] - 1.0, pos[2]];
|
||||
is_water_source(level, ParticleLevel::block_at(below_pos))
|
||||
&& have_line_of_sight(level, below_source, below_pos)
|
||||
}
|
||||
|
||||
/// `PotentSulfurBlockEntity.haveLineOfSight`: a clip over collider shapes and
|
||||
/// no fluids (as the mobs' line of sight does) finds no block between the
|
||||
/// points.
|
||||
fn have_line_of_sight(level: &ParticleLevel, a: [f64; 3], b: [f64; 3]) -> bool {
|
||||
let (from, to) = (Vec3::new(a[0], a[1], a[2]), Vec3::new(b[0], b[1], b[2]));
|
||||
let mut scene = SceneCollisions {
|
||||
world: level.world,
|
||||
binding: level.collision,
|
||||
subject: None,
|
||||
};
|
||||
let hit = traverse_blocks(from, to, 512, |p: BlockPos| {
|
||||
let state = level.world.block(p.x, p.y, p.z);
|
||||
if level.facts.is_air(state) {
|
||||
return None;
|
||||
}
|
||||
let shape = level
|
||||
.collision
|
||||
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
|
||||
.ok()?;
|
||||
shape.clip(from, to, p).map(|_| ())
|
||||
});
|
||||
!matches!(hit, Ok(Some(())))
|
||||
}
|
||||
@@ -121,6 +121,10 @@ pub fn warden_can_listen(event: &str) -> bool {
|
||||
|| event == SHRIEK
|
||||
|| shrieker_can_listen(event)
|
||||
}
|
||||
/// `GameEventTags.ALLAY_CAN_LISTEN`: note block notes.
|
||||
pub fn allay_can_listen(event: &str) -> bool {
|
||||
event == "minecraft:note_block_play"
|
||||
}
|
||||
/// `GameEventTags.IGNORE_VIBRATIONS_SNEAKING`.
|
||||
pub fn ignored_when_sneaking(event: &str) -> bool {
|
||||
matches!(
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
//! Sets of section indexes in a chunk column, a bit per section.
|
||||
//!
|
||||
//! Vanilla's tallest level has 254 sections (`DimensionType.Y_SIZE / 16`), and
|
||||
//! light data adds a section below and above it: 256 bits hold every layout a
|
||||
//! dimension type allows.
|
||||
|
||||
/// Section indexes of one column, 0 being the lowest.
|
||||
#[derive(Clone, Copy, Default, PartialEq, Eq, Hash)]
|
||||
pub struct SectionMask([u64; 4]);
|
||||
|
||||
impl SectionMask {
|
||||
pub const CAPACITY: usize = 256;
|
||||
pub const EMPTY: Self = Self([0; 4]);
|
||||
pub const ALL: Self = Self([u64::MAX; 4]);
|
||||
|
||||
/// The indexes `0..n`.
|
||||
pub const fn below(n: usize) -> Self {
|
||||
let mut words = [0; 4];
|
||||
let mut w = 0;
|
||||
while w < 4 {
|
||||
let bits = n.saturating_sub(w * 64);
|
||||
words[w] = if bits >= 64 {
|
||||
u64::MAX
|
||||
} else {
|
||||
(1 << bits) - 1
|
||||
};
|
||||
w += 1;
|
||||
}
|
||||
Self(words)
|
||||
}
|
||||
pub fn single(i: usize) -> Self {
|
||||
let mut mask = Self::EMPTY;
|
||||
mask.insert(i);
|
||||
mask
|
||||
}
|
||||
#[inline]
|
||||
pub fn contains(&self, i: usize) -> bool {
|
||||
i < Self::CAPACITY && self.0[i >> 6] >> (i & 63) & 1 != 0
|
||||
}
|
||||
#[inline]
|
||||
pub fn insert(&mut self, i: usize) {
|
||||
self.0[i >> 6] |= 1 << (i & 63);
|
||||
}
|
||||
#[inline]
|
||||
pub fn remove(&mut self, i: usize) {
|
||||
self.0[i >> 6] &= !(1 << (i & 63));
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0 == [0; 4]
|
||||
}
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.iter().map(|w| w.count_ones() as usize).sum()
|
||||
}
|
||||
/// The indexes from `i` up.
|
||||
pub fn from(self, i: usize) -> Self {
|
||||
self & !Self::below(i)
|
||||
}
|
||||
/// The indexes, lowest first.
|
||||
pub fn iter(self) -> Indexes {
|
||||
Indexes(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::BitOr for SectionMask {
|
||||
type Output = Self;
|
||||
fn bitor(self, other: Self) -> Self {
|
||||
Self(std::array::from_fn(|w| self.0[w] | other.0[w]))
|
||||
}
|
||||
}
|
||||
impl std::ops::BitOrAssign for SectionMask {
|
||||
fn bitor_assign(&mut self, other: Self) {
|
||||
*self = *self | other;
|
||||
}
|
||||
}
|
||||
impl std::ops::BitAnd for SectionMask {
|
||||
type Output = Self;
|
||||
fn bitand(self, other: Self) -> Self {
|
||||
Self(std::array::from_fn(|w| self.0[w] & other.0[w]))
|
||||
}
|
||||
}
|
||||
impl std::ops::Not for SectionMask {
|
||||
type Output = Self;
|
||||
fn not(self) -> Self {
|
||||
Self(self.0.map(|w| !w))
|
||||
}
|
||||
}
|
||||
impl std::fmt::Debug for SectionMask {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_set().entries(self.iter()).finish()
|
||||
}
|
||||
}
|
||||
impl FromIterator<usize> for SectionMask {
|
||||
fn from_iter<I: IntoIterator<Item = usize>>(indexes: I) -> Self {
|
||||
let mut mask = Self::EMPTY;
|
||||
for i in indexes {
|
||||
mask.insert(i);
|
||||
}
|
||||
mask
|
||||
}
|
||||
}
|
||||
|
||||
/// `SectionMask::iter`: the set's indexes in ascending order.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Indexes(SectionMask);
|
||||
impl Iterator for Indexes {
|
||||
type Item = usize;
|
||||
#[inline]
|
||||
fn next(&mut self) -> Option<usize> {
|
||||
let words = &mut (self.0).0;
|
||||
let w = words.iter().position(|&w| w != 0)?;
|
||||
let bit = words[w].trailing_zeros() as usize;
|
||||
words[w] &= words[w] - 1;
|
||||
Some(w * 64 + bit)
|
||||
}
|
||||
fn size_hint(&self) -> (usize, Option<usize>) {
|
||||
let len = self.0.len();
|
||||
(len, Some(len))
|
||||
}
|
||||
}
|
||||
impl ExactSizeIterator for Indexes {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sets_across_words() {
|
||||
let mut mask = SectionMask::EMPTY;
|
||||
for i in [0, 23, 63, 64, 130, 255] {
|
||||
mask.insert(i);
|
||||
}
|
||||
assert_eq!(mask.iter().collect::<Vec<_>>(), [0, 23, 63, 64, 130, 255]);
|
||||
assert_eq!(mask.len(), 6);
|
||||
assert!(mask.contains(130) && !mask.contains(131) && !mask.contains(300));
|
||||
mask.remove(64);
|
||||
assert_eq!(mask.from(24).iter().collect::<Vec<_>>(), [63, 130, 255]);
|
||||
assert_eq!(mask.from(256), SectionMask::EMPTY);
|
||||
assert_eq!(mask.from(0), mask);
|
||||
assert!(SectionMask::EMPTY.is_empty() && !mask.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn below_counts_whole_and_partial_words() {
|
||||
for n in [0, 1, 24, 63, 64, 65, 131, 254, 256] {
|
||||
let mask = SectionMask::below(n);
|
||||
assert_eq!(mask.len(), n);
|
||||
assert_eq!(mask.iter().collect::<Vec<_>>(), (0..n).collect::<Vec<_>>());
|
||||
}
|
||||
assert_eq!(SectionMask::below(1000), SectionMask::ALL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operators() {
|
||||
let a: SectionMask = [1, 70, 200].into_iter().collect();
|
||||
let b: SectionMask = [70, 3].into_iter().collect();
|
||||
assert_eq!((a | b).iter().collect::<Vec<_>>(), [1, 3, 70, 200]);
|
||||
assert_eq!((a & b).iter().collect::<Vec<_>>(), [70]);
|
||||
assert_eq!(
|
||||
(!a & SectionMask::below(4)).iter().collect::<Vec<_>>(),
|
||||
[0, 2, 3]
|
||||
);
|
||||
assert_eq!(format!("{a:?}"), "{1, 70, 200}");
|
||||
}
|
||||
}
|
||||
@@ -150,6 +150,8 @@ pub struct Settings {
|
||||
pub chat_links: bool,
|
||||
pub chat_links_prompt: bool,
|
||||
pub chat_opacity: f32,
|
||||
/// Chat Delay in seconds (`chatDelay`): player chat shows this far apart.
|
||||
pub chat_delay: f64,
|
||||
pub text_background_opacity: f32,
|
||||
pub chat_scale: f32,
|
||||
pub chat_width: f32,
|
||||
@@ -246,6 +248,7 @@ impl Default for Settings {
|
||||
chat_links: true,
|
||||
chat_links_prompt: true,
|
||||
chat_opacity: 1.0,
|
||||
chat_delay: 0.0,
|
||||
text_background_opacity: 0.5,
|
||||
chat_scale: 1.0,
|
||||
chat_width: 1.0,
|
||||
@@ -448,6 +451,7 @@ impl Settings {
|
||||
chat_links: f.bool("chatLinks"),
|
||||
chat_links_prompt: f.bool("chatLinksPrompt"),
|
||||
chat_opacity: f.double("chatOpacity") as f32,
|
||||
chat_delay: f.double("chatDelay"),
|
||||
text_background_opacity: f.double("textBackgroundOpacity") as f32,
|
||||
chat_scale: f.double("chatScale") as f32,
|
||||
chat_width: f.double("chatWidth") as f32,
|
||||
@@ -551,6 +555,9 @@ impl Settings {
|
||||
put_b(&mut f, "chatLinks", self.chat_links);
|
||||
put_b(&mut f, "chatLinksPrompt", self.chat_links_prompt);
|
||||
put_f(&mut f, "chatOpacity", self.chat_opacity);
|
||||
if self.chat_delay != f.double("chatDelay") {
|
||||
f.set("chatDelay", V::Double(self.chat_delay));
|
||||
}
|
||||
put_f(
|
||||
&mut f,
|
||||
"textBackgroundOpacity",
|
||||
|
||||
@@ -31,9 +31,45 @@ impl Default for SignText {
|
||||
lines: Default::default(),
|
||||
color: BLACK,
|
||||
glowing: false,
|
||||
click: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The click events `SignBlockEntity.executeClickCommandsIfPresent` acts on.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum ClickAction<'a> {
|
||||
/// `ClickEvent.RunCommand`.
|
||||
RunCommand(String),
|
||||
/// `ClickEvent.ShowDialog`: a registry id or the dialog itself.
|
||||
ShowDialog(&'a Tag),
|
||||
/// `ClickEvent.Custom`.
|
||||
Custom {
|
||||
id: String,
|
||||
payload: Option<&'a Tag>,
|
||||
},
|
||||
}
|
||||
/// A saved `click_event` as its action: the three a sign acts on, read the
|
||||
/// way `ClickEvent.CODEC` does; any other action or a malformed one is None.
|
||||
pub fn click_action(event: &Tag) -> Option<ClickAction<'_>> {
|
||||
let Some(Tag::String(action)) = event.get("action") else {
|
||||
return None;
|
||||
};
|
||||
match action.to_string_lossy().as_str() {
|
||||
"run_command" => match event.get("command") {
|
||||
Some(Tag::String(command)) => Some(ClickAction::RunCommand(command.to_string_lossy())),
|
||||
_ => None,
|
||||
},
|
||||
"show_dialog" => event.get("dialog").map(ClickAction::ShowDialog),
|
||||
"custom" => match event.get("id") {
|
||||
Some(Tag::String(id)) => Some(ClickAction::Custom {
|
||||
id: id.to_string_lossy(),
|
||||
payload: event.get("payload"),
|
||||
}),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
impl SignText {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.lines.iter().all(String::is_empty)
|
||||
@@ -42,6 +78,14 @@ impl SignText {
|
||||
pub fn text_color(&self) -> u32 {
|
||||
DYE_TEXT[usize::from(self.color.min(15))].1
|
||||
}
|
||||
/// `SignText.hasAnyClickCommands`: a line whose style has a `run_command`
|
||||
/// click event.
|
||||
pub fn has_any_click_commands(&self) -> bool {
|
||||
self.click
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|event| matches!(click_action(event), Some(ClickAction::RunCommand(_))))
|
||||
}
|
||||
/// `AbstractSignRenderer.getDarkColor`.
|
||||
pub fn dark_color(&self) -> u32 {
|
||||
let c = self.text_color();
|
||||
@@ -70,6 +114,13 @@ impl SignData {
|
||||
pub fn line_height(&self) -> f32 {
|
||||
if self.hanging { 9.0 } else { 10.0 }
|
||||
}
|
||||
/// `SignBlockEntity.canExecuteClickCommands`: a waxed sign whose side
|
||||
/// has a `run_command` click event. (Horizon keeps no filtered text, so
|
||||
/// `Player.isTextFilteringEnabled` never selects it.)
|
||||
pub fn can_execute_click_commands(&self, front: bool) -> bool {
|
||||
let side = if front { &self.front } else { &self.back };
|
||||
self.waxed && side.has_any_click_commands()
|
||||
}
|
||||
/// NBT entries after the common block-entity header.
|
||||
pub fn encode(&self) -> Vec<(&'static str, Tag)> {
|
||||
let side = |t: &SignText| {
|
||||
@@ -79,7 +130,18 @@ impl SignData {
|
||||
Tag::List(
|
||||
t.lines
|
||||
.iter()
|
||||
.map(|l| Tag::String(l.as_str().into()))
|
||||
.zip(&t.click)
|
||||
.map(|(line, click)| {
|
||||
let text = Tag::String(line.as_str().into());
|
||||
match click {
|
||||
// The line's style keeps its click event.
|
||||
Some(event) => Tag::compound([
|
||||
("text", text),
|
||||
("click_event", event.clone()),
|
||||
]),
|
||||
None => text,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
),
|
||||
@@ -90,19 +152,25 @@ impl SignData {
|
||||
("has_glowing_text", Tag::Byte(i8::from(t.glowing))),
|
||||
])
|
||||
};
|
||||
vec![
|
||||
let mut out = Vec::new();
|
||||
if self.allow_op_features {
|
||||
out.push(("allow_op_features", Tag::Byte(1)));
|
||||
}
|
||||
out.extend([
|
||||
("front_text", side(&self.front)),
|
||||
("back_text", side(&self.back)),
|
||||
("is_waxed", Tag::Byte(i8::from(self.waxed))),
|
||||
]
|
||||
]);
|
||||
out
|
||||
}
|
||||
pub fn decode(&mut self, tag: &Tag) {
|
||||
let side = |t: Option<&Tag>| -> SignText {
|
||||
let mut out = SignText::default();
|
||||
let Some(t) = t else { return out };
|
||||
if let Some(Tag::List(messages)) = t.get("messages") {
|
||||
for (line, m) in out.lines.iter_mut().zip(messages) {
|
||||
for ((line, click), m) in out.lines.iter_mut().zip(&mut out.click).zip(messages) {
|
||||
*line = component_text(m);
|
||||
*click = click_event(m);
|
||||
}
|
||||
}
|
||||
if let Some(Tag::String(c)) = t.get("color") {
|
||||
@@ -117,6 +185,21 @@ impl SignData {
|
||||
self.front = side(tag.get("front_text"));
|
||||
self.back = side(tag.get("back_text"));
|
||||
self.waxed = matches!(tag.get("is_waxed"), Some(Tag::Byte(b)) if *b != 0);
|
||||
self.allow_op_features =
|
||||
matches!(tag.get("allow_op_features"), Some(Tag::Byte(b)) if *b != 0);
|
||||
}
|
||||
}
|
||||
/// The `click_event` in the style of a stored line: the one of a component
|
||||
/// object, or of the first component of a list (`createFromList` copies it
|
||||
/// and appends the rest as siblings).
|
||||
fn click_event(tag: &Tag) -> Option<Tag> {
|
||||
let root = match tag {
|
||||
Tag::List(list) => list.first()?,
|
||||
other => other,
|
||||
};
|
||||
match root.get("click_event") {
|
||||
Some(event @ Tag::Compound(_)) => Some(event.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Plain text of a stored text component (string, `{text: ...}` or JSON string).
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
//! `SoundPreviewHandler`: the sound a category volume slider plays while it
|
||||
//! moves on the title screen's options (`Options.createSoundSliderOptionInstance`
|
||||
//! calls `preview` when there is no level).
|
||||
use crate::settings::SOUND_SOURCES;
|
||||
use crate::sound_engine::{InstanceId, SoundEngine, SoundInstance, Sounds};
|
||||
|
||||
/// The sound `SoundPreviewHandler.preview` plays for a `SoundSource` index,
|
||||
/// or `None` (`SoundEvents.EMPTY`) for master, music and voice.
|
||||
pub fn preview_event(source: usize) -> Option<&'static str> {
|
||||
Some(match *SOUND_SOURCES.get(source)? {
|
||||
"record" => "minecraft:block.note_block.guitar",
|
||||
"weather" => "minecraft:entity.lightning_bolt.thunder",
|
||||
"block" => "minecraft:block.grass.place",
|
||||
"hostile" => "minecraft:entity.zombie.ambient",
|
||||
// SoundEvents.COW_SOUNDS.get(CowSoundVariants.SoundSet.CLASSIC).ambientSound()
|
||||
"neutral" => "minecraft:entity.cow.ambient",
|
||||
"player" => "minecraft:entity.generic.eat",
|
||||
"ambient" => "minecraft:ambient.cave",
|
||||
"ui" => "minecraft:ui.button.click",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `SoundPreviewHandler`'s `activePreview` and `previousCategory`.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SoundPreview {
|
||||
active: Option<InstanceId>,
|
||||
previous: Option<usize>,
|
||||
}
|
||||
|
||||
impl SoundPreview {
|
||||
/// `SoundPreviewHandler.preview(soundManager, category, volume)`: stop the
|
||||
/// preview of another category, then, unless the last preview still
|
||||
/// plays, play this category's sound (`SimpleSoundInstance.forUI` at
|
||||
/// pitch 1 and the slider's volume).
|
||||
pub fn preview(&mut self, sounds: &mut impl Sounds, source: usize, volume: f32) {
|
||||
self.stop_other_category_preview(sounds, source);
|
||||
if !self.can_play_sound(sounds) {
|
||||
return;
|
||||
}
|
||||
let Some(event) = preview_event(source) else {
|
||||
return;
|
||||
};
|
||||
if let Some(old) = self.active.take() {
|
||||
sounds.release(old);
|
||||
}
|
||||
let instance = SoundInstance::for_ui(event, 1.0, volume, SoundEngine::unseeded());
|
||||
self.active = Some(sounds.play_instance(instance, true).0);
|
||||
}
|
||||
|
||||
/// The preview playing now, if any.
|
||||
pub fn active(&self) -> Option<InstanceId> {
|
||||
self.active
|
||||
}
|
||||
|
||||
/// `stopOtherCategoryPreview`.
|
||||
fn stop_other_category_preview(&mut self, sounds: &mut impl Sounds, source: usize) {
|
||||
if self.previous != Some(source) {
|
||||
self.previous = Some(source);
|
||||
if let Some(active) = self.active {
|
||||
sounds.stop_instance(active);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `canPlaySound`: no preview yet, or the last one has finished.
|
||||
fn can_play_sound(&self, sounds: &mut impl Sounds) -> bool {
|
||||
self.active.is_none_or(|active| !sounds.is_active(active))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::sound_engine::{Bound, TickContext, UI, test_backend::Null};
|
||||
use crate::sound_events::SoundRegistry;
|
||||
|
||||
fn engine() -> SoundEngine {
|
||||
let mut registry = SoundRegistry::default();
|
||||
registry.add_file(
|
||||
"minecraft",
|
||||
br#"{"entity.zombie.ambient": {"sounds": ["mob/zombie/say1"]},
|
||||
"entity.cow.ambient": {"sounds": ["mob/cow/say1"]},
|
||||
"ui.button.click": {"sounds": ["random/click"]}}"#,
|
||||
&|_, _| true,
|
||||
);
|
||||
SoundEngine::new(registry)
|
||||
}
|
||||
|
||||
fn source(name: &str) -> usize {
|
||||
SOUND_SOURCES.iter().position(|s| *s == name).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_category_previews_its_vanilla_sound() {
|
||||
let expected = [
|
||||
("master", None),
|
||||
("music", None),
|
||||
("record", Some("minecraft:block.note_block.guitar")),
|
||||
("weather", Some("minecraft:entity.lightning_bolt.thunder")),
|
||||
("block", Some("minecraft:block.grass.place")),
|
||||
("hostile", Some("minecraft:entity.zombie.ambient")),
|
||||
("neutral", Some("minecraft:entity.cow.ambient")),
|
||||
("player", Some("minecraft:entity.generic.eat")),
|
||||
("ambient", Some("minecraft:ambient.cave")),
|
||||
("voice", None),
|
||||
("ui", Some("minecraft:ui.button.click")),
|
||||
];
|
||||
for (name, event) in expected {
|
||||
assert_eq!(preview_event(source(name)), event, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
fn slide(
|
||||
preview: &mut SoundPreview,
|
||||
engine: &mut SoundEngine,
|
||||
backend: &mut Null,
|
||||
name: &str,
|
||||
volume: f32,
|
||||
) {
|
||||
preview.preview(&mut Bound { engine, backend }, source(name), volume);
|
||||
}
|
||||
|
||||
/// Ticks enough for the engine to drop finished or stopped channels.
|
||||
fn settle(engine: &mut SoundEngine, backend: &mut Null) {
|
||||
for _ in 0..40 {
|
||||
engine.tick(false, &TickContext::default(), backend);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_moving_slider_plays_one_preview_at_a_time_and_switching_stops_it() {
|
||||
let (mut engine, mut backend) = (engine(), Null::default());
|
||||
let mut preview = SoundPreview::default();
|
||||
slide(&mut preview, &mut engine, &mut backend, "hostile", 0.4);
|
||||
let first = preview
|
||||
.active()
|
||||
.expect("the hostile slider plays a preview");
|
||||
let instance = engine.instance(first).unwrap();
|
||||
assert_eq!(instance.identifier, "minecraft:entity.zombie.ambient");
|
||||
assert_eq!(instance.source, UI);
|
||||
assert_eq!((instance.volume, instance.pitch), (0.4, 1.0));
|
||||
assert!(instance.relative);
|
||||
assert!(engine.is_active(first));
|
||||
// canPlaySound: still playing, so dragging further does not restart it.
|
||||
slide(&mut preview, &mut engine, &mut backend, "hostile", 0.6);
|
||||
assert_eq!(preview.active(), Some(first));
|
||||
assert_eq!(engine.active_instances(), vec![first]);
|
||||
// Once it has finished, the next move plays it again.
|
||||
for stopped in backend.stopped.values_mut() {
|
||||
*stopped = true;
|
||||
}
|
||||
settle(&mut engine, &mut backend);
|
||||
assert!(!engine.is_active(first));
|
||||
slide(&mut preview, &mut engine, &mut backend, "hostile", 0.6);
|
||||
let second = preview.active().unwrap();
|
||||
assert_ne!(second, first);
|
||||
assert!(engine.is_active(second));
|
||||
// Another category stops the playing preview (SoundManager.stop); the
|
||||
// engine lets its channel go on the next ticks, then that category's
|
||||
// own sound plays.
|
||||
slide(&mut preview, &mut engine, &mut backend, "neutral", 1.0);
|
||||
assert!(backend.stopped.values().all(|stopped| *stopped));
|
||||
settle(&mut engine, &mut backend);
|
||||
assert!(engine.active_instances().is_empty());
|
||||
slide(&mut preview, &mut engine, &mut backend, "neutral", 1.0);
|
||||
let third = preview.active().unwrap();
|
||||
assert_eq!(
|
||||
engine.instance(third).unwrap().identifier,
|
||||
"minecraft:entity.cow.ambient"
|
||||
);
|
||||
// Master has no sound but still stops the other category's preview.
|
||||
slide(&mut preview, &mut engine, &mut backend, "master", 1.0);
|
||||
settle(&mut engine, &mut backend);
|
||||
assert!(engine.active_instances().is_empty());
|
||||
slide(&mut preview, &mut engine, &mut backend, "master", 1.0);
|
||||
assert!(engine.active_instances().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -159,6 +159,14 @@ impl Sprites {
|
||||
/// up to a power of two and limited to [`MAX_ARRAY_BYTES`]. Smaller
|
||||
/// textures are scaled up by whole pixels, so UVs stay per layer.
|
||||
pub fn finish(&mut self) {
|
||||
self.lay_out(true);
|
||||
}
|
||||
/// [`Sprites::finish`] for a server, which draws nothing: the layers'
|
||||
/// size, but neither their pixels nor the animations' frames.
|
||||
pub fn finish_without_pixels(&mut self) {
|
||||
self.lay_out(false);
|
||||
}
|
||||
fn lay_out(&mut self, pixels: bool) {
|
||||
let layers = self.native.len().max(1) as u64;
|
||||
let largest = self.native.iter().map(|n| n.0).max().unwrap_or(SPRITE_SIZE);
|
||||
self.minimum_size = self.native.iter().map(|n| n.0).min().unwrap_or(SPRITE_SIZE);
|
||||
@@ -166,6 +174,12 @@ impl Sprites {
|
||||
while size > SPRITE_SIZE && layers * u64::from(size * size) * 4 > MAX_ARRAY_BYTES {
|
||||
size /= 2;
|
||||
}
|
||||
if !pixels {
|
||||
self.pixels = Vec::new();
|
||||
self.animations = Vec::new();
|
||||
self.size = size;
|
||||
return;
|
||||
}
|
||||
let mut resized = Vec::with_capacity(self.native.len());
|
||||
self.pixels.clear();
|
||||
for (from, frames, initial) in &self.native {
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
//! hands immutable `Arc` snapshots to workers; results carry a sequence number
|
||||
//! so stale work is dropped.
|
||||
use crate::fast_hash::{FxHashMap, FxHashSet};
|
||||
use crate::section_mask::SectionMask;
|
||||
use crate::{
|
||||
chunk::{Chunk, SECTIONS},
|
||||
chunk::Chunk,
|
||||
light::{LightChanges, LightTables, apply_lighting, apply_relighting, light_chunk},
|
||||
mesher::{MeshTables, Mesher, Region, SectionMesh, borrow},
|
||||
world::World,
|
||||
@@ -16,11 +17,15 @@ use std::{
|
||||
collections::{BinaryHeap, HashSet, VecDeque},
|
||||
sync::{
|
||||
Arc, Condvar, Mutex, MutexGuard, RwLock, TryLockError,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
},
|
||||
thread,
|
||||
};
|
||||
|
||||
/// How long a worker waits with nothing to do before it gives back its
|
||||
/// generator's scratch buffers ([`ChunkGenerator::idle`]).
|
||||
pub const IDLE_RELEASE: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
/// A chunk under generation, opaque to the streamer.
|
||||
pub type Proto = Box<dyn std::any::Any + Send>;
|
||||
/// Chunk generation in vanilla's stages: a neighbour-independent proto chunk
|
||||
@@ -57,6 +62,9 @@ pub trait ChunkGenerator: Send + Sync + 'static {
|
||||
/// What a loaded chunk needs from the generator before the game uses it,
|
||||
/// on the worker that loaded it.
|
||||
fn prepare_loaded(&self, _worker: &mut Self::Worker, _chunk: &Chunk) {}
|
||||
/// The worker has had nothing to do for [`IDLE_RELEASE`]: give back what
|
||||
/// it keeps between jobs only to go faster.
|
||||
fn idle(&self, _worker: &mut Self::Worker) {}
|
||||
fn proto(&self, worker: &mut Self::Worker, x: i32, z: i32) -> Proto;
|
||||
fn decorates(&self) -> bool {
|
||||
false
|
||||
@@ -74,6 +82,12 @@ pub trait ChunkGenerator: Send + Sync + 'static {
|
||||
fn light_view(&self, _proto: &Proto) -> Option<Chunk> {
|
||||
None
|
||||
}
|
||||
/// A decorated proto chunk's column sections (`Chunk::sections`
|
||||
/// indexes) holding a block other than air: what its neighbours' light
|
||||
/// layers count once it is at INITIALIZE_LIGHT.
|
||||
fn non_air_sections(&self, _proto: &Proto) -> Option<SectionMask> {
|
||||
None
|
||||
}
|
||||
fn saved_proto(&self, _proto: &Proto) -> Option<crate::chunk::SavedProto> {
|
||||
None
|
||||
}
|
||||
@@ -220,6 +234,9 @@ struct Shared {
|
||||
/// packs reload; each mesh job takes the current one.
|
||||
context: RwLock<Arc<WorkerContext>>,
|
||||
done: Mailbox,
|
||||
/// Workers that have freed their buffers ([`IDLE_RELEASE`]) and taken no
|
||||
/// job since: the last of them gives the freed memory back to the OS.
|
||||
released: AtomicUsize,
|
||||
}
|
||||
/// Finished jobs, from the workers to the game thread, in the order they
|
||||
/// finished. Not an `mpsc` channel: its receiver waits for a sender it finds
|
||||
@@ -265,6 +282,7 @@ impl Mailbox {
|
||||
pub struct Streaming {
|
||||
shared: Arc<Shared>,
|
||||
light_view: Arc<dyn Fn(&Proto) -> Option<Chunk> + Send + Sync>,
|
||||
non_air_sections: Arc<dyn Fn(&Proto) -> Option<SectionMask> + Send + Sync>,
|
||||
threads: Vec<thread::JoinHandle<()>>,
|
||||
order: u64,
|
||||
/// Ticks since the columns and protos that fell out of range were swept.
|
||||
@@ -310,6 +328,8 @@ pub struct Streaming {
|
||||
deferred: Vec<Done>,
|
||||
sequence: u64,
|
||||
pub center: (i32, i32),
|
||||
/// The view distance around `center`; below 0, no area there (a dedicated
|
||||
/// server nobody is on).
|
||||
pub radius: i32,
|
||||
/// `/forceload`ed chunks: kept loaded whatever the distance.
|
||||
pub forced: HashSet<(i32, i32)>,
|
||||
@@ -513,7 +533,9 @@ impl Streaming {
|
||||
stop: AtomicBool::new(false),
|
||||
context: RwLock::new(context.clone()),
|
||||
done: Mailbox::default(),
|
||||
released: AtomicUsize::new(0),
|
||||
});
|
||||
let workers = threads.max(1);
|
||||
let light_tables = context.light.clone();
|
||||
let saves: Arc<dyn Saves> = generator.clone();
|
||||
let threads = (0..threads.max(1))
|
||||
@@ -534,14 +556,42 @@ impl Streaming {
|
||||
loop {
|
||||
let job = {
|
||||
let mut queue = shared.queue.lock().unwrap();
|
||||
let mut released = false;
|
||||
loop {
|
||||
if shared.stop.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
if let Some(job) = queue.pop() {
|
||||
if released {
|
||||
shared.released.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
break job.job;
|
||||
}
|
||||
queue = shared.ready.wait(queue).unwrap();
|
||||
if released {
|
||||
queue = shared.ready.wait(queue).unwrap();
|
||||
continue;
|
||||
}
|
||||
let (woken, wait) =
|
||||
shared.ready.wait_timeout(queue, IDLE_RELEASE).unwrap();
|
||||
queue = woken;
|
||||
if wait.timed_out() && queue.is_empty() {
|
||||
// A server nobody is on, a client standing still:
|
||||
// free the generator's buffers (and the mesher's
|
||||
// face cache) until the next job needs them.
|
||||
drop(queue);
|
||||
generator.idle(&mut worker);
|
||||
faces = None;
|
||||
released = true;
|
||||
// The allocator keeps freed pages until memory
|
||||
// runs short: once every worker has freed its own,
|
||||
// hand them back.
|
||||
if shared.released.fetch_add(1, Ordering::Relaxed) + 1
|
||||
== workers
|
||||
{
|
||||
horizon_threads::release_free_memory();
|
||||
}
|
||||
queue = shared.queue.lock().unwrap();
|
||||
}
|
||||
}
|
||||
};
|
||||
let done = match job {
|
||||
@@ -611,9 +661,14 @@ impl Streaming {
|
||||
let generator = generator.clone();
|
||||
Arc::new(move |p: &Proto| generator.light_view(p))
|
||||
};
|
||||
let non_air_sections = {
|
||||
let generator = generator.clone();
|
||||
Arc::new(move |p: &Proto| generator.non_air_sections(p))
|
||||
};
|
||||
Self {
|
||||
shared,
|
||||
light_view,
|
||||
non_air_sections,
|
||||
threads,
|
||||
order: 0,
|
||||
scan_age: 0,
|
||||
@@ -677,7 +732,7 @@ impl Streaming {
|
||||
self.meshing.clear();
|
||||
for &(x, z) in world.chunks.keys() {
|
||||
if self.lit.contains(&(x, z)) {
|
||||
for sy in 0..SECTIONS as i32 {
|
||||
for sy in 0..world.layout().sections() as i32 {
|
||||
world.dirty_sections.insert((x, sy, z));
|
||||
}
|
||||
}
|
||||
@@ -695,7 +750,7 @@ impl Streaming {
|
||||
self.generated_total += 1;
|
||||
for dz in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
for sy in 0..SECTIONS as i32 {
|
||||
for sy in 0..world.layout().sections() as i32 {
|
||||
world.dirty_sections.insert((key.0 + dx, sy, key.1 + dz));
|
||||
}
|
||||
}
|
||||
@@ -710,7 +765,7 @@ impl Streaming {
|
||||
for dy in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
let s = sy as i32 + dy;
|
||||
if (0..SECTIONS as i32).contains(&s) {
|
||||
if (0..world.layout().sections() as i32).contains(&s) {
|
||||
world.dirty_sections.insert((x + dx, s, z + dz));
|
||||
}
|
||||
}
|
||||
@@ -736,7 +791,7 @@ impl Streaming {
|
||||
self.lighting.remove(&(x, z));
|
||||
// By key, not by a pass over every pending section: a moved or
|
||||
// shrunk storage forgets thousands of columns in one frame.
|
||||
for sy in 0..SECTIONS as i32 {
|
||||
for sy in 0..world.layout().sections() as i32 {
|
||||
self.meshing.remove(&(x, sy, z));
|
||||
self.waiting.remove(&(x, sy, z));
|
||||
}
|
||||
@@ -854,7 +909,7 @@ impl Streaming {
|
||||
world.chunks.insert(key, Arc::new(chunk));
|
||||
if loaded {
|
||||
self.lit.insert(key);
|
||||
for sy in 0..SECTIONS as i32 {
|
||||
for sy in 0..world.layout().sections() as i32 {
|
||||
world.dirty_sections.insert((key.0, sy, key.1));
|
||||
}
|
||||
}
|
||||
@@ -914,6 +969,15 @@ impl Streaming {
|
||||
pub fn is_lit(&self, x: i32, z: i32) -> bool {
|
||||
self.lit.contains(&(x, z))
|
||||
}
|
||||
/// The non-air sections of a column that is a decorated proto chunk
|
||||
/// here (vanilla's INITIALIZE_LIGHT and on), if it is one: a level
|
||||
/// chunk's light layers count them (#4294).
|
||||
pub fn proto_sections(&self, x: i32, z: i32) -> Option<SectionMask> {
|
||||
if !self.decorated.contains(&(x, z)) {
|
||||
return None;
|
||||
}
|
||||
(self.non_air_sections)(self.protos.get(&(x, z))?.as_ref()?)
|
||||
}
|
||||
/// `ChunkMap.getLatestStatus` for the loading screen's chunk map: the
|
||||
/// furthest step this column has reached, `None` before it is asked for.
|
||||
pub fn latest_status(
|
||||
@@ -1037,7 +1101,7 @@ impl Streaming {
|
||||
if first {
|
||||
for dz in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
for sy in 0..SECTIONS as i32 {
|
||||
for sy in 0..world.layout().sections() as i32 {
|
||||
world.dirty_sections.insert((x + dx, sy, z + dz));
|
||||
}
|
||||
}
|
||||
@@ -1105,7 +1169,9 @@ impl Streaming {
|
||||
.iter()
|
||||
.filter(|&(&(x, z), p)| {
|
||||
p.is_some()
|
||||
&& i64::from(x - center.0).pow(2) + i64::from(z - center.1).pow(2) > r4 * r4
|
||||
&& (self.radius < 0
|
||||
|| i64::from(x - center.0).pow(2) + i64::from(z - center.1).pow(2)
|
||||
> r4 * r4)
|
||||
&& !ranges.in_extra(x, z, 4)
|
||||
&& !ranges.in_band(x, z, 4)
|
||||
&& !ranges.forced.iter().any(|f| {
|
||||
@@ -1331,7 +1397,7 @@ impl Streaming {
|
||||
}
|
||||
if !self.ranges().in_view(x, z) {
|
||||
world.dirty_sections.take_column(x, z);
|
||||
for sy in section_indexes(sections) {
|
||||
for sy in sections.iter().map(|sy| sy as i32) {
|
||||
self.waiting.insert((x, sy, z));
|
||||
}
|
||||
continue;
|
||||
@@ -1355,7 +1421,7 @@ impl Streaming {
|
||||
continue;
|
||||
};
|
||||
world.dirty_sections.take_column(x, z);
|
||||
for sy in section_indexes(sections) {
|
||||
for sy in sections.iter().map(|sy| sy as i32) {
|
||||
let first = self.meshed.insert((x, z));
|
||||
self.sequence += 1;
|
||||
self.meshing.insert((x, sy, z), self.sequence);
|
||||
@@ -1431,6 +1497,7 @@ impl Streaming {
|
||||
&mut changed,
|
||||
);
|
||||
Self::mark_light_changes(world, &changed);
|
||||
Self::relight_beside_unlit(world, &changed);
|
||||
if !self.light_snapshots.is_empty() {
|
||||
for p in positions {
|
||||
self.light_edit_sequence += 1;
|
||||
@@ -1460,6 +1527,7 @@ impl Streaming {
|
||||
&mut changed,
|
||||
);
|
||||
Self::mark_light_changes(world, &changed);
|
||||
Self::relight_beside_unlit(world, &changed);
|
||||
}
|
||||
let oldest = self.light_snapshots.values().copied().min();
|
||||
match oldest {
|
||||
@@ -1467,12 +1535,20 @@ impl Streaming {
|
||||
None => self.light_log.clear(),
|
||||
}
|
||||
}
|
||||
/// A column whose light an edit changed beside a column not lit yet gets
|
||||
/// a full light job ([`crate::light::beside_unlit`]).
|
||||
fn relight_beside_unlit(world: &mut World, changed: &LightChanges) {
|
||||
let columns = crate::light::beside_unlit(changed, |x, z| {
|
||||
world.chunks.get(&(x, z)).is_some_and(|c| !c.lit)
|
||||
});
|
||||
world.dirty_light.extend(columns);
|
||||
}
|
||||
/// Sections whose meshes sample a changed light value (faces and corners
|
||||
/// read light from neighbouring blocks).
|
||||
fn mark_light_changes(world: &mut World, changed: &LightChanges) {
|
||||
if let Some(log) = &mut world.light_log {
|
||||
for (x, sy, z) in changed.sections() {
|
||||
*log.entry((x, z)).or_default() |= 1 << sy;
|
||||
log.entry((x, z)).or_default().insert(sy as usize);
|
||||
}
|
||||
}
|
||||
changed.dirty_sections(|section| {
|
||||
@@ -1556,6 +1632,16 @@ impl Streaming {
|
||||
fn in_range(&self, x: i32, z: i32, margin: i32) -> bool {
|
||||
self.ranges().in_range(x, z, margin)
|
||||
}
|
||||
/// Rank the 3x3 a chunk loaded to FULL decorates, in one generation
|
||||
/// task's order (`ChunkGenerationTask`: x-outer, z-inner), after every
|
||||
/// chunk ranked before.
|
||||
pub fn rank_full_task(&mut self, x: i32, z: i32) {
|
||||
for dx in -1..=1 {
|
||||
for dz in -1..=1 {
|
||||
self.decoration_order.rank_or_append(x + dx, z + dz);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn band(&self) -> Option<Band> {
|
||||
self.band
|
||||
}
|
||||
@@ -1861,7 +1947,7 @@ impl Ranges<'_> {
|
||||
let (dx, dz) = (gap(x - self.center.0), gap(z - self.center.1));
|
||||
let r = i64::from(self.radius + 1);
|
||||
// The extra areas are squares: their finished square grown by `reach`.
|
||||
dx * dx + dz * dz <= r * r
|
||||
(self.radius >= 0 && dx * dx + dz * dz <= r * r)
|
||||
|| self.in_extra(x, z, 1 + reach)
|
||||
|| self.in_band(x, z, 1 + reach)
|
||||
}
|
||||
@@ -1894,7 +1980,7 @@ impl Ranges<'_> {
|
||||
fn in_main_range(&self, x: i32, z: i32, margin: i32) -> bool {
|
||||
let r = i64::from(self.radius + margin);
|
||||
let (dx, dz) = (i64::from(x - self.center.0), i64::from(z - self.center.1));
|
||||
if dx * dx + dz * dz <= r * r {
|
||||
if self.radius >= 0 && dx * dx + dz * dz <= r * r {
|
||||
return true;
|
||||
}
|
||||
// A forced chunk holds a FORCED ticket at level 31
|
||||
@@ -1931,16 +2017,6 @@ impl Drop for Streaming {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The section indexes set in a column's bits, lowest first.
|
||||
fn section_indexes(mut bits: u32) -> impl Iterator<Item = i32> {
|
||||
std::iter::from_fn(move || {
|
||||
(bits != 0).then(|| {
|
||||
let sy = bits.trailing_zeros() as i32;
|
||||
bits &= bits - 1;
|
||||
sy
|
||||
})
|
||||
})
|
||||
}
|
||||
/// The queued jobs' priorities computed again from each one's column
|
||||
/// `distance` (`Streaming::submit`).
|
||||
fn rank_again(queue: &mut BinaryHeap<Queued>, distance: impl Fn(i32, i32) -> i64) {
|
||||
@@ -2529,6 +2605,34 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// An idle dedicated server keeps no area around its centre: only a spawn
|
||||
/// search's chunk (FULL alone: its 3x3 decorated, 5x5 generated) and the
|
||||
/// forced chunks.
|
||||
#[test]
|
||||
fn no_area_around_an_idle_servers_centre() {
|
||||
let forced = HashSet::from([(30, 30)]);
|
||||
let extra = [((5, -3), -1)];
|
||||
let r = Ranges {
|
||||
center: (5, -3),
|
||||
radius: -1,
|
||||
extra: &extra,
|
||||
forced: &forced,
|
||||
cover: None,
|
||||
band: None,
|
||||
};
|
||||
for x in -5i32..=15 {
|
||||
for z in -13i32..=7 {
|
||||
let d = (x - 5).abs().max((z + 3).abs());
|
||||
assert_eq!(r.in_range(x, z, 1), d == 0, "({x}, {z})");
|
||||
assert_eq!(r.chunk_kept(x, z, true), d <= 2, "({x}, {z})");
|
||||
assert_eq!(r.requested(x, z, true), d <= 2, "({x}, {z})");
|
||||
}
|
||||
}
|
||||
assert!(r.in_range(30, 30, 1));
|
||||
let idle = Ranges { extra: &[], ..r };
|
||||
assert!(!idle.requested(5, -3, true) && idle.unloaded(5, -3, true));
|
||||
}
|
||||
|
||||
/// A server player's area (`lan_update_streaming`: the server view
|
||||
/// distance + 1) finishes vanilla's FULL square: PLAYER_LOADING tickets at
|
||||
/// level 31 out to the view distance, FULL (33) two chunks past it. Every
|
||||
|
||||
@@ -1078,6 +1078,21 @@ impl EditBox {
|
||||
self.set_cursor(pos, shift);
|
||||
}
|
||||
}
|
||||
/// `MultilineTextField.selectWordAtCursor` on a book page: the word before
|
||||
/// the cursor is selected, its end found past non-whitespace only
|
||||
/// (`getWordEndPosition`).
|
||||
pub fn select_word_at_cursor(&mut self) {
|
||||
let begin = multiline_word_position(&self.value, -1, self.cursor);
|
||||
let end = begin
|
||||
+ self
|
||||
.value
|
||||
.chars()
|
||||
.skip(begin)
|
||||
.take_while(|c| !java_whitespace(*c))
|
||||
.count();
|
||||
self.set_cursor(begin, false);
|
||||
self.set_cursor(end, true);
|
||||
}
|
||||
/// EditBox.onDrag: the selection follows the pointer.
|
||||
pub fn drag_to(&mut self, atlas: &HudAtlas, x: f32) {
|
||||
let pos = self.position_at(atlas, x);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
//! Resident client world: streamed chunk columns plus the bookkeeping the game
|
||||
//! loop needs to relight and remesh after edits.
|
||||
use crate::chunk::{Chunk, MIN_Y, SECTIONS};
|
||||
use crate::fast_hash::{FxHashMap, FxHashSet};
|
||||
use crate::{
|
||||
chunk::{Chunk, ColumnLayout, LevelShape},
|
||||
fast_hash::{FxHashMap, FxHashSet},
|
||||
section_mask::SectionMask,
|
||||
};
|
||||
use horizon_core::random::LegacyRandom;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::sync::Arc;
|
||||
@@ -11,28 +14,30 @@ use std::sync::Arc;
|
||||
/// rather than at every section of it.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct DirtySections {
|
||||
columns: crate::fast_hash::FxHashMap<(i32, i32), u32>,
|
||||
columns: crate::fast_hash::FxHashMap<(i32, i32), SectionMask>,
|
||||
}
|
||||
const _: () = assert!(SECTIONS <= 32);
|
||||
impl DirtySections {
|
||||
pub fn insert(&mut self, (x, sy, z): (i32, i32, i32)) {
|
||||
debug_assert!((0..SECTIONS as i32).contains(&sy));
|
||||
*self.columns.entry((x, z)).or_default() |= 1 << sy;
|
||||
debug_assert!((0..SectionMask::CAPACITY as i32).contains(&sy));
|
||||
self.columns.entry((x, z)).or_default().insert(sy as usize);
|
||||
}
|
||||
pub fn contains(&self, &(x, sy, z): &(i32, i32, i32)) -> bool {
|
||||
(0..SECTIONS as i32).contains(&sy)
|
||||
&& self.columns.get(&(x, z)).is_some_and(|m| m & 1 << sy != 0)
|
||||
(0..SectionMask::CAPACITY as i32).contains(&sy)
|
||||
&& self
|
||||
.columns
|
||||
.get(&(x, z))
|
||||
.is_some_and(|m| m.contains(sy as usize))
|
||||
}
|
||||
/// The dirty section indexes of column (x, z), as bits, removed.
|
||||
pub fn take_column(&mut self, x: i32, z: i32) -> u32 {
|
||||
self.columns.remove(&(x, z)).unwrap_or(0)
|
||||
/// The dirty section indexes of column (x, z), removed.
|
||||
pub fn take_column(&mut self, x: i32, z: i32) -> SectionMask {
|
||||
self.columns.remove(&(x, z)).unwrap_or_default()
|
||||
}
|
||||
/// Keep the columns `keep` accepts.
|
||||
pub fn retain_columns(&mut self, mut keep: impl FnMut(i32, i32) -> bool) {
|
||||
self.columns.retain(|&(x, z), _| keep(x, z));
|
||||
}
|
||||
/// The columns holding dirty sections, with their section bits.
|
||||
pub fn columns(&self) -> impl Iterator<Item = ((i32, i32), u32)> + '_ {
|
||||
/// The columns holding dirty sections, with their section indexes.
|
||||
pub fn columns(&self) -> impl Iterator<Item = ((i32, i32), SectionMask)> + '_ {
|
||||
self.columns.iter().map(|(&c, &m)| (c, m))
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
@@ -43,10 +48,13 @@ impl DirtySections {
|
||||
}
|
||||
/// How many sections are dirty.
|
||||
pub fn len(&self) -> usize {
|
||||
self.columns.values().map(|m| m.count_ones() as usize).sum()
|
||||
self.columns.values().map(SectionMask::len).sum()
|
||||
}
|
||||
}
|
||||
pub struct World {
|
||||
/// The level's build range and skylight (its dimension type); its
|
||||
/// chunks hold `shape.column()`.
|
||||
pub shape: LevelShape,
|
||||
pub fast_lava: bool,
|
||||
pub chunks: crate::chunk_map::ChunkMap,
|
||||
pub random: LegacyRandom,
|
||||
@@ -88,7 +96,7 @@ pub struct World {
|
||||
/// The sections whose light changed since the log was last drained, as
|
||||
/// a bit per section of each column, kept while `block_log` is
|
||||
/// (`ChunkHolder.sectionLightChanged`).
|
||||
pub light_log: Option<crate::fast_hash::FxHashMap<(i32, i32), u32>>,
|
||||
pub light_log: Option<crate::fast_hash::FxHashMap<(i32, i32), SectionMask>>,
|
||||
/// The villager POIs of each section (`PoiManager`'s sections).
|
||||
pub pois: crate::poi_sections::PoiSections<crate::poi_sections::Poi>,
|
||||
/// Moving-piston states removed during the last tick; retained until the
|
||||
@@ -165,6 +173,7 @@ impl Weather {
|
||||
impl World {
|
||||
pub fn new(air: BlockStateId, seed: i64) -> Self {
|
||||
Self {
|
||||
shape: LevelShape::OVERWORLD,
|
||||
chunks: Default::default(),
|
||||
random: LegacyRandom::new(seed),
|
||||
revision: 0,
|
||||
@@ -249,6 +258,18 @@ impl World {
|
||||
world.revision = 0;
|
||||
Ok(world)
|
||||
}
|
||||
/// The column the level's chunks hold.
|
||||
pub fn layout(&self) -> ColumnLayout {
|
||||
self.shape.column()
|
||||
}
|
||||
/// The lowest block Y the level's chunks hold.
|
||||
pub fn bottom_y(&self) -> i32 {
|
||||
self.layout().min_y()
|
||||
}
|
||||
/// One above the highest block Y the level's chunks hold.
|
||||
pub fn top_y(&self) -> i32 {
|
||||
self.layout().max_y()
|
||||
}
|
||||
/// Resident storage write; marks the touched sections (and neighbours on
|
||||
/// section borders) for remeshing and the surrounding chunks for relighting.
|
||||
/// Neighbour updates, drops and block entities belong to the gameplay layer.
|
||||
@@ -256,7 +277,8 @@ impl World {
|
||||
let Some(chunk) = self.chunks.get_mut(&(x >> 4, z >> 4)) else {
|
||||
return Err("write outside resident chunks".into());
|
||||
};
|
||||
if y < MIN_Y || y >= MIN_Y + (SECTIONS as i32) * 16 {
|
||||
let (min_y, sections) = (chunk.min_y(), chunk.sections.len() as i32);
|
||||
if chunk.section_index(y).is_none() {
|
||||
return Err("write outside world height".into());
|
||||
}
|
||||
let previous = Arc::make_mut(chunk).set(x & 15, y, z & 15, state.0);
|
||||
@@ -276,14 +298,14 @@ impl World {
|
||||
// the sections it can reach: this section, the one above/below
|
||||
// only on a section face, and a neighbour column only on a chunk
|
||||
// border (the diagonal only where both borders meet).
|
||||
let sy = (y - MIN_Y) >> 4;
|
||||
let sy = (y - min_y) >> 4;
|
||||
let local_y = y & 15;
|
||||
let mark = |dirty: &mut DirtySections, bx: i32, bz: i32| {
|
||||
dirty.insert((bx >> 4, sy, bz >> 4));
|
||||
if local_y == 0 && sy > 0 {
|
||||
dirty.insert((bx >> 4, sy - 1, bz >> 4));
|
||||
}
|
||||
if local_y == 15 && sy + 1 < SECTIONS as i32 {
|
||||
if local_y == 15 && sy + 1 < sections {
|
||||
dirty.insert((bx >> 4, sy + 1, bz >> 4));
|
||||
}
|
||||
};
|
||||
@@ -319,17 +341,20 @@ impl World {
|
||||
Ok(())
|
||||
}
|
||||
/// `getHeight(Heightmap.Types.WORLD_SURFACE, x, z)`: one above the highest
|
||||
/// block that isn't `air`, or `MIN_Y` (also where the chunk isn't loaded).
|
||||
/// block that isn't `air`, or the column's minimum Y (also where the chunk
|
||||
/// isn't loaded).
|
||||
pub fn world_surface(&self, x: i32, z: i32, air: impl Fn(BlockStateId) -> bool) -> i32 {
|
||||
self.chunks.get(&(x >> 4, z >> 4)).map_or(MIN_Y, |chunk| {
|
||||
let bottom = self.layout().min_y();
|
||||
self.chunks.get(&(x >> 4, z >> 4)).map_or(bottom, |chunk| {
|
||||
chunk.world_surface(x, z, |v| air(BlockStateId(v)))
|
||||
})
|
||||
}
|
||||
/// `getHeight(Heightmap.Types.MOTION_BLOCKING, x, z)`: one above the
|
||||
/// highest block where `blocks` (motion blocking or holding a fluid),
|
||||
/// or `MIN_Y` (also where the chunk isn't loaded).
|
||||
/// or the column's minimum Y (also where the chunk isn't loaded).
|
||||
pub fn motion_blocking(&self, x: i32, z: i32, blocks: impl Fn(BlockStateId) -> bool) -> i32 {
|
||||
self.chunks.get(&(x >> 4, z >> 4)).map_or(MIN_Y, |chunk| {
|
||||
let bottom = self.layout().min_y();
|
||||
self.chunks.get(&(x >> 4, z >> 4)).map_or(bottom, |chunk| {
|
||||
chunk.motion_blocking(x & 15, z & 15, |v| blocks(BlockStateId(v)))
|
||||
})
|
||||
}
|
||||
@@ -348,12 +373,13 @@ impl World {
|
||||
hi: [i32; 3],
|
||||
mut matches: impl FnMut(BlockStateId) -> bool,
|
||||
) -> bool {
|
||||
let top = MIN_Y + SECTIONS as i32 * 16 - 1;
|
||||
let layout = self.layout();
|
||||
let (bottom, top) = (layout.min_y(), layout.max_y() - 1);
|
||||
// Heights outside the sections read as state 0.
|
||||
if (lo[1] < MIN_Y || hi[1] > top) && matches(BlockStateId(0)) {
|
||||
if (lo[1] < bottom || hi[1] > top) && matches(BlockStateId(0)) {
|
||||
return true;
|
||||
}
|
||||
let sections = (lo[1].max(MIN_Y) - MIN_Y) >> 4..=(hi[1].min(top) - MIN_Y) >> 4;
|
||||
let sections = (lo[1].max(bottom) - bottom) >> 4..=(hi[1].min(top) - bottom) >> 4;
|
||||
for cx in lo[0] >> 4..=hi[0] >> 4 {
|
||||
for cz in lo[2] >> 4..=hi[2] >> 4 {
|
||||
let Some(chunk) = self.chunks.get(&(cx, cz)) else {
|
||||
@@ -465,8 +491,8 @@ mod tests {
|
||||
}
|
||||
let mut got: Vec<_> = world.dirty_sections.columns().collect();
|
||||
let mut want: Vec<_> = expected.columns().collect();
|
||||
got.sort_unstable();
|
||||
want.sort_unstable();
|
||||
got.sort_unstable_by_key(|&(column, _)| column);
|
||||
want.sort_unstable_by_key(|&(column, _)| column);
|
||||
assert_eq!(got, want, "write at {x} {y} {z}");
|
||||
if (x & 15, z & 15) == (3, 3) {
|
||||
assert_eq!(world.dirty_sections.len(), 1);
|
||||
|
||||
@@ -18,6 +18,7 @@ use horizon_client::{
|
||||
};
|
||||
use horizon_core::{geometry::Axis, random::LegacyRandom};
|
||||
use horizon_world::chunk::BlockState;
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use serde_json::{Map, Value, json};
|
||||
use std::{collections::BTreeMap, fs, path::Path};
|
||||
|
||||
@@ -77,9 +78,9 @@ fn main() -> Result<()> {
|
||||
let assets = Assets::load(&root).map_err(step("assets"))?;
|
||||
let registry = assets.registry;
|
||||
let facts = BlockFacts::load(&root, registry).map_err(step("block facts"))?;
|
||||
let items = Items::load(&root, registry, &facts).map_err(step("items"))?;
|
||||
let recipes = Recipes::load(&root, &items).map_err(step("recipes"))?;
|
||||
let stations = StationRecipes::load(&root, &items);
|
||||
let items = Items::load(&root, registry, &facts, &PackOverlay::NONE).map_err(step("items"))?;
|
||||
let recipes = Recipes::load(&root, &items, &PackOverlay::NONE).map_err(step("recipes"))?;
|
||||
let stations = StationRecipes::load(&root, &items, &PackOverlay::NONE);
|
||||
let collisions =
|
||||
horizon_client::collisions::load(&root, registry).map_err(step("collisions"))?;
|
||||
let loaded = began.elapsed().as_secs_f64() - built;
|
||||
|
||||
@@ -7,6 +7,7 @@ use horizon_client::{
|
||||
chunk::{Chunk, MIN_Y, SECTIONS, Section},
|
||||
fast_hash::FxHashMap,
|
||||
portal_sections::PortalSections,
|
||||
section_mask::SectionMask,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
@@ -83,17 +84,14 @@ fn may_hold_portal_cached(
|
||||
if count > 64 {
|
||||
return true;
|
||||
}
|
||||
let mut y_mask = 0u32;
|
||||
for sy in y0..=y1 {
|
||||
y_mask |= 1 << sy;
|
||||
}
|
||||
let y_mask = SectionMask::below(y1 as usize + 1).from(y0 as usize);
|
||||
(x0..=x1).any(|cx| {
|
||||
(z0..=z1).any(|cz| {
|
||||
world.get(&(cx, cz)).is_some_and(|chunk| {
|
||||
cache.get((cx, cz), chunk, |v| {
|
||||
let portals = cache.get((cx, cz), chunk, |v| {
|
||||
behaviours.portal_axis(BlockStateId(v)).is_some()
|
||||
}) & y_mask
|
||||
!= 0
|
||||
});
|
||||
!(portals & y_mask).is_empty()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ use horizon_client::{
|
||||
items::Items,
|
||||
};
|
||||
use horizon_core::random::LegacyRandom;
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
@@ -127,7 +128,7 @@ pub fn main() -> Result<()> {
|
||||
let registry = assets.registry;
|
||||
let facts = BlockFacts::load(root, registry)?;
|
||||
let renderer = BlockEntityRenderer::load(root, registry)?;
|
||||
let tags = Items::tags_only(root)?;
|
||||
let tags = Items::tags_only(root, &PackOverlay::NONE)?;
|
||||
let frames = tags
|
||||
.block_tags
|
||||
.get("minecraft:conduit_effect_block")
|
||||
|
||||
@@ -9,6 +9,7 @@ use horizon_client::{
|
||||
items::Items,
|
||||
};
|
||||
use horizon_world::chunk::BlockState;
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use std::{fs, path::Path};
|
||||
|
||||
fn parse(text: &str) -> BlockState {
|
||||
@@ -58,7 +59,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
.ok_or("usage: block_transform_fixture RESOURCES INPUT OUTPUT")?,
|
||||
);
|
||||
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
|
||||
let items = Items::catalog(root)?;
|
||||
let items = Items::catalog(root, &PackOverlay::NONE)?;
|
||||
let assets = Assets::load(root)?;
|
||||
let transformers = Transformers::load(root, assets.registry)?;
|
||||
let mut out = String::new();
|
||||
|
||||
@@ -10,6 +10,7 @@ use horizon_client::{
|
||||
menu::{Env, Kind, Menu},
|
||||
};
|
||||
use horizon_world::chunk::BlockState;
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use serde_json::Value;
|
||||
use std::{fs, path::Path};
|
||||
|
||||
@@ -77,9 +78,9 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
.ok_or("usage: cauldron_fixture RESOURCES INPUT OUTPUT")?,
|
||||
);
|
||||
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
|
||||
let items = Items::catalog(root)?;
|
||||
let recipes = Recipes::load(root, &items)?;
|
||||
let brewing = horizon_client::brewing::Brewing::load(root, &items);
|
||||
let items = Items::catalog(root, &PackOverlay::NONE)?;
|
||||
let recipes = Recipes::load(root, &items, &PackOverlay::NONE)?;
|
||||
let brewing = horizon_client::brewing::Brewing::load(root, &items, &PackOverlay::NONE);
|
||||
// The inventory menu never reads banner patterns.
|
||||
let banners = horizon_client::banner::Banners::default();
|
||||
let station_recipes = horizon_client::stations::StationRecipes::default();
|
||||
|
||||
@@ -3,6 +3,7 @@ use horizon_client::{
|
||||
item_data::StackData,
|
||||
items::{ItemStack, Items},
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use horizon_world::nbt::Tag;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
@@ -14,7 +15,7 @@ use std::{
|
||||
pub fn main() -> Result<()> {
|
||||
let args: Vec<_> = crate::args().collect();
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
|
||||
let items = Items::catalog(&root)?;
|
||||
let items = Items::catalog(&root, &PackOverlay::NONE)?;
|
||||
let mut out = BufWriter::new(File::create(&args[2])?);
|
||||
for line in BufReader::new(File::open(&args[1])?).lines() {
|
||||
let row: Value = serde_json::from_str(&line?)?;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! The creative inventory, for tools/test_creative_parity.py: loads an
|
||||
//! exported `creative-inventory.json` into `horizon_client::creative::Creative`
|
||||
//! and prints the tabs it shows (canonical stacks) and the search tab's
|
||||
//! results for every query of the vanilla oracle's output.
|
||||
//! results for every query of the vanilla oracle's output; with `permissions`,
|
||||
//! as an operator with the Operator Items Tab option on.
|
||||
//!
|
||||
//! usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT
|
||||
//! usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT [permissions]
|
||||
use horizon_client::{
|
||||
creative::{Creative, Names},
|
||||
item_data::canon_stack,
|
||||
@@ -13,11 +14,13 @@ use std::{fs, path::Path};
|
||||
|
||||
pub fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let usage = "usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT";
|
||||
let usage =
|
||||
"usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT [permissions]";
|
||||
let names = Names::load(Path::new(args.get(1).ok_or(usage)?));
|
||||
let creative = Creative::from_json(&serde_json::from_slice(&fs::read(
|
||||
let mut creative = Creative::from_json(&serde_json::from_slice(&fs::read(
|
||||
args.get(2).ok_or(usage)?,
|
||||
)?)?)?;
|
||||
creative.set_permissions(args.get(5).is_some_and(|a| a == "permissions"), &names);
|
||||
let vanilla: Value = serde_json::from_slice(&fs::read(args.get(3).ok_or(usage)?)?)?;
|
||||
let tabs: Vec<Value> = creative
|
||||
.tabs
|
||||
|
||||
@@ -8,6 +8,7 @@ use horizon_client::{
|
||||
items::{ItemStack, Items},
|
||||
stations,
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use std::{fmt::Write as _, fs, path::Path};
|
||||
|
||||
pub fn main() -> horizon_client::Result<()> {
|
||||
@@ -17,7 +18,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
.ok_or("usage: enchanting_fixture RESOURCES INPUT OUTPUT")?,
|
||||
);
|
||||
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
|
||||
let items = Items::tags_only(root)?;
|
||||
let items = Items::tags_only(root, &PackOverlay::NONE)?;
|
||||
let e = Enchantments::load(root);
|
||||
let mut out = String::new();
|
||||
for line in input.lines() {
|
||||
|
||||
@@ -593,6 +593,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
num("maxCrossbowChageDuration"),
|
||||
num("ticksUsingItem"),
|
||||
false,
|
||||
flag("isRiding"),
|
||||
);
|
||||
}
|
||||
Species::Evoker | Species::Illusioner | Species::Pillager | Species::Vindicator => {
|
||||
|
||||
@@ -4,11 +4,16 @@
|
||||
//!
|
||||
//! usage: entity_death_loot_fixture RESOURCES INPUT OUTPUT
|
||||
use horizon_client::{
|
||||
mobs::{Mob, MobTypes, Species, cube, drops, gear, raider, render::DYE_NAMES, sulfur_cube},
|
||||
assets::Assets,
|
||||
blocks::BlockFacts,
|
||||
items::Items,
|
||||
mobs::{
|
||||
Mob, MobTypes, Species, cube, drops, enderman, gear, raider, render::DYE_NAMES, sulfur_cube,
|
||||
},
|
||||
random_sequences::RandomSequences,
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, random::LegacyRandom};
|
||||
use horizon_world::loot::{LootDamage, LootData, LootEntity, LootStack};
|
||||
use horizon_world::loot::{LootDamage, LootData, LootEntity, LootStack, PackOverlay};
|
||||
use std::{collections::BTreeMap, fmt::Write as _, fs, path::Path, sync::Arc};
|
||||
|
||||
/// `id[;enchantment:level...]`.
|
||||
@@ -64,7 +69,14 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
);
|
||||
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
|
||||
let types = MobTypes::load(root)?;
|
||||
let loot = LootData::load(root)?;
|
||||
let loot = LootData::load(root, &PackOverlay::NONE)?;
|
||||
// Block loot for an enderman's carried block (BlockState.getDrops).
|
||||
let assets = Assets::load(root)?;
|
||||
let registry = assets.registry;
|
||||
let facts = BlockFacts::load(root, registry)?;
|
||||
let block_items = Items::load(root, registry, &facts, &PackOverlay::NONE)?;
|
||||
// The stand-in level's random (RandomSource.create(0)), never reset.
|
||||
let mut level_random = LegacyRandom::new(0);
|
||||
let mut out = String::new();
|
||||
for raw in input.lines() {
|
||||
if raw.trim().is_empty() || raw.starts_with('#') {
|
||||
@@ -121,6 +133,13 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
m.extra.color = DYE_NAMES.iter().position(|d| d == color).unwrap_or(0) as u8;
|
||||
}
|
||||
m.extra.sheared = get("sheared", "0") == "1";
|
||||
if let Some(block) = c.get("carried") {
|
||||
m.extra.carried = Some(
|
||||
registry
|
||||
.default_state(block)
|
||||
.ok_or_else(|| format!("unknown block {block}"))?,
|
||||
);
|
||||
}
|
||||
if get("leader", "0") == "1" {
|
||||
raider::state_mut(&mut m).patrol_leader = true;
|
||||
}
|
||||
@@ -272,6 +291,23 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
for d in items {
|
||||
let _ = match d {
|
||||
drops::Drop::Item(s) => writeln!(out, "{}", line(&s)),
|
||||
drops::Drop::CarriedBlock(state) => {
|
||||
let stacks = enderman::carried_block_drops(
|
||||
&block_items,
|
||||
registry,
|
||||
state,
|
||||
&mut level_random,
|
||||
Some((&mut sequences, world_seed)),
|
||||
);
|
||||
for s in stacks {
|
||||
let _ = write!(out, "drop {} {}", s.item, s.count);
|
||||
if s.damage != 0 {
|
||||
let _ = write!(out, " damage={}", s.damage);
|
||||
}
|
||||
let _ = writeln!(out);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
drops::Drop::Orb(v) => writeln!(out, "orb {v}"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use std::{collections::HashMap, fmt::Write as _, fs, path::Path, sync::Arc};
|
||||
|
||||
struct Level<'a> {
|
||||
@@ -142,8 +143,8 @@ const GENES: [&str; 7] = [
|
||||
"aggressive",
|
||||
];
|
||||
const MEMORIES: [&str; 7] = [
|
||||
"long_jump_cooldown",
|
||||
"ram_cooldown",
|
||||
"long_jump_cooling_down",
|
||||
"ram_cooldown_ticks",
|
||||
"attack_target_cooldown",
|
||||
"hunted_recently",
|
||||
"dig_cooldown",
|
||||
@@ -399,7 +400,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
let assets = Assets::load(root)?;
|
||||
let facts = BlockFacts::load(root, assets.registry)?;
|
||||
let mob_data = MobData::load(root, &facts);
|
||||
let items = Items::tags_only(root)?;
|
||||
let items = Items::tags_only(root, &PackOverlay::NONE)?;
|
||||
let enchantments = Enchantments::load(root);
|
||||
let types = MobTypes::load(root)?;
|
||||
let registries = SpawnRegistries::load(root);
|
||||
@@ -506,6 +507,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
difficulty,
|
||||
data: &data,
|
||||
created: Vec::new(),
|
||||
jockey_rider: None,
|
||||
};
|
||||
group = finalize::finalize(&mut cx, &mut mob, reason, group);
|
||||
let created = std::mem::take(&mut cx.created);
|
||||
|
||||
@@ -10,6 +10,7 @@ use horizon_client::{
|
||||
items::Items,
|
||||
recipe_book::{Book, BookType, GhostTarget, RecipeIndex},
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use serde_json::Value;
|
||||
use std::{fmt::Write as _, fs, path::Path};
|
||||
|
||||
@@ -18,9 +19,9 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
let usage = "usage: ghost_recipe_fixture RESOURCES CASES OUTPUT";
|
||||
let root = Path::new(args.get(1).ok_or(usage)?);
|
||||
let cases = fs::read_to_string(args.get(2).ok_or(usage)?)?;
|
||||
let items = Items::catalog(root)?;
|
||||
let recipes = Recipes::load(root, &items)?;
|
||||
let index = RecipeIndex::load(root);
|
||||
let items = Items::catalog(root, &PackOverlay::NONE)?;
|
||||
let recipes = Recipes::load(root, &items, &PackOverlay::NONE)?;
|
||||
let index = RecipeIndex::load(root, &PackOverlay::NONE);
|
||||
let mut book = Book::build(&recipes, &items);
|
||||
book.set_display_ids(&index);
|
||||
let mut out = String::new();
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//!
|
||||
//! usage: hotbar_fixture RESOURCES CASES OUTPUT
|
||||
use horizon_client::{hotbar, item_data::canon_stack, items::Items};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use horizon_world::nbt::{Tag, parse_snbt};
|
||||
use serde_json::Value;
|
||||
use std::path::Path;
|
||||
@@ -13,7 +14,10 @@ use std::{fmt::Write as _, fs};
|
||||
pub fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let usage = "usage: hotbar_fixture RESOURCES CASES OUTPUT";
|
||||
let items = Items::catalog(Path::new(args.first().and(args.get(1)).ok_or(usage)?))?;
|
||||
let items = Items::catalog(
|
||||
Path::new(args.first().and(args.get(1)).ok_or(usage)?),
|
||||
&PackOverlay::NONE,
|
||||
)?;
|
||||
let known = |id: &str| items.get(id).is_some();
|
||||
let cases = fs::read_to_string(args.get(2).ok_or(usage)?)?;
|
||||
let mut out = String::new();
|
||||
|
||||
@@ -15,6 +15,9 @@ struct World {
|
||||
passing: bool,
|
||||
}
|
||||
impl InteractionWorld for World {
|
||||
// The fixture's targets don't change within a tick.
|
||||
fn pick_hit_result(&mut self) {}
|
||||
fn release_hit_result(&mut self) {}
|
||||
fn using_item(&self) -> bool {
|
||||
self.using
|
||||
}
|
||||
|
||||
@@ -99,6 +99,7 @@ mod toggle_keys_probe;
|
||||
mod tooltip_display_fixture;
|
||||
mod trading_fixture;
|
||||
mod translucency_fixture;
|
||||
mod trident_probe;
|
||||
mod weather_fixture;
|
||||
mod world_border_render_fixture;
|
||||
mod world_options_fixture;
|
||||
@@ -191,6 +192,7 @@ probes! {
|
||||
text_display_fixture,
|
||||
text_display_render_fixture,
|
||||
trading_fixture,
|
||||
trident_probe,
|
||||
translucency_fixture,
|
||||
weather_fixture,
|
||||
world_border_render_fixture,
|
||||
|
||||
@@ -76,6 +76,7 @@ pub fn main() -> Result<()> {
|
||||
raining_at: &|_| false,
|
||||
height_no_leaves: &|_| 0,
|
||||
firefly_bush_sounds: false,
|
||||
creaking_active: false,
|
||||
sky_darken: 0,
|
||||
marker: target.map(|name| (name, &options as &dyn Fn(_) -> _)),
|
||||
model_offset: &|_, _, _| None,
|
||||
|
||||
@@ -13,6 +13,7 @@ use horizon_client::{
|
||||
menu::{Env, Input, Kind, Menu},
|
||||
};
|
||||
use horizon_protocol::registry::Ids;
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
cell::{Cell, RefCell},
|
||||
@@ -83,11 +84,12 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
.ok_or("usage: menu_fixture RESOURCES INPUT OUTPUT")?,
|
||||
);
|
||||
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
|
||||
let items = Items::catalog(root)?;
|
||||
let recipes = Recipes::load(root, &items)?;
|
||||
let brewing = horizon_client::brewing::Brewing::load(root, &items);
|
||||
let items = Items::catalog(root, &PackOverlay::NONE)?;
|
||||
let recipes = Recipes::load(root, &items, &PackOverlay::NONE)?;
|
||||
let brewing = horizon_client::brewing::Brewing::load(root, &items, &PackOverlay::NONE);
|
||||
let banners = horizon_client::banner::Banners::load(root);
|
||||
let station_recipes = horizon_client::stations::StationRecipes::load(root, &items);
|
||||
let station_recipes =
|
||||
horizon_client::stations::StationRecipes::load(root, &items, &PackOverlay::NONE);
|
||||
let enchantments = horizon_client::enchant::Enchantments::load(root);
|
||||
let names = horizon_client::creative::Names::load(root);
|
||||
let case_maps = CaseMaps::default();
|
||||
|
||||
@@ -27,6 +27,7 @@ use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
random::LegacyRandom,
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use horizon_world::{chunk::BlockState, registry::BlockRegistry, registry::BlockStateId};
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
@@ -196,7 +197,11 @@ fn who(w: Option<Who>) -> String {
|
||||
fn goal_name(m: &Mob, name: &str) -> String {
|
||||
let vanilla = match name {
|
||||
"Float" => "FloatGoal",
|
||||
"RandomFloatAround" => "RandomFloatAroundGoal",
|
||||
"GhastLook" => "GhastLookGoal",
|
||||
"GhastShoot" => "GhastShootFireballGoal",
|
||||
"Stroll" => "WaterAvoidingRandomStrollGoal",
|
||||
"ParrotWander" => "ParrotWanderGoal",
|
||||
"RandomStroll" => "RandomStrollGoal",
|
||||
// AbstractFish's private subclass of RandomSwimmingGoal.
|
||||
"RandomSwimming"
|
||||
@@ -223,6 +228,8 @@ fn goal_name(m: &Mob, name: &str) -> String {
|
||||
"MoveThroughVillage" => "MoveThroughVillageGoal",
|
||||
"RestrictSun" => "RestrictSunGoal",
|
||||
"FleeSun" => "FleeSunGoal",
|
||||
// `Cat.CatAvoidEntityGoal`, `Fox.FoxAvoidEntityGoal`... name their subclass.
|
||||
"AvoidEntity" if m.species() == Species::Cat => "CatAvoidEntityGoal",
|
||||
"AvoidEntity" => "AvoidEntityGoal",
|
||||
"BowAttack" => "RangedBowAttackGoal",
|
||||
"Swell" => "SwellGoal",
|
||||
@@ -873,7 +880,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
|
||||
.collect();
|
||||
let biomes = Biomes::load(root)?;
|
||||
let items = Items::load(root, registry, &facts)?;
|
||||
let items = Items::load(root, registry, &facts, &PackOverlay::NONE)?;
|
||||
let tables = LightTables::load(root, registry, &facts)?;
|
||||
let collision = collisions::load(root, registry)?;
|
||||
let air = registry.default_state("minecraft:air").ok_or("no air")?;
|
||||
|
||||
@@ -53,7 +53,8 @@ use horizon_core::{
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
use horizon_world::{
|
||||
block_collision::BlockCollisionRegistry, chunk::BlockState, nbt::Tag, registry::BlockStateId,
|
||||
block_collision::BlockCollisionRegistry, chunk::BlockState, loot::PackOverlay, nbt::Tag,
|
||||
registry::BlockStateId,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
@@ -522,11 +523,11 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
let registry = assets.registry;
|
||||
let facts = BlockFacts::load(root, registry)?;
|
||||
let data = MobData::load(root, &facts);
|
||||
let items = Items::tags_only(root)?;
|
||||
let items = Items::tags_only(root, &PackOverlay::NONE)?;
|
||||
// Trial spawner rewards: the loot tables and the configs.
|
||||
let loot = if input.contains("mode=trial") || input.contains("mode=vault") {
|
||||
horizon_client::mobs::trial_spawner::load_registry(root);
|
||||
horizon_client::structure_blocks::loot_data(root)
|
||||
horizon_client::structure_blocks::loot_data(root, &PackOverlay::NONE)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -795,7 +796,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
chunk,
|
||||
&mut gen_random,
|
||||
&mut rules,
|
||||
&mut spawn,
|
||||
&mut |l, r, _| spawn(l, r).is_some(),
|
||||
&mut |e| match e {
|
||||
GenEvent::Column(x, z) => log.push(format!("col {x} {z}")),
|
||||
GenEvent::Spawn(t, p, yaw) => spawns.push(format!(
|
||||
|
||||
@@ -76,6 +76,14 @@ fn options(case: &Value) -> Option<ParticleOptions> {
|
||||
..ParticleOptions::simple(ty)
|
||||
},
|
||||
ParticleType::SculkCharge => ParticleOptions::sculk_charge(scale),
|
||||
ParticleType::Geyser | ParticleType::GeyserPlume => {
|
||||
ParticleOptions::geyser(ty, o["water_blocks"].as_i64()? as i32)
|
||||
}
|
||||
ParticleType::GeyserBase | ParticleType::GeyserPoof => ParticleOptions::geyser_base(
|
||||
ty,
|
||||
o["water_blocks"].as_i64()? as i32,
|
||||
o["burst_impulse_base"].as_f64()? as f32,
|
||||
),
|
||||
ParticleType::Shriek => ParticleOptions::shriek(scale as i32),
|
||||
ParticleType::Trail => {
|
||||
let t = &o["target"];
|
||||
|
||||
@@ -10,6 +10,7 @@ use horizon_client::{
|
||||
items::Items,
|
||||
menu::{Env, Kind, Menu, place::PostPlace},
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use serde_json::Value;
|
||||
use std::{fs, path::Path};
|
||||
|
||||
@@ -34,9 +35,9 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
let root = Path::new(args.get(1).ok_or(usage)?);
|
||||
let cases = fs::read_to_string(args.get(2).ok_or(usage)?)?;
|
||||
let orders = fs::read_to_string(args.get(3).ok_or(usage)?)?;
|
||||
let items = Items::catalog(root)?;
|
||||
let recipes = Recipes::load(root, &items)?;
|
||||
let brewing = horizon_client::brewing::Brewing::load(root, &items);
|
||||
let items = Items::catalog(root, &PackOverlay::NONE)?;
|
||||
let recipes = Recipes::load(root, &items, &PackOverlay::NONE)?;
|
||||
let brewing = horizon_client::brewing::Brewing::load(root, &items, &PackOverlay::NONE);
|
||||
let banners = horizon_client::banner::Banners::default();
|
||||
let station_recipes = horizon_client::stations::StationRecipes::default();
|
||||
let enchantments = horizon_client::enchant::Enchantments::default();
|
||||
|
||||
@@ -19,6 +19,7 @@ use horizon_client::{
|
||||
raid::{self, Action, BarName, RaidLevel, RaidPlayer, RaiderInfo, Raids},
|
||||
};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use horizon_world::{chunk::BlockState, nbt::Tag, registry::BlockStateId};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
@@ -470,7 +471,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
let registry = assets.registry;
|
||||
let facts = BlockFacts::load(root, registry)?;
|
||||
let data = MobData::load(root, &facts);
|
||||
let items = Items::tags_only(root)?;
|
||||
let items = Items::tags_only(root, &PackOverlay::NONE)?;
|
||||
let attributes = GameplayAttributes::load(root);
|
||||
let shared = Shared {
|
||||
facts: &facts,
|
||||
|
||||
@@ -9,6 +9,7 @@ use horizon_protocol::{
|
||||
buf::{Codec, Reader, Writer},
|
||||
packets::recipes::RecipeBookChangeSettings,
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use horizon_world::nbt::Tag;
|
||||
use serde_json::Value;
|
||||
use std::{collections::BTreeSet, fs, path::Path};
|
||||
@@ -66,7 +67,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
let usage = "usage: recipe_book_fixture RESOURCES CASES OUTPUT";
|
||||
let root = Path::new(args.get(1).ok_or(usage)?);
|
||||
let cases = fs::read_to_string(args.get(2).ok_or(usage)?)?;
|
||||
let index = RecipeIndex::load(root);
|
||||
let index = RecipeIndex::load(root, &PackOverlay::NONE);
|
||||
let mut out = String::new();
|
||||
for line in cases.lines() {
|
||||
let case: Value = serde_json::from_str(line)?;
|
||||
|
||||
@@ -16,6 +16,7 @@ use horizon_client::{
|
||||
TabView, draw, overlay_cells, overlay_origin,
|
||||
},
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use serde_json::{Value, json};
|
||||
use std::{fmt::Write as _, fs, path::Path};
|
||||
|
||||
@@ -35,8 +36,8 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
let usage = "usage: recipe_book_render_fixture RESOURCES CASES OUTPUT";
|
||||
let root = Path::new(args.get(1).ok_or(usage)?);
|
||||
let cases = fs::read_to_string(args.get(2).ok_or(usage)?)?;
|
||||
let items = Items::catalog(root)?;
|
||||
let recipes = Recipes::load(root, &items)?;
|
||||
let items = Items::catalog(root, &PackOverlay::NONE)?;
|
||||
let recipes = Recipes::load(root, &items, &PackOverlay::NONE)?;
|
||||
let book = Book::build(&recipes, &items);
|
||||
let atlas = HudAtlas::build(root)?;
|
||||
let root_buf = root.to_path_buf();
|
||||
|
||||
@@ -12,6 +12,7 @@ use horizon_client::{
|
||||
menu::place::Stacked,
|
||||
recipe_book::{Book, BookType, ClientBook, Display, RecipeIndex, Tab},
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use serde_json::Value;
|
||||
use std::{fmt::Write as _, fs, path::Path};
|
||||
|
||||
@@ -20,9 +21,9 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
let usage = "usage: recipe_book_view_fixture RESOURCES CASES OUTPUT";
|
||||
let root = Path::new(args.get(1).ok_or(usage)?);
|
||||
let cases = fs::read_to_string(args.get(2).ok_or(usage)?)?;
|
||||
let items = Items::catalog(root)?;
|
||||
let recipes = Recipes::load(root, &items)?;
|
||||
let index = RecipeIndex::load(root);
|
||||
let items = Items::catalog(root, &PackOverlay::NONE)?;
|
||||
let recipes = Recipes::load(root, &items, &PackOverlay::NONE)?;
|
||||
let index = RecipeIndex::load(root, &PackOverlay::NONE);
|
||||
let mut book = Book::build(&recipes, &items);
|
||||
book.set_display_ids(&index);
|
||||
let max = |s: &horizon_client::items::ItemStack| items.get(&s.item).map_or(64, |i| i.max_stack);
|
||||
|
||||
@@ -10,6 +10,7 @@ use horizon_client::{
|
||||
items::Items,
|
||||
stations::StationRecipes,
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use std::{fs, path::Path};
|
||||
|
||||
pub fn main() -> horizon_client::Result<()> {
|
||||
@@ -19,9 +20,9 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
.ok_or("usage: recipe_fixture RESOURCES INPUT OUTPUT")?,
|
||||
);
|
||||
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
|
||||
let items = Items::catalog(root)?;
|
||||
let recipes = Recipes::load(root, &items)?;
|
||||
let stations = StationRecipes::load(root, &items);
|
||||
let items = Items::catalog(root, &PackOverlay::NONE)?;
|
||||
let recipes = Recipes::load(root, &items, &PackOverlay::NONE)?;
|
||||
let stations = StationRecipes::load(root, &items, &PackOverlay::NONE);
|
||||
let enchantments = Enchantments::load(root);
|
||||
let mut out = String::new();
|
||||
for line in input.lines() {
|
||||
|
||||
@@ -14,6 +14,7 @@ use horizon_client::{
|
||||
},
|
||||
};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use horizon_world::{chunk::BlockState, registry::BlockStateId};
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap, HashSet},
|
||||
@@ -147,7 +148,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
let registry = assets.registry;
|
||||
let facts = BlockFacts::load(root, registry)?;
|
||||
let data = MobData::load(root, &facts);
|
||||
let items = Items::tags_only(root)?;
|
||||
let items = Items::tags_only(root, &PackOverlay::NONE)?;
|
||||
let types = MobTypes::load(root)?;
|
||||
let mut out = String::new();
|
||||
for raw in input.lines() {
|
||||
|
||||
@@ -36,7 +36,7 @@ pub fn main() -> Result<()> {
|
||||
aquatic::squid_cycle_event(&mut mob);
|
||||
}
|
||||
let velocity = mob.velocity;
|
||||
aquatic::squid_client_step(&mut mob);
|
||||
aquatic::squid_client_step(&mut mob, &mut Vec::new());
|
||||
assert_eq!(
|
||||
velocity, mob.velocity,
|
||||
"a remote animation must preserve packet velocity"
|
||||
|
||||
@@ -13,6 +13,7 @@ use horizon_client::{
|
||||
random_sequences::RandomSequences,
|
||||
};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::{collections::HashMap, fmt::Write as _, fs, path::Path};
|
||||
|
||||
@@ -237,12 +238,12 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
let root = Path::new(args.get(1).ok_or(usage)?);
|
||||
let input = fs::read_to_string(args.get(2).ok_or(usage)?)?;
|
||||
let vanilla = fs::read_to_string(args.get(3).ok_or(usage)?)?;
|
||||
let items = Items::catalog(root)?;
|
||||
let items = Items::catalog(root, &PackOverlay::NONE)?;
|
||||
let enchantments = Enchantments::load(root);
|
||||
let names = horizon_client::creative::Names::default();
|
||||
let trades = Trades::load(root);
|
||||
let recipes = horizon_client::crafting::Recipes::load(root, &items)?;
|
||||
let brewing = horizon_client::brewing::Brewing::load(root, &items);
|
||||
let recipes = horizon_client::crafting::Recipes::load(root, &items, &PackOverlay::NONE)?;
|
||||
let brewing = horizon_client::brewing::Brewing::load(root, &items, &PackOverlay::NONE);
|
||||
let banners = horizon_client::banner::Banners::load(root);
|
||||
let station_recipes = horizon_client::stations::StationRecipes::default();
|
||||
let types: HashMap<_, _> = horizon_client::mobs::MobTypes::load(root)?
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Thrown tridents over the client's resident scene (`arrows::clip_block_stop`),
|
||||
//! for tools/test_trident_parity.py: `AbstractArrow.tick`'s block step and
|
||||
//! `AbstractArrow.onHitBlock`, one line of state per fixture.
|
||||
#![forbid(unsafe_code)]
|
||||
use horizon_client::{
|
||||
Result, arrows, assets::Assets, blocks::BlockFacts, collisions, world::World,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs,
|
||||
io::{BufRead, Write},
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
fn number(v: &Value) -> f64 {
|
||||
f64::from_bits(u64::from_str_radix(v.as_str().unwrap(), 16).unwrap())
|
||||
}
|
||||
fn vector(v: &Value) -> Vec3 {
|
||||
Vec3::new(number(&v[0]), number(&v[1]), number(&v[2]))
|
||||
}
|
||||
fn bits(v: Vec3) -> String {
|
||||
format!(
|
||||
"{:016x},{:016x},{:016x}",
|
||||
v.x.to_bits(),
|
||||
v.y.to_bits(),
|
||||
v.z.to_bits()
|
||||
)
|
||||
}
|
||||
|
||||
pub fn main() -> Result<()> {
|
||||
let args: Vec<_> = crate::args().skip(1).collect();
|
||||
if args.len() != 3 {
|
||||
return Err("usage: trident_probe RESOURCES FIXTURE_DIRECTORY --describe|REFERENCE".into());
|
||||
}
|
||||
let root = PathBuf::from(&args[0]);
|
||||
let dir = PathBuf::from(&args[1]);
|
||||
let assets = Assets::load(&root)?;
|
||||
let render = assets.reload(&root)?;
|
||||
let mut scene = World::test_scene(&assets, &render, 1)?;
|
||||
fs::write(
|
||||
dir.join("scene.json"),
|
||||
serde_json::to_vec(
|
||||
&serde_json::json!({"radius":1,"resident_chunks":scene.chunks.len(),"model_gallery":render.model_gallery}),
|
||||
)?,
|
||||
)?;
|
||||
if args[2] == "--describe" {
|
||||
return Ok(());
|
||||
}
|
||||
let binding = collisions::load(&root, assets.registry)?;
|
||||
let facts = BlockFacts::load(&root, assets.registry)?;
|
||||
let side = &args[2];
|
||||
let mut out = std::io::BufWriter::new(fs::File::create(dir.join(format!("rust-{side}.txt")))?);
|
||||
for line in std::io::BufReader::new(fs::File::open(dir.join("inputs.txt"))?).lines() {
|
||||
let f: Value = serde_json::from_str(&line?)?;
|
||||
let mut position = vector(&f["position"]);
|
||||
let mut velocity = vector(&f["velocity"]);
|
||||
let mut in_ground = false;
|
||||
let mut shake = 0u32;
|
||||
for op in f["ops"].as_array().unwrap() {
|
||||
match op["mode"].as_str().unwrap() {
|
||||
"block" => {
|
||||
let p = &op["position"];
|
||||
let mut state = assets
|
||||
.registry
|
||||
.default_state(op["name"].as_str().unwrap())
|
||||
.ok_or("missing fixture block")?;
|
||||
if let Some(properties) = op["properties"].as_object() {
|
||||
let mut definition = assets.registry.state(state).unwrap().clone();
|
||||
for (name, value) in properties {
|
||||
definition
|
||||
.properties
|
||||
.insert(name.clone(), value.as_str().unwrap().into());
|
||||
}
|
||||
state = assets
|
||||
.registry
|
||||
.id(&definition)
|
||||
.ok_or("missing fixture state")?;
|
||||
}
|
||||
scene.set(
|
||||
p[0].as_i64().unwrap() as i32,
|
||||
p[1].as_i64().unwrap() as i32,
|
||||
p[2].as_i64().unwrap() as i32,
|
||||
state,
|
||||
)?;
|
||||
}
|
||||
// Ops only the vanilla oracle needs.
|
||||
"level_seed" | "owner" | "pickup" | "touch" | "target" | "velocity" | "ignite" => {}
|
||||
"tick" => {
|
||||
// AbstractArrow.tick: shakeTime down, then the block step.
|
||||
shake = shake.saturating_sub(1);
|
||||
if in_ground {
|
||||
continue;
|
||||
}
|
||||
if let Some((stop, _)) =
|
||||
arrows::clip_block_stop(&scene, &facts, &binding, position, velocity)
|
||||
{
|
||||
// onHitBlock: pulled back, stopped, stuck, shaking 7 ticks.
|
||||
position = stop;
|
||||
velocity = Vec3::ZERO;
|
||||
in_ground = true;
|
||||
shake = 7;
|
||||
} else {
|
||||
// A MISS moves the whole movement, then air drag and gravity.
|
||||
position = Vec3::new(
|
||||
position.x + velocity.x,
|
||||
position.y + velocity.y,
|
||||
position.z + velocity.z,
|
||||
);
|
||||
velocity = Vec3::new(
|
||||
velocity.x * f64::from(0.99f32),
|
||||
velocity.y * f64::from(0.99f32) - 0.05,
|
||||
velocity.z * f64::from(0.99f32),
|
||||
);
|
||||
}
|
||||
}
|
||||
other => return Err(format!("unknown operation {other}").into()),
|
||||
}
|
||||
}
|
||||
out.write_all(
|
||||
format!(
|
||||
"trident {} {} {in_ground} {shake}\n",
|
||||
bits(position),
|
||||
bits(velocity)
|
||||
)
|
||||
.as_bytes(),
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -14,6 +14,7 @@ use horizon_client::{
|
||||
light::LightTables, mesher::MeshTables, models, motion::MotionResources,
|
||||
vanillagen::VanillaTerrain,
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use std::{
|
||||
alloc::{GlobalAlloc, Layout, System},
|
||||
sync::atomic::{AtomicUsize, Ordering::Relaxed},
|
||||
@@ -105,9 +106,9 @@ fn main() -> Result<()> {
|
||||
// The game decodes gameplay facts once for both tables (BlockFacts::load_pair).
|
||||
let (facts, behaviours) = BlockFacts::load_pair(&root, assets.registry)?;
|
||||
step("block facts+behaviours");
|
||||
let items = Items::load(&root, assets.registry, &facts)?;
|
||||
let items = Items::load(&root, assets.registry, &facts, &PackOverlay::NONE)?;
|
||||
step("items");
|
||||
let recipes = Recipes::load(&root, &items)?;
|
||||
let recipes = Recipes::load(&root, &items, &PackOverlay::NONE)?;
|
||||
step("recipes");
|
||||
let biomes = Biomes::load(&root)?;
|
||||
step("biomes");
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet, HashMap},
|
||||
path::Path,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex, Weak},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -66,8 +67,36 @@ fn frame(v: &Value) -> Frame {
|
||||
}
|
||||
}
|
||||
impl Tree {
|
||||
/// [`Tree::load`], shared while anyone holds it: a server's advancement
|
||||
/// packets (`ServerAdvancements`) and its game's criteria read the same
|
||||
/// files, and each kept its own copy (7 MB). A world whose data packs bring
|
||||
/// advancements gets its own tree ([`Tree::load_with`]).
|
||||
pub fn shared(data: &Path, overlay: &horizon_world::loot::PackOverlay) -> Arc<Tree> {
|
||||
if overlay.kind("advancement").next().is_some() {
|
||||
return Arc::new(Tree::load_with(data, overlay));
|
||||
}
|
||||
static TREES: Mutex<Vec<(PathBuf, Weak<Tree>)>> = Mutex::new(Vec::new());
|
||||
// Held while loading: a second reader waits for the first one's tree.
|
||||
let mut trees = TREES.lock().unwrap_or_else(|e| e.into_inner());
|
||||
trees.retain(|(_, tree)| tree.strong_count() > 0);
|
||||
if let Some(tree) = trees
|
||||
.iter()
|
||||
.find(|(path, _)| path == data)
|
||||
.and_then(|(_, tree)| tree.upgrade())
|
||||
{
|
||||
return tree;
|
||||
}
|
||||
let tree = Arc::new(Tree::load(data));
|
||||
trees.push((data.to_path_buf(), Arc::downgrade(&tree)));
|
||||
tree
|
||||
}
|
||||
/// Loads `<data>/<namespace>/advancement/**/*.json`.
|
||||
pub fn load(data: &Path) -> Tree {
|
||||
Self::load_with(data, &horizon_world::loot::PackOverlay::NONE)
|
||||
}
|
||||
/// The tree with a world's data packs' advancement files over the game's
|
||||
/// own: the top pack's file for each id replaces it.
|
||||
pub fn load_with(data: &Path, overlay: &horizon_world::loot::PackOverlay) -> Tree {
|
||||
let mut tree = Tree::default();
|
||||
let Ok(namespaces) = std::fs::read_dir(data) else {
|
||||
return tree;
|
||||
@@ -77,6 +106,8 @@ impl Tree {
|
||||
let root = ns.path().join("advancement");
|
||||
collect(&root, &root, &ns.file_name().to_string_lossy(), &mut files);
|
||||
}
|
||||
// One JSON per id, the packs' over the game's own.
|
||||
let mut json: BTreeMap<String, Value> = BTreeMap::new();
|
||||
files.sort();
|
||||
for (id, path) in files {
|
||||
let Ok(text) = std::fs::read_to_string(&path) else {
|
||||
@@ -85,6 +116,17 @@ impl Tree {
|
||||
let Ok(v) = serde_json::from_str::<Value>(&text) else {
|
||||
continue;
|
||||
};
|
||||
json.insert(id, v);
|
||||
}
|
||||
for (id, files) in overlay.kind("advancement") {
|
||||
match serde_json::from_slice::<Value>(horizon_world::loot::PackOverlay::top(files)) {
|
||||
Ok(v) => {
|
||||
json.insert(id.to_owned(), v);
|
||||
}
|
||||
Err(e) => log::warn!("Horizon: pack advancement {id} not loaded: {e}"),
|
||||
}
|
||||
}
|
||||
for (id, v) in json {
|
||||
if let Some(a) = parse(&id, &v) {
|
||||
let index = tree.list.len();
|
||||
for (ci, c) in a.criteria.iter().enumerate() {
|
||||
|
||||
@@ -101,6 +101,8 @@ enum Animate {
|
||||
DeadBush,
|
||||
/// `ShortDryGrassBlock` and `TallDryGrassBlock`.
|
||||
DryGrass,
|
||||
/// A creaking heart that is not uprooted (`CreakingHeartBlock.animateTick`).
|
||||
CreakingHeart,
|
||||
}
|
||||
fn step(facing: &str) -> [i32; 3] {
|
||||
match facing {
|
||||
@@ -236,6 +238,9 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
|
||||
"firefly_bush" => Animate::FireflyBush,
|
||||
"open_eyeblossom" => Animate::OpenEyeblossom,
|
||||
"pale_hanging_moss" => Animate::PaleHangingMoss,
|
||||
"creaking_heart" if prop("creaking_heart_state").as_deref() != Some("uprooted") => {
|
||||
Animate::CreakingHeart
|
||||
}
|
||||
"dead_bush" => Animate::DeadBush,
|
||||
"short_dry_grass" | "tall_dry_grass" => Animate::DryGrass,
|
||||
"sculk_sensor" | "calibrated_sculk_sensor"
|
||||
@@ -442,6 +447,8 @@ pub struct AnimateContext<'a> {
|
||||
pub height_no_leaves: &'a dyn Fn([i32; 3]) -> i32,
|
||||
/// `EnvironmentAttributes.FIREFLY_BUSH_SOUNDS` (overworld day timeline).
|
||||
pub firefly_bush_sounds: bool,
|
||||
/// `EnvironmentAttributes.CREAKING_ACTIVE` (overworld day timeline).
|
||||
pub creaking_active: bool,
|
||||
/// `Level.getSkyDarken`, for `getMaxLocalRawBrightness`.
|
||||
pub sky_darken: i32,
|
||||
/// Creative main-hand marker block and a resolver for each placed state's sprite.
|
||||
@@ -1259,6 +1266,39 @@ fn block_animate_tick(
|
||||
}
|
||||
}
|
||||
}
|
||||
Animate::CreakingHeart => {
|
||||
// CreakingHeartBlock.animateTick: the idle creak is drawn only while
|
||||
// the attribute is on, then needs pale oak logs on all six sides
|
||||
// (`CreakingHeartBlock.isSurroundedByLogs`).
|
||||
if ctx.creaking_active
|
||||
&& next_int(engine.random(), 16) == 0
|
||||
&& [
|
||||
[-1, 0, 0],
|
||||
[1, 0, 0],
|
||||
[0, -1, 0],
|
||||
[0, 1, 0],
|
||||
[0, 0, -1],
|
||||
[0, 0, 1],
|
||||
]
|
||||
.iter()
|
||||
.all(|d| {
|
||||
let p = [pos[0] + d[0], pos[1] + d[1], pos[2] + d[2]];
|
||||
tables
|
||||
.pale_oak_logs
|
||||
.get(ctx.state(p).0 as usize)
|
||||
.copied()
|
||||
.unwrap_or(false)
|
||||
})
|
||||
{
|
||||
sound(
|
||||
engine,
|
||||
"minecraft:block.creaking_heart.idle",
|
||||
[fx, fy, fz],
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
Animate::DeadBush => {
|
||||
// AmbientDesertBlockSoundsPlayer.playAmbientDeadBushSounds.
|
||||
if next_int(engine.random(), 130) == 0 {
|
||||
|
||||
@@ -186,6 +186,8 @@ pub struct ArrowState {
|
||||
/// The arrow's own random (`Entity.random`).
|
||||
pub random: LegacyRandom,
|
||||
pub events: Vec<ArrowEvent>,
|
||||
/// The speed (`deltaMovement.length()`) it hit a block at, before it stuck.
|
||||
pub hit_speed: f64,
|
||||
}
|
||||
impl fmt::Debug for ArrowState {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
@@ -230,6 +232,7 @@ impl ArrowState {
|
||||
removed: false,
|
||||
random,
|
||||
events: Vec::new(),
|
||||
hit_speed: 0.0,
|
||||
}
|
||||
}
|
||||
/// The `AbstractArrow` constructor's `pickupItemStack`: the drawn ammo
|
||||
@@ -375,6 +378,61 @@ impl ArrowScene<'_, '_> {
|
||||
true
|
||||
}
|
||||
}
|
||||
/// `AbstractArrow.tick`'s block phase for a thrown trident: the zero-width
|
||||
/// `level().clip(new ClipContext(position(), position().add(movement),
|
||||
/// ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, this))`, then
|
||||
/// `AbstractArrow.onHitBlock`'s `position().subtract(signum * 0.05)`. Returns
|
||||
/// the position to stop at (pull-back included) and the block that was hit.
|
||||
pub fn clip_block_stop(
|
||||
world: &World,
|
||||
facts: &BlockFacts,
|
||||
collision: &BlockCollisionRegistry<'_>,
|
||||
from: Vec3,
|
||||
movement: Vec3,
|
||||
) -> Option<(Vec3, BlockPos)> {
|
||||
let to = Vec3::new(
|
||||
from.x + movement.x,
|
||||
from.y + movement.y,
|
||||
from.z + movement.z,
|
||||
);
|
||||
let mut scene = SceneCollisions {
|
||||
world,
|
||||
binding: collision,
|
||||
subject: None,
|
||||
};
|
||||
let hit = horizon_core::geometry::traverse_blocks(from, to, 1024, |p: BlockPos| {
|
||||
let state = world.block(p.x, p.y, p.z);
|
||||
if facts.is_air(state) {
|
||||
return None;
|
||||
}
|
||||
let shape = collision
|
||||
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
|
||||
.ok()?;
|
||||
shape.clip(from, to, p)
|
||||
})
|
||||
.ok()
|
||||
.flatten()?;
|
||||
Some((back_off(hit.location, movement), hit.block))
|
||||
}
|
||||
/// `AbstractArrow.onHitBlock`'s back-off: vanilla scales `(double) 0.05f` by
|
||||
/// `Math.signum` per axis, whose value at zero is zero (not Rust's -1.0).
|
||||
pub fn back_off(location: Vec3, movement: Vec3) -> Vec3 {
|
||||
fn signum(v: f64) -> f64 {
|
||||
if v > 0.0 {
|
||||
1.0
|
||||
} else if v < 0.0 {
|
||||
-1.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
let offset = f64::from(0.05f32);
|
||||
Vec3::new(
|
||||
location.x - signum(movement.x) * offset,
|
||||
location.y - signum(movement.y) * offset,
|
||||
location.z - signum(movement.z) * offset,
|
||||
)
|
||||
}
|
||||
fn bounds(at: Vec3) -> Aabb {
|
||||
// EntityDimensions.makeBoundingBox: half the float width.
|
||||
let w = f64::from(WIDTH / 2.0);
|
||||
@@ -664,7 +722,7 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
if !self.st.first_tick && self.st.fluids.is_in_fluid(&Tags, LAVA) {
|
||||
self.st.fall_distance *= 0.5;
|
||||
}
|
||||
if self.position().y < f64::from(crate::chunk::MIN_Y - 64) {
|
||||
if self.position().y < f64::from(self.scene.world.bottom_y() - 64) {
|
||||
self.st.removed = true;
|
||||
}
|
||||
self.st.first_tick = false;
|
||||
@@ -848,6 +906,8 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
/// it moved on, stop, and stick.
|
||||
fn on_hit_block(&mut self, pos: BlockPos, state: BlockStateId) {
|
||||
self.st.last_state = Some(state);
|
||||
// BlockState.onProjectileHit still sees the deltaMovement it hit at.
|
||||
self.st.hit_speed = self.p.velocity.length();
|
||||
// Projectile.onHitBlock: BlockState.onProjectileHit.
|
||||
if self.st.on_fire()
|
||||
&& let Some(reaction) = self.block_reaction(state)
|
||||
@@ -1159,10 +1219,10 @@ impl FluidInteractionWorld for Ticker<'_, '_, '_, '_> {
|
||||
Ok(self.scene.world.chunks.contains_key(&(x, z)).then_some(()))
|
||||
}
|
||||
fn chunk_sections(&mut self, _: ()) -> Result<FluidSections<'_>, Error> {
|
||||
static SECTIONS: [bool; crate::chunk::SECTIONS] = [true; crate::chunk::SECTIONS];
|
||||
let layout = self.scene.world.layout();
|
||||
Ok(FluidSections {
|
||||
minimum_section_y: crate::chunk::MIN_Y >> 4,
|
||||
has_fluid: &SECTIONS,
|
||||
minimum_section_y: layout.min_y() >> 4,
|
||||
has_fluid: crate::chunk::every_section(layout),
|
||||
})
|
||||
}
|
||||
fn bounding_box_min_y(&self) -> f64 {
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::{
|
||||
items::{ItemStack, Items},
|
||||
particles::{DYE_FIREWORK_COLORS, FireworkExplosion, FireworkShape},
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use horizon_world::nbt::Tag;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
@@ -1180,7 +1181,7 @@ fn matches_all(ingredients: &[Ingredient], stacks: &[&ItemStack]) -> bool {
|
||||
})
|
||||
}
|
||||
impl Recipes {
|
||||
pub fn load(root: &Path, items: &Items) -> Result<Self> {
|
||||
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Result<Self> {
|
||||
let mut crafting = Vec::new();
|
||||
let mut cooking = Vec::new();
|
||||
let mut meta = HashMap::new();
|
||||
@@ -1193,27 +1194,47 @@ impl Recipes {
|
||||
files.sort();
|
||||
Ok(files)
|
||||
};
|
||||
let stem = |p: &Path| {
|
||||
p.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("")
|
||||
.to_owned()
|
||||
};
|
||||
// Vanilla's recipes by bare name, then content mods' as `<ns>:<name>`.
|
||||
let mut files: Vec<(String, PathBuf)> = json_files(&root.join("data/minecraft/recipe"))?
|
||||
.into_iter()
|
||||
.map(|p| (String::new(), p))
|
||||
.collect();
|
||||
let mut sources: Vec<(String, Vec<u8>)> = Vec::new();
|
||||
for p in json_files(&root.join("data/minecraft/recipe"))? {
|
||||
sources.push((stem(&p), fs::read(&p)?));
|
||||
}
|
||||
for namespace in crate::mod_content::namespaces(root) {
|
||||
let dir = root.join(format!("data/{namespace}/recipe"));
|
||||
if dir.is_dir() {
|
||||
files.extend(
|
||||
json_files(&dir)?
|
||||
.into_iter()
|
||||
.map(|p| (format!("{namespace}:"), p)),
|
||||
);
|
||||
for p in json_files(&dir)? {
|
||||
sources.push((format!("{namespace}:{}", stem(&p)), fs::read(&p)?));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (prefix, path) in files {
|
||||
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
|
||||
let name = format!("{prefix}{stem}");
|
||||
// The world's packs' recipes over the game's own: the top pack's
|
||||
// file for each id replaces it (`SimpleJsonResourceReloadListener`
|
||||
// sees one resource per id, the resource stack's top).
|
||||
for (id, files) in overlay.kind("recipe") {
|
||||
let Some((ns, path)) = id.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
let name = if ns == "minecraft" {
|
||||
path.to_owned()
|
||||
} else {
|
||||
id.to_owned()
|
||||
};
|
||||
let bytes = PackOverlay::top(files).to_vec();
|
||||
match sources.iter_mut().find(|(n, _)| *n == name) {
|
||||
Some(slot) => slot.1 = bytes,
|
||||
None => sources.push((name, bytes)),
|
||||
}
|
||||
}
|
||||
for (name, bytes) in sources {
|
||||
// Vanilla's RecipeManager logs a file that does not parse and
|
||||
// goes on ("Couldn't parse data file").
|
||||
let v: Value = match serde_json::from_slice(&fs::read(&path)?) {
|
||||
let v: Value = match serde_json::from_slice(&bytes) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: recipe {name} not loaded: {e}");
|
||||
@@ -1420,7 +1441,7 @@ impl Recipes {
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let curses = crate::items::load_tags(root, "enchantment")?
|
||||
let curses = crate::items::load_tags(root, "enchantment", overlay)?
|
||||
.remove("minecraft:curse")
|
||||
.unwrap_or_default();
|
||||
Ok(Self {
|
||||
|
||||
@@ -20,6 +20,34 @@ pub struct Tab {
|
||||
pub column: u32,
|
||||
pub search: bool,
|
||||
pub inventory: bool,
|
||||
/// `CreativeModeTab.Type.HOTBAR`: the saved toolbars, filled when the
|
||||
/// tab is selected (`set_saved_hotbars`).
|
||||
pub hotbar: bool,
|
||||
}
|
||||
impl Tab {
|
||||
/// The tab without its stacks.
|
||||
fn header(&self) -> Self {
|
||||
Self {
|
||||
id: self.id.clone(),
|
||||
title: self.title.clone(),
|
||||
icon: self.icon.clone(),
|
||||
items: Vec::new(),
|
||||
top: self.top,
|
||||
column: self.column,
|
||||
search: self.search,
|
||||
inventory: self.inventory,
|
||||
hotbar: self.hotbar,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A tab as exported, built with permissions: `operator` marks the stacks a
|
||||
/// build without them leaves out (the export's `permissions_only`).
|
||||
#[derive(Clone, Debug)]
|
||||
struct Exported {
|
||||
tab: Tab,
|
||||
/// `CreativeModeTab.Type.CATEGORY`: shown only with a stack (`shouldDisplay`).
|
||||
category: bool,
|
||||
operator: Vec<bool>,
|
||||
}
|
||||
/// What the client's creative search trees (SessionSearchTrees) index for one
|
||||
/// stack of the search tab: its creative tooltip lines and its item tags.
|
||||
@@ -34,6 +62,8 @@ struct SearchEntry {
|
||||
tags: Vec<(String, String)>,
|
||||
}
|
||||
pub struct Creative {
|
||||
/// The tabs shown (`CreativeModeTabs.tabs()`), with the stacks the current
|
||||
/// permissions put in them.
|
||||
pub tabs: Vec<Tab>,
|
||||
pub selected: usize,
|
||||
/// Scroll position 0..1 (scrollOffs).
|
||||
@@ -45,6 +75,13 @@ pub struct Creative {
|
||||
/// The search tab's stacks, in its display order.
|
||||
search_items: Vec<ItemStack>,
|
||||
search_index: Vec<SearchEntry>,
|
||||
/// Every exported tab, and the search tab's stacks with their entries and
|
||||
/// whether only permissions list them: `rebuild` keeps what is shown.
|
||||
exported: Vec<Exported>,
|
||||
exported_search: Vec<(ItemStack, SearchEntry, bool)>,
|
||||
/// The `hasPermissions` the tabs were last built with
|
||||
/// (`CreativeModeTabs.tryRebuildTabContents`).
|
||||
permissions: bool,
|
||||
/// ItemPickerMenu.items: what the grid shows, filled when the tab or the
|
||||
/// search changes (selectTab, refreshSearchResults), not on every frame.
|
||||
shown: Mutex<Option<Shown>>,
|
||||
@@ -199,14 +236,22 @@ impl Creative {
|
||||
}
|
||||
pub fn from_json(raw: &Value) -> Result<Self> {
|
||||
let mut tabs = Vec::new();
|
||||
let mut search_items = Vec::new();
|
||||
let mut search_index = Vec::new();
|
||||
let mut search = Vec::new();
|
||||
for t in raw["tabs"].as_array().into_iter().flatten() {
|
||||
// The stacks only a build with permissions lists, by index.
|
||||
let only: Vec<u64> = t["permissions_only"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_u64)
|
||||
.collect();
|
||||
let mut items = Vec::new();
|
||||
for v in t["items"].as_array().into_iter().flatten() {
|
||||
let mut operator = Vec::new();
|
||||
for (i, v) in t["items"].as_array().into_iter().flatten().enumerate() {
|
||||
let snbt = v.as_str().unwrap_or("-");
|
||||
if let Some(stack) = crate::item_data::parse_stack(snbt)? {
|
||||
items.push(stack);
|
||||
operator.push(only.contains(&(i as u64)));
|
||||
}
|
||||
}
|
||||
let kind = t["type"].as_str().unwrap_or("");
|
||||
@@ -220,33 +265,35 @@ impl Creative {
|
||||
};
|
||||
for (i, stack) in items.iter().enumerate() {
|
||||
let (namespace, path) = split_id(&java_lower(&stack.item));
|
||||
search_index.push(SearchEntry {
|
||||
let entry = SearchEntry {
|
||||
namespace,
|
||||
path,
|
||||
lines: strings(&t["lines"][i]),
|
||||
tags: strings(&t["tags"][i]).iter().map(|t| split_id(t)).collect(),
|
||||
});
|
||||
};
|
||||
search.push((stack.clone(), entry, operator[i]));
|
||||
}
|
||||
search_items = items.clone();
|
||||
}
|
||||
if kind == "HOTBAR" || t["visible"] == false {
|
||||
continue;
|
||||
}
|
||||
let icon = t["icon"]
|
||||
.as_str()
|
||||
.and_then(|s| crate::item_data::parse_stack(s).ok().flatten());
|
||||
tabs.push(Tab {
|
||||
id: t["id"].as_str().unwrap_or("").to_owned(),
|
||||
title: t["title"].as_str().unwrap_or("").to_owned(),
|
||||
icon: icon.map_or_else(|| "minecraft:stone".to_owned(), |s| s.item.to_string()),
|
||||
items,
|
||||
top: t["row"] == "TOP",
|
||||
column: t["column"].as_u64().unwrap_or(0) as u32,
|
||||
search: kind == "SEARCH",
|
||||
inventory: kind == "INVENTORY",
|
||||
tabs.push(Exported {
|
||||
tab: Tab {
|
||||
id: t["id"].as_str().unwrap_or("").to_owned(),
|
||||
title: t["title"].as_str().unwrap_or("").to_owned(),
|
||||
icon: icon.map_or_else(|| "minecraft:stone".to_owned(), |s| s.item.to_string()),
|
||||
items,
|
||||
top: t["row"] == "TOP",
|
||||
column: t["column"].as_u64().unwrap_or(0) as u32,
|
||||
search: kind == "SEARCH",
|
||||
inventory: kind == "INVENTORY",
|
||||
hotbar: kind == "HOTBAR",
|
||||
},
|
||||
category: kind == "CATEGORY",
|
||||
operator,
|
||||
});
|
||||
}
|
||||
Ok(Self::new(tabs, search_items, search_index))
|
||||
Ok(Self::new(tabs, search))
|
||||
}
|
||||
/// The old export: item ids per tab, no components, tooltips or tags.
|
||||
fn from_legacy(raw: &Value) -> Result<Self> {
|
||||
@@ -268,33 +315,39 @@ impl Creative {
|
||||
}
|
||||
}
|
||||
let kind = t["type"].as_str().unwrap_or("");
|
||||
tabs.push(Tab {
|
||||
id: t["id"].as_str().unwrap_or("").to_owned(),
|
||||
title: t["title"].as_str().unwrap_or("").to_owned(),
|
||||
icon: t["icon"].as_str().unwrap_or("minecraft:stone").to_owned(),
|
||||
items,
|
||||
top: t["row"] == "TOP",
|
||||
column: t["column"].as_u64().unwrap_or(0) as u32,
|
||||
search: kind == "SEARCH",
|
||||
inventory: kind == "INVENTORY",
|
||||
tabs.push(Exported {
|
||||
operator: vec![false; items.len()],
|
||||
tab: Tab {
|
||||
id: t["id"].as_str().unwrap_or("").to_owned(),
|
||||
title: t["title"].as_str().unwrap_or("").to_owned(),
|
||||
icon: t["icon"].as_str().unwrap_or("minecraft:stone").to_owned(),
|
||||
items,
|
||||
top: t["row"] == "TOP",
|
||||
column: t["column"].as_u64().unwrap_or(0) as u32,
|
||||
search: kind == "SEARCH",
|
||||
inventory: kind == "INVENTORY",
|
||||
hotbar: false,
|
||||
},
|
||||
category: false,
|
||||
});
|
||||
}
|
||||
let search_index = search_items
|
||||
.iter()
|
||||
let search = search_items
|
||||
.into_iter()
|
||||
.map(|s| {
|
||||
let (namespace, path) = split_id(&java_lower(&s.item));
|
||||
SearchEntry {
|
||||
let entry = SearchEntry {
|
||||
namespace,
|
||||
path,
|
||||
..Default::default()
|
||||
}
|
||||
};
|
||||
(s, entry, false)
|
||||
})
|
||||
.collect();
|
||||
Ok(Self::new(tabs, search_items, search_index))
|
||||
Ok(Self::new(tabs, search))
|
||||
}
|
||||
fn new(tabs: Vec<Tab>, search_items: Vec<ItemStack>, search_index: Vec<SearchEntry>) -> Self {
|
||||
Self {
|
||||
tabs,
|
||||
fn new(exported: Vec<Exported>, exported_search: Vec<(ItemStack, SearchEntry, bool)>) -> Self {
|
||||
let mut creative = Self {
|
||||
tabs: Vec::new(),
|
||||
selected: 0,
|
||||
scroll: 0.0,
|
||||
scrolling: false,
|
||||
@@ -305,10 +358,81 @@ impl Creative {
|
||||
search.text_color = u32::MAX;
|
||||
search
|
||||
},
|
||||
search_items,
|
||||
search_index,
|
||||
search_items: Vec::new(),
|
||||
search_index: Vec::new(),
|
||||
exported,
|
||||
exported_search,
|
||||
permissions: false,
|
||||
shown: Mutex::new(None),
|
||||
};
|
||||
creative.rebuild();
|
||||
creative
|
||||
}
|
||||
/// `CreativeModeTabs.buildAllTabContents` with `permissions`: the operator
|
||||
/// tab's display items generator lists its stacks only with them
|
||||
/// (`hasPermissions`), and the search tab with it; a category tab is
|
||||
/// shown with a stack (`CreativeModeTab.shouldDisplay`).
|
||||
fn rebuild(&mut self) {
|
||||
let permissions = self.permissions;
|
||||
let keep = |operator: bool| permissions || !operator;
|
||||
self.tabs = self
|
||||
.exported
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
let items: Vec<ItemStack> = (e.tab.items.iter().zip(&e.operator))
|
||||
.filter(|(_, o)| keep(**o))
|
||||
.map(|(s, _)| s.clone())
|
||||
.collect();
|
||||
(!e.category || !items.is_empty()).then(|| Tab {
|
||||
items,
|
||||
..e.tab.header()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
(self.search_items, self.search_index) = self
|
||||
.exported_search
|
||||
.iter()
|
||||
.filter(|(_, _, o)| keep(*o))
|
||||
.map(|(s, e, _)| (s.clone(), e.clone()))
|
||||
.unzip();
|
||||
self.forget_items();
|
||||
}
|
||||
/// `CreativeModeInventoryScreen.tryRefreshInvalidatedTabs`, which its
|
||||
/// constructor and `containerTick` run with `hasPermissions`
|
||||
/// (`Player.canUseGameMasterBlocks` and the Operator Items Tab option):
|
||||
/// when that changed, the tabs are built again. A selected tab left
|
||||
/// without stacks (the operator tab) gives way to the default tab; any
|
||||
/// other keeps its first row (`refreshCurrentTabContents`). True when the
|
||||
/// tabs were rebuilt.
|
||||
pub fn set_permissions(&mut self, permissions: bool, names: &Names) -> bool {
|
||||
if permissions == self.permissions {
|
||||
return false;
|
||||
}
|
||||
let selected = self.tab().id.clone();
|
||||
let row = self.first_row(names);
|
||||
self.permissions = permissions;
|
||||
self.rebuild();
|
||||
match self.tabs.iter().position(|t| t.id == selected) {
|
||||
Some(tab) => {
|
||||
self.selected = tab;
|
||||
// ItemPickerMenu.getScrollForRowIndex.
|
||||
let hidden = self.hidden_rows(names);
|
||||
self.scroll = if hidden > 0 {
|
||||
(row as f32 / hidden as f32).clamp(0.0, 1.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
None => {
|
||||
// CreativeModeTabs.getDefaultTab.
|
||||
let default = self
|
||||
.tabs
|
||||
.iter()
|
||||
.position(|t| t.id == "minecraft:building_blocks");
|
||||
self.select(default.unwrap_or(0));
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
/// Where a stack comes in the creative tabs (the search tab lists them
|
||||
/// all, in tab order): the same stack's place, else its item's first.
|
||||
@@ -322,19 +446,7 @@ impl Creative {
|
||||
}
|
||||
/// The tabs without their stacks: what the screen draws of them each frame.
|
||||
pub fn tab_headers(&self) -> Vec<Tab> {
|
||||
self.tabs
|
||||
.iter()
|
||||
.map(|t| Tab {
|
||||
id: t.id.clone(),
|
||||
title: t.title.clone(),
|
||||
icon: t.icon.clone(),
|
||||
items: Vec::new(),
|
||||
top: t.top,
|
||||
column: t.column,
|
||||
search: t.search,
|
||||
inventory: t.inventory,
|
||||
})
|
||||
.collect()
|
||||
self.tabs.iter().map(Tab::header).collect()
|
||||
}
|
||||
pub fn tab(&self) -> &Tab {
|
||||
&self.tabs[self.selected.min(self.tabs.len() - 1)]
|
||||
@@ -464,6 +576,23 @@ impl Creative {
|
||||
});
|
||||
items
|
||||
}
|
||||
/// The stack in grid cell `index` (row-major from the tab's top), None
|
||||
/// for an empty cell: a saved toolbar's empty slot, or past the end.
|
||||
pub fn shown(&self, names: &Names, index: usize) -> Option<ItemStack> {
|
||||
self.items(names)
|
||||
.get(index)
|
||||
.filter(|s| s.count > 0)
|
||||
.cloned()
|
||||
}
|
||||
/// `CreativeModeInventoryScreen.selectTab` for the HOTBAR tab: the nine
|
||||
/// saved rows of `HotbarManager` as the tab's items, an empty stack (count
|
||||
/// 0) for an empty slot.
|
||||
pub fn set_saved_hotbars(&mut self, items: Vec<ItemStack>) {
|
||||
if let Some(tab) = self.tabs.iter_mut().find(|t| t.hotbar) {
|
||||
tab.items = items;
|
||||
}
|
||||
self.forget_items();
|
||||
}
|
||||
/// Drop the shown items, so the next `items` works them out again: a
|
||||
/// reload can rename items without changing the Search label.
|
||||
pub fn forget_items(&self) {
|
||||
@@ -578,7 +707,7 @@ impl Creative {
|
||||
pub fn tab_position(tab: &Tab) -> (f32, f32) {
|
||||
let mut x = tab.column as f32 * 27.0;
|
||||
if tab.column >= 5 {
|
||||
// Right-aligned tabs (search, inventory).
|
||||
// Right-aligned tabs (search, operator, inventory).
|
||||
x = Self::WIDTH - 27.0 * (7 - tab.column) as f32 + 1.0;
|
||||
}
|
||||
let y = if tab.top { -28.0 } else { Self::HEIGHT - 4.0 };
|
||||
@@ -664,6 +793,13 @@ pub enum DisplayClick {
|
||||
Drop(ItemStack),
|
||||
}
|
||||
|
||||
/// Whether a stack has `minecraft:creative_slot_lock`.
|
||||
pub fn slot_locked(stack: &ItemStack) -> bool {
|
||||
stack
|
||||
.data()
|
||||
.is_some_and(|d| d.other("minecraft:creative_slot_lock").is_some())
|
||||
}
|
||||
|
||||
/// `CreativeModeInventoryScreen.slotClicked` on a display slot
|
||||
/// (`slot.container == CONTAINER`): `shown` is the slot's item (None past
|
||||
/// the tab's end) and `max` gives an item's max stack size. The tab's items
|
||||
@@ -676,6 +812,11 @@ pub fn click_display(
|
||||
input: crate::menu::Input,
|
||||
) -> DisplayClick {
|
||||
use crate::menu::Input;
|
||||
// CustomCreativeSlot.mayPickup: a `creative_slot_lock` stack (the saved
|
||||
// toolbars' hint) can't be clicked at all.
|
||||
if shown.is_some_and(slot_locked) {
|
||||
return DisplayClick::Nothing;
|
||||
}
|
||||
let copy = |s: &ItemStack, count: u32| ItemStack { count, ..s.clone() };
|
||||
let quick_key = input == Input::QuickMove;
|
||||
match input {
|
||||
@@ -738,6 +879,7 @@ pub fn click_display(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
fn stack(item: &str, count: u32) -> ItemStack {
|
||||
ItemStack {
|
||||
@@ -1047,6 +1189,131 @@ mod tests {
|
||||
assert_eq!(c.search.value, "");
|
||||
}
|
||||
|
||||
/// An export built with permissions: the operator tab's stacks, and their
|
||||
/// place at the end of the search tab, only with them.
|
||||
fn with_operator_tab() -> Creative {
|
||||
let stack = |id: &str| format!("{{count:1,id:\"minecraft:{id}\"}}");
|
||||
let raw = serde_json::json!({"schema": 3, "tabs": [
|
||||
{"id": "minecraft:building_blocks", "type": "CATEGORY", "row": "TOP", "column": 0, "title": "Building Blocks",
|
||||
"icon": stack("bricks"), "visible": true, "items": [stack("stone")]},
|
||||
{"id": "minecraft:op_blocks", "type": "CATEGORY", "row": "BOTTOM", "column": 5, "title": "Operator Utilities",
|
||||
"icon": stack("command_block"), "visible": false,
|
||||
"items": [stack("command_block"), stack("barrier")], "permissions_only": [0, 1]},
|
||||
{"id": "minecraft:search", "type": "SEARCH", "row": "TOP", "column": 6, "title": "Search Items",
|
||||
"icon": stack("compass"), "visible": true,
|
||||
"items": [stack("stone"), stack("command_block"), stack("barrier")],
|
||||
"lines": [["Stone"], ["Command Block"], ["Barrier"]], "tags": [[], [], []],
|
||||
"permissions_only": [1, 2]},
|
||||
{"id": "minecraft:inventory", "type": "INVENTORY", "row": "BOTTOM", "column": 6, "title": "Survival Inventory",
|
||||
"icon": stack("chest"), "visible": true, "items": []}
|
||||
]});
|
||||
Creative::from_json(&raw).unwrap()
|
||||
}
|
||||
|
||||
fn tab_ids(c: &Creative) -> Vec<&str> {
|
||||
c.tabs.iter().map(|t| t.id.as_str()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operator_stacks_stay_out_without_permissions() {
|
||||
let c = with_operator_tab();
|
||||
let names = Names::default();
|
||||
assert_eq!(
|
||||
tab_ids(&c),
|
||||
[
|
||||
"minecraft:building_blocks",
|
||||
"minecraft:search",
|
||||
"minecraft:inventory"
|
||||
]
|
||||
);
|
||||
assert_eq!(c.search_items().len(), 1);
|
||||
assert!(c.search_results("command", &names).is_empty());
|
||||
assert_eq!(c.search_results("", &names), [0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operator_tab_comes_and_goes_with_permissions() {
|
||||
let mut c = with_operator_tab();
|
||||
let names = Names::default();
|
||||
assert!(!c.set_permissions(false, &names));
|
||||
assert!(c.set_permissions(true, &names));
|
||||
assert!(!c.set_permissions(true, &names));
|
||||
assert_eq!(
|
||||
tab_ids(&c),
|
||||
[
|
||||
"minecraft:building_blocks",
|
||||
"minecraft:op_blocks",
|
||||
"minecraft:search",
|
||||
"minecraft:inventory"
|
||||
]
|
||||
);
|
||||
let op: Vec<&str> = c.tabs[1].items.iter().map(|s| s.item.as_ref()).collect();
|
||||
assert_eq!(op, ["minecraft:command_block", "minecraft:barrier"]);
|
||||
assert_eq!(c.search_results("command", &names), [1]);
|
||||
assert_eq!(c.search_results("", &names), [0, 1, 2]);
|
||||
// The search tab keeps its place across the rebuild.
|
||||
c.select(2);
|
||||
c.search.set_value("block");
|
||||
assert!(c.set_permissions(false, &names));
|
||||
assert!(c.tab().search);
|
||||
assert_eq!(c.search.value, "block");
|
||||
assert!(c.items(&names).is_empty());
|
||||
// The selected operator tab is left empty: the default tab instead.
|
||||
c.set_permissions(true, &names);
|
||||
c.select(1);
|
||||
assert_eq!(c.items(&names).len(), 2);
|
||||
c.set_permissions(false, &names);
|
||||
assert_eq!(c.tab().id, "minecraft:building_blocks");
|
||||
assert_eq!(c.items(&names)[0].item.as_ref(), "minecraft:stone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_hotbars_tab_shows_its_rows_and_locks_the_hint() {
|
||||
let mut c = Creative::from_json(&serde_json::json!({"schema": 2, "tabs": [
|
||||
{"id": "minecraft:hotbar", "type": "HOTBAR", "row": "TOP", "column": 5, "title": "Saved Hotbars",
|
||||
"icon": "{count:1,id:\"minecraft:bookshelf\"}", "visible": true, "items": []},
|
||||
{"id": "minecraft:inventory", "type": "INVENTORY", "row": "BOTTOM", "column": 6, "title": "Survival Inventory",
|
||||
"icon": "{count:1,id:\"minecraft:chest\"}", "visible": true, "items": []}
|
||||
]}))
|
||||
.unwrap();
|
||||
assert!(c.tab().hotbar);
|
||||
assert!(!c.tab_headers()[1].hotbar);
|
||||
let mut hint = stack("paper", 1);
|
||||
hint.data_mut().set_other(
|
||||
"minecraft:creative_slot_lock",
|
||||
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
|
||||
);
|
||||
// A saved sword in slot 0, an empty slot, then the hint.
|
||||
c.set_saved_hotbars(vec![stack("diamond_sword", 1), stack("air", 0), hint]);
|
||||
let names = Names {
|
||||
map: HashMap::new(),
|
||||
};
|
||||
assert_eq!(c.shown(&names, 0), Some(stack("diamond_sword", 1)));
|
||||
assert_eq!(c.shown(&names, 1), None);
|
||||
assert_eq!(c.shown(&names, 3), None);
|
||||
let hint = c.shown(&names, 2).unwrap();
|
||||
assert!(slot_locked(&hint));
|
||||
assert!(!slot_locked(&stack("diamond_sword", 1)));
|
||||
// CustomCreativeSlot.mayPickup: the hint is never taken, and a carried
|
||||
// stack stays as it is.
|
||||
use crate::menu::Input::*;
|
||||
for (carried, button, input) in [
|
||||
(None, 0, Pickup),
|
||||
(None, 0, QuickMove),
|
||||
(None, 2, Clone),
|
||||
(None, 3, Swap),
|
||||
(None, 0, Throw),
|
||||
(Some(stack("dirt", 2)), 0, Pickup),
|
||||
(Some(stack("dirt", 2)), 1, Pickup),
|
||||
] {
|
||||
let mut carried = carried;
|
||||
let before = carried.clone();
|
||||
let effect = click_display(&mut carried, Some(&hint), |_| 64, button, input);
|
||||
assert_eq!(effect, DisplayClick::Nothing);
|
||||
assert_eq!(carried, before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_matches_vanilla_trees() {
|
||||
let c = creative(0);
|
||||
@@ -1191,6 +1458,7 @@ mod tests {
|
||||
column,
|
||||
search: false,
|
||||
inventory: false,
|
||||
hotbar: false,
|
||||
};
|
||||
// Top row, second column: the button spans x 27..53, y -28..4.
|
||||
let t = tab(true, 1);
|
||||
|
||||
@@ -179,12 +179,17 @@ fn input(font: &Font, i: usize, control: &Control) -> Element {
|
||||
};
|
||||
}
|
||||
// CommonLayouts.labeledElement: the label, 4 below it the box,
|
||||
// both on the left.
|
||||
// both on the left. The label is a plain StringWidget: it has no
|
||||
// click handler, so a click event in it does nothing.
|
||||
let mut shown = label.clone();
|
||||
for span in &mut shown.spans {
|
||||
span.style.click = None;
|
||||
}
|
||||
Element {
|
||||
w: w.max(font.width_text(label)),
|
||||
h: 9.0 + 4.0 + 20.0,
|
||||
parts: vec![
|
||||
Part::Line(label.clone(), 0.0, 0.0),
|
||||
Part::Line(shown, 0.0, 0.0),
|
||||
Part::Field {
|
||||
input: i,
|
||||
rect: [0.0, 13.0, w, 20.0],
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! (the entity goes through on the next tick), and
|
||||
//! `TheEndGatewayBlockEntity.getPortalPosition` (where it lands).
|
||||
use crate::nether_portal::{ENTITY_DELAY, PortalState};
|
||||
use crate::projectiles::{Projectile, ProjectileKind};
|
||||
use horizon_core::{geometry::Vec3, position::BlockPos};
|
||||
|
||||
/// The End gateway travel of one entity.
|
||||
@@ -65,6 +66,35 @@ pub fn swept_gateway(
|
||||
found
|
||||
}
|
||||
|
||||
/// The End gateway a thrown ender pearl passed through on its last move, from
|
||||
/// where it started the tick to where it is (`Entity.checkInsideBlocks` from
|
||||
/// `ThrowableProjectile.tick`'s `applyEffectsFromBlocks`). Other projectiles
|
||||
/// are not looked at.
|
||||
pub fn pearl_gateway(
|
||||
pearl: &Projectile,
|
||||
is_gateway: impl FnMut(BlockPos) -> bool,
|
||||
) -> Option<BlockPos> {
|
||||
if !matches!(pearl.kind, ProjectileKind::EnderPearl) {
|
||||
return None;
|
||||
}
|
||||
let size = pearl.kind.size() as f32;
|
||||
swept_gateway(pearl.previous, pearl.position, size, size, is_gateway)
|
||||
}
|
||||
|
||||
/// A thrown ender pearl at its gateway's exit `at`
|
||||
/// (`EndGatewayBlock.getPortalDestination` for a `ThrownEnderpearl`: a
|
||||
/// `TeleportTransition` with no motion, rotation 0 and nothing relative).
|
||||
/// It falls from there and takes its owner where it lands.
|
||||
pub fn place_pearl(pearl: &mut Projectile, at: Vec3) {
|
||||
pearl.position = at;
|
||||
pearl.previous = at;
|
||||
pearl.velocity = Vec3::ZERO;
|
||||
pearl.yaw = 0.0;
|
||||
pearl.pitch = 0.0;
|
||||
// A server tells its clients of the jump at once.
|
||||
pearl.needs_sync = true;
|
||||
}
|
||||
|
||||
/// The bottom centre of a block (`Vec3.atBottomCenterOf`): where a traveller
|
||||
/// lands at a gateway's exit.
|
||||
pub fn bottom_center(p: [i32; 3]) -> Vec3 {
|
||||
@@ -150,6 +180,75 @@ mod tests {
|
||||
assert_eq!(traveller.travel(false), None);
|
||||
}
|
||||
|
||||
/// A world with no blocks in the way and nothing to hit: the gateway
|
||||
/// block has no collision shape, so a projectile flies into it.
|
||||
struct Open;
|
||||
impl crate::projectiles::ProjectileWorld for Open {
|
||||
fn clip(&self, _: Vec3, _: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> {
|
||||
None
|
||||
}
|
||||
fn in_water(&self, _: horizon_core::geometry::Aabb) -> bool {
|
||||
false
|
||||
}
|
||||
fn targets(&self) -> &[(crate::mobs::Who, horizon_core::geometry::Aabb)] {
|
||||
&[]
|
||||
}
|
||||
}
|
||||
|
||||
/// The ticks a projectile flies, thrown east at the gateway, until its
|
||||
/// move goes through one (`None` if it never does in 5 ticks).
|
||||
fn thrown_at_gateway(kind: ProjectileKind) -> (Projectile, Option<BlockPos>) {
|
||||
let mut shot = Projectile::new(kind, at(699.5, 100.3, 0.5), None);
|
||||
shot.velocity = at(1.5, 0.0, 0.0);
|
||||
for _ in 0..5 {
|
||||
shot.tick(&Open);
|
||||
if let Some(g) = pearl_gateway(&shot, is_gateway) {
|
||||
return (shot, Some(g));
|
||||
}
|
||||
}
|
||||
(shot, None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_thrown_pearl_goes_through_the_gateway_and_falls_from_its_exit() {
|
||||
let (mut pearl, found) = thrown_at_gateway(ProjectileKind::EnderPearl);
|
||||
let gateway = BlockPos::new(702, 100, 0);
|
||||
// ThrownEnderpearl in the gateway block: EndGatewayBlock.entityInside
|
||||
// takes it in, and it goes through on the next tick.
|
||||
assert_eq!(found, Some(gateway));
|
||||
let mut traveller = Traveller::default();
|
||||
traveller.enter(gateway);
|
||||
assert_eq!(traveller.travel(true), Some(gateway));
|
||||
// getPortalDestination: at the exit, no motion and rotation 0.
|
||||
let exit = bottom_center([8, 100, 0]);
|
||||
place_pearl(&mut pearl, exit);
|
||||
assert_eq!(pearl.position, exit);
|
||||
assert_eq!(pearl.previous, exit);
|
||||
assert_eq!(pearl.velocity, Vec3::ZERO);
|
||||
assert_eq!((pearl.yaw, pearl.pitch), (0.0, 0.0));
|
||||
// It falls straight down from the exit (ThrowableProjectile gravity,
|
||||
// then inertia), where it lands and takes its owner along.
|
||||
pearl.tick(&Open);
|
||||
assert_eq!((pearl.position.x, pearl.position.z), (8.5, 0.5));
|
||||
assert!((pearl.position.y - (100.0 - 0.03 * 0.99)).abs() < 1e-9);
|
||||
// The pearl is out of the gateway: it does not go back at once.
|
||||
assert_eq!(traveller.travel(true), None);
|
||||
assert_eq!(traveller.cooldown(), ENTITY_DELAY - 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pearl_flying_past_the_gateway_stays_out_of_it() {
|
||||
// Thrown one block higher, over the gateway block.
|
||||
let mut pearl = Projectile::new(ProjectileKind::EnderPearl, at(699.5, 101.3, 0.5), None);
|
||||
pearl.velocity = at(1.5, 0.0, 0.0);
|
||||
for _ in 0..5 {
|
||||
pearl.tick(&Open);
|
||||
assert_eq!(pearl_gateway(&pearl, is_gateway), None);
|
||||
}
|
||||
// Only the pearl travels: a snowball on the same path is not taken in.
|
||||
assert_eq!(thrown_at_gateway(ProjectileKind::Snowball).1, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_traveller_lands_at_the_bottom_centre_of_the_exit() {
|
||||
// TheEndGatewayBlockEntity.getPortalPosition: the exit's bottom
|
||||
|
||||
@@ -980,6 +980,7 @@ mod experience_tests {
|
||||
#[cfg(test)]
|
||||
mod fire_environment_tests {
|
||||
use super::*;
|
||||
use horizon_world::loot::PackOverlay;
|
||||
#[test]
|
||||
fn ordinary_items_burn_but_native_netherite_resists_lava() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
@@ -991,7 +992,8 @@ mod fire_environment_tests {
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let items = crate::items::Items::load(&root, assets.registry, &facts).unwrap();
|
||||
let items =
|
||||
crate::items::Items::load(&root, assets.registry, &facts, &PackOverlay::NONE).unwrap();
|
||||
let mut world = World::test_scene(&assets, &render, 3).unwrap();
|
||||
let at = Vec3::new(20.5, 4.0, 20.5);
|
||||
world
|
||||
|
||||
@@ -83,10 +83,10 @@ impl FluidInteractionWorld for ItemFluidWorld<'_> {
|
||||
Ok(self.world.chunks.contains_key(&(x, z)).then_some(()))
|
||||
}
|
||||
fn chunk_sections(&mut self, _: ()) -> Result<FluidSections<'_>, Error> {
|
||||
static SECTIONS: [bool; crate::chunk::SECTIONS] = [true; crate::chunk::SECTIONS];
|
||||
let layout = self.world.layout();
|
||||
Ok(FluidSections {
|
||||
minimum_section_y: crate::chunk::MIN_Y >> 4,
|
||||
has_fluid: &SECTIONS,
|
||||
minimum_section_y: layout.min_y() >> 4,
|
||||
has_fluid: crate::chunk::every_section(layout),
|
||||
})
|
||||
}
|
||||
fn bounding_box_min_y(&self) -> f64 {
|
||||
|
||||
@@ -87,6 +87,11 @@ impl Base<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The mobs that save `Age` and `AgeLocked`.
|
||||
fn saves_age(s: crate::mobs::Species) -> bool {
|
||||
use crate::mobs::{Species, drops::can_be_baby, finalize::ageable};
|
||||
s == Species::Tadpole || (ageable(s) && can_be_baby(s))
|
||||
}
|
||||
/// `LivingEntity` + `Mob` (+ `AgeableMob`, creeper and skeleton specifics).
|
||||
/// The mobs whose class extends vanilla's `Animal` (and so save `InLove`):
|
||||
/// not allays, villagers, golems, dolphins or fish.
|
||||
@@ -150,6 +155,123 @@ fn player_uuid() -> [i32; 4] {
|
||||
u as u32 as i32,
|
||||
]
|
||||
}
|
||||
/// `ResolvableProfile.CODEC`: the name, id and properties of a profile, then the
|
||||
/// skin's texture ids and model (`PlayerSkin.Patch`: "texture", "cape", "elytra", "model").
|
||||
fn profile_nbt(p: &horizon_protocol::entity_data::Profile) -> Tag {
|
||||
let mut entries: Vec<(&'static str, Tag)> = Vec::new();
|
||||
if let Some(name) = &p.name {
|
||||
entries.push(("name", string(name)));
|
||||
}
|
||||
if let Some(id) = p.uuid {
|
||||
entries.push((
|
||||
"id",
|
||||
Tag::IntArray(vec![
|
||||
(id >> 96) as i32,
|
||||
(id >> 64) as i32,
|
||||
(id >> 32) as i32,
|
||||
id as i32,
|
||||
]),
|
||||
));
|
||||
}
|
||||
if !p.properties.is_empty() {
|
||||
let properties = p
|
||||
.properties
|
||||
.iter()
|
||||
.map(|property| {
|
||||
let mut entries: Vec<(&'static str, Tag)> = vec![
|
||||
("name", string(&property.name)),
|
||||
("value", string(&property.value)),
|
||||
];
|
||||
if let Some(signature) = &property.signature {
|
||||
entries.push(("signature", string(signature)));
|
||||
}
|
||||
Tag::compound(entries)
|
||||
})
|
||||
.collect();
|
||||
entries.push(("properties", Tag::List(properties)));
|
||||
}
|
||||
for (key, texture) in [
|
||||
("texture", &p.body),
|
||||
("cape", &p.cape),
|
||||
("elytra", &p.elytra),
|
||||
] {
|
||||
if let Some(texture) = texture {
|
||||
entries.push((key, string(texture)));
|
||||
}
|
||||
}
|
||||
if let Some(slim) = p.slim {
|
||||
entries.push(("model", string(if slim { "slim" } else { "wide" })));
|
||||
}
|
||||
Tag::compound(entries)
|
||||
}
|
||||
/// `ResolvableProfile.CODEC` read back: the compound `profile_nbt` writes, or a
|
||||
/// bare player name (`Codec.withAlternative(FULL_CODEC, PLAYER_NAME, ...)`).
|
||||
fn profile_from_nbt(tag: &Tag) -> Option<horizon_protocol::entity_data::Profile> {
|
||||
if let Tag::String(name) = tag {
|
||||
return Some(horizon_protocol::entity_data::Profile {
|
||||
name: Some(name.to_string_lossy()),
|
||||
uuid: None,
|
||||
properties: Vec::new(),
|
||||
body: None,
|
||||
cape: None,
|
||||
elytra: None,
|
||||
slim: None,
|
||||
});
|
||||
}
|
||||
let property = |name: String, value: &Tag, signature: Option<String>| match value {
|
||||
Tag::String(v) => Some(horizon_protocol::packets::login::Property {
|
||||
name,
|
||||
value: v.to_string_lossy(),
|
||||
signature,
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
let properties: Vec<horizon_protocol::packets::login::Property> = match tag.get("properties") {
|
||||
// `ExtraCodecs.PROPERTY_MAP`: a list of properties ...
|
||||
Some(Tag::List(list)) => list
|
||||
.iter()
|
||||
.filter_map(|p| {
|
||||
let name = match p.get("name")? {
|
||||
Tag::String(s) => s.to_string_lossy(),
|
||||
_ => return None,
|
||||
};
|
||||
let signature = match p.get("signature") {
|
||||
Some(Tag::String(s)) => Some(s.to_string_lossy()),
|
||||
_ => None,
|
||||
};
|
||||
property(name, p.get("value")?, signature)
|
||||
})
|
||||
.collect(),
|
||||
// ... or a map from a name to its values.
|
||||
Some(Tag::Compound(map)) => map
|
||||
.iter()
|
||||
.flat_map(|(name, values)| match values {
|
||||
Tag::List(values) => values
|
||||
.iter()
|
||||
.filter_map(|v| property(name.to_string_lossy(), v, None))
|
||||
.collect::<Vec<_>>(),
|
||||
_ => Vec::new(),
|
||||
})
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let text = |key: &str| match tag.get(key) {
|
||||
Some(Tag::String(s)) => Some(s.to_string_lossy()),
|
||||
_ => None,
|
||||
};
|
||||
Some(horizon_protocol::entity_data::Profile {
|
||||
name: text("name"),
|
||||
uuid: uuid_from_tag(tag.get("id")).map(|a| {
|
||||
a.iter()
|
||||
.fold(0u128, |u, &x| (u << 32) | u128::from(x as u32))
|
||||
}),
|
||||
properties,
|
||||
body: text("texture"),
|
||||
cape: text("cape"),
|
||||
elytra: text("elytra"),
|
||||
slim: text("model").map(|model| model == "slim"),
|
||||
})
|
||||
}
|
||||
pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
let base = Base {
|
||||
id: &m.kind.id,
|
||||
@@ -196,6 +318,12 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
put(&mut tag, "angry_at", Tag::IntArray(player_uuid().to_vec()));
|
||||
}
|
||||
}
|
||||
// `Mannequin.addAdditionalSaveData`: the profile whose skin it wears.
|
||||
if m.species() == crate::mobs::Species::Mannequin
|
||||
&& let Some(profile) = &m.profile
|
||||
{
|
||||
put(&mut tag, "profile", profile_nbt(profile));
|
||||
}
|
||||
// `Entity.saveWithoutId` writes the flag only when set.
|
||||
if m.name_visible {
|
||||
put(&mut tag, "CustomNameVisible", Tag::Byte(1));
|
||||
@@ -286,19 +414,14 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
}
|
||||
let id = m.kind.id.as_str();
|
||||
// Only breedable animals have an age; a loaded creature that was saved without
|
||||
// one (a frog) stays without.
|
||||
let aged = raw.is_none_or(|r| r.get("Age").is_some());
|
||||
if (m.kind.category == "creature" || m.species() == crate::mobs::Species::Hoglin) && aged {
|
||||
// AgeableMob.addAdditionalSaveData, only if canBeABaby (not a frog or a
|
||||
// parrot); a tadpole saves its own age.
|
||||
let species = m.species();
|
||||
if saves_age(species) {
|
||||
map.insert("Age".into(), Tag::Int(m.extra.age));
|
||||
map.insert("AgeLocked".into(), Tag::Byte(i8::from(m.extra.age_locked)));
|
||||
map.insert("ForcedAge".into(), Tag::Int(m.extra.forced_age));
|
||||
for (key, value) in [
|
||||
("Age", Tag::Int(0)),
|
||||
("ForcedAge", Tag::Int(0)),
|
||||
("AgeLocked", Tag::Byte(0)),
|
||||
] {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
if species != crate::mobs::Species::Tadpole {
|
||||
map.insert("ForcedAge".into(), Tag::Int(m.extra.forced_age));
|
||||
}
|
||||
}
|
||||
// Animal.addAdditionalSaveData: the love ticks left, whatever the save had.
|
||||
@@ -324,19 +447,29 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
);
|
||||
}
|
||||
match id {
|
||||
// Fox.addAdditionalSaveData; Horizon models only the type.
|
||||
// Fox.addAdditionalSaveData; Horizon models only the type, the
|
||||
// flags and no trusted players yet.
|
||||
"minecraft:fox" => {
|
||||
let kind = if m.extra.variant == 1 { "snow" } else { "red" };
|
||||
map.insert("Type".into(), string(kind));
|
||||
for (key, value) in [
|
||||
("Trusted", Tag::List(Vec::new())),
|
||||
("Sleeping", Tag::Byte(0)),
|
||||
("Sitting", Tag::Byte(0)),
|
||||
("Crouching", Tag::Byte(0)),
|
||||
map.entry("Trusted".into()).or_insert(Tag::List(Vec::new()));
|
||||
for (key, bit) in [
|
||||
("Sleeping", crate::mobs::fox::SLEEPING),
|
||||
("Sitting", crate::mobs::fox::SITTING),
|
||||
("Crouching", crate::mobs::fox::CROUCHING),
|
||||
] {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
map.insert(
|
||||
key.into(),
|
||||
Tag::Byte(i8::from(crate::mobs::fox::flag(m, bit))),
|
||||
);
|
||||
}
|
||||
}
|
||||
// MushroomCow.addAdditionalSaveData: the Variant codec's name (stew
|
||||
// effects are not modelled).
|
||||
"minecraft:mooshroom" => {
|
||||
let kind = if m.extra.variant == 1 { "brown" } else { "red" };
|
||||
map.insert("Type".into(), string(kind));
|
||||
}
|
||||
// Rabbit.addAdditionalSaveData (the legacy id; 99 is the killer bunny).
|
||||
"minecraft:rabbit" => {
|
||||
map.insert("RabbitType".into(), Tag::Int(i32::from(m.extra.variant)));
|
||||
@@ -531,6 +664,8 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
}
|
||||
"minecraft:villager" => {
|
||||
map.insert("VillagerData".into(), villager_data(m));
|
||||
// A loaded villager with VillagerData is finalized.
|
||||
map.insert("VillagerDataFinalized".into(), Tag::Byte(1));
|
||||
let t = m.extra.trade.as_deref().cloned().unwrap_or_default();
|
||||
map.insert("Xp".into(), Tag::Int(t.xp));
|
||||
map.insert("LastRestock".into(), Tag::Long(t.last_restock));
|
||||
@@ -584,32 +719,56 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
]));
|
||||
}
|
||||
map.insert("Inventory".into(), Tag::List(inv));
|
||||
if let Some(st) = &m.extra.allay {
|
||||
map.insert("listener".into(), st.vibration.to_nbt());
|
||||
}
|
||||
map.insert(
|
||||
"DuplicationCooldown".into(),
|
||||
Tag::Long(i64::from(m.extra.egg_time)),
|
||||
);
|
||||
let mut memories: Vec<(&str, Tag)> = Vec::new();
|
||||
if m.extra.tame {
|
||||
map.insert(
|
||||
"Brain".into(),
|
||||
memories.push((
|
||||
"minecraft:liked_player",
|
||||
Tag::compound([(
|
||||
"memories",
|
||||
Tag::compound([(
|
||||
"minecraft:liked_player",
|
||||
Tag::compound([(
|
||||
"value",
|
||||
Tag::IntArray(m.extra.owner.unwrap_or([0, 0, 0, 1]).to_vec()),
|
||||
)]),
|
||||
)]),
|
||||
"value",
|
||||
Tag::IntArray(m.extra.owner.unwrap_or([0, 0, 0, 1]).to_vec()),
|
||||
)]),
|
||||
);
|
||||
));
|
||||
}
|
||||
// GlobalPos.CODEC, and the cooldown while it runs.
|
||||
if let Some(liked) = m.extra.allay.as_ref().and_then(|st| st.noteblock) {
|
||||
memories.push((
|
||||
"minecraft:liked_noteblock",
|
||||
Tag::compound([(
|
||||
"value",
|
||||
Tag::compound([
|
||||
("dimension", string(liked.dimension)),
|
||||
("pos", Tag::IntArray(liked.pos.to_vec())),
|
||||
]),
|
||||
)]),
|
||||
));
|
||||
}
|
||||
if m.extra.ints[2] > 0 {
|
||||
memories.push((
|
||||
"minecraft:liked_noteblock_cooldown_ticks",
|
||||
Tag::compound([("value", Tag::Int(m.extra.ints[2]))]),
|
||||
));
|
||||
}
|
||||
// Always written, so a forgotten note block does not come back
|
||||
// from the compound the allay was loaded with.
|
||||
map.insert(
|
||||
"Brain".into(),
|
||||
Tag::compound([("memories", Tag::compound(memories))]),
|
||||
);
|
||||
}
|
||||
"minecraft:camel" | "minecraft:camel_husk" => {
|
||||
// LastPoseTick: negative while sitting.
|
||||
let sitting = crate::mobs::camel::sitting(m);
|
||||
// LastPoseTick: the pose change time, negative while sitting. The
|
||||
// game time is not known here; the world's save overwrites it
|
||||
// with the real one (`camel::synced_pose_tick`).
|
||||
map.insert(
|
||||
"LastPoseTick".into(),
|
||||
Tag::Long(if sitting { -1 } else { 0 }),
|
||||
Tag::Long(crate::mobs::camel::synced_pose_tick(m, 0)),
|
||||
);
|
||||
if let Some(saddle) = m.saddle_item() {
|
||||
map.insert(
|
||||
@@ -667,6 +826,13 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
map.insert("Peek".into(), Tag::Byte(m.extra.counter as i8));
|
||||
map.insert("Color".into(), Tag::Byte(m.extra.color as i8));
|
||||
}
|
||||
// GlowSquid.addAdditionalSaveData.
|
||||
"minecraft:glow_squid" => {
|
||||
map.insert(
|
||||
"DarkTicksRemaining".into(),
|
||||
Tag::Int(crate::mobs::aquatic::dark_ticks(m)),
|
||||
);
|
||||
}
|
||||
"minecraft:slime" | "minecraft:magma_cube" => {
|
||||
map.insert("Size".into(), Tag::Int(m.extra.size.max(1) - 1));
|
||||
map.insert("wasOnGround".into(), Tag::Byte(i8::from(m.extra.flag2)));
|
||||
@@ -728,9 +894,64 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
) {
|
||||
map.insert("IsBaby".into(), Tag::Byte(i8::from(m.is_baby())));
|
||||
}
|
||||
// NeutralMob.addPersistentAngerSaveData; the bee saves its own.
|
||||
if matches!(
|
||||
id,
|
||||
"minecraft:iron_golem"
|
||||
| "minecraft:wolf"
|
||||
| "minecraft:polar_bear"
|
||||
| "minecraft:zombified_piglin"
|
||||
| "minecraft:enderman"
|
||||
) {
|
||||
map.entry("anger_end_time".into()).or_insert(Tag::Long(-1));
|
||||
}
|
||||
if let Some(a) = m.extra.attributes.as_deref() {
|
||||
put_attributes(map, m, a);
|
||||
}
|
||||
// The attribute their load makes, which AttributeMap.pack then saves:
|
||||
// Zombie and Piglin.setBaby (even for an adult) the speed, Rabbit.setVariant
|
||||
// the attack damage.
|
||||
use crate::mobs::finalize::Attr;
|
||||
let made = if crate::mobs::doors::is_zombie(species) || species == crate::mobs::Species::Piglin
|
||||
{
|
||||
Some(Attr::MovementSpeed)
|
||||
} else if species == crate::mobs::Species::Rabbit {
|
||||
Some(Attr::AttackDamage)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(attr) = made {
|
||||
let base = crate::mobs::finalize::base_value(m, attr);
|
||||
let mut list = match map.get(&"attributes".into()) {
|
||||
Some(Tag::List(l)) => l.clone(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let attr_id = format!("minecraft:{}", attr.name());
|
||||
if !list
|
||||
.iter()
|
||||
.any(|t| text(t.get("id")).as_deref() == Some(attr_id.as_str()))
|
||||
{
|
||||
list.push(Tag::compound([
|
||||
("id", string(&attr_id)),
|
||||
("base", Tag::Double(base.to_bits())),
|
||||
]));
|
||||
map.insert("attributes".into(), Tag::List(list));
|
||||
}
|
||||
}
|
||||
// Mob.addAdditionalSaveData: the chances that are not the default.
|
||||
if let Some(chances) = &m.extra.drop_chances {
|
||||
let set: Vec<(&str, Tag)> = crate::mobs::drops::SLOTS
|
||||
.iter()
|
||||
.zip(chances.iter())
|
||||
.filter(|(_, c)| **c != crate::mobs::drops::DEFAULT_DROP_CHANCE)
|
||||
.map(|(slot, c)| (*slot, Tag::Float(c.to_bits())))
|
||||
.collect();
|
||||
if set.is_empty() {
|
||||
map.remove(&"drop_chances".into());
|
||||
} else {
|
||||
map.insert("drop_chances".into(), Tag::compound(set));
|
||||
}
|
||||
}
|
||||
crate::mobs::raider::write_nbt(m, map);
|
||||
crate::mobs::riders::write_nbt(m, map);
|
||||
// LivingEntity equipment: what the mob holds now.
|
||||
@@ -1046,11 +1267,24 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.extra.angry_at = Some(crate::mobs::Who::Player);
|
||||
}
|
||||
}
|
||||
// `Mannequin.readAdditionalSaveData`: the profile comes back with the skin.
|
||||
if m.species() == crate::mobs::Species::Mannequin {
|
||||
m.profile = tag.get("profile").and_then(profile_from_nbt);
|
||||
}
|
||||
m.extra.pick_up_loot = flag(tag, "CanPickUpLoot");
|
||||
m.custom_name = custom_name(tag);
|
||||
m.custom_name_text = tag.get("CustomName").map(name_text);
|
||||
m.extra.left_handed = flag(tag, "LeftHanded");
|
||||
if m.kind.category == "creature" || m.species() == crate::mobs::Species::Hoglin {
|
||||
if let Some(Tag::Compound(c)) = tag.get("drop_chances") {
|
||||
let mut chances = [crate::mobs::drops::DEFAULT_DROP_CHANCE; 8];
|
||||
for (chance, slot) in chances.iter_mut().zip(crate::mobs::drops::SLOTS) {
|
||||
if let Some(Tag::Float(v)) = c.get(&slot.into()) {
|
||||
*chance = f32::from_bits(*v);
|
||||
}
|
||||
}
|
||||
m.extra.drop_chances = Some(Box::new(chances));
|
||||
}
|
||||
if saves_age(m.species()) {
|
||||
m.extra.age = number(tag.get("Age")).unwrap_or(0) as i32;
|
||||
m.extra.forced_age = number(tag.get("ForcedAge")).unwrap_or(0) as i32;
|
||||
m.extra.age_locked = flag(tag, "AgeLocked");
|
||||
@@ -1181,6 +1415,21 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
match m.species() {
|
||||
crate::mobs::Species::Fox => {
|
||||
m.extra.variant = u8::from(text(tag.get("Type")).as_deref() == Some("snow"));
|
||||
// Fox.readAdditionalSaveData: the sleeping, sitting and crouching flags.
|
||||
for (key, bit) in [
|
||||
("Sleeping", crate::mobs::fox::SLEEPING),
|
||||
("Sitting", crate::mobs::fox::SITTING),
|
||||
("Crouching", crate::mobs::fox::CROUCHING),
|
||||
] {
|
||||
if flag(tag, key) {
|
||||
m.extra.ints[0] |= bit;
|
||||
}
|
||||
}
|
||||
}
|
||||
// MushroomCow.readAdditionalSaveData: a missing or unknown name is
|
||||
// Variant.DEFAULT, red.
|
||||
crate::mobs::Species::Mooshroom => {
|
||||
m.extra.variant = u8::from(text(tag.get("Type")).as_deref() == Some("brown"));
|
||||
}
|
||||
crate::mobs::Species::Rabbit => {
|
||||
// Rabbit.Variant.byId: an unknown id is brown.
|
||||
@@ -1256,6 +1505,9 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.extra.ints[1] = number(first.get("count")).unwrap_or(1) as i32;
|
||||
}
|
||||
m.extra.egg_time = number(tag.get("DuplicationCooldown")).unwrap_or(0) as i32;
|
||||
if let (Some(st), Some(listener)) = (m.extra.allay.as_mut(), tag.get("listener")) {
|
||||
st.vibration = crate::sculk::Data::from_nbt(listener);
|
||||
}
|
||||
let liked = tag
|
||||
.get("Brain")
|
||||
.and_then(|b| b.get("memories"))
|
||||
@@ -1264,13 +1516,37 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.extra.owner = liked
|
||||
.and_then(|l| l.get("value"))
|
||||
.and_then(|v| crate::save::uuid_from_tag(Some(v)));
|
||||
let memory = |key: &str| {
|
||||
tag.get("Brain")
|
||||
.and_then(|b| b.get("memories"))
|
||||
.and_then(|b| b.get(key))
|
||||
.and_then(|l| l.get("value"))
|
||||
};
|
||||
m.extra.ints[2] =
|
||||
number(memory("minecraft:liked_noteblock_cooldown_ticks")).unwrap_or(0) as i32;
|
||||
if let Some(st) = m.extra.allay.as_mut()
|
||||
&& let Some(noteblock) = memory("minecraft:liked_noteblock")
|
||||
&& let Some(Tag::IntArray(p)) = noteblock.get("pos")
|
||||
&& p.len() == 3
|
||||
{
|
||||
st.noteblock = Some(crate::mobs::allay::GlobalPos {
|
||||
dimension: crate::mobs::allay::dimension_id(
|
||||
&text(noteblock.get("dimension")).unwrap_or_default(),
|
||||
),
|
||||
pos: [p[0], p[1], p[2]],
|
||||
});
|
||||
}
|
||||
}
|
||||
if crate::mobs::camel::is_camel(m.species()) {
|
||||
let sitting = matches!(tag.get("LastPoseTick"), Some(Tag::Long(v)) if *v < 0);
|
||||
// The tick is a game time: `camel::finish_load` puts it on the mob's
|
||||
// clock once the world's time is known.
|
||||
let tick = match tag.get("LastPoseTick") {
|
||||
Some(Tag::Long(v)) => *v,
|
||||
_ => 0,
|
||||
};
|
||||
crate::mobs::camel::stand_up_instantly(&mut m);
|
||||
if sitting {
|
||||
m.extra.egg_time = -1 - 400;
|
||||
}
|
||||
m.extra.pose_change_tick = tick;
|
||||
m.extra.camel_sitting = tick < 0;
|
||||
// Camels are always tame (`Camel.register` in Horizon).
|
||||
m.extra.tame = true;
|
||||
}
|
||||
@@ -1357,6 +1633,13 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.extra.color = number(tag.get("Color")).map_or(0, |c| (c as u8) & 15);
|
||||
m.extra.sheared = flag(tag, "Sheared");
|
||||
}
|
||||
// GlowSquid.readAdditionalSaveData: not dimmed when absent.
|
||||
if m.species() == crate::mobs::Species::GlowSquid {
|
||||
crate::mobs::aquatic::set_dark_ticks(
|
||||
&mut m,
|
||||
number(tag.get("DarkTicksRemaining")).unwrap_or(0) as i32,
|
||||
);
|
||||
}
|
||||
// IronGolem.readAdditionalSaveData: not player created when absent.
|
||||
if m.species() == crate::mobs::Species::IronGolem {
|
||||
m.extra.player_created = flag(tag, "PlayerCreated");
|
||||
@@ -1703,6 +1986,8 @@ pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projecti
|
||||
power: number(tag.get("ExplosionPower")).unwrap_or(1) as i32,
|
||||
},
|
||||
"minecraft:small_fireball" => ProjectileKind::SmallFireball,
|
||||
"minecraft:wind_charge" => ProjectileKind::WindCharge,
|
||||
"minecraft:breeze_wind_charge" => ProjectileKind::BreezeWindCharge,
|
||||
_ => return None,
|
||||
};
|
||||
let owner = uuid_from_tag(tag.get("Owner"));
|
||||
@@ -3014,7 +3299,34 @@ mod sheep_nbt_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foxes_rabbits_and_goats_keep_their_kind() {
|
||||
fn a_baby_villager_keeps_its_age() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let data = crate::mobs::MobTypes::load(&root).unwrap();
|
||||
let types: HashMap<String, Arc<MobType>> = data
|
||||
.types
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
|
||||
.collect();
|
||||
let saved = Tag::compound([
|
||||
("id", string("minecraft:villager")),
|
||||
("Pos", doubles(&[0.5, 64.0, 0.5])),
|
||||
("Age", Tag::Int(-21359)),
|
||||
]);
|
||||
let m = mob_from_nbt(&saved, &types, 1).unwrap();
|
||||
assert_eq!(m.extra.age, -21359);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
|
||||
assert_eq!(number(tag.get("Age")), Some(-21359));
|
||||
assert_eq!(number(tag.get("AgeLocked")), Some(0));
|
||||
assert_eq!(number(tag.get("VillagerDataFinalized")), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foxes_mooshrooms_rabbits_and_goats_keep_their_kind() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
@@ -3038,6 +3350,17 @@ mod sheep_nbt_tests {
|
||||
assert_eq!(text(tag.get("Type")).as_deref(), Some("snow"));
|
||||
assert_eq!(number(tag.get("Sleeping")), Some(0));
|
||||
assert_eq!(back.extra.variant, 1);
|
||||
// MushroomCow: a brown mooshroom stays brown, and a save without (or
|
||||
// with an unknown) Type reads as red.
|
||||
let mut mooshroom = new("minecraft:mooshroom");
|
||||
mooshroom.extra.variant = 1;
|
||||
let (back, tag) = round_trip(&mooshroom);
|
||||
assert_eq!(text(tag.get("Type")).as_deref(), Some("brown"));
|
||||
assert_eq!(back.extra.variant, 1);
|
||||
mooshroom.extra.variant = 0;
|
||||
let (back, tag) = round_trip(&mooshroom);
|
||||
assert_eq!(text(tag.get("Type")).as_deref(), Some("red"));
|
||||
assert_eq!(back.extra.variant, 0);
|
||||
let mut rabbit = new("minecraft:rabbit");
|
||||
rabbit.extra.variant = 4;
|
||||
let (back, tag) = round_trip(&rabbit);
|
||||
@@ -3098,6 +3421,20 @@ mod fireball_nbt_tests {
|
||||
(p.kind, p.acceleration),
|
||||
(ProjectileKind::LargeFireball { power: 1 }, 0.1)
|
||||
);
|
||||
// `/summon` and a saved world give back both wind charges.
|
||||
for (id, kind) in [
|
||||
("minecraft:wind_charge", ProjectileKind::WindCharge),
|
||||
(
|
||||
"minecraft:breeze_wind_charge",
|
||||
ProjectileKind::BreezeWindCharge,
|
||||
),
|
||||
] {
|
||||
let tag = Tag::compound([
|
||||
("id", Tag::String(id.into())),
|
||||
("Pos", doubles(&[0.0, 0.0, 0.0])),
|
||||
]);
|
||||
assert_eq!(fireball_from_nbt(&tag, None).unwrap().kind, kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! FallingBlockEntity's impact damage, anvil wear, saved impact settings and
|
||||
//! the cobwebs that hold it back.
|
||||
//! the blocks it moves through (cobwebs, bubble columns).
|
||||
use crate::motion::BubbleColumn;
|
||||
use crate::{
|
||||
mobs::{Damage as MobDamage, DamageKind, Mob},
|
||||
save::{float, number, put},
|
||||
@@ -118,30 +119,76 @@ pub fn bounds(position: Vec3) -> Aabb {
|
||||
pub fn web_multiplier() -> Vec3 {
|
||||
Vec3::new(0.25, f64::from(0.05_f32), 0.25)
|
||||
}
|
||||
/// What a block a falling block moves through does to it in `entityInside`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Inside {
|
||||
/// `WebBlock`: `Entity.makeStuckInBlock`.
|
||||
Cobweb,
|
||||
/// `BubbleColumnBlock`: lifts or drags it.
|
||||
BubbleColumn(BubbleColumn),
|
||||
Nothing,
|
||||
}
|
||||
/// `Entity.applyEffectsFromBlocks` after a falling block's move this tick
|
||||
/// (`from` to `to`; `original` the move it asked for, when the move was
|
||||
/// recorded): whether `checkInsideBlocks` reaches a cobweb (`is_web`), whose
|
||||
/// `entityInside` makes it stuck. Each movement sweeps its axes in
|
||||
/// `Direction.axisStepOrder` within 16 iterations, then the end alone.
|
||||
pub fn passes_cobweb(
|
||||
/// recorded; `velocity` already zeroed on the axes `Entity.move` collided on):
|
||||
/// the blocks it went through (`block` says what each is) push it and reset
|
||||
/// its fall. Returns whether a cobweb made it stuck.
|
||||
pub fn apply_effects_from_blocks(
|
||||
from: Vec3,
|
||||
to: Vec3,
|
||||
original: Option<Vec3>,
|
||||
mut is_web: impl FnMut(BlockPos) -> bool,
|
||||
velocity: &mut Vec3,
|
||||
fall_distance: &mut f64,
|
||||
mut block: impl FnMut(BlockPos) -> Inside,
|
||||
) -> bool {
|
||||
let mut stuck = false;
|
||||
inside_blocks(from, to, original, |p, intersects| match block(p) {
|
||||
Inside::Cobweb => {
|
||||
// makeStuckInBlock forgets the fall.
|
||||
*fall_distance = 0.0;
|
||||
stuck = true;
|
||||
}
|
||||
// BubbleColumnBlock.entityInside acts only when intersecting.
|
||||
Inside::BubbleColumn(column) if intersects => {
|
||||
if column.push(velocity) {
|
||||
*fall_distance = 0.0;
|
||||
}
|
||||
}
|
||||
Inside::BubbleColumn(_) | Inside::Nothing => {}
|
||||
});
|
||||
stuck
|
||||
}
|
||||
/// Each block `checkInsideBlocks` reaches, once, in order, with
|
||||
/// whether its `entityInside` counts the block as intersected (every block of a
|
||||
/// movement a block or longer, else only those the box ends in). Each movement
|
||||
/// sweeps its axes in `Direction.axisStepOrder` within 16 iterations, then the
|
||||
/// end alone. The blocks the caller acts on (cobwebs, bubble columns) have a
|
||||
/// whole-block `getEntityInsideCollisionShape`, so every one visited counts.
|
||||
fn inside_blocks(
|
||||
from: Vec3,
|
||||
to: Vec3,
|
||||
original: Option<Vec3>,
|
||||
mut visit: impl FnMut(BlockPos, bool),
|
||||
) {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
// checkInsideBlocks(from, to, ..): the iterations used plus one.
|
||||
let mut check = |from: Vec3, to: Vec3, max: i32| {
|
||||
let d = f64::from(1.0e-5_f32);
|
||||
let target = bounds(to).deflate(Vec3::new(d, d, d));
|
||||
// The move spans a block or more: Mth.square(1.0 - 1.0E-5F).
|
||||
let long = from.distance_squared(to) > (1.0 - d) * (1.0 - d);
|
||||
let mut iterations = 0;
|
||||
let _ = sweep_intersected_blocks(from, to, target, 1_000_000, |p, iteration| {
|
||||
if iteration >= max {
|
||||
return false;
|
||||
}
|
||||
iterations = iteration;
|
||||
// getEntityInsideCollisionShape is the whole block for cobwebs.
|
||||
stuck |= is_web(p);
|
||||
if seen.insert(p) {
|
||||
let corner = Vec3::new(f64::from(p.x), f64::from(p.y), f64::from(p.z));
|
||||
let inside =
|
||||
target.intersects_corners(corner, corner.add_vector(Vec3::new(1.0, 1.0, 1.0)));
|
||||
visit(p, long || inside);
|
||||
}
|
||||
true
|
||||
});
|
||||
iterations + 1
|
||||
@@ -170,7 +217,6 @@ pub fn passes_cobweb(
|
||||
if budget <= 0 {
|
||||
check(to, to, 1);
|
||||
}
|
||||
stuck
|
||||
}
|
||||
pub fn hurt_mobs(mobs: &mut [Mob], position: Vec3, amount: f32) {
|
||||
hurt_mobs_with_kind(mobs, position, amount, DamageKind::FallingBlock);
|
||||
@@ -249,6 +295,23 @@ mod tests {
|
||||
assert!(!wear_anvil(&mut state));
|
||||
}
|
||||
}
|
||||
fn passes_cobweb(
|
||||
from: Vec3,
|
||||
to: Vec3,
|
||||
original: Option<Vec3>,
|
||||
is_web: impl Fn(BlockPos) -> bool,
|
||||
) -> bool {
|
||||
let (mut velocity, mut fall) = (Vec3::ZERO, 3.0);
|
||||
let stuck = apply_effects_from_blocks(from, to, original, &mut velocity, &mut fall, |p| {
|
||||
if is_web(p) {
|
||||
Inside::Cobweb
|
||||
} else {
|
||||
Inside::Nothing
|
||||
}
|
||||
});
|
||||
assert_eq!(fall == 0.0, stuck);
|
||||
stuck
|
||||
}
|
||||
#[test]
|
||||
fn a_cobweb_on_the_path_or_at_the_end_holds_a_falling_block() {
|
||||
let web = BlockPos::new(0, 5, 0);
|
||||
@@ -285,6 +348,50 @@ mod tests {
|
||||
assert!(!passes_cobweb(beside, beside, None, is_web));
|
||||
}
|
||||
#[test]
|
||||
fn bubble_columns_lift_or_drag_a_falling_block() {
|
||||
// Soul sand under x 0 lifts, magma under x 3 drags, both up to y 9.
|
||||
let block = |p: BlockPos| match (p.x, p.y, p.z) {
|
||||
(0 | 3, 0..=9, 0) => Inside::BubbleColumn(BubbleColumn {
|
||||
drag: p.x == 3,
|
||||
surface: p.y == 9,
|
||||
}),
|
||||
_ => Inside::Nothing,
|
||||
};
|
||||
let step = |x: f64, from: f64, to: f64, vy: f64| {
|
||||
let (from, to) = (Vec3::new(x, from, 0.5), Vec3::new(x, to, 0.5));
|
||||
let mut velocity = Vec3::new(0.0, vy, 0.0);
|
||||
let mut fall = 2.0;
|
||||
let original = Some(to.subtract(from));
|
||||
let stuck =
|
||||
apply_effects_from_blocks(from, to, original, &mut velocity, &mut fall, block);
|
||||
assert!(!stuck);
|
||||
(velocity.y, fall)
|
||||
};
|
||||
let near = |a: f64, b: f64| (a - b).abs() < 1.0e-9;
|
||||
// Released in the column: its box ends in blocks 5 and 6, each lifts
|
||||
// it by 0.06 (handleOnInsideBubbleColumn), and its fall is forgotten.
|
||||
let (vy, fall) = step(0.5, 5.5, 5.46, -0.04);
|
||||
assert!(near(vy, 0.08), "{vy}");
|
||||
assert_eq!(fall, 0.0);
|
||||
// The lift is capped at 0.7.
|
||||
assert!(near(step(0.5, 5.0, 5.3, 0.68).0, 0.7));
|
||||
// Falling a block or more into the surface block: every column
|
||||
// block on the way counts, the surface throws it up by 0.1
|
||||
// (handleOnAboveBubbleColumn), which keeps its fall.
|
||||
let (vy, fall) = step(0.5, 11.0, 9.5, -1.5);
|
||||
assert!(near(vy, -1.5 + 0.1), "{vy}");
|
||||
assert_eq!(fall, 2.0);
|
||||
// Magma drags down by 0.03, at most to -0.3 inside and -0.9 at the surface.
|
||||
assert!(near(step(3.5, 5.5, 5.4, -0.1).0, -0.16));
|
||||
assert!(near(step(3.5, 5.5, 5.4, -0.29).0, -0.3));
|
||||
assert!(near(step(3.5, 9.8, 9.6, -0.89).0, -0.9));
|
||||
// A short move that leaves the column behind: the block it went
|
||||
// through no longer intersects its box, so nothing pushes it.
|
||||
assert_eq!(step(0.5, 9.95, 10.5, 0.55), (0.55, 2.0));
|
||||
// Beside the column.
|
||||
assert_eq!(step(1.5, 5.5, 5.46, -0.04), (-0.04, 2.0));
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "requires prepared entity models/data in HORIZON_TEST_RESOURCES"]
|
||||
fn impact_hurts_only_living_intersecting_noninvulnerable_mobs() {
|
||||
use crate::mobs::{Mob, MobTypes};
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
//! FlatLevelSource: superflat worlds (`minecraft:flat` generator).
|
||||
use crate::{chunk::Chunk, streaming::ChunkGenerator};
|
||||
use crate::{
|
||||
chunk::{Chunk, LevelShape},
|
||||
streaming::ChunkGenerator,
|
||||
};
|
||||
use horizon_world::{nbt::Tag, registry::BlockRegistry};
|
||||
|
||||
/// The layers of a flat generator, bottom up from the world's minimum Y.
|
||||
pub struct FlatTerrain {
|
||||
/// The Overworld's shape: the layers start at its minimum Y and stop at
|
||||
/// its top.
|
||||
shape: LevelShape,
|
||||
layers: Vec<u32>,
|
||||
biome: u16,
|
||||
surface: Option<crate::lod::Surface>,
|
||||
@@ -16,6 +22,7 @@ impl FlatTerrain {
|
||||
settings: &Tag,
|
||||
registry: &BlockRegistry,
|
||||
biomes: &crate::biome::Biomes,
|
||||
shape: LevelShape,
|
||||
) -> Option<Self> {
|
||||
let generator = settings
|
||||
.get("dimensions")?
|
||||
@@ -38,12 +45,7 @@ impl FlatTerrain {
|
||||
let biome = biomes.id(&biome);
|
||||
let mut water = None;
|
||||
let mut surface = None;
|
||||
for (i, &id) in layers
|
||||
.iter()
|
||||
.take(crate::chunk::HEIGHT as usize)
|
||||
.enumerate()
|
||||
.rev()
|
||||
{
|
||||
for (i, &id) in layers.iter().take(shape.height as usize).enumerate().rev() {
|
||||
let state = horizon_world::registry::BlockStateId(id);
|
||||
let name = registry.state(state)?.name.as_str();
|
||||
if matches!(
|
||||
@@ -52,7 +54,7 @@ impl FlatTerrain {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let height = crate::chunk::MIN_Y + i as i32 + 1;
|
||||
let height = shape.min_y + i as i32 + 1;
|
||||
if matches!(name, "minecraft:water" | "minecraft:lava") {
|
||||
water.get_or_insert((height, state));
|
||||
} else {
|
||||
@@ -66,6 +68,7 @@ impl FlatTerrain {
|
||||
}
|
||||
}
|
||||
Some(Self {
|
||||
shape,
|
||||
layers,
|
||||
biome,
|
||||
surface,
|
||||
@@ -73,7 +76,7 @@ impl FlatTerrain {
|
||||
}
|
||||
/// FlatLevelSource.getSpawnHeight: the top of the layers.
|
||||
pub fn spawn_height(&self) -> i32 {
|
||||
crate::chunk::MIN_Y + self.layers.len().min(crate::chunk::HEIGHT as usize) as i32
|
||||
self.shape.min_y + self.layers.len().min(self.shape.height as usize) as i32
|
||||
}
|
||||
}
|
||||
/// DimensionType.Y_SIZE: the most layers FlatLevelGeneratorSettings takes.
|
||||
@@ -190,20 +193,21 @@ impl ChunkGenerator for FlatTerrain {
|
||||
self.surface
|
||||
}
|
||||
fn proto(&self, _: &mut (), cx: i32, cz: i32) -> crate::streaming::Proto {
|
||||
let mut chunk = Chunk::empty(cx, cz);
|
||||
let mut chunk = Chunk::with_layout(cx, cz, self.shape.column());
|
||||
for s in &mut chunk.sections {
|
||||
std::sync::Arc::make_mut(s).biomes = horizon_core::paletted::PalettedContainer::new(
|
||||
crate::chunk::BIOME_STRATEGY,
|
||||
u32::from(self.biome),
|
||||
);
|
||||
}
|
||||
for (i, &block) in self.layers.iter().enumerate().take(384) {
|
||||
let height = self.shape.height as usize;
|
||||
for (i, &block) in self.layers.iter().enumerate().take(height) {
|
||||
if block == 0 {
|
||||
continue;
|
||||
}
|
||||
for z in 0..16 {
|
||||
for x in 0..16 {
|
||||
chunk.set(x, -64 + i as i32, z, block);
|
||||
chunk.set(x, self.shape.min_y + i as i32, z, block);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+920
-354
File diff suppressed because it is too large.
Load diff
@@ -115,10 +115,18 @@ pub(super) struct AdvState {
|
||||
pub(super) announced: Vec<Text>,
|
||||
/// The tree being read on another thread since the world opened
|
||||
/// (`prefetch_advancement_tree`), taken by the first `advancement_tree`.
|
||||
pub(super) pending_tree: Option<std::thread::JoinHandle<Tree>>,
|
||||
pub(super) pending_tree: Option<std::thread::JoinHandle<Arc<Tree>>>,
|
||||
/// `inventory_changed`'s criteria by item, built for `tree`.
|
||||
inventory_index: Option<(Arc<Tree>, Arc<adv::InventoryIndex>)>,
|
||||
}
|
||||
impl AdvState {
|
||||
/// Drop the prefetched tree and the index built for the old one (a
|
||||
/// `/reload` rebuilt the tree).
|
||||
pub(super) fn reset_tree(&mut self) {
|
||||
self.pending_tree = None;
|
||||
self.inventory_index = None;
|
||||
}
|
||||
}
|
||||
/// The game's world for predicates.
|
||||
struct View<'a, 'g>(&'a Game<'g>);
|
||||
impl adv::World for View<'_, '_> {
|
||||
@@ -261,9 +269,12 @@ impl Game<'_> {
|
||||
if self.adv.tree.list.is_empty() {
|
||||
let tree = match self.adv.pending_tree.take().map(|h| h.join()) {
|
||||
Some(Ok(tree)) => tree,
|
||||
_ => Tree::load(&self.options.resources.join("data")),
|
||||
_ => {
|
||||
let overlay = self.pack_files();
|
||||
Tree::shared(&self.options.resources.join("data"), &overlay)
|
||||
}
|
||||
};
|
||||
self.adv.tree = Arc::new(tree);
|
||||
self.adv.tree = tree;
|
||||
// With the tree, while the world loads: built on the first
|
||||
// inventory change it cost that tick ~10 ms.
|
||||
self.inventory_index();
|
||||
@@ -278,7 +289,7 @@ impl Game<'_> {
|
||||
{
|
||||
return index.clone();
|
||||
}
|
||||
let items = self.res.items();
|
||||
let items = self.items();
|
||||
let index = Arc::new(adv::InventoryIndex::build(&tree, |tag| {
|
||||
items
|
||||
.item_tags
|
||||
@@ -297,9 +308,15 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let data = self.options.resources.join("data");
|
||||
// The world's packs' advancements go over the game's own.
|
||||
let files = super::functions::content_overlay(
|
||||
&self.world_root,
|
||||
&self.options.resources,
|
||||
self.cmds.vanilla_disabled,
|
||||
);
|
||||
self.adv.pending_tree = std::thread::Builder::new()
|
||||
.name("horizon-advancements".into())
|
||||
.spawn(move || Tree::load(&data))
|
||||
.spawn(move || Tree::shared(&data, &horizon_world::loot::PackOverlay { files: &files }))
|
||||
.ok();
|
||||
}
|
||||
pub(super) fn load_advancements(&mut self) {
|
||||
@@ -537,13 +554,13 @@ impl Game<'_> {
|
||||
.rewards
|
||||
.recipes
|
||||
.iter()
|
||||
.filter(|r| self.res.stations().recipe_index.contains(r))
|
||||
.filter(|r| world_stations!(self).recipe_index.contains(r))
|
||||
.cloned()
|
||||
.collect();
|
||||
let (_, unlocked) = self
|
||||
.level
|
||||
.recipe_book
|
||||
.add_recipes(&self.res.stations().recipe_index, &rewards);
|
||||
.add_recipes(&world_stations!(self).recipe_index, &rewards);
|
||||
for table in &a.rewards.loot {
|
||||
let items = self
|
||||
.res
|
||||
@@ -1089,6 +1106,31 @@ impl Game<'_> {
|
||||
let it = item_view(stack);
|
||||
self.trigger(name, |c, w| adv::item_entity(c, &me, target, &it, w));
|
||||
}
|
||||
/// `ServerPlayer.onItemPickup`: the player in place (the host, or the
|
||||
/// guest `as_guest` runs) picked up an item entity whose owner
|
||||
/// (`ItemEntity.getOwner`, the thrower) is `thrower`: the whole stack, or
|
||||
/// what is left after a partial take. An owner that is that player also
|
||||
/// fires `LivingEntity.onItemPickup` (the by-entity trigger); one that is
|
||||
/// gone, or another player, fires nothing here.
|
||||
pub(super) fn trigger_thrown_picked_up_by_player(
|
||||
&mut self,
|
||||
stack: &ItemStack,
|
||||
thrower: [i32; 4],
|
||||
) {
|
||||
let target = if thrower == self.acting_uuid() {
|
||||
let me = self.player_entity();
|
||||
self.trigger_item_entity("minecraft:thrown_item_picked_up_by_entity", stack, &me);
|
||||
me
|
||||
} else if let Some(id) = self.mob_by_uuid(thrower) {
|
||||
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
|
||||
return;
|
||||
};
|
||||
self.mob_entity(m)
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
self.trigger_item_entity("minecraft:thrown_item_picked_up_by_player", stack, &target);
|
||||
}
|
||||
/// `ConstructBeaconTrigger`: the pyramid levels of the new beacon.
|
||||
pub(super) fn trigger_construct_beacon(&mut self, levels: i32) {
|
||||
let me = self.player_entity();
|
||||
|
||||
@@ -7,7 +7,6 @@ use super::{Game, Screen};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
block_entity_anim::Ticker,
|
||||
chunk::{MIN_Y, SECTIONS},
|
||||
items::ItemStack,
|
||||
mobs::{
|
||||
Species,
|
||||
@@ -486,6 +485,8 @@ impl Game<'_> {
|
||||
// LivingEntity.deathTime while dead (the camera's death roll).
|
||||
"death_time": self.death_time(),
|
||||
"air": p.air,
|
||||
// LocalPlayer.portalEffectIntensity: the Nether portal overlay.
|
||||
"portal_effect": self.screen_fx.portal,
|
||||
"food": p.health.food.food,
|
||||
"saturation": p.health.food.saturation,
|
||||
"shield_cooldown": self.shield_cooldown,
|
||||
@@ -547,7 +548,7 @@ impl Game<'_> {
|
||||
selected,
|
||||
} => json!({
|
||||
"mode": "select-world",
|
||||
"worlds": list.iter().map(|w| json!({ "folder": w.folder, "name": w.name, "creative": w.game_mode == 1 })).collect::<Vec<_>>(),
|
||||
"worlds": list.iter().map(|w| json!({ "folder": w.folder, "name": w.name, "creative": w.game_mode == 1, "corrupted": w.corrupted })).collect::<Vec<_>>(),
|
||||
"selected": selected,
|
||||
}),
|
||||
Mode::Create {
|
||||
@@ -598,9 +599,20 @@ impl Game<'_> {
|
||||
Mode::Delete { world } => {
|
||||
json!({ "mode": "delete-world", "folder": world.folder, "name": world.name })
|
||||
}
|
||||
Mode::BackupBeforeUpgrade { world } => {
|
||||
Mode::BackupBeforeUpgrade { world, .. } => {
|
||||
json!({ "mode": "backup-before-upgrade", "folder": world.folder, "name": world.name })
|
||||
}
|
||||
Mode::Recover { world, issues } => {
|
||||
let issue = |i: &Option<horizon_client::save::DatIssue>| match i {
|
||||
None => json!(null),
|
||||
Some(horizon_client::save::DatIssue::Missing) => json!("missing file"),
|
||||
Some(horizon_client::save::DatIssue::Unreadable(why)) => json!(why),
|
||||
};
|
||||
json!({ "mode": "recover-world", "folder": world.folder, "level_dat": issue(&issues[0]), "level_dat_old": issue(&issues[1]) })
|
||||
}
|
||||
Mode::Recovered { world, success } => {
|
||||
json!({ "mode": "recovery-done", "folder": world.folder, "success": success })
|
||||
}
|
||||
Mode::Error { message, .. } => {
|
||||
json!({ "mode": "world-operation-error", "message": message })
|
||||
}
|
||||
@@ -1086,7 +1098,9 @@ impl Game<'_> {
|
||||
.world
|
||||
.chunks
|
||||
.get(&(x >> 4, z >> 4))
|
||||
.filter(|_| (MIN_Y..MIN_Y + SECTIONS as i32 * 16).contains(&y))
|
||||
.filter(|_| {
|
||||
(self.world.bottom_y()..self.world.top_y()).contains(&y)
|
||||
})
|
||||
.map(|c| c.light(x & 15, y, z & 15));
|
||||
let digit = |v: u8| char::from_digit(u32::from(v), 16).unwrap_or('.');
|
||||
sky.push(packed.map_or('.', |v| digit(v >> 4)));
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
//! throwing their load to the player or note block, dancing to jukeboxes,
|
||||
//! and the hand-over interaction (`Allay.mobInteract`).
|
||||
use super::Game;
|
||||
use horizon_client::{containers::BlockEntity, items::ItemStack, mobs::Species};
|
||||
use horizon_client::{
|
||||
containers::BlockEntity,
|
||||
items::ItemStack,
|
||||
mobs::{Species, allay},
|
||||
};
|
||||
use horizon_core::random::RandomSource;
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -131,14 +135,20 @@ impl Game<'_> {
|
||||
if m.extra.ints[1] <= 0 {
|
||||
return;
|
||||
}
|
||||
// AllayAi.getItemDepositPosition: the liked note block, else the player.
|
||||
let dimension = self.dim.id();
|
||||
let noteblock = allay::noteblock_deposit(&mut self.mobs[i], dimension, |p| {
|
||||
&*self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(p[0], p[1], p[2]))
|
||||
.name
|
||||
== "minecraft:note_block"
|
||||
});
|
||||
let player = self.allay_liked_player(i);
|
||||
let m = &self.mobs[i];
|
||||
let target = match m.extra.home.filter(|_| m.extra.ints[2] > 0) {
|
||||
Some(h) => horizon_core::geometry::Vec3::new(
|
||||
f64::from(h[0]) + 0.5,
|
||||
f64::from(h[1]) + 1.0,
|
||||
f64::from(h[2]) + 0.5,
|
||||
),
|
||||
let target = match noteblock {
|
||||
Some(p) => horizon_core::geometry::Vec3::new(p[0], p[1], p[2]),
|
||||
None => match player.filter(|_| m.extra.tame) {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
@@ -181,6 +191,11 @@ impl Game<'_> {
|
||||
m.extra.ints[3] = 60;
|
||||
// ItemEntity.setDefaultPickUpDelay.
|
||||
self.spawn_item(ItemStack::new(&item, 1), from, v, 10);
|
||||
// BehaviorUtils.throwItem: ItemEntity.setThrower(the allay).
|
||||
let allay = self.mob_uuid(self.mobs[i].id);
|
||||
if let Some(thrown) = self.dropped.last_mut() {
|
||||
thrown.thrower = Some(allay);
|
||||
}
|
||||
if self.world.game_time % 7 == 0 && self.world.random.next_double() < 0.9 {
|
||||
const PITCHES: [f32; 16] = [
|
||||
0.5625, 0.625, 0.75, 0.9375, 1.0, 1.0, 1.125, 1.25, 1.5, 1.875, 2.0, 2.25, 2.5,
|
||||
|
||||
@@ -137,16 +137,16 @@ impl ArmorStandAssets {
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `HumanoidArmorLayer` for the player: (model, texture rect, colour) of
|
||||
/// each worn piece, head to feet.
|
||||
pub(super) fn player_armor_layers(&self) -> Vec<(&Part, [f32; 4], u32)> {
|
||||
/// `HumanoidArmorLayer` for the player: (model, texture rect, colour,
|
||||
/// overlay) of each worn piece, head to feet.
|
||||
pub(super) fn player_armor_layers(&self) -> Vec<(&Part, [f32; 4], u32, u32)> {
|
||||
self.stack_armor_layers(&self.armor)
|
||||
}
|
||||
/// `HumanoidArmorLayer` for worn stacks (head to feet), trims included.
|
||||
pub(super) fn stack_armor_layers(
|
||||
&self,
|
||||
armor: &[Option<ItemStack>; 4],
|
||||
) -> Vec<(&Part, [f32; 4], u32)> {
|
||||
) -> Vec<(&Part, [f32; 4], u32, u32)> {
|
||||
self.armor_layers(
|
||||
armor
|
||||
.each_ref()
|
||||
@@ -155,7 +155,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// `HumanoidArmorLayer` for another player's model: the armour it wears
|
||||
/// (`extra.head` .. `extra.feet`).
|
||||
pub(super) fn worn_armor_layers(&self, m: &Mob) -> Vec<(&Part, [f32; 4], u32)> {
|
||||
pub(super) fn worn_armor_layers(&self, m: &Mob) -> Vec<(&Part, [f32; 4], u32, u32)> {
|
||||
let e = &m.extra;
|
||||
self.armor_layers(
|
||||
[
|
||||
@@ -172,7 +172,7 @@ impl Game<'_> {
|
||||
fn armor_layers(
|
||||
&self,
|
||||
worn: [Option<(&str, Option<&ItemStack>)>; 4],
|
||||
) -> Vec<(&Part, [f32; 4], u32)> {
|
||||
) -> Vec<(&Part, [f32; 4], u32, u32)> {
|
||||
let assets = &self.armor_stand_assets;
|
||||
let mut out = Vec::new();
|
||||
for (item, layer) in worn
|
||||
@@ -206,13 +206,27 @@ impl Game<'_> {
|
||||
continue;
|
||||
};
|
||||
let color = layer_color(*undyed, stack);
|
||||
out.push((part, [r.u0, r.v0, r.u1, r.v1], color));
|
||||
// EquipmentLayerRenderer.renderLayers: NO_OVERLAY, the armour
|
||||
// glint on the layer and its trim when `stack.hasFoil()`.
|
||||
let item_glint = self
|
||||
.res
|
||||
.items()
|
||||
.enchantments
|
||||
.props
|
||||
.get(item)
|
||||
.is_some_and(|p| p.glint);
|
||||
let overlay = if stack.is_some_and(|s| s.has_foil(item_glint)) {
|
||||
horizon_client::mobs::render::ARMOR_GLINT
|
||||
} else {
|
||||
0
|
||||
};
|
||||
out.push((part, [r.u0, r.v0, r.u1, r.v1], color, overlay));
|
||||
// EquipmentLayerRenderer: the trim layer over the armour.
|
||||
if let Some(r) = stack
|
||||
.and_then(|s| assets.trim_texture(&asset, kind, s))
|
||||
.and_then(|t| self.gui.atlas.sprites.get(&t.sprite()))
|
||||
{
|
||||
out.push((part, [r.u0, r.v0, r.u1, r.v1], 0xFFFF_FFFF));
|
||||
out.push((part, [r.u0, r.v0, r.u1, r.v1], 0xFFFF_FFFF, overlay));
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -224,7 +238,7 @@ impl Game<'_> {
|
||||
pub(super) fn prepare_trims(&mut self) {
|
||||
use horizon_client::hud::TRIM_SLOTS;
|
||||
let assets = &self.armor_stand_assets;
|
||||
let items = self.res.items();
|
||||
let items = self.items();
|
||||
let mut wanted: Vec<TrimTexture> = Vec::new();
|
||||
let guests = self.lan.iter().flat_map(|host| host.hub.guests.iter());
|
||||
let stacks = self
|
||||
@@ -621,17 +635,23 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
for i in dead.into_iter().rev() {
|
||||
let a = self.armor_stands.remove(i);
|
||||
let at = [a.position.x, a.position.y, a.position.z];
|
||||
let (pos, drops) = burnt_armor_stand_equipment(a);
|
||||
for stack in drops {
|
||||
self.pop_resource(pos, stack);
|
||||
}
|
||||
self.audio
|
||||
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
|
||||
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
|
||||
self.break_armor_stand_by_anything(i);
|
||||
}
|
||||
}
|
||||
/// `ArmorStand.brokenByAnything`, then `kill`, as burning out or a blast
|
||||
/// breaks a stand: its equipment pops above its block, the stand item does
|
||||
/// not drop.
|
||||
pub(super) fn break_armor_stand_by_anything(&mut self, i: usize) {
|
||||
let a = self.armor_stands.remove(i);
|
||||
let at = [a.position.x, a.position.y, a.position.z];
|
||||
let (pos, drops) = broken_armor_stand_equipment(a);
|
||||
for stack in drops {
|
||||
self.pop_resource(pos, stack);
|
||||
}
|
||||
self.audio
|
||||
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
|
||||
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
|
||||
}
|
||||
/// ArmorStandRenderer: the stand with its default pose, then armor layers.
|
||||
pub(super) fn armor_stand_geometry(
|
||||
&self,
|
||||
@@ -820,6 +840,7 @@ impl Game<'_> {
|
||||
horizon_client::mobs::ElytraAnimation::RESTING,
|
||||
false,
|
||||
packed,
|
||||
self.wings_overlay(a.equipment[4].as_ref()),
|
||||
out,
|
||||
);
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
@@ -866,7 +887,7 @@ impl Game<'_> {
|
||||
/// `Equippable.swapWithEquipmentSlot`: right-clicking armor swaps it into
|
||||
/// its slot (the worn piece comes back to the hand).
|
||||
pub(super) fn equip_from_hand(&mut self, stack: &ItemStack) -> bool {
|
||||
let Some(info) = self.res.items().get(&stack.item) else {
|
||||
let Some(info) = self.items().get(&stack.item) else {
|
||||
return false;
|
||||
};
|
||||
let (index, slot) = match info.equip_slot.as_deref() {
|
||||
@@ -1040,7 +1061,15 @@ pub(super) fn skull_kind(item: &str) -> Option<&'static str> {
|
||||
})
|
||||
}
|
||||
|
||||
fn burnt_armor_stand_equipment(a: ArmorStand) -> ([i32; 3], Vec<ItemStack>) {
|
||||
/// `ArmorStand.ignoreExplosion` (an invisible stand), and `hurtServer`
|
||||
/// refusing an invisible or marker stand: such a stand outlasts any blast.
|
||||
pub(super) fn spared_by_blast(a: &ArmorStand) -> bool {
|
||||
a.invisible || a.marker
|
||||
}
|
||||
|
||||
/// `ArmorStand.brokenByAnything`: the equipment, less what curse of vanishing
|
||||
/// keeps (`PREVENT_EQUIPMENT_DROP`), popped at `blockPosition().above()`.
|
||||
fn broken_armor_stand_equipment(a: ArmorStand) -> ([i32; 3], Vec<ItemStack>) {
|
||||
let pos = [
|
||||
a.position.x.floor() as i32,
|
||||
a.position.y.floor() as i32 + 1,
|
||||
@@ -1058,6 +1087,16 @@ fn burnt_armor_stand_equipment(a: ArmorStand) -> ([i32; 3], Vec<ItemStack>) {
|
||||
mod burn_drop_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn only_visible_non_marker_stands_are_broken_by_blasts() {
|
||||
let mut stand = ArmorStand::new(1, Vec3::new(0.5, 64.0, 0.5), 0.0);
|
||||
assert!(!spared_by_blast(&stand));
|
||||
stand.invisible = true;
|
||||
assert!(spared_by_blast(&stand));
|
||||
stand.invisible = false;
|
||||
stand.marker = true;
|
||||
assert!(spared_by_blast(&stand));
|
||||
}
|
||||
#[test]
|
||||
fn burnt_equipment_drops_above_the_block_and_vanishing_items_do_not_drop() {
|
||||
let mut stand = ArmorStand::new(1, Vec3::new(-0.1, 4.8, 0.0), 0.0);
|
||||
let kept = ItemStack::new("minecraft:diamond_boots", 1);
|
||||
@@ -1068,7 +1107,7 @@ mod burn_drop_tests {
|
||||
.push(("minecraft:vanishing_curse".into(), 1));
|
||||
stand.equipment[0] = Some(kept.clone());
|
||||
stand.equipment[1] = Some(vanished);
|
||||
let (pos, drops) = burnt_armor_stand_equipment(stand);
|
||||
let (pos, drops) = broken_armor_stand_equipment(stand);
|
||||
assert_eq!(pos, [-1, 5, 0]);
|
||||
assert_eq!(drops, vec![kept]);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ impl Game<'_> {
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
layer_textures(&self.res.stations().banners, &b.layers)
|
||||
layer_textures(&self.stations().banners, &b.layers)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -177,7 +177,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
if b.last_check_y >= surface {
|
||||
b.last_check_y = super::MIN_Y - 1;
|
||||
b.last_check_y = self.world.bottom_y() - 1;
|
||||
b.sections = std::mem::take(&mut b.checking);
|
||||
let (was, is) = (previous > 0, b.levels > 0);
|
||||
if !was && is {
|
||||
@@ -231,7 +231,7 @@ impl Game<'_> {
|
||||
let mut levels = 0;
|
||||
for step in 1..=4 {
|
||||
let ly = y - step;
|
||||
if ly < super::MIN_Y {
|
||||
if ly < self.world.bottom_y() {
|
||||
break;
|
||||
}
|
||||
let ok = (x - step..=x + step).all(|lx| {
|
||||
|
||||
Loaded 100 of 470 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user