mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Compare commits
931
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
660a170f61 | ||
|
|
ad5f05a8c7 | ||
|
|
1fc3f6724e | ||
|
|
e422bd138a | ||
|
|
2b1cd3e96e | ||
|
|
64a4c72ebd | ||
|
|
69d03e546a | ||
|
|
0f3a9e1508 | ||
|
|
d652b25ead | ||
|
|
43d5ed8246 | ||
|
|
e6068b1246 | ||
|
|
f535d1b48c | ||
|
|
ee3a160cbd | ||
|
|
652e547b53 | ||
|
|
f3f2301dce | ||
|
|
f04562c640 | ||
|
|
000fafa9bd | ||
|
|
7d57523c63 | ||
|
|
2e2645ba9d | ||
|
|
b324ee7e3a | ||
|
|
19aac92bb7 | ||
|
|
44c3ebb0d8 | ||
|
|
4e9495175c | ||
|
|
9726af184b | ||
|
|
5dac64f770 | ||
|
|
89740d39e0 | ||
|
|
167339c03c | ||
|
|
9b2825df34 | ||
|
|
7a27781ab5 | ||
|
|
af15fb1a98 | ||
|
|
4ff55f7a99 | ||
|
|
6b86fa9bef | ||
|
|
b9d4ef3194 | ||
|
|
949751656a | ||
|
|
500241cbc6 | ||
|
|
2483f90779 | ||
|
|
07f7e3aeab | ||
|
|
a1b0d95db1 | ||
|
|
9c03535011 | ||
|
|
820936b975 | ||
|
|
7f77772e15 | ||
|
|
36b3fef0ed | ||
|
|
e21919c114 | ||
|
|
cff7a66881 | ||
|
|
0c532fc1b0 | ||
|
|
cbb8c6a239 | ||
|
|
db45b6f87a | ||
|
|
56dc4352d2 | ||
|
|
c2f4ecc188 | ||
|
|
c57c409f40 | ||
|
|
a08add231a | ||
|
|
c7ee8baa25 | ||
|
|
47182eccbf | ||
|
|
05d1b92d56 | ||
|
|
cce44ad97d | ||
|
|
24bb3f97aa | ||
|
|
bfb6a0458d | ||
|
|
ed6af00ff8 | ||
|
|
3510764cc6 | ||
|
|
e8832190d1 | ||
|
|
9d21209ae8 | ||
|
|
b4ad65386c | ||
|
|
9d75d839aa | ||
|
|
38640fc845 | ||
|
|
586acadd81 | ||
|
|
72ec93a089 | ||
|
|
020e01746f | ||
|
|
be6a9d2cb5 | ||
|
|
6754c52e82 | ||
|
|
673ac84724 | ||
|
|
85858610bf | ||
|
|
ac7b7f98c0 | ||
|
|
002cfcd859 | ||
|
|
746c888cd3 | ||
|
|
c7073db8b5 | ||
|
|
71635fbab7 | ||
|
|
dd7282c9da | ||
|
|
4c3b2b789f | ||
|
|
0981e0abcf | ||
|
|
0cdcbd17ba | ||
|
|
c47dc83eab | ||
|
|
a06367edf1 | ||
|
|
5bc981e577 | ||
|
|
bd8dff2076 | ||
|
|
69b3eb1864 | ||
|
|
ece9ab2b79 | ||
|
|
b9d6d1c7d9 | ||
|
|
b27e4b9323 | ||
|
|
65f9819686 | ||
|
|
e86b05333e | ||
|
|
18dc7e7d8a | ||
|
|
8c5efc54d8 | ||
|
|
f7d042c6d0 | ||
|
|
55945ee975 | ||
|
|
bc4d0cc806 | ||
|
|
8a48f3b383 | ||
|
|
54acb709e4 | ||
|
|
4f1735170e | ||
|
|
dbe28edc9b | ||
|
|
baaa5e7374 | ||
|
|
e4b336fe6d | ||
|
|
1e1c3b79e5 | ||
|
|
9e73a8417f | ||
|
|
65e0ec9f97 | ||
|
|
93ce080d71 | ||
|
|
81243f7897 | ||
|
|
5c7fa1c575 | ||
|
|
8953448987 | ||
|
|
45397e4b30 | ||
|
|
b8f1011cb1 | ||
|
|
9c87672a0c | ||
|
|
b523567d1e | ||
|
|
330c3ef07e | ||
|
|
b1a3adff81 | ||
|
|
018c5e43e3 | ||
|
|
7cfc19305a | ||
|
|
6ff898ec29 | ||
|
|
56fec974ee | ||
|
|
f5c91eded1 | ||
|
|
8da9c8c4c6 | ||
|
|
04bdf82ac9 | ||
|
|
e8757cd75c | ||
|
|
9bbe5c4fd3 | ||
|
|
a950a046ae | ||
|
|
e27e556ab2 | ||
|
|
ac4ed86925 | ||
|
|
91360e2f29 | ||
|
|
ee088c0022 | ||
|
|
4224687e54 | ||
|
|
a8b6eea474 | ||
|
|
bd42496af0 | ||
|
|
4410064c58 | ||
|
|
7e1e5eb7a0 | ||
|
|
3e6052ac92 | ||
|
|
bc0cd9cc84 | ||
|
|
a8579b4df8 | ||
|
|
2a33f2c500 | ||
|
|
984a06e582 | ||
|
|
487d76c0ae | ||
|
|
74fafdd787 | ||
|
|
11130e6ef7 | ||
|
|
3f7fba836e | ||
|
|
ce49480b3e | ||
|
|
fc59063ada | ||
|
|
23a54d4a22 | ||
|
|
257b5e9f2d | ||
|
|
64f7ecd367 | ||
|
|
36d7f02802 | ||
|
|
2e1212346f | ||
|
|
e289980c1e | ||
|
|
90644915c5 | ||
|
|
7b53b06aca | ||
|
|
53fcf14850 | ||
|
|
84999e0762 | ||
|
|
3c81a89d1e | ||
|
|
d993c36e02 | ||
|
|
9311cd5cd3 | ||
|
|
600bae5703 | ||
|
|
327aa5af6e | ||
|
|
dac18e8350 | ||
|
|
e27efc16ba | ||
|
|
924698e506 | ||
|
|
705fab9bed | ||
|
|
7e8dd577ac | ||
|
|
a75c3a315f | ||
|
|
932c7bfc17 | ||
|
|
d0fa05b3e7 | ||
|
|
592ac6966b | ||
|
|
3e19e0d4dd | ||
|
|
6a0d4284d0 | ||
|
|
d9deabd54e | ||
|
|
3cfb7b8053 | ||
|
|
f188dfeb45 | ||
|
|
45404c4507 | ||
|
|
d7c7c56e05 | ||
|
|
d1c753e085 | ||
|
|
9126df5ac5 | ||
|
|
d3b9441833 | ||
|
|
e6a2d72467 | ||
|
|
3c57aba6c9 | ||
|
|
9b9b23bf3a | ||
|
|
953cdc947f | ||
|
|
4de37ac0e4 | ||
|
|
1bf51d786f | ||
|
|
64a82acdab | ||
|
|
f2bbd6e3dc | ||
|
|
63453086b6 | ||
|
|
c4f915c9d1 | ||
|
|
ed5427197f | ||
|
|
3ac3efe749 | ||
|
|
2c07b5e621 | ||
|
|
2567a4a41a | ||
|
|
d5fbe2b7d6 | ||
|
|
9044ae30c9 | ||
|
|
c72a5643a4 | ||
|
|
3315138536 | ||
|
|
f8c22f4e72 | ||
|
|
128111ff27 | ||
|
|
09e01f066f | ||
|
|
fb5a307903 | ||
|
|
7cad2d7284 | ||
|
|
c163fbff83 | ||
|
|
608507699c | ||
|
|
03fe857ec2 | ||
|
|
5373998ee0 | ||
|
|
93ea338bfd | ||
|
|
98321f5d8d | ||
|
|
9f7c1c3ce5 | ||
|
|
a8e0dead35 | ||
|
|
8ca9cda5f6 | ||
|
|
9cec0dfb45 | ||
|
|
d333780478 | ||
|
|
964280aa00 | ||
|
|
457a5d7ce2 | ||
|
|
310a3fb73b | ||
|
|
d56c6dfd23 | ||
|
|
d30df8ce33 | ||
|
|
e0b5e25c1a | ||
|
|
b2e9fafbe0 | ||
|
|
71fc3482ba | ||
|
|
561efb340e | ||
|
|
2862f17e86 | ||
|
|
23cadd8b65 | ||
|
|
6aea14c8a0 | ||
|
|
504a70c062 | ||
|
|
5350f3465b | ||
|
|
99cd0ec0ad | ||
|
|
a42db752d7 | ||
|
|
de53b7c14e | ||
|
|
e2fa721c7a | ||
|
|
1c91fad962 | ||
|
|
824fad45cf | ||
|
|
5f45cf3830 | ||
|
|
08c4805e10 | ||
|
|
fc97b78931 | ||
|
|
37f6481e14 | ||
|
|
e1d9ae1dd7 | ||
|
|
38a9f6543f | ||
|
|
98ebdcad7b | ||
|
|
b22583d057 | ||
|
|
d28521f765 | ||
|
|
7d6936e000 | ||
|
|
5419ef0ed4 | ||
|
|
457fb77ab0 | ||
|
|
850788d46c | ||
|
|
77127ccfed | ||
|
|
13c4230431 | ||
|
|
99dfeabfa5 | ||
|
|
4a0c639f63 | ||
|
|
18b5310b0f | ||
|
|
5a0ad89207 | ||
|
|
d1495076e6 | ||
|
|
b66eea6506 | ||
|
|
b5a7138489 | ||
|
|
725146c0ca | ||
|
|
d675918db9 | ||
|
|
0c103a4a97 | ||
|
|
9b4d5d5613 | ||
|
|
292873e11a | ||
|
|
c6aefdb82b | ||
|
|
66dc8ce3a4 | ||
|
|
6bf89f912c | ||
|
|
17feed1620 | ||
|
|
32f7356eda | ||
|
|
2f23a776f8 | ||
|
|
bf4fcc3aa9 | ||
|
|
8e6a129f6b | ||
|
|
dd856ec5f6 | ||
|
|
fd1acd5ed7 | ||
|
|
227edf7517 | ||
|
|
9948ff42aa | ||
|
|
8d6b8b3f03 | ||
|
|
f601630f05 | ||
|
|
4718fef2ab | ||
|
|
9e2d46d54e | ||
|
|
adef2bdc24 | ||
|
|
3761f2a5fb | ||
|
|
05a1e10bbe | ||
|
|
3e0793af21 | ||
|
|
5efb5ea335 | ||
|
|
a061c40fde | ||
|
|
b9e926588b | ||
|
|
aca5f3a8ba | ||
|
|
f8bcfcfec6 | ||
|
|
be57f82a07 | ||
|
|
6663af199e | ||
|
|
6086758f94 | ||
|
|
df3244d335 | ||
|
|
0681064767 | ||
|
|
a316768bfc | ||
|
|
36f61d68f1 | ||
|
|
d53a366718 | ||
|
|
713ff1d4a1 | ||
|
|
4566d3175b | ||
|
|
1264cb6556 | ||
|
|
1ce266f66c | ||
|
|
ac955934b5 | ||
|
|
466e260514 | ||
|
|
9cb9f8beeb | ||
|
|
1a04810baf | ||
|
|
c3b32fdab5 | ||
|
|
b8ed3a113a | ||
|
|
68cbad54a3 | ||
|
|
24a702181a | ||
|
|
f6ceb1da22 | ||
|
|
e6588a13f3 | ||
|
|
c8d5d13813 | ||
|
|
49fc287254 | ||
|
|
92d1338e24 | ||
|
|
43134d5ebf | ||
|
|
6b26f926df | ||
|
|
293c589e35 | ||
|
|
58af88f809 | ||
|
|
7e9bca982b | ||
|
|
b4cb428ca9 | ||
|
|
a6047e59c0 | ||
|
|
1b468100aa | ||
|
|
e0ecbf1db1 | ||
|
|
3137c7b977 | ||
|
|
7f631bb36f | ||
|
|
047bf45af1 | ||
|
|
35f78ae27c | ||
|
|
9739740379 | ||
|
|
eb466fe2d2 | ||
|
|
6d4aa954bb | ||
|
|
b1c365fca1 | ||
|
|
0892f944c0 | ||
|
|
41f82e8db0 | ||
|
|
1a067b3d9e | ||
|
|
304ea50a5f | ||
|
|
1573e4e553 | ||
|
|
337ee470b5 | ||
|
|
1286523047 | ||
|
|
b0abb62883 | ||
|
|
a2b62913ba | ||
|
|
8dc2a0e90a | ||
|
|
969ff7e4d9 | ||
|
|
7b8189d19c | ||
|
|
8c5d1ed86b | ||
|
|
90edb028bf | ||
|
|
0248df4d90 | ||
|
|
b3906d803d | ||
|
|
670e055f4a | ||
|
|
c6eb50f10b | ||
|
|
62b704ceaf | ||
|
|
59d0148057 | ||
|
|
64f480ce65 | ||
|
|
7c0d3ceea6 | ||
|
|
93bb113e0d | ||
|
|
58ad81674b | ||
|
|
720e91fb8c | ||
|
|
81c3e58a1c | ||
|
|
a3cf73d9be | ||
|
|
44e21e6601 | ||
|
|
ce6bc9c5c7 | ||
|
|
96268647d3 | ||
|
|
d4c16084d7 | ||
|
|
fd3d456de8 | ||
|
|
23ac335b53 | ||
|
|
204e4fdccb | ||
|
|
e00ce7c89e | ||
|
|
40d5b43e43 | ||
|
|
d5e7dc76e6 | ||
|
|
ed0febf163 | ||
|
|
ed3d1e1168 | ||
|
|
ca76075cc9 | ||
|
|
ef92004ede | ||
|
|
bea9d4b765 | ||
|
|
ce4d520f55 | ||
|
|
e162f9cdb7 | ||
|
|
64739d2b8b | ||
|
|
5e2935747b | ||
|
|
ffd0297dc4 | ||
|
|
e2473aafd8 | ||
|
|
72ff742273 | ||
|
|
fac4944cfa | ||
|
|
050b203846 | ||
|
|
b6ced87869 | ||
|
|
89465a8c6d | ||
|
|
fd8223061c | ||
|
|
0c161a0336 | ||
|
|
889fff562d | ||
|
|
fbb458252f | ||
|
|
b92cc7c9e8 | ||
|
|
61a127a2a2 | ||
|
|
adb19abd4c | ||
|
|
4abf648a85 | ||
|
|
5d17c56d77 | ||
|
|
9044c69429 | ||
|
|
68ec2236e2 | ||
|
|
5876014b57 | ||
|
|
1f4154c0df | ||
|
|
dc863a66a4 | ||
|
|
6ae018055d | ||
|
|
df0acb2810 | ||
|
|
a8bd497132 | ||
|
|
f2e87721f4 | ||
|
|
3ed8abd25e | ||
|
|
c0e1095ea9 | ||
|
|
9593d9e329 | ||
|
|
587695e108 | ||
|
|
b18261cc79 | ||
|
|
6953710c74 | ||
|
|
02cfbf6f28 | ||
|
|
eb1467f45f | ||
|
|
bab7cf92a7 | ||
|
|
cf67db1873 | ||
|
|
81b07d1dd9 | ||
|
|
1bfef88477 | ||
|
|
111b237b6d | ||
|
|
5530d682ab | ||
|
|
f88bfc66f8 | ||
|
|
fa188fca42 | ||
|
|
b5a2afb71e | ||
|
|
77cad63374 | ||
|
|
271a26515a | ||
|
|
7c2a31206c | ||
|
|
2bcb30b375 | ||
|
|
8cd97334bf | ||
|
|
fb15822b3b | ||
|
|
e626a727a1 | ||
|
|
6dde5ec2d0 | ||
|
|
282acfe086 | ||
|
|
0f569a4918 | ||
|
|
a6860bbec6 | ||
|
|
18234d3a01 | ||
|
|
7b6ef36d90 | ||
|
|
0c685d9db4 | ||
|
|
375a685b84 | ||
|
|
e64c80243d | ||
|
|
78f206bc22 | ||
|
|
4f3326e983 | ||
|
|
69925a57c5 | ||
|
|
cfa6033535 | ||
|
|
ff29d50f73 | ||
|
|
146c0fd3ce | ||
|
|
014b1f5267 | ||
|
|
0f61ed6b63 | ||
|
|
32695d4680 | ||
|
|
309dea385b | ||
|
|
49b6367d2e | ||
|
|
7d36d09edd | ||
|
|
2a91612928 | ||
|
|
bb1c292a40 | ||
|
|
c78f2d3e27 | ||
|
|
650ad39d85 | ||
|
|
a500e25705 | ||
|
|
e0ab3f8fcc | ||
|
|
c44d311d46 | ||
|
|
c8ce1e081d | ||
|
|
9b0d8fd7d7 | ||
|
|
639d315859 | ||
|
|
407e863d9f | ||
|
|
5071cd785a | ||
|
|
3df34df87b | ||
|
|
8965ca5e2b | ||
|
|
30144e478d | ||
|
|
1c3fd6e23c | ||
|
|
4901bdf231 | ||
|
|
087cbacdc4 | ||
|
|
416012ad4c | ||
|
|
5c2201a781 | ||
|
|
44a78fe65e | ||
|
|
213af61b92 | ||
|
|
12b5e39ac8 | ||
|
|
7a184a41f0 | ||
|
|
ef569d3201 | ||
|
|
20ecaf0254 | ||
|
|
503b91df33 | ||
|
|
75a30185a5 | ||
|
|
bd4101e9d1 | ||
|
|
a0f3315a79 | ||
|
|
cccb5bff96 | ||
|
|
6cdcf6c85a | ||
|
|
22e548471c | ||
|
|
25858f63ae | ||
|
|
50a1649dea | ||
|
|
ed8f3d70b8 | ||
|
|
4c7d77352e | ||
|
|
21411176a7 | ||
|
|
ede136bdd2 | ||
|
|
e6be303517 | ||
|
|
b691b5ebaa | ||
|
|
ff5df6aa5e | ||
|
|
7cfede86f4 | ||
|
|
bf74ac6f30 | ||
|
|
635622652e | ||
|
|
210a6f5ddd | ||
|
|
9927d06049 | ||
|
|
c9b3a06cf7 | ||
|
|
7a475a948c | ||
|
|
fe7df463fa | ||
|
|
9e6d248fbe | ||
|
|
872e77e19b | ||
|
|
a3913be641 | ||
|
|
241dee2649 | ||
|
|
341aa98a96 | ||
|
|
21a70c8e97 | ||
|
|
c7f30d2105 | ||
|
|
7784350bad | ||
|
|
5d29f31663 | ||
|
|
6ddabae4dd | ||
|
|
dbc85822c1 | ||
|
|
6da701a669 | ||
|
|
61b0aafbf8 | ||
|
|
5189a735c7 | ||
|
|
de464a8fc0 | ||
|
|
74c1a90980 | ||
|
|
60ddf3daf8 | ||
|
|
186cf8c320 | ||
|
|
56861be08c | ||
|
|
f12578238e | ||
|
|
b3755bd6b1 | ||
|
|
12c1af0261 | ||
|
|
d7b975185c | ||
|
|
a2b2b3f776 | ||
|
|
89b58991bb | ||
|
|
cc8744a06b | ||
|
|
0229615560 | ||
|
|
43138159b2 | ||
|
|
1295fccd17 | ||
|
|
8177726f70 | ||
|
|
dd5a361a58 | ||
|
|
cb28f691ff | ||
|
|
ded40d60c6 | ||
|
|
2746802152 | ||
|
|
4cac0a13cd | ||
|
|
5c076ca38f | ||
|
|
44144373ea | ||
|
|
da0c985d1d | ||
|
|
a794c05417 | ||
|
|
90bdf5bb1a | ||
|
|
34628c1471 | ||
|
|
8df59b96a9 | ||
|
|
de75330b21 | ||
|
|
2491d49bf5 | ||
|
|
142b8da33e | ||
|
|
954b67ffa2 | ||
|
|
f9fdcd2912 | ||
|
|
930f6fa19e | ||
|
|
90f84ac10d | ||
|
|
e96e1571e9 | ||
|
|
af0e0db4ec | ||
|
|
adbbff5904 | ||
|
|
1ac9e4860a | ||
|
|
51a939bd00 | ||
|
|
ccbf6f09c6 | ||
|
|
61cb0c92bb | ||
|
|
fa8056e172 | ||
|
|
a18410f92f | ||
|
|
48c85fa19f | ||
|
|
f02db7aede | ||
|
|
4d7ca0fc93 | ||
|
|
b980ae9150 | ||
|
|
b6f1ca63ba | ||
|
|
2ce2ddcd4d | ||
|
|
7a07251acf | ||
|
|
cc0d6736a1 | ||
|
|
86e0e122f4 | ||
|
|
4552494550 | ||
|
|
339bed0b7d | ||
|
|
440d9257ed | ||
|
|
8f3f72c7ef | ||
|
|
aec1519f91 | ||
|
|
d85aa27aa8 | ||
|
|
8b6a2861b5 | ||
|
|
a3fcacb212 | ||
|
|
aec497c94b | ||
|
|
e08687f362 | ||
|
|
576e88bd8c | ||
|
|
bf7b3d140f | ||
|
|
dacdf69293 | ||
|
|
830e8adbe5 | ||
|
|
2217ebcb33 | ||
|
|
02df01adbb | ||
|
|
fe1b08f83a | ||
|
|
b009c7652f | ||
|
|
0a0504dd63 | ||
|
|
792aa79b0a | ||
|
|
99514c4b79 | ||
|
|
8a95aa79a7 | ||
|
|
5763ec7e58 | ||
|
|
3e39dfae33 | ||
|
|
a248e9242a | ||
|
|
cd39680534 | ||
|
|
2094fb58ad | ||
|
|
275f60efec | ||
|
|
fcbbff26a7 | ||
|
|
c49a91a22c | ||
|
|
7df75b9d3b | ||
|
|
86149f7da6 | ||
|
|
7d26cb0a5b | ||
|
|
1a730659f0 | ||
|
|
4b4b36b716 | ||
|
|
a5bd5ebf7a | ||
|
|
c8dbb00031 | ||
|
|
288ea9dfa1 | ||
|
|
e17a40fc6b | ||
|
|
b91288a937 | ||
|
|
1643b78d7b | ||
|
|
d93b347b41 | ||
|
|
95db0fce9d | ||
|
|
b48539197a | ||
|
|
73a019967e | ||
|
|
3bf8387ca8 | ||
|
|
d6f729321b | ||
|
|
de5fb47174 | ||
|
|
3319e6e204 | ||
|
|
ee697cadc6 | ||
|
|
6ab958a423 | ||
|
|
087b2526c6 | ||
|
|
e8efd36e5b | ||
|
|
e57a43745a | ||
|
|
89eaaab2ee | ||
|
|
24d8c57932 | ||
|
|
f8341dc415 | ||
|
|
aa5305772d | ||
|
|
b37db61713 | ||
|
|
329334723d | ||
|
|
94aeab9a42 | ||
|
|
cab02f7e5e | ||
|
|
237e9b9ff3 | ||
|
|
c176b4f8bc | ||
|
|
7d1b2a5777 | ||
|
|
b857fc2b72 | ||
|
|
4704e98bda | ||
|
|
2f34bf7b46 | ||
|
|
42eda649a8 | ||
|
|
e7e35fa6e1 | ||
|
|
792a681497 | ||
|
|
f465e1b6ae | ||
|
|
07f1c34f96 | ||
|
|
37043b3dff | ||
|
|
228bdda201 | ||
|
|
1d93642094 | ||
|
|
645109d427 | ||
|
|
f442374e6d | ||
|
|
60d9bffc86 | ||
|
|
ce9c1d3a5c | ||
|
|
5797cf2702 | ||
|
|
43b45fabc7 | ||
|
|
21aab8b164 | ||
|
|
35120b0e91 | ||
|
|
294010913b | ||
|
|
ae460db2e8 | ||
|
|
af6bb6bc8c | ||
|
|
7566a4753c | ||
|
|
12e8d5b71e | ||
|
|
c166ea57d1 | ||
|
|
beac11eaa5 | ||
|
|
98ca844f07 | ||
|
|
e57c32efb8 | ||
|
|
76f3e7c2a8 | ||
|
|
fa61f262c3 | ||
|
|
6d759dc7bb | ||
|
|
9e016b6235 | ||
|
|
fb256809a7 | ||
|
|
cfad57d0b1 | ||
|
|
e994c8ab95 | ||
|
|
7b82ee8c16 | ||
|
|
7adfe44f6c | ||
|
|
f6191fe8ee | ||
|
|
8aacb48798 | ||
|
|
989f843b1c | ||
|
|
874d111d60 | ||
|
|
03309dee95 | ||
|
|
7338f55b9a | ||
|
|
4559a92942 | ||
|
|
e9da898f97 | ||
|
|
6c4ad9d825 | ||
|
|
70fd5f5c77 | ||
|
|
88b9a82688 | ||
|
|
9ae39fac28 | ||
|
|
f04d47e14d | ||
|
|
af8700bd20 | ||
|
|
d456dadafa | ||
|
|
d299d1f07a | ||
|
|
7156602a7f | ||
|
|
1f8a2b28d6 | ||
|
|
8caee51897 | ||
|
|
d69a380851 | ||
|
|
2fbbe1c5e2 | ||
|
|
79ab688bda | ||
|
|
322a87cf93 | ||
|
|
517e275c79 | ||
|
|
cb3b5abf6b | ||
|
|
1df16591b4 | ||
|
|
56aae0e902 | ||
|
|
e15c879fe7 | ||
|
|
11b7038b39 | ||
|
|
20988e810e | ||
|
|
9f07f64bff | ||
|
|
d25d6184c2 | ||
|
|
ba3f4003cd | ||
|
|
0ed50ae6ae | ||
|
|
1b1e129112 | ||
|
|
72074eb080 | ||
|
|
027ac3cdf1 | ||
|
|
190f89b214 | ||
|
|
1596210bba | ||
|
|
aab88753b6 | ||
|
|
8987369b07 | ||
|
|
d4e0dc4917 | ||
|
|
6bed9b92ca | ||
|
|
324eb2836c | ||
|
|
82bee83dc5 | ||
|
|
f38e8cc393 | ||
|
|
0acd686720 | ||
|
|
6957d74742 | ||
|
|
ac97e9c0b9 | ||
|
|
89b2ca64a9 | ||
|
|
c8629f3378 | ||
|
|
c6634830d3 | ||
|
|
f83bc0ab70 | ||
|
|
eebf5621bf | ||
|
|
d1517fa79b | ||
|
|
b7f14763a7 | ||
|
|
663f61c8c3 | ||
|
|
365d35f53a | ||
|
|
a08577e301 | ||
|
|
c4b0e8efe0 | ||
|
|
b7fc86279c | ||
|
|
40fce014f1 | ||
|
|
ccdc4fe682 | ||
|
|
b61455e562 | ||
|
|
d8ad1dad7a | ||
|
|
3dcbd2228b | ||
|
|
1be820b091 | ||
|
|
97a65cd5aa | ||
|
|
61e5b70fde | ||
|
|
4ac53c0867 | ||
|
|
afce3f7aa1 | ||
|
|
79e04e22d6 | ||
|
|
db1a895c05 | ||
|
|
b5690ad295 | ||
|
|
32bb3612fb | ||
|
|
e8ca05af19 | ||
|
|
ce85fd98ed | ||
|
|
2470ba9b83 | ||
|
|
318988d767 | ||
|
|
25d1d3cc07 | ||
|
|
840e8ad4a8 | ||
|
|
ea93297579 | ||
|
|
51dc44f64f | ||
|
|
90115e5574 | ||
|
|
5cf5b6bfd2 | ||
|
|
02104eab29 | ||
|
|
28b01e9eca | ||
|
|
b6df5a91a9 | ||
|
|
2edc69ef99 | ||
|
|
0dd8ed546a | ||
|
|
52975ad988 | ||
|
|
79ea17ffc5 | ||
|
|
b58e96436a | ||
|
|
cbf4b53b0c | ||
|
|
e145a32322 | ||
|
|
a28667923a | ||
|
|
f1e0b093a5 | ||
|
|
e70b5e2882 | ||
|
|
d1d727f838 | ||
|
|
98c71567e3 | ||
|
|
e9e4ef96a7 | ||
|
|
842d11efc4 | ||
|
|
425021dda5 | ||
|
|
b7330ac0cc | ||
|
|
e905c10400 | ||
|
|
8355a6ff14 | ||
|
|
74821bf188 | ||
|
|
0818190ee4 | ||
|
|
767ed4cb47 | ||
|
|
1f06156649 | ||
|
|
9f0df29d6c | ||
|
|
b2462d54c4 | ||
|
|
eccb222e9f | ||
|
|
6915757dd4 | ||
|
|
1c957610bc | ||
|
|
3f61995c8b | ||
|
|
a3d3297840 | ||
|
|
7ace13cc82 | ||
|
|
80645bf0de | ||
|
|
45663c9e39 | ||
|
|
f22e18114d | ||
|
|
29fbebf3d7 | ||
|
|
2cfeed118b | ||
|
|
452e2b7a9f | ||
|
|
66c6bd6b13 | ||
|
|
6c210fe9fd | ||
|
|
39e8bbb2f4 | ||
|
|
2b9674af8f | ||
|
|
9bc9a1e159 | ||
|
|
e828354040 | ||
|
|
744b61efc3 | ||
|
|
1569572ba0 | ||
|
|
832a8e3f46 | ||
|
|
ead31a40f9 | ||
|
|
ac96218bfe | ||
|
|
dc74a3fe71 | ||
|
|
51601ba2a3 | ||
|
|
b746580c50 | ||
|
|
70583cbf2a | ||
|
|
cadc4698c1 | ||
|
|
158e0c6346 | ||
|
|
63bb9bc37c | ||
|
|
a995fd3b46 | ||
|
|
9fa3a684c6 | ||
|
|
efa2f0e3be | ||
|
|
383fc116c9 | ||
|
|
27fc327195 | ||
|
|
ada71df410 | ||
|
|
2236c9607f | ||
|
|
70fad91ee5 | ||
|
|
f3dda9c412 | ||
|
|
f881917d91 | ||
|
|
d9461c6d2d | ||
|
|
6ce83021b5 | ||
|
|
6f2823b652 | ||
|
|
4e36b2a217 | ||
|
|
4ce9a5642d | ||
|
|
2b5313b943 | ||
|
|
6ca6d0b919 | ||
|
|
a56d63d03f | ||
|
|
8dd63c69ef | ||
|
|
0b362e270f | ||
|
|
0d0378916b | ||
|
|
d75ad778e3 | ||
|
|
84adffc3f2 | ||
|
|
162e3d36f7 | ||
|
|
9bbb674184 | ||
|
|
f1cc118df0 | ||
|
|
66ce394d2d | ||
|
|
b4c63fa207 | ||
|
|
97d3282161 | ||
|
|
115b0b856b | ||
|
|
0d3628a035 | ||
|
|
12eca8a5c1 | ||
|
|
8c0586db4b | ||
|
|
b723ef9828 | ||
|
|
726f88fcfc | ||
|
|
778890207a | ||
|
|
5903b5981d | ||
|
|
6c1000a374 | ||
|
|
71cee5279c | ||
|
|
d1cf9b4930 | ||
|
|
2b7c72255c | ||
|
|
e5c061dba6 | ||
|
|
3879ca8ec7 | ||
|
|
069acd9aba | ||
|
|
1499c2a888 | ||
|
|
2c57fcc09f | ||
|
|
317da28481 | ||
|
|
147ea6661f | ||
|
|
19ec3213fd | ||
|
|
4c4e579d3f | ||
|
|
2c8de2b015 | ||
|
|
897ca2aa5e | ||
|
|
8ad1e52067 | ||
|
|
373fa31a35 | ||
|
|
0944280fb3 | ||
|
|
a72dd98e2a | ||
|
|
7b21d4bc6c | ||
|
|
c810a3213c | ||
|
|
193c08e90f | ||
|
|
393d113dfb | ||
|
|
92209ddeee | ||
|
|
5cb14ee496 | ||
|
|
5a5e8ffa71 | ||
|
|
d17e5da848 | ||
|
|
7fab5ec1ff | ||
|
|
43cff3b44e | ||
|
|
2e7e249096 | ||
|
|
59ce240633 | ||
|
|
502c8b9311 | ||
|
|
2ee7d7af9f | ||
|
|
a3dd34a8f3 | ||
|
|
299280baa2 | ||
|
|
60fc617402 | ||
|
|
cac89a9995 | ||
|
|
5ea60fbd8d | ||
|
|
c0f468a871 | ||
|
|
6716b0d953 | ||
|
|
601fbb533a | ||
|
|
772e0a7070 | ||
|
|
24a70e84a8 | ||
|
|
7bb3347edd | ||
|
|
2df60cd388 | ||
|
|
e67ccd5589 | ||
|
|
97245773d1 | ||
|
|
1404dd5a0a | ||
|
|
870f2b45f7 | ||
|
|
8fa3eef809 | ||
|
|
22756c28d8 | ||
|
|
f0be91777f | ||
|
|
b797839f77 | ||
|
|
3a522af067 | ||
|
|
c989692bd9 | ||
|
|
a96580aa2e | ||
|
|
cfdcd7f7eb | ||
|
|
7f7cdf01c2 | ||
|
|
6cd64ddcae | ||
|
|
f4719d974d | ||
|
|
4ecb4c85e7 | ||
|
|
189119a861 | ||
|
|
3738c876bc | ||
|
|
1c0a47e012 | ||
|
|
2518075339 | ||
|
|
6d7f11feb2 | ||
|
|
17f0029d08 | ||
|
|
f53f198f03 | ||
|
|
383afd81eb | ||
|
|
3f3f294c52 | ||
|
|
b6ee20d0e8 | ||
|
|
85851909d6 | ||
|
|
929fa4ec5a | ||
|
|
9006566bbd | ||
|
|
73ca04b10d | ||
|
|
9dc0803a24 | ||
|
|
87733155b3 | ||
|
|
7f1e197428 | ||
|
|
9bc1425177 | ||
|
|
ad9cd6c67e | ||
|
|
ef4fc67f1b | ||
|
|
e0283c70db | ||
|
|
c2c7614a2f | ||
|
|
ad701d73d9 | ||
|
|
1a9452d408 | ||
|
|
45bd1a7be8 | ||
|
|
d9351ec054 | ||
|
|
eb18bb42af | ||
|
|
c982fadaf5 | ||
|
|
1a10d4efe8 | ||
|
|
59fd98dea4 |
No files matched your search
@@ -0,0 +1,8 @@
|
||||
# Docker builds (the relay) need the Rust sources only.
|
||||
target/
|
||||
.local/
|
||||
**/node_modules/
|
||||
website/
|
||||
benchmarks/
|
||||
packs/
|
||||
mods/
|
||||
@@ -0,0 +1,3 @@
|
||||
self-hosted-runner:
|
||||
# The Mac mini's runner (ludovics-mac-mini), which holds the maintainer's own Minecraft files.
|
||||
labels: [horizon-game, ludovics-mac-mini]
|
||||
@@ -0,0 +1,19 @@
|
||||
## What and why
|
||||
|
||||
<!-- What this changes, and why. Link the issue it fixes ("Fixes #123"). -->
|
||||
|
||||
## Vanilla reference
|
||||
|
||||
<!-- The vanilla classes and methods this follows (tools/rea_vanilla.py), and the harness that checks it.
|
||||
"Horizon-only" for a feature vanilla doesn't have; "none" for tooling or docs. -->
|
||||
|
||||
## Self-check
|
||||
|
||||
<!-- Paste the block `python3 tools/pr_ready.py` prints (`--base upstream/main` from a fork). -->
|
||||
|
||||
## Screenshots
|
||||
|
||||
<!-- For anything players see: before and after. Delete this section otherwise. -->
|
||||
|
||||
<!-- Please tick "Allow edits by maintainers": we push small fixups ourselves instead of a round trip.
|
||||
See CONTRIBUTING.md. -->
|
||||
@@ -1,14 +1,25 @@
|
||||
name: checks
|
||||
# verify: every pull request and every push to main, on GitHub's runners (the repository is public).
|
||||
# parity: the vanilla harnesses a pull request's changes can affect, on the Mac mini's runner, which holds the
|
||||
# maintainer's own Minecraft files (Horizon never downloads the game). A fork's pull request runs it only when
|
||||
# a maintainer adds the run-parity label, once per label. See docs/release-flow.md.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, labeled]
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
# A new push to a pull request replaces its running checks; main's runs all finish (releases follow them).
|
||||
group: checks-${{ github.event.pull_request.number || github.sha }}-${{ github.event.action == 'labeled' && 'label' || 'run' }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
verify:
|
||||
if: github.event.action != 'labeled'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -16,22 +27,71 @@ jobs:
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Does this pull request need Rust checks?
|
||||
if: github.event_name == 'pull_request'
|
||||
id: rust
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: python3 tools/ci/rust_changes.py "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
|
||||
- name: Keep game artifacts out of Git
|
||||
run: |
|
||||
python tools/check_public_tree.py
|
||||
python tools/check_public_tree.py --history
|
||||
- name: Test offline research tooling
|
||||
run: python -m unittest discover -s tests -v
|
||||
- name: Install Rust toolchain and the ALSA headers the client's audio links against
|
||||
- name: Install Rust toolchain and the ALSA and udev headers the client links against
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev
|
||||
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
rustup toolchain install stable --profile minimal --component rustfmt --component clippy --target wasm32-unknown-unknown
|
||||
# Dependencies and the workspace's own build, saved from main's runs (pull requests read main's cache).
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
with:
|
||||
shared-key: verify
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Check formatting and lint
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: |
|
||||
cargo +stable fmt --all -- --check
|
||||
cargo +stable clippy --workspace --all-targets -- -D warnings
|
||||
- name: Test Rust core and voxel kernel correctness
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: cargo +stable test --workspace
|
||||
parity:
|
||||
if: >-
|
||||
github.event_name == 'pull_request' && (
|
||||
(github.event.pull_request.head.repo.full_name == github.repository && github.event.action != 'labeled') ||
|
||||
(github.event.action == 'labeled' && github.event.label.name == 'run-parity'))
|
||||
runs-on: [self-hosted, horizon-game]
|
||||
timeout-minutes: 120 # a change to core crates reruns ~300 harnesses: ~60-90 min on the Mac mini
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# The commit the event names: a fork's later pushes need another run-parity label.
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
# Keeps .local/ (parity state, probe builds, oracle results) and target/ between runs.
|
||||
clean: false
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Tools from the runner
|
||||
run: bash tools/ci/runner-env.sh
|
||||
- name: Vanilla data, and resources prepared by this preparer
|
||||
run: bash tools/ci/vanilla-data.sh
|
||||
- name: The harnesses this change can affect
|
||||
env:
|
||||
PARITY_HOSTS: ''
|
||||
run: python3 tools/parity.py --changed ${{ github.event.pull_request.base.sha }} --timeout 1500
|
||||
- name: Logs of the failed harnesses
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: parity-logs
|
||||
path: .local/parity-runner/logs/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 3
|
||||
# Horizon never downloads the game. These jobs need a self-hosted Linux runner
|
||||
# (label horizon-game) holding the maintainer's own copy: the official launcher's
|
||||
# game directory in HORIZON_MINECRAFT_DIR and the pinned server jar in
|
||||
@@ -493,3 +553,4 @@ jobs:
|
||||
dist/client-graphics-smoke.json
|
||||
dist/client-graphics-macos-title.json
|
||||
dist/client-graphics-macos-world.json
|
||||
retention-days: 3
|
||||
+202
-33
@@ -1,86 +1,255 @@
|
||||
name: launcher
|
||||
# Builds the launcher on Linux, macOS and Windows. A pushed `launcher-vX.Y.Z` tag also puts the
|
||||
# three archives on a draft GitHub release of that tag, to publish by hand. Horizon's own
|
||||
# releases are tagged `vYYYY.MM.DD`; the launcher reads only `launcher-v*` releases for itself.
|
||||
# Builds the launcher on Linux, macOS and Windows, and releases it by itself (docs/release-flow.md): after a
|
||||
# push to main that changes the launcher or what it builds in (crates/horizon-prepare, crates/horizon-theme,
|
||||
# crates/horizon-telemetry),
|
||||
# every 10 minutes (a release waiting for merges to settle goes out) and by hand, tools/release_train.py decides as
|
||||
# for the game.
|
||||
# A release is launcher-vX.Y.Z: the patch after the last launcher tag, or the next minor when a merged pull
|
||||
# request is labelled feature. The version is given to the build (HORIZON_LAUNCHER_VERSION): no bump commits.
|
||||
# Never marked latest. Pushes to launcher branches only build. RELEASES_PAUSED=1 stops releases.
|
||||
# gh workflow run launcher --ref <branch> # dry run: builds everything, publishes nothing
|
||||
# gh workflow run launcher --ref main -f dry_run=false -f force=true
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [launcher]
|
||||
tags: ['launcher-v*']
|
||||
branches: [main, launcher, 'launcher-*']
|
||||
paths:
|
||||
- 'launcher/**'
|
||||
- 'crates/horizon-prepare/**'
|
||||
- 'crates/horizon-theme/**'
|
||||
- 'crates/horizon-telemetry/**'
|
||||
- '.github/workflows/launcher.yml'
|
||||
schedule:
|
||||
- cron: '7-59/10 * * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Build everything and write the notes, but publish nothing
|
||||
type: boolean
|
||||
default: true
|
||||
force:
|
||||
description: Release even without launcher changes, before merges settle or while paused
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: launcher-${{ github.ref == 'refs/heads/main' && 'release' || github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
decide:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
release: ${{ steps.decide.outputs.release }}
|
||||
tag: ${{ steps.decide.outputs.tag }}
|
||||
head: ${{ steps.decide.outputs.head }}
|
||||
version: ${{ steps.decide.outputs.version }}
|
||||
dry_run: ${{ steps.mode.outputs.dry_run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: mode
|
||||
# Off main every run is a dry run (a launcher branch's push only builds).
|
||||
run: echo "dry_run=${{ github.ref != 'refs/heads/main' || (github.event_name == 'workflow_dispatch' && inputs.dry_run) }}" >> "$GITHUB_OUTPUT"
|
||||
- id: decide
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASES_PAUSED: ${{ vars.RELEASES_PAUSED }}
|
||||
# A push or a run off main builds its own commit; otherwise main's newest commit with green checks.
|
||||
HEAD_SHA: ${{ (github.event_name == 'push' || steps.mode.outputs.dry_run == 'true') && github.sha || '' }}
|
||||
FORCE: ${{ inputs.force && 'yes' || '' }}
|
||||
run: |
|
||||
python3 tools/release_train.py decide --kind launcher ${HEAD_SHA:+--head "$HEAD_SHA"} ${FORCE:+--force} | tee decision.json
|
||||
echo "### $(jq -r '.reason' decision.json)" >> "$GITHUB_STEP_SUMMARY"
|
||||
tag=$(jq -r '.tag' decision.json)
|
||||
echo "version=${tag#launcher-v}" >> "$GITHUB_OUTPUT"
|
||||
# A dry run (and a launcher branch's push) builds whatever the decision.
|
||||
if [[ "${{ steps.mode.outputs.dry_run }}" == true ]]; then
|
||||
echo "release=true" >> "$GITHUB_OUTPUT"
|
||||
echo "head=$GITHUB_SHA" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
build:
|
||||
needs: decide
|
||||
if: needs.decide.outputs.release == 'true'
|
||||
name: build ${{ matrix.name }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
- name: linux
|
||||
os: ubuntu-22.04
|
||||
asset: horizon-launcher-linux-x86_64.tar.gz
|
||||
binary: horizon-launcher
|
||||
- os: macos-latest
|
||||
- name: macos
|
||||
mac: true
|
||||
asset: horizon-launcher-macos-arm64.zip
|
||||
binary: horizon-launcher
|
||||
- os: windows-latest
|
||||
- name: windows
|
||||
os: windows-latest
|
||||
asset: horizon-launcher-windows-x64.zip
|
||||
binary: horizon-launcher.exe
|
||||
runs-on: ${{ matrix.os }}
|
||||
# The C runtime linked in: without it the launcher asks for VCRUNTIME140.dll,
|
||||
# which only the Visual C++ redistributable installs.
|
||||
rustflags: -C target-feature=+crt-static
|
||||
runs-on: ${{ matrix.mac && (vars.MAC_RUNNER || 'ludovics-mac-mini') || matrix.os }}
|
||||
timeout-minutes: 40
|
||||
env:
|
||||
RUSTFLAGS: ${{ matrix.rustflags }}
|
||||
# Horizon's Azure app for Microsoft sign-in (docs/microsoft-account.md): a repository
|
||||
# variable, not a secret. Unset, the launcher says sign-in is not configured.
|
||||
HORIZON_MSA_CLIENT_ID: ${{ vars.HORIZON_MSA_CLIENT_ID }}
|
||||
# The release's version, built in (launcher/src/backend.rs VERSION); a launcher branch's build keeps
|
||||
# Cargo.toml's.
|
||||
HORIZON_LAUNCHER_VERSION: ${{ (github.event_name != 'push' || github.ref == 'refs/heads/main') && needs.decide.outputs.version || '' }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: launcher
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.decide.outputs.head }}
|
||||
persist-credentials: false
|
||||
# The Mac mini's runner keeps its target/ between runs.
|
||||
clean: ${{ runner.environment == 'github-hosted' }}
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: runner.environment == 'github-hosted'
|
||||
with:
|
||||
workspaces: launcher
|
||||
shared-key: launcher-${{ matrix.name }}
|
||||
- name: Linux libraries GPUI links to
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libxkbcommon-x11-dev libwayland-dev libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev
|
||||
- name: The tag matches Cargo.toml
|
||||
if: startsWith(github.ref, 'refs/tags/launcher-v')
|
||||
run: grep -qx "version = \"${GITHUB_REF_NAME#launcher-v}\"" Cargo.toml
|
||||
- run: cargo test --release --locked --lib
|
||||
- run: cargo build --release --locked
|
||||
- name: Metal shaders compiled now, or at run time without the Metal toolchain
|
||||
# GPUI compiles its Metal shaders when it builds; the Mac mini has Xcode without the Metal toolchain
|
||||
# (xcodebuild -downloadComponent MetalToolchain), so there they are compiled when the launcher starts.
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
if ! xcrun metal --version > /dev/null 2>&1; then
|
||||
echo "::notice::No Metal toolchain on this runner: GPUI compiles its shaders at run time"
|
||||
echo "LAUNCHER_FEATURES=gpui/runtime_shaders" >> "$GITHUB_ENV"
|
||||
fi
|
||||
- run: cargo test --release --locked --lib ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
|
||||
- run: cargo build --release --locked ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
|
||||
- name: The version is the release's
|
||||
run: |
|
||||
out=$(target/release/${{ matrix.binary }} --version)
|
||||
echo "$out"
|
||||
expected="${HORIZON_LAUNCHER_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)}"
|
||||
[[ "$out" == "horizon-launcher $expected" ]] || { echo "::error::expected horizon-launcher $expected"; exit 1; }
|
||||
- name: Only system libraries
|
||||
run: |
|
||||
file=target/release/${{ matrix.binary }}
|
||||
case "$RUNNER_OS" in
|
||||
Windows) python ../tools/check_imports.py "$file" ;;
|
||||
macOS) others=$(otool -L "$file" | tail -n +2 | awk '{print $1}' | grep -v -E '^(/usr/lib/|/System/Library/)' || true)
|
||||
[[ -z "$others" ]] || { echo "::error::links $others"; exit 1; } ;;
|
||||
esac
|
||||
- name: Smoke test
|
||||
env:
|
||||
HORIZON_LAUNCHER_NO_SELF_UPDATE: '1'
|
||||
HORIZON_GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export HORIZON_LAUNCHER_HOME="${RUNNER_TEMP//\\//}/horizon-launcher-smoke"
|
||||
export HORIZON_HOME="$HORIZON_LAUNCHER_HOME/game"
|
||||
rm -rf "$HORIZON_LAUNCHER_HOME"
|
||||
launcher="$PWD/target/release/${{ matrix.binary }}"
|
||||
# GitHub's API rate-limits shared runner addresses now and then: try a few times.
|
||||
retry() { for i in 1 2 3 4; do "$@" && return 0; sleep $((i * 15)); done; return 1; }
|
||||
retry "$launcher" versions
|
||||
retry "$launcher" install latest
|
||||
"$launcher" versions --installed
|
||||
"$launcher" links --json
|
||||
"$launcher" menu-style classic
|
||||
grep -qx 'menuStyle:classic' "$HORIZON_HOME/horizon.txt"
|
||||
"$launcher" menu-style horizon
|
||||
clients=("$HORIZON_LAUNCHER_HOME"/versions/v*/horizon-client*)
|
||||
client="${clients[0]}"
|
||||
echo "installed client: $client"
|
||||
# The game's Linux build is the game's to keep portable (release.yml checks it): a
|
||||
# release linked to a Nix store is a warning here, not a broken launcher.
|
||||
soft() { if [[ "$RUNNER_OS" == "Linux" ]]; then "$@" || echo "::warning::$* failed"; else "$@"; fi; }
|
||||
if [[ "$RUNNER_OS" == "Windows" ]]; then
|
||||
python ../tools/check_imports.py "$client"
|
||||
elif [[ "$RUNNER_OS" == "Linux" ]]; then
|
||||
soft python3 ../tools/check_imports.py "$client"
|
||||
fi
|
||||
# The client starts: --help prints before any window or GPU is needed.
|
||||
help() { "$client" --help > "$RUNNER_TEMP/help.txt" 2>&1 && head -3 "$RUNNER_TEMP/help.txt" && grep -q -- '--resources' "$RUNNER_TEMP/help.txt"; }
|
||||
soft help
|
||||
rm -rf "$HORIZON_LAUNCHER_HOME"
|
||||
- name: Package
|
||||
run: |
|
||||
mkdir -p dist
|
||||
cp target/release/${{ matrix.binary }} dist/
|
||||
cd dist
|
||||
if [[ "${{ matrix.asset }}" == *.tar.gz ]]; then
|
||||
tar czf ../${{ matrix.asset }} ${{ matrix.binary }}
|
||||
elif [[ "$RUNNER_OS" == "Windows" ]]; then
|
||||
7z a ../${{ matrix.asset }} ${{ matrix.binary }}
|
||||
rm -rf dist out && mkdir -p out
|
||||
if [[ "$RUNNER_OS" == "macOS" ]]; then
|
||||
# Horizon.app, zipped (self-update) and in a .dmg (installer): packaging/macos.
|
||||
python3 -m venv "$RUNNER_TEMP/dmg" && "$RUNNER_TEMP/dmg/bin/pip" install --quiet dmgbuild==1.6.5
|
||||
version=${HORIZON_LAUNCHER_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)}
|
||||
DMGBUILD="$RUNNER_TEMP/dmg/bin/dmgbuild" ../packaging/macos/package.sh target/release/${{ matrix.binary }} "$version" out
|
||||
rm -rf out/Horizon.app
|
||||
else
|
||||
zip ../${{ matrix.asset }} ${{ matrix.binary }}
|
||||
mkdir -p dist
|
||||
cp target/release/${{ matrix.binary }} dist/
|
||||
if [[ "${{ matrix.asset }}" == *.tar.gz ]]; then
|
||||
(cd dist && tar czf ../out/${{ matrix.asset }} ${{ matrix.binary }})
|
||||
else
|
||||
(cd dist && 7z a ../out/${{ matrix.asset }} ${{ matrix.binary }})
|
||||
fi
|
||||
fi
|
||||
ls -l out
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.asset }}
|
||||
path: launcher/${{ matrix.asset }}
|
||||
path: launcher/out/*
|
||||
retention-days: 3
|
||||
|
||||
release:
|
||||
if: startsWith(github.ref, 'refs/tags/launcher-v')
|
||||
needs: build
|
||||
needs: [decide, build]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Notes
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
python3 tools/release_train.py notes --kind launcher --tag "${{ needs.decide.outputs.tag }}" \
|
||||
--head "${{ needs.decide.outputs.head }}" --out notes > /dev/null
|
||||
{ echo "## $(cat notes/title.txt)"; echo; cat notes/body.md; } >> "$GITHUB_STEP_SUMMARY"
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
# A draft, and never marked latest: Horizon's own releases stay the repository's latest.
|
||||
- uses: softprops/action-gh-release@v2
|
||||
# Never marked latest: Horizon's own releases stay the repository's latest.
|
||||
- name: Publish ${{ needs.decide.outputs.tag }}
|
||||
if: needs.decide.outputs.dry_run != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.decide.outputs.tag }}
|
||||
run: |
|
||||
gh release create "$TAG" --target "${{ needs.decide.outputs.head }}" --title "$(cat notes/title.txt)" \
|
||||
--notes-file notes/body.md --latest=false dist/horizon-launcher-*
|
||||
- name: Dry run, the release that would be published
|
||||
if: needs.decide.outputs.dry_run == 'true'
|
||||
run: |
|
||||
echo "Would publish ${{ needs.decide.outputs.tag }} at ${{ needs.decide.outputs.head }}: $(cat notes/title.txt)"
|
||||
ls -l dist
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
name: Horizon launcher ${{ github.ref_name }}
|
||||
draft: true
|
||||
make_latest: false
|
||||
files: dist/*
|
||||
name: launcher-release-notes
|
||||
path: notes/
|
||||
retention-days: 3
|
||||
@@ -0,0 +1,79 @@
|
||||
name: nightly audit
|
||||
# Every vanilla harness, from scratch, on main, at 03:00 Paris time on the Mac mini's runner (it holds the
|
||||
# maintainer's own Minecraft files). The resources are prepared again first when the preparer or its exporters
|
||||
# changed (tools/ci/vanilla-data.sh). A harness that passed in the previous audit and fails now gets an issue
|
||||
# labelled parity-regression (one per harness); its issue closes when it passes again
|
||||
# (tools/ci/parity_issues.py). The bugs session picks those up. See docs/release-flow.md.
|
||||
# gh workflow run "nightly audit" # now
|
||||
# gh workflow run "nightly audit" -f dry_run=true # the audit, without touching issues
|
||||
on:
|
||||
schedule:
|
||||
# GitHub's schedules are in UTC: both, and the first step keeps the one that is 03:00 in Paris.
|
||||
- cron: '7 1 * * *'
|
||||
- cron: '7 2 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Run the audit, but only print what it would do with issues
|
||||
type: boolean
|
||||
default: false
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
concurrency:
|
||||
group: nightly-audit
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
audit:
|
||||
runs-on: [self-hosted, horizon-game]
|
||||
timeout-minutes: 150
|
||||
steps:
|
||||
- name: 03:00 in Paris
|
||||
id: when
|
||||
run: |
|
||||
hour=$(TZ=Europe/Paris date +%H)
|
||||
if [[ "${{ github.event_name }}" == schedule && "$hour" != 03 ]]; then
|
||||
echo "It is ${hour}:00 in Paris: the other schedule runs the audit."
|
||||
echo "run=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- uses: actions/checkout@v4
|
||||
if: steps.when.outputs.run == 'true'
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
# Keeps .local/ (parity state, probe builds, the previous audit) and target/.
|
||||
clean: false
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
if: steps.when.outputs.run == 'true'
|
||||
- name: Tools from the runner
|
||||
if: steps.when.outputs.run == 'true'
|
||||
run: bash tools/ci/runner-env.sh
|
||||
- name: Vanilla data, prepared again if the preparer changed
|
||||
if: steps.when.outputs.run == 'true'
|
||||
run: bash tools/ci/vanilla-data.sh
|
||||
- name: Every harness
|
||||
id: audit
|
||||
if: steps.when.outputs.run == 'true'
|
||||
env:
|
||||
PARITY_HOSTS: ''
|
||||
run: |
|
||||
echo "since=$(date +%Y-%m-%dT%H:%M:%S)" >> "$GITHUB_OUTPUT"
|
||||
set +e
|
||||
python3 tools/parity.py --all --timeout 1800 2>&1 | tee "$RUNNER_TEMP/audit.log"
|
||||
echo "exit=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT"
|
||||
{ echo '```'; tail -40 "$RUNNER_TEMP/audit.log"; echo '```'; } >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Issues for the harnesses that regressed
|
||||
if: steps.when.outputs.run == 'true' && always() && steps.audit.outputs.since
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: python3 tools/ci/parity_issues.py --since "${{ steps.audit.outputs.since }}" ${{ inputs.dry_run && '--dry-run' || '' }} | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
- name: Logs of the failed harnesses
|
||||
if: steps.when.outputs.run == 'true' && steps.audit.outputs.exit != '0'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: nightly-parity-logs
|
||||
path: .local/parity-runner/logs/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
+215
-16
@@ -1,27 +1,226 @@
|
||||
name: release builds
|
||||
# Started by hand: gh workflow run release.yml --ref <branch>
|
||||
# Builds the Windows x64 client and uploads the playable folder as an artifact.
|
||||
# Linux and macOS builds are made on the development machines.
|
||||
name: release
|
||||
# Automatic game releases (docs/release-flow.md). After every green `checks` run on main, every 10 minutes (so a
|
||||
# release waiting for merges to settle goes out without another merge) and by hand, tools/release_train.py decides:
|
||||
# a release when pull requests merged since the last one changed what players run and the newest merged 15 minutes
|
||||
# ago (the oldest an hour ago, if merges never pause), or at once for a pull request labelled release-now.
|
||||
# RELEASES_PAUSED=1 (repository variable) stops them.
|
||||
# Builds Linux (an older Ubuntu, so the binary runs on any recent distribution and on NixOS through nix-ld) and
|
||||
# Windows on GitHub, macOS on the Mac mini's runner (MAC_RUNNER=macos-latest sends it to GitHub), then
|
||||
# publishes vYYYY.MM.DD[.N] with notes from the merged pull requests.
|
||||
# gh workflow run release --ref <branch> # dry run: builds and notes, publishes nothing
|
||||
# gh workflow run release -f dry_run=false -f force=true # on main: release now, whatever the rules say
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [checks]
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '2-59/10 * * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Build everything and write the notes, but publish nothing
|
||||
type: boolean
|
||||
default: true
|
||||
force:
|
||||
description: Release even without player-facing changes, before merges settle or while paused
|
||||
type: boolean
|
||||
default: false
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: game-release
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
windows:
|
||||
runs-on: windows-latest
|
||||
decide:
|
||||
if: >-
|
||||
github.event_name != 'workflow_run' ||
|
||||
(github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
release: ${{ steps.decide.outputs.release }}
|
||||
tag: ${{ steps.decide.outputs.tag }}
|
||||
head: ${{ steps.decide.outputs.head }}
|
||||
dry_run: ${{ steps.mode.outputs.dry_run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Rust toolchain
|
||||
run: rustup toolchain install stable --profile minimal
|
||||
- name: Build the client
|
||||
# The C runtime linked in, so the game runs without the Visual C++ redistributable.
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: mode
|
||||
# A dry run never publishes; a hand-started run off main is always one.
|
||||
run: echo "dry_run=${{ github.event_name == 'workflow_dispatch' && (inputs.dry_run || github.ref != 'refs/heads/main') }}" >> "$GITHUB_OUTPUT"
|
||||
- id: decide
|
||||
env:
|
||||
RUSTFLAGS: -C target-feature=+crt-static
|
||||
run: cargo +stable build --release --locked -p horizon-client --bin horizon-client
|
||||
- name: Package
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASES_PAUSED: ${{ vars.RELEASES_PAUSED }}
|
||||
# The commit whose checks just passed; a dry run builds the branch it was started on; otherwise
|
||||
# main's newest commit with green checks.
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha || (steps.mode.outputs.dry_run == 'true' && github.sha) || '' }}
|
||||
FORCE: ${{ inputs.force && 'yes' || '' }}
|
||||
run: |
|
||||
python3 tools/release_train.py decide --kind game ${HEAD_SHA:+--head "$HEAD_SHA"} ${FORCE:+--force} | tee decision.json
|
||||
echo "### $(jq -r '.reason' decision.json)" >> "$GITHUB_STEP_SUMMARY"
|
||||
# A dry run builds whatever the decision, to test the builds.
|
||||
if [[ "${{ steps.mode.outputs.dry_run }}" == true ]]; then echo "release=true" >> "$GITHUB_OUTPUT"; fi
|
||||
|
||||
build:
|
||||
needs: decide
|
||||
if: needs.decide.outputs.release == 'true'
|
||||
name: build ${{ matrix.name }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: linux
|
||||
os: ubuntu-22.04
|
||||
dir: horizon-linux-x86_64
|
||||
asset: horizon-linux-x86_64.tar.gz
|
||||
- name: windows
|
||||
os: windows-latest
|
||||
dir: horizon-windows-x64
|
||||
asset: horizon-windows-x64.zip
|
||||
exe: .exe
|
||||
# The C runtime linked in, so the game runs without the Visual C++ redistributable.
|
||||
rustflags: -C target-feature=+crt-static
|
||||
- name: macos
|
||||
mac: true
|
||||
dir: horizon-macos-arm64
|
||||
asset: horizon-macos-arm64.zip
|
||||
runs-on: ${{ matrix.mac && (vars.MAC_RUNNER || 'ludovics-mac-mini') || matrix.os }}
|
||||
timeout-minutes: 40
|
||||
env:
|
||||
RUSTFLAGS: ${{ matrix.rustflags }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
run: packaging/package.sh target/release/horizon-client.exe dist/horizon-windows-x64
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.decide.outputs.head }}
|
||||
persist-credentials: false
|
||||
# On the Mac mini's runner, keep target/ (incremental) and .local/ (the prepared resources).
|
||||
clean: ${{ runner.environment == 'github-hosted' }}
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: runner.environment == 'github-hosted'
|
||||
with:
|
||||
shared-key: release-${{ matrix.name }}
|
||||
- name: ALSA and udev headers
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
|
||||
run: cargo build --release --locked -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
|
||||
- name: Every program the release folder takes was built
|
||||
run: |
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
test -f "target/release/$binary${{ matrix.exe }}" || { echo "::error::target/release/$binary${{ matrix.exe }} is missing"; exit 1; }
|
||||
done
|
||||
- name: Only system libraries
|
||||
# Windows: no non-system DLL (a "DLL not found" dialog on some players' machines). Linux: the standard
|
||||
# loader and libraries every desktop distribution has. macOS: only the system's own libraries.
|
||||
run: |
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
file="target/release/$binary${{ matrix.exe }}"
|
||||
if [[ "$RUNNER_OS" == macOS ]]; then
|
||||
others=$(otool -L "$file" | tail -n +2 | awk '{print $1}' | grep -v -E '^(/usr/lib/|/System/Library/)' || true)
|
||||
[[ -z "$others" ]] || { echo "::error::$binary links $others"; exit 1; }
|
||||
else
|
||||
${{ runner.os == 'Windows' && 'python' || 'python3' }} tools/check_imports.py "$file"
|
||||
fi
|
||||
done
|
||||
- name: Every program starts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bin=target/release
|
||||
"$bin/horizon-client${{ matrix.exe }}" --help | grep -q -- '--resources'
|
||||
"$bin/horizon-ctl${{ matrix.exe }}" --help > /dev/null
|
||||
"$bin/horizon-prepare${{ matrix.exe }}" --version
|
||||
"$bin/horizon-prepare${{ matrix.exe }}" --help | grep -q -- '--minecraft-dir'
|
||||
"$bin/horizon-server${{ matrix.exe }}" --version
|
||||
- name: Tools from the runner
|
||||
if: runner.environment == 'self-hosted'
|
||||
run: bash tools/ci/runner-env.sh
|
||||
- name: Resources prepared from the runner's own Minecraft
|
||||
if: runner.environment == 'self-hosted'
|
||||
run: bash tools/ci/vanilla-data.sh
|
||||
- name: The game runs (offscreen, a new world, 1500 frames)
|
||||
if: runner.environment == 'self-hosted'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# From a scratch folder: the game keeps its saves and options in the folder it starts in.
|
||||
home="$RUNNER_TEMP/horizon-smoke"
|
||||
rm -rf "$home" && mkdir -p "$home"
|
||||
version=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' research-lock.json | head -1)
|
||||
client="$PWD/target/release/horizon-client"
|
||||
resources="$PWD/.local/horizon-client/$version"
|
||||
(cd "$home" && HORIZON_HOME="$home" HORIZON_NO_AUDIO=1 "$client" --resources "$resources" --offscreen \
|
||||
--world smoke --seed 1 --render-distance 4 --world-frames 1500 --timeout-seconds 180 \
|
||||
--smoke-report "$home/smoke.json" > "$home/client.log" 2>&1) || { tail -40 "$home/client.log"; exit 1; }
|
||||
python3 - "$home/smoke.json" <<'EOF'
|
||||
import json, sys
|
||||
report = json.load(open(sys.argv[1]))
|
||||
print(json.dumps(report))
|
||||
if report.get("error") is not None or report.get("frames", 0) < 1500 or not report.get("chunks") \
|
||||
or not report.get("meshed_sections") or not report.get("player"):
|
||||
sys.exit("the smoke run did not draw a world")
|
||||
EOF
|
||||
- name: Package
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo run --locked -p xtask -- package "target/release/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
|
||||
cd dist
|
||||
rm -f "${{ matrix.asset }}"
|
||||
case "${{ matrix.asset }}" in
|
||||
*.tar.gz) tar czf "${{ matrix.asset }}" "${{ matrix.dir }}" ;;
|
||||
*) if [[ "$RUNNER_OS" == Windows ]]; then 7z a -tzip "${{ matrix.asset }}" "${{ matrix.dir }}" > /dev/null
|
||||
else ditto -c -k --keepParent "${{ matrix.dir }}" "${{ matrix.asset }}"; fi ;;
|
||||
esac
|
||||
ls -l "${{ matrix.asset }}"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: horizon-windows-x64
|
||||
path: dist/horizon-windows-x64
|
||||
name: ${{ matrix.asset }}
|
||||
path: dist/${{ matrix.asset }}
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
publish:
|
||||
needs: [decide, build]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Notes
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
python3 tools/release_train.py notes --kind game --tag "${{ needs.decide.outputs.tag }}" \
|
||||
--head "${{ needs.decide.outputs.head }}" --out notes > /dev/null
|
||||
{ echo "## $(cat notes/title.txt)"; echo; cat notes/body.md; } >> "$GITHUB_STEP_SUMMARY"
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
- name: Publish ${{ needs.decide.outputs.tag }}
|
||||
if: needs.decide.outputs.dry_run != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.decide.outputs.tag }}
|
||||
run: |
|
||||
gh release create "$TAG" --target "${{ needs.decide.outputs.head }}" --title "$(cat notes/title.txt)" \
|
||||
--notes-file notes/body.md --latest dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
|
||||
dist/horizon-macos-arm64.zip
|
||||
- name: Dry run, the release that would be published
|
||||
if: needs.decide.outputs.dry_run == 'true'
|
||||
run: |
|
||||
echo "Would publish ${{ needs.decide.outputs.tag }} at ${{ needs.decide.outputs.head }}: $(cat notes/title.txt)"
|
||||
ls -l dist
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-notes
|
||||
path: notes/
|
||||
retention-days: 3
|
||||
@@ -0,0 +1,34 @@
|
||||
name: telemetry-server
|
||||
# The ingest server is its own workspace (crates/horizon-telemetry-server), so the checks job doesn't build
|
||||
# it: this one does, when it or the crate it shares with the launcher and the game changes.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'crates/horizon-telemetry/**'
|
||||
- 'crates/horizon-telemetry-server/**'
|
||||
- '.github/workflows/telemetry-server.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'crates/horizon-telemetry/**'
|
||||
- 'crates/horizon-telemetry-server/**'
|
||||
- '.github/workflows/telemetry-server.yml'
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
defaults:
|
||||
run:
|
||||
working-directory: crates/horizon-telemetry-server
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: rustup toolchain install stable --profile minimal --component rustfmt --component clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: crates/horizon-telemetry-server
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- run: cargo +stable fmt --check
|
||||
- run: cargo +stable clippy --all-targets --locked -- -D warnings
|
||||
- run: cargo +stable test --locked
|
||||
+12
@@ -16,5 +16,17 @@
|
||||
/dist/
|
||||
/mods/horizon/build/
|
||||
/saves/
|
||||
/backups/
|
||||
/tools/logs/
|
||||
.DS_Store
|
||||
# What the client writes to its working directory when run from the repo root.
|
||||
/options.txt
|
||||
/horizon.txt
|
||||
/horizon-graphics.txt
|
||||
/horizon-controller.txt
|
||||
/horizon-extras.txt
|
||||
/horizon-player.txt
|
||||
/servers.dat
|
||||
/screenshots/
|
||||
/crash-reports/
|
||||
/debug/
|
||||
@@ -0,0 +1,80 @@
|
||||
# Contributing to Horizon
|
||||
|
||||
Thanks for helping. Horizon is Minecraft: Java Edition 26.3 rewritten in Rust, and the one rule that shapes
|
||||
everything is that **it plays exactly like vanilla**. This page is the short version of how we work; questions are
|
||||
welcome on [Discord](https://discord.gg/HcTeMZxe5E).
|
||||
|
||||
This repository holds the game, the launcher and the website, plus the tools, tests and docs that build, check and
|
||||
release them. Anything else (videos, Discord and other community material) lives elsewhere.
|
||||
|
||||
## Build it
|
||||
|
||||
You need Rust (stable), Python 3.10+, JDK 25, and on Linux the ALSA and udev headers (`libasound2-dev libudev-dev`).
|
||||
Horizon never downloads the game: it prepares its resources from your own copy of Minecraft 26.3, as described in
|
||||
[Build from source](README.md#build-from-source).
|
||||
|
||||
```sh
|
||||
cargo run --release -p horizon-prepare -- --prepare --server-jar ~/Downloads/server.jar
|
||||
cargo run --release -p horizon-client
|
||||
```
|
||||
|
||||
The launcher is its own Cargo workspace in `launcher/` (`cd launcher && cargo run`; on NixOS, `nix-shell` first).
|
||||
|
||||
## Vanilla first
|
||||
|
||||
Wherever vanilla has a feature, vanilla is the specification, down to the order of random calls. "Parity" here
|
||||
means Horizon gives the same results as the pinned 26.3 client and server for the same inputs, checked by a
|
||||
**harness**: a `tools/test_*_parity.py` script that runs a small Java oracle against the real game and a Rust
|
||||
probe against Horizon, and compares their outputs. There are about 290 of them.
|
||||
|
||||
- **Read the vanilla code before writing Horizon's.** `tools/rea_vanilla.py` queries the pinned game bytecode:
|
||||
`python3 tools/rea_vanilla.py method net.minecraft.world.level.block.DispenserBlock dispenseFrom`.
|
||||
Say in your pull request which vanilla classes and methods you followed.
|
||||
- **Gameplay changes come with a harness**, new or extended, so the next change can't silently break them.
|
||||
[The parity workflow](docs/parity-workflow.md) shows how harnesses run; copy a nearby one.
|
||||
- **Horizon's own features** (the launcher, the new menus) follow [the design system](docs/design-system.md) and are
|
||||
off by default wherever they would change the vanilla look.
|
||||
- **Everything players run is Rust.** Python and Java are fine for dev tools and harness drivers in `tools/`, never
|
||||
for player-facing code.
|
||||
|
||||
Never commit game files, decompiled source or prepared resources (`.local/`): `tools/check_public_tree.py` rejects
|
||||
them (`git config core.hooksPath .githooks` runs it before every commit).
|
||||
|
||||
## Before asking for review
|
||||
|
||||
Run the self-check and paste the block it prints into your pull request:
|
||||
|
||||
```sh
|
||||
python3 tools/pr_ready.py --base upstream/main # or origin/main if you work on the main repository
|
||||
```
|
||||
|
||||
It looks at what your branch changes and runs what matters: rustfmt, clippy with warnings denied, the tests of the
|
||||
crates you touched (and the launcher's), generated files, `Cargo.lock`, the Python tests, and the vanilla harnesses
|
||||
your change can affect. Without the vanilla data on your machine it lists those harnesses instead; CI runs them.
|
||||
`--quick` skips the harnesses. It exits non-zero when something fails.
|
||||
|
||||
## Pull requests
|
||||
|
||||
- **Small.** One change per pull request, under about 500 lines of code where you can; split bigger work into
|
||||
pieces that each make sense alone. Small pull requests get reviewed and merged the same day.
|
||||
- **Fill in the template**: what and why, the vanilla reference, the pr_ready block, screenshots for anything you
|
||||
can see.
|
||||
- **Tick "Allow edits by maintainers".** When something small is left (a lint, a rebase, a nit) we push the fixup
|
||||
ourselves instead of sending it back to you; only design questions come back.
|
||||
- **Labels** (we set them if you can't): `feature` when players get something new (it appears under "New" in the
|
||||
release notes), `skip-release` when players see no change (tooling, docs, tests), nothing for a fix.
|
||||
`release-now` is for crash fixes that shouldn't wait. pr_ready suggests one.
|
||||
|
||||
Every merge to main is released automatically ([release flow](docs/release-flow.md)), so main always stays playable.
|
||||
|
||||
## Parity checks on forks
|
||||
|
||||
The `verify` check (formatting, lints, tests) runs on every pull request. The `parity` check runs the affected
|
||||
harnesses on a maintainer's machine, which holds the game files, so it never runs a fork's code on its own: a
|
||||
maintainer reads your change, then adds the `run-parity` label, which runs it once on your current commit. After
|
||||
new pushes, we add it again.
|
||||
|
||||
## Talk to us
|
||||
|
||||
[Discord](https://discord.gg/HcTeMZxe5E) for questions and ideas, [issues](https://github.com/ludovic111/horizon/issues)
|
||||
for bugs. If you want to take something on, say so in its issue so nobody else starts the same work.
|
||||
Generated
+2568
-13
File diff suppressed because it is too large.
Load diff
+2
-2
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
exclude = ["launcher"]
|
||||
members = ["crates/horizon-core", "crates/horizon-world", "crates/voxel-kernels", "crates/horizon-client", "crates/horizon-assets", "crates/horizon-protocol"]
|
||||
exclude = ["launcher", "crates/horizon-telemetry-server"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-world", "crates/voxel-kernels", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-shaderpack", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="docs/media/banner.jpg" alt="Horizon: Minecraft Java Edition 26.3, rewritten from scratch in Rust" width="100%">
|
||||
<img src="docs/media/banner.webp" alt="Horizon: Minecraft: Java Edition 26.3, rewritten in Rust and checked against vanilla by 281 differential harnesses" width="100%">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -12,7 +12,9 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://playhorizon.dev"><b>Website</b></a> ·
|
||||
<a href="#download"><b>Download</b></a> ·
|
||||
<a href="#what-changed-in-v202610131"><b>What changed</b></a> ·
|
||||
<a href="#screenshots"><b>Screenshots</b></a> ·
|
||||
<a href="#project-status"><b>Parity status</b></a> ·
|
||||
<a href="docs/README.md"><b>Documentation</b></a>
|
||||
@@ -26,9 +28,18 @@ Every subsystem matches vanilla in all 281 parity harnesses, on Linux and macOS:
|
||||
|
||||
> Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft. Horizon contains no Minecraft code, assets or data: you need your own legally obtained copy of Minecraft: Java Edition 26.3.
|
||||
|
||||
## What changed in v2026.10.13.1
|
||||
|
||||
The latest release ([notes](https://github.com/ludovic111/horizon/releases/tag/v2026.10.13.1)):
|
||||
|
||||
- **Horizon's own menus:** a new title screen, world list, options and pause menu in the launcher's style, over a live frozen landscape. The vanilla look is still available: Options → Interface → Menu style → Classic.
|
||||
- **Graphics settings, Simple and Advanced:** presets (Performance, Balanced, Quality, Ultra), or every rendering, quality, display and performance setting, each with a short description and its cost, plus a performance overlay.
|
||||
- **Much faster rendering:** for example, at render distance 8 over a village, 363 → 2,409 FPS on an RTX 4080. Render distance 32 still stutters in places.
|
||||
- **Fixed:** no sound on Linux; Linux builds that only ran on the machine they were built on; a missing DLL on Windows (the launcher no longer needs the Visual C++ runtime); memory growing and the frame rate dropping near villages (mobs were duplicated when chunks reloaded); resource preparation failing on Linux with "Missing … java-objc-bridge" ([#41](https://github.com/ludovic111/horizon/issues/41)); the hand and held item at high FOV; missing swing animations.
|
||||
|
||||
## Download
|
||||
|
||||
**[Download the Horizon launcher](https://github.com/ludovic111/horizon/releases?q=launcher-v&expanded=true)** for Linux x86-64, macOS Apple Silicon or Windows x64: take the newest `launcher-v*` release (currently [launcher-v0.1.1](https://github.com/ludovic111/horizon/releases/tag/launcher-v0.1.1)); the launcher then updates itself. The launcher is the way to get Horizon: it installs and updates the game, prepares its resources from your own copy of Minecraft, and starts the client or a dedicated server.
|
||||
Horizon is downloaded only through **the Horizon launcher**: get it from [playhorizon.dev](https://playhorizon.dev) or from [the launcher's releases](https://github.com/ludovic111/horizon/releases?q=launcher-v&expanded=true) (the newest `launcher-v*` release, currently [launcher-v0.1.3](https://github.com/ludovic111/horizon/releases/tag/launcher-v0.1.3)), for Linux x86-64, macOS Apple Silicon or Windows x64. The launcher installs and updates the game, prepares its resources from your own copy of Minecraft, starts the client or a dedicated server, and updates itself.
|
||||
|
||||
**You need** Minecraft: Java Edition installed with the official launcher, with 26.3 played once; Python 3.10 or newer and Java 25 (a JDK, e.g. Eclipse Temurin 25) for the one-time resource preparation; and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac.
|
||||
|
||||
@@ -36,31 +47,47 @@ Every subsystem matches vanilla in all 281 parity harnesses, on Linux and macOS:
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="launcher/docs/play.png" alt="The launcher's Play page"></td>
|
||||
<td width="50%"><img src="launcher/docs/versions.png" alt="The launcher's Versions page"></td>
|
||||
<td width="50%"><img src="docs/media/launcher-play.webp" alt="The launcher's Play page: v2026.10.14.1 ready to play, its summary, Client and Server, the version picker and Play, the resources and the Minecraft folder as a numbered list"></td>
|
||||
<td width="50%"><img src="docs/media/launcher-patch-notes.webp" alt="The launcher's Patch notes page, showing the notes of Horizon 2026.10.14.1"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
It lists Horizon's releases and installs the build for your platform, shows each release's patch notes and the roadmap, updates Horizon and itself, and doubles as a command-line tool and an MCP server. See [the launcher's README](launcher/README.md).
|
||||
It lists Horizon's releases and installs the build for your platform, shows each release's patch notes, updates Horizon and itself, and doubles as a command-line tool and an MCP server. See [the launcher's README](launcher/README.md).
|
||||
|
||||
## Screenshots
|
||||
|
||||
Captured in the Rust client itself, with vanilla assets and no mods or shaders.
|
||||
|
||||
### Menus and settings
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td colspan="2"><img src="docs/media/sunset.jpg" alt="Sunset over a savanna river valley and a plains village"></td>
|
||||
<td colspan="2"><img src="docs/media/menu-title.webp" alt="Horizon's title screen: the mark and the pages on the left, the Horizon title, Singleplayer and Multiplayer, and the last world played and the faint wordmark, over a veiled snowy landscape"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/media/village.jpg" alt="A plains village with farms, an iron golem and animals"></td>
|
||||
<td width="50%"><img src="docs/media/survival.jpg" alt="Survival at dusk: a zombie attacks near the village"></td>
|
||||
<td width="50%"><img src="docs/media/menu-worlds.webp" alt="The Singleplayer page: three worlds with their dates and game modes, and Create world and Play buttons"></td>
|
||||
<td width="50%"><img src="docs/media/settings.webp" alt="The Video settings in their Simple view: a preset, render distance, field of view, framerate, VSync and GUI scale, each with what it costs"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
The vanilla menus are one setting away: Options → Interface → Menu style → Classic. See [Graphics settings](docs/graphics.md).
|
||||
|
||||
### In game
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td colspan="2"><img src="docs/media/sunset.webp" alt="Sunset over a savanna river valley and a plains village"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/media/nether.jpg" alt="A crimson forest in the Nether with a zombified piglin"></td>
|
||||
<td width="50%"><img src="docs/media/night.jpg" alt="Moonrise over a torch-lit village and river"></td>
|
||||
<td width="50%"><img src="docs/media/village.webp" alt="A plains village with farms, an iron golem and animals"></td>
|
||||
<td width="50%"><img src="docs/media/survival.webp" alt="Survival at dusk: a zombie attacks near the village"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/media/underwater.jpg" alt="Salmon swimming in a river"></td>
|
||||
<td width="50%"><img src="docs/media/nether.webp" alt="A crimson forest in the Nether with a zombified piglin"></td>
|
||||
<td width="50%"><img src="docs/media/night.webp" alt="Moonrise over a torch-lit village and river"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/media/underwater.webp" alt="Salmon swimming in a river"></td>
|
||||
<td width="50%" valign="middle">
|
||||
Vanilla world generation ported to Rust (terrain, aquifers, carvers, features, villages and the Nether), every block model with smooth lighting and biome tints, the vanilla sky and fog, mobs, the HUD, chat and advancements: everything in these frames is drawn by Horizon.
|
||||
</td>
|
||||
@@ -86,9 +113,24 @@ The [native client](docs/native-client.md) opens on a vanilla-style title screen
|
||||
- shields, maps, books, fireworks, beacons, advancements and statistics;
|
||||
- Anvil saves, joining vanilla servers, and opening a world to LAN.
|
||||
|
||||
## Agent control
|
||||
|
||||
AI agents and scripts can play Horizon. `horizon-ctl`, shipped beside the game in every release folder, is a command line that reads the game as JSON and acts like a player; `horizon-ctl mcp` serves the same commands as an MCP server (one tool per command, screenshots returned as images).
|
||||
|
||||
```sh
|
||||
horizon-ctl launch --world Survival --seed 5 --wait # finds the version the launcher installed
|
||||
horizon-ctl state # position, health, food, target, screen...
|
||||
horizon-ctl find-block birch_log # the nearest logs
|
||||
horizon-ctl goto -0.5 65 -2.5 --near 2.5 # walks there along a path
|
||||
horizon-ctl break -1 65 -3 # mines it; replies with the drops
|
||||
horizon-ctl open-inventory && horizon-ctl click-slot 0 # menus: slots, buttons by label, text fields
|
||||
```
|
||||
|
||||
Commands block until the action is done and reply with what happened; errors are JSON with distinct exit codes, `horizon-ctl help` lists every command and `horizon-ctl COMMAND --help` explains one with examples. It talks to the game over a Unix socket on Linux and macOS and over a token-protected localhost port on Windows. See [Agent control in the native client guide](docs/native-client.md#agent-control-horizon-ctl-and-mcp).
|
||||
|
||||
## Build from source
|
||||
|
||||
For developers. Requirements: Rust 1.90 or newer, Python 3.10 or newer, JDK 25, and on Linux the ALSA development headers (`libasound2-dev` on Debian and Ubuntu). Development and the vanilla harnesses run on Linux.
|
||||
For developers. Requirements: Rust 1.90 or newer, Python 3.10 or newer, JDK 25, and on Linux the ALSA and udev development headers (`libasound2-dev libudev-dev` on Debian and Ubuntu). Development and the vanilla harnesses run on Linux.
|
||||
|
||||
Horizon contains no game files and never downloads them. It builds its resources from **your own copy** of Minecraft: Java Edition 26.3, verified against the checksums in `research-lock.json`:
|
||||
|
||||
@@ -98,18 +140,18 @@ Horizon contains no game files and never downloads them. It builds its resources
|
||||
```sh
|
||||
git clone https://github.com/ludovic111/horizon.git
|
||||
cd horizon
|
||||
python3 tools/prepare_native_client.py --prepare --server-jar ~/Downloads/server.jar # needs JDK 25
|
||||
python3 tools/fetch_sounds.py # optional: sounds from your launcher install
|
||||
cargo run --release -p horizon-prepare -- --prepare --server-jar ~/Downloads/server.jar # needs JDK 25
|
||||
cargo run --release -p horizon-prepare -- sounds # optional: sounds from your launcher install
|
||||
cargo run --release -p horizon-client
|
||||
```
|
||||
|
||||
Everything prepared lands in the gitignored `.local/`. It is derived from the game: never commit it or share it.
|
||||
|
||||
The client starts on the title screen. Command-line options (`--world`, `--seed`, `--creative`, `--render-distance`, `--dedicated`), controls, scripting through `horizon-ctl`, and the architecture are described in [the native client guide](docs/native-client.md). The rest of the documentation is indexed in [docs/README.md](docs/README.md), including [the parity workflow](docs/parity-workflow.md).
|
||||
The client starts on the title screen. Command-line options (`--world`, `--seed`, `--creative`, `--render-distance`, `--dedicated`), controls, playing and scripting through `horizon-ctl` and its MCP server, and the architecture are described in [the native client guide](docs/native-client.md). The rest of the documentation is indexed in [docs/README.md](docs/README.md), including [the parity workflow](docs/parity-workflow.md).
|
||||
|
||||
## Project status
|
||||
|
||||
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 and the final checks are listed in the roadmap.
|
||||
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%
|
||||
|
||||
@@ -138,39 +180,27 @@ them too (Rust uses Java's own float maths on each platform).
|
||||
| Audio | 1 | None: the sound engine, sound events, music, situational music, ambience and subtitles match vanilla |
|
||||
| Block behaviours and ticking | 1 | None: block updates, random ticks, redstone, dispensers (6,000 cases), crafters (4,000 crafts), block entity removal (containers spill, 4,000 cases), copper golem chest sorting and the sculk family (435 scenarios: shrieker warnings and warden summoning included) match vanilla |
|
||||
|
||||
### Roadmap
|
||||
### Known issues
|
||||
|
||||
**Known differences.** Online-mode servers (Mojang's session servers) are not supported; 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.
|
||||
|
||||
**Final checks.** A from-scratch audit (`tools/parity.py --all`) on Linux and on macOS, and the launch checks on the packaged Linux, macOS and Windows builds listed in [Rust rewrite status](docs/rust-parity.md#completion-criteria-and-playable-builds).
|
||||
|
||||
**Next.** With vanilla parity as the base:
|
||||
|
||||
- **Performance:** optimize the client and the server as far as they can go (rendering, chunk generation and meshing, memory, multithreaded ticking) while the differential harnesses keep gameplay identical to vanilla.
|
||||
- **Modding support:** a stable API for writing mods against Horizon.
|
||||
- **Agent control:** an MCP server and a CLI so AI agents and scripts can play and drive the game, growing out of today's `horizon-ctl` scripting socket.
|
||||
- **Built-in recording:** capture gameplay video in a keypress, without external tools.
|
||||
- **Automatic version updates:** a pipeline that ports each new Minecraft release on its own: compare the new version with the pinned one, regenerate the data, rewrite the changed parts and prove them with the same differential harnesses.
|
||||
Online-mode servers (Mojang's session servers) are not supported; 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.
|
||||
|
||||
## Contributing
|
||||
|
||||
Issues and pull requests are welcome. Before opening a pull request, run the offline checks:
|
||||
Issues and pull requests are welcome: see [CONTRIBUTING.md](CONTRIBUTING.md) for how we work (vanilla first, small pull requests, labels). Before opening a pull request, run the self-check and paste the block it prints:
|
||||
|
||||
```sh
|
||||
git config core.hooksPath .githooks
|
||||
python3 -m unittest discover -s tests -v
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo test --workspace
|
||||
python3 tools/check_public_tree.py --history
|
||||
python3 tools/pr_ready.py
|
||||
```
|
||||
|
||||
CI runs these checks on GitHub's Linux runners. The vanilla comparisons need the game, which Horizon never downloads, so they run only when started by hand on a self-hosted runner holding the maintainer's own copy. Never commit game files, decompiled source or prepared resources: `tools/check_public_tree.py` rejects them, and tracked Java is allowed only under the project's own `dev/horizonlab` namespace.
|
||||
It runs rustfmt, clippy with warnings denied, the tests of the crates you touched, the Python tests, the public-tree check, and the vanilla harnesses your change can affect. CI runs the same checks on GitHub's Linux runners; the vanilla harnesses need the game, which Horizon never downloads, so they run on a self-hosted runner holding the maintainer's own copy. Never commit game files, decompiled source or prepared resources: `tools/check_public_tree.py` rejects them, and tracked Java is allowed only under the project's own `dev/horizonlab` namespace.
|
||||
|
||||
## Licence
|
||||
|
||||
[MIT](LICENSE), for the project's original work only. Dependencies keep their own licences. Minecraft is a trademark of Mojang Synergies AB; Minecraft code and assets belong to Mojang and Microsoft and are covered by the [Minecraft EULA](https://www.minecraft.net/en-us/eula). Decompiled game source and prepared resources stay on your machine and are never published.
|
||||
|
||||
Third-party credits and licenses: [Third-party notices](THIRD_PARTY_NOTICES.md).
|
||||
|
||||
## Author
|
||||
|
||||
Made by [Ludovic Marie](https://lu4ovic.com) ([@ludovic111](https://github.com/ludovic111)).
|
||||
@@ -0,0 +1,31 @@
|
||||
# Third-party notices
|
||||
|
||||
## CullLeaves
|
||||
|
||||
The optional leaf and mangrove-root face-culling behavior is adapted from
|
||||
[CullLeaves](https://github.com/TeamMidnightDust/CullLeaves) by MidnightDust.
|
||||
Its MIT license is reproduced below.
|
||||
|
||||
```text
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 MidnightDust
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
@@ -0,0 +1,78 @@
|
||||
# Entity model document loading
|
||||
|
||||
The three startup batches (boats/chest boats, minecarts, armor stands/player armor)
|
||||
now share one parsed document. For this fixture, that replaces 43 full document
|
||||
reads/parses (boat discovery plus 42 selected layers) with one. Selected models
|
||||
still produce independent `Part` trees; the document is dropped after the batches
|
||||
are built. Other entity loaders retain their existing behavior.
|
||||
|
||||
## Reproduce
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
python3 benchmarks/entity-model-document-2026-10-14/generate_fixture.py /tmp/horizon-entity-model-document-fixture.json
|
||||
ENTITY_MODEL_BENCH_DOCUMENT=/tmp/horizon-entity-model-document-fixture.json \
|
||||
CARGO_TARGET_DIR=/home/bob/Documents/horizon/target \
|
||||
cargo test -p horizon-client --profile fast --bin horizon-client \
|
||||
entity_model_document_benchmark -- --ignored --nocapture
|
||||
```
|
||||
|
||||
`CARGO_TARGET_DIR` is optional and can be changed for another machine. Supply a
|
||||
prepared `entity-models.json` through `ENTITY_MODEL_BENCH_DOCUMENT` to benchmark
|
||||
that document instead; the harness copies only that file to a temporary root.
|
||||
`ENTITY_MODEL_BENCH_REPETITIONS` controls repetitions (default 15).
|
||||
|
||||
The opt-in Rust test calls the actual three optimized production loaders and
|
||||
compares every selected part name, pose, polygon normal/vertex and child against
|
||||
the previous algorithm using the compatible per-model `load_model` API. Timings
|
||||
include reading, parsing, constructing, normalizing into a common result map, and
|
||||
dropping the result. Each variant gets three warmups and 15 measured calls, with
|
||||
order alternating. `black_box` consumes inputs/results. No other builds or
|
||||
optimization benchmarks ran during the measured calls.
|
||||
|
||||
## Input and limitations
|
||||
|
||||
Prepared resources were unavailable in the repository and local Horizon asset
|
||||
directories. This is a **synthetic** 2,473,695-byte document with 458 model layers,
|
||||
each containing 12 posed parts with six exported quads and normals. It selects 22
|
||||
boat/chest-boat layers, six minecart layers and 14 armor/player layers (42 total).
|
||||
One wood name is synthetic (`test_wood`); boat discovery accepts arbitrary wood
|
||||
names. The extra layers model the cost of parsing a full production-size document
|
||||
for each selected model.
|
||||
|
||||
File data is warm in the OS page cache. Equipment files are absent, so equipment
|
||||
texture loading is excluded. This measures the entity-model batch only and does
|
||||
not establish overall startup, frame time or FPS improvement. Shapes/layer counts
|
||||
in real prepared documents can produce different absolute timings.
|
||||
|
||||
Machine: AMD Ryzen 5 5600X (6 cores / 12 threads), Linux x86_64,
|
||||
Rust 1.99.0 (b940084d7, LLVM 23.1.1), Cargo `fast` profile (opt-level 3,
|
||||
16 codegen units, no LTO). Baseline algorithm corresponds to main
|
||||
`73a019967e9665c6b96332988c13cb66679d8477`.
|
||||
|
||||
## Results
|
||||
|
||||
| Variant | Median batch time | Full document reads/parses |
|
||||
| --- | ---: | ---: |
|
||||
| Previous per-model algorithm | 1,434.974 ms | 43 |
|
||||
| Shared document | 33.420 ms | 1 |
|
||||
|
||||
The measured batch is **42.94× faster** on this synthetic input. All 15 samples
|
||||
for both variants, warmup count, document size and selected model count are in
|
||||
[`results.json`](results.json).
|
||||
|
||||
## Validation
|
||||
|
||||
```sh
|
||||
cargo fmt --all -- --check
|
||||
CARGO_TARGET_DIR=/home/bob/Documents/horizon/target \
|
||||
cargo test -p horizon-client --profile fast --bin horizon-client \
|
||||
entity_model_loading_tests -- --nocapture
|
||||
CARGO_TARGET_DIR=/home/bob/Documents/horizon/target \
|
||||
cargo clippy -p horizon-client --profile fast --lib --bin horizon-client --tests -- -D warnings
|
||||
```
|
||||
|
||||
The regular tests cover full/sparse documents, unrelated layers, a missing file,
|
||||
invalid JSON, non-object JSON, missing model keys, independent model trees, and
|
||||
equipment loading when the document is unavailable.
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Generate a production-size synthetic document; no prepared assets required."""
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
woods = "oak spruce birch jungle acacia dark_oak mangrove cherry bamboo pale_oak test_wood".split()
|
||||
carts = "minecart chest_minecart hopper_minecart furnace_minecart tnt_minecart command_block_minecart".split()
|
||||
keys = [f"minecraft:{prefix}/{wood}#main" for prefix in ("boat", "chest_boat") for wood in woods]
|
||||
keys += [f"minecraft:{cart}#main" for cart in carts]
|
||||
keys += [f"minecraft:{model}#{slot}" for model in ("player", "armor_stand", "armor_stand_small")
|
||||
for slot in ("helmet", "chestplate", "leggings", "boots")]
|
||||
keys += [f"minecraft:{model}#main" for model in ("armor_stand", "armor_stand_small")]
|
||||
|
||||
face = [[0, 0, 0, 0, 0], [16, 0, 0, 1, 0], [16, 16, 0, 1, 1], [0, 16, 0, 0, 1]]
|
||||
part = {"pose": [1, 2, 3, 0.25, -0.5, 0.75, 1, 1, 1], "polygons": [face] * 6,
|
||||
"normals": [[0, 0, 1]] * 6}
|
||||
model = {"pose": [0, 0, 0, 0, 0, 0, 1, 1, 1],
|
||||
"children": {f"part_{i}": part for i in range(12)}}
|
||||
encoded = json.dumps(model, separators=(",", ":"))
|
||||
entries = [json.dumps(key) + ":" + encoded for key in keys]
|
||||
size = 2 + sum(map(len, entries)) + len(entries) - 1
|
||||
index = 0
|
||||
while size < 2_470_000:
|
||||
entry = json.dumps(f"minecraft:fixture_{index}#main") + ":" + encoded
|
||||
entries.append(entry)
|
||||
size += 1 + len(entry)
|
||||
index += 1
|
||||
output = pathlib.Path(sys.argv[1])
|
||||
output.write_text("{" + ",".join(entries) + "}")
|
||||
print(json.dumps({"path": str(output), "bytes": output.stat().st_size,
|
||||
"layers": len(entries), "selected_layers": len(keys)}))
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"baseline_median_ms": 1434.973835,
|
||||
"baseline_ms": [
|
||||
1400.008286,
|
||||
1406.761694,
|
||||
1408.9131360000001,
|
||||
1418.6680549999999,
|
||||
1419.149658,
|
||||
1427.082872,
|
||||
1433.088092,
|
||||
1434.973835,
|
||||
1449.7688150000001,
|
||||
1452.143883,
|
||||
1453.987998,
|
||||
1455.518231,
|
||||
1457.0334540000001,
|
||||
1458.588552,
|
||||
1485.34133
|
||||
],
|
||||
"document_bytes": 2473695,
|
||||
"optimized_median_ms": 33.419768000000005,
|
||||
"optimized_ms": [
|
||||
32.64795,
|
||||
32.812732,
|
||||
33.054488,
|
||||
33.056914,
|
||||
33.102999000000004,
|
||||
33.23532,
|
||||
33.28287,
|
||||
33.419768000000005,
|
||||
33.610569,
|
||||
33.874749,
|
||||
33.915657,
|
||||
33.93877,
|
||||
34.009893999999996,
|
||||
34.550526,
|
||||
36.096408000000004
|
||||
],
|
||||
"repetitions": 15,
|
||||
"selected_models": 42,
|
||||
"speedup": 42.937875421516985,
|
||||
"warmup_pairs": 3
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
{
|
||||
"label": "after-4080",
|
||||
"date": "2026-10-13",
|
||||
"commit": "9006566b",
|
||||
"host": "ludodesktop",
|
||||
"offscreen": true,
|
||||
"seed": "1234",
|
||||
"flight_seconds": 12.0,
|
||||
"scenes": {
|
||||
"village": {
|
||||
"from": [
|
||||
-960.0,
|
||||
125.0,
|
||||
80.0
|
||||
],
|
||||
"to": [
|
||||
-960.0,
|
||||
125.0,
|
||||
272.0
|
||||
],
|
||||
"pitch": 35.0
|
||||
},
|
||||
"forest": {
|
||||
"from": [
|
||||
-1100.0,
|
||||
120.0,
|
||||
-560.0
|
||||
],
|
||||
"to": [
|
||||
-1100.0,
|
||||
120.0,
|
||||
-368.0
|
||||
],
|
||||
"pitch": 25.0
|
||||
},
|
||||
"ocean": {
|
||||
"from": [
|
||||
-300.0,
|
||||
85.0,
|
||||
-420.0
|
||||
],
|
||||
"to": [
|
||||
-300.0,
|
||||
85.0,
|
||||
-228.0
|
||||
],
|
||||
"pitch": 10.0
|
||||
},
|
||||
"mountain": {
|
||||
"from": [
|
||||
560.0,
|
||||
260.0,
|
||||
860.0
|
||||
],
|
||||
"to": [
|
||||
752.0,
|
||||
260.0,
|
||||
860.0
|
||||
],
|
||||
"pitch": 30.0
|
||||
}
|
||||
},
|
||||
"distances": {
|
||||
"8": {
|
||||
"renderer": {
|
||||
"backend": "Vulkan",
|
||||
"drawn_sections": 133,
|
||||
"gpu": "NVIDIA GeForce RTX 4080",
|
||||
"present_mode": "Immediate",
|
||||
"size": [
|
||||
1280,
|
||||
720
|
||||
],
|
||||
"uploaded_bytes": 941908352
|
||||
},
|
||||
"village": {
|
||||
"avg_fps": 2409.179688612205,
|
||||
"cpu": {
|
||||
"avg_ms": 0.41004928978864236,
|
||||
"max_ms": 18.15971565246582,
|
||||
"p50_ms": 0.2718150019645691,
|
||||
"p95_ms": 0.7112299799919128,
|
||||
"p99_ms": 1.316559076309204
|
||||
},
|
||||
"frames": 28910,
|
||||
"gpu": {
|
||||
"avg_ms": 0.09866036293994015,
|
||||
"max_ms": 0.25088000297546387,
|
||||
"p50_ms": 0.08806400001049042,
|
||||
"p95_ms": 0.1740799993276596,
|
||||
"p99_ms": 0.19968000054359436
|
||||
},
|
||||
"low_1pct_fps": 160.80638022914025,
|
||||
"max_ms": 18.175312042236328,
|
||||
"p50_ms": 0.2760099768638611,
|
||||
"p95_ms": 0.7161250114440918,
|
||||
"p99_ms": 1.4078609943389893,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.20953580908804606,
|
||||
"render": 0.12439006986512069,
|
||||
"tick": 0.05299293669455945,
|
||||
"update": 0.023244019639293335
|
||||
},
|
||||
"seconds": 11.999935138359666,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.4052206484151298,
|
||||
"render": 0.41893499056840766,
|
||||
"tick": 5.252727109792939,
|
||||
"update": 0.0936474929949076
|
||||
}
|
||||
},
|
||||
"forest": {
|
||||
"avg_fps": 1099.4635681039538,
|
||||
"cpu": {
|
||||
"avg_ms": 0.9003613499442573,
|
||||
"max_ms": 23.31729507446289,
|
||||
"p50_ms": 0.6657320261001587,
|
||||
"p95_ms": 0.9898859858512878,
|
||||
"p99_ms": 9.592857360839844
|
||||
},
|
||||
"frames": 13194,
|
||||
"gpu": {
|
||||
"avg_ms": 0.2639814413552015,
|
||||
"max_ms": 3.0013439655303955,
|
||||
"p50_ms": 0.2549760043621063,
|
||||
"p95_ms": 0.3553279936313629,
|
||||
"p99_ms": 0.37171199917793274
|
||||
},
|
||||
"low_1pct_fps": 82.87026130908886,
|
||||
"max_ms": 27.990703582763672,
|
||||
"p50_ms": 0.6705269813537598,
|
||||
"p95_ms": 1.0082690715789795,
|
||||
"p99_ms": 9.64776611328125,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.571024256178027,
|
||||
"render": 0.12442494783992657,
|
||||
"tick": 0.1706307831885292,
|
||||
"update": 0.03442592136820218
|
||||
},
|
||||
"seconds": 12.000397632777691,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.7136096191225629,
|
||||
"render": 0.43175565880356415,
|
||||
"tick": 10.713303016893791,
|
||||
"update": 0.05390534066651581
|
||||
}
|
||||
},
|
||||
"ocean": {
|
||||
"avg_fps": 687.0806476294383,
|
||||
"cpu": {
|
||||
"avg_ms": 1.4490217101725757,
|
||||
"max_ms": 29.75178337097168,
|
||||
"p50_ms": 0.9425880312919617,
|
||||
"p95_ms": 1.6511069536209106,
|
||||
"p99_ms": 15.603099822998047
|
||||
},
|
||||
"frames": 8245,
|
||||
"gpu": {
|
||||
"avg_ms": 0.3711021953564546,
|
||||
"max_ms": 1.5022079944610596,
|
||||
"p50_ms": 0.3665919899940491,
|
||||
"p95_ms": 0.43827199935913086,
|
||||
"p99_ms": 0.4679679870605469
|
||||
},
|
||||
"low_1pct_fps": 47.486143092805435,
|
||||
"max_ms": 29.770263671875,
|
||||
"p50_ms": 0.947784960269928,
|
||||
"p95_ms": 1.6628600358963013,
|
||||
"p99_ms": 15.621638298034668,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.8449565766073126,
|
||||
"render": 0.1554101111230668,
|
||||
"tick": 0.3840128544967872,
|
||||
"update": 0.06481705411812297
|
||||
},
|
||||
"seconds": 12.000046906352043,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 1.2885668083845851,
|
||||
"render": 0.45316039797771407,
|
||||
"tick": 19.216593845781073,
|
||||
"update": 0.08380924088947744
|
||||
}
|
||||
},
|
||||
"mountain": {
|
||||
"avg_fps": 343.77852072094186,
|
||||
"cpu": {
|
||||
"avg_ms": 1.5961282111390964,
|
||||
"max_ms": 22.24184799194336,
|
||||
"p50_ms": 0.8254669904708862,
|
||||
"p95_ms": 11.660508155822754,
|
||||
"p99_ms": 14.398658752441406
|
||||
},
|
||||
"frames": 4113,
|
||||
"gpu": {
|
||||
"avg_ms": 0.3449904050323872,
|
||||
"max_ms": 2.9040639400482178,
|
||||
"p50_ms": 0.3338240087032318,
|
||||
"p95_ms": 0.43724799156188965,
|
||||
"p99_ms": 0.5171200037002563
|
||||
},
|
||||
"low_1pct_fps": 29.95597087331344,
|
||||
"max_ms": 33.422760009765625,
|
||||
"p50_ms": 0.8299460411071777,
|
||||
"p95_ms": 33.33942413330078,
|
||||
"p99_ms": 33.37727355957031,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.7147062004496516,
|
||||
"render": 0.14707977256612884,
|
||||
"tick": 0.6855548198381525,
|
||||
"update": 0.04897363143349227
|
||||
},
|
||||
"seconds": 11.96409825539589,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.8949875491006034,
|
||||
"render": 0.6183000469491595,
|
||||
"tick": 14.63794419879005,
|
||||
"update": 0.8699657806594457
|
||||
}
|
||||
}
|
||||
},
|
||||
"16": {
|
||||
"renderer": {
|
||||
"backend": "Vulkan",
|
||||
"drawn_sections": 0,
|
||||
"gpu": "NVIDIA GeForce RTX 4080",
|
||||
"present_mode": "Immediate",
|
||||
"size": [
|
||||
1280,
|
||||
720
|
||||
],
|
||||
"uploaded_bytes": 16462208
|
||||
},
|
||||
"village": {
|
||||
"avg_fps": 1041.3622747953375,
|
||||
"cpu": {
|
||||
"avg_ms": 0.9549695556642304,
|
||||
"max_ms": 26.009796142578125,
|
||||
"p50_ms": 0.4810290038585663,
|
||||
"p95_ms": 2.5506999492645264,
|
||||
"p99_ms": 9.879941940307617
|
||||
},
|
||||
"frames": 12496,
|
||||
"gpu": {
|
||||
"avg_ms": 0.22781361336253403,
|
||||
"max_ms": 1.7469439506530762,
|
||||
"p50_ms": 0.18534399569034576,
|
||||
"p95_ms": 0.6113280057907104,
|
||||
"p99_ms": 0.809984028339386
|
||||
},
|
||||
"low_1pct_fps": 64.04348583959742,
|
||||
"max_ms": 26.032779693603516,
|
||||
"p50_ms": 0.48582199215888977,
|
||||
"p95_ms": 2.5584940910339355,
|
||||
"p99_ms": 9.892412185668945,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.37678885421859964,
|
||||
"render": 0.3450905326050295,
|
||||
"tick": 0.1742891420732452,
|
||||
"update": 0.0590072420405441
|
||||
},
|
||||
"seconds": 11.999666496902703,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 1.4649118003845214,
|
||||
"render": 1.1414484503269196,
|
||||
"tick": 12.881806396484375,
|
||||
"update": 0.11011978423595428
|
||||
}
|
||||
},
|
||||
"forest": {
|
||||
"avg_fps": 316.2068527731055,
|
||||
"cpu": {
|
||||
"avg_ms": 3.154168446387541,
|
||||
"max_ms": 26.147607803344727,
|
||||
"p50_ms": 2.2156569957733154,
|
||||
"p95_ms": 12.254712104797363,
|
||||
"p99_ms": 17.7707462310791
|
||||
},
|
||||
"frames": 3795,
|
||||
"gpu": {
|
||||
"avg_ms": 0.7502970771905776,
|
||||
"max_ms": 2.599936008453369,
|
||||
"p50_ms": 0.7239680290222168,
|
||||
"p95_ms": 1.3158400058746338,
|
||||
"p99_ms": 1.3772799968719482
|
||||
},
|
||||
"low_1pct_fps": 49.59272936233826,
|
||||
"max_ms": 26.164094924926758,
|
||||
"p50_ms": 2.222438097000122,
|
||||
"p95_ms": 12.270298957824707,
|
||||
"p99_ms": 17.786956787109375,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 1.5666706890613822,
|
||||
"render": 0.7056146899387927,
|
||||
"tick": 0.7469899302897249,
|
||||
"update": 0.13521884845002838
|
||||
},
|
||||
"seconds": 12.001637430429458,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 1.9408628783727948,
|
||||
"render": 1.194917630208166,
|
||||
"tick": 16.762235842253034,
|
||||
"update": 0.24904731876756014
|
||||
}
|
||||
},
|
||||
"ocean": {
|
||||
"avg_fps": 385.09978163469907,
|
||||
"cpu": {
|
||||
"avg_ms": 2.589322079316407,
|
||||
"max_ms": 18.984909057617188,
|
||||
"p50_ms": 2.0551369190216064,
|
||||
"p95_ms": 8.740593910217285,
|
||||
"p99_ms": 11.559625625610352
|
||||
},
|
||||
"frames": 4622,
|
||||
"gpu": {
|
||||
"avg_ms": 0.6778284030938654,
|
||||
"max_ms": 2.398207902908325,
|
||||
"p50_ms": 0.7833600044250488,
|
||||
"p95_ms": 0.9431040287017822,
|
||||
"p99_ms": 1.511423945426941
|
||||
},
|
||||
"low_1pct_fps": 71.97424221133898,
|
||||
"max_ms": 19.0070858001709,
|
||||
"p50_ms": 2.061105966567993,
|
||||
"p95_ms": 8.753417015075684,
|
||||
"p99_ms": 11.577004432678223,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 1.3568510900633204,
|
||||
"render": 0.7359144338432817,
|
||||
"tick": 0.41578289517198963,
|
||||
"update": 0.0810434000275639
|
||||
},
|
||||
"seconds": 12.002084188103677,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 1.6523635336693296,
|
||||
"render": 1.2426407299143203,
|
||||
"tick": 10.818658037388579,
|
||||
"update": 0.16372418847489864
|
||||
}
|
||||
},
|
||||
"mountain": {
|
||||
"avg_fps": 219.70895893367268,
|
||||
"cpu": {
|
||||
"avg_ms": 3.098228604710274,
|
||||
"max_ms": 23.518693923950195,
|
||||
"p50_ms": 2.0422279834747314,
|
||||
"p95_ms": 10.511551856994629,
|
||||
"p99_ms": 16.436803817749023
|
||||
},
|
||||
"frames": 2634,
|
||||
"gpu": {
|
||||
"avg_ms": 0.9377313036310374,
|
||||
"max_ms": 9.588735580444336,
|
||||
"p50_ms": 0.8488960266113281,
|
||||
"p95_ms": 0.9728000164031982,
|
||||
"p99_ms": 3.4201600551605225
|
||||
},
|
||||
"low_1pct_fps": 29.957383952355947,
|
||||
"max_ms": 33.38852310180664,
|
||||
"p50_ms": 2.0481808185577393,
|
||||
"p95_ms": 33.341556549072266,
|
||||
"p99_ms": 33.377445220947266,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 1.3405477057013806,
|
||||
"render": 0.8202416233113071,
|
||||
"tick": 0.8190081106798682,
|
||||
"update": 0.11872799601335143
|
||||
},
|
||||
"seconds": 11.988587141752243,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 1.648652986243919,
|
||||
"render": 1.7585702317732352,
|
||||
"tick": 14.372832368921351,
|
||||
"update": 1.0560209235107456
|
||||
}
|
||||
}
|
||||
},
|
||||
"32": {
|
||||
"renderer": {
|
||||
"backend": "Vulkan",
|
||||
"drawn_sections": 212,
|
||||
"gpu": "NVIDIA GeForce RTX 4080",
|
||||
"present_mode": "Immediate",
|
||||
"size": [
|
||||
1280,
|
||||
720
|
||||
],
|
||||
"uploaded_bytes": 255897088
|
||||
},
|
||||
"village": {
|
||||
"avg_fps": 48.3055081533635,
|
||||
"cpu": {
|
||||
"avg_ms": 9.376506657416572,
|
||||
"max_ms": 861.2249145507812,
|
||||
"p50_ms": 3.0502548217773438,
|
||||
"p95_ms": 32.94725036621094,
|
||||
"p99_ms": 74.04153442382812
|
||||
},
|
||||
"frames": 1271,
|
||||
"gpu": {
|
||||
"avg_ms": 1.7700931664342303,
|
||||
"max_ms": 20.69094467163086,
|
||||
"p50_ms": 1.2410880327224731,
|
||||
"p95_ms": 7.598080158233643,
|
||||
"p99_ms": 15.925248146057129
|
||||
},
|
||||
"low_1pct_fps": 0.761712600806078,
|
||||
"max_ms": 14330.3583984375,
|
||||
"p50_ms": 3.071429967880249,
|
||||
"p95_ms": 33.099002838134766,
|
||||
"p99_ms": 75.7547378540039,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 1.518610580008767,
|
||||
"render": 1.6828305973582713,
|
||||
"tick": 5.15484752775804,
|
||||
"update": 1.020680267595335
|
||||
},
|
||||
"seconds": 26.31169919514656,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.9654910931220422,
|
||||
"render": 3.5708756630237284,
|
||||
"tick": 196.6683132465069,
|
||||
"update": 14.971596607795128
|
||||
}
|
||||
},
|
||||
"forest": {
|
||||
"avg_fps": 27.53318156189366,
|
||||
"cpu": {
|
||||
"avg_ms": 40.13187835841161,
|
||||
"max_ms": 1166.3994140625,
|
||||
"p50_ms": 6.5535569190979,
|
||||
"p95_ms": 63.877315521240234,
|
||||
"p99_ms": 1108.11865234375
|
||||
},
|
||||
"frames": 271,
|
||||
"gpu": {
|
||||
"avg_ms": 2.297342118961785,
|
||||
"max_ms": 22.554624557495117,
|
||||
"p50_ms": 0.7065600156784058,
|
||||
"p95_ms": 7.245823860168457,
|
||||
"p99_ms": 9.055232048034668
|
||||
},
|
||||
"low_1pct_fps": 0.8848759980013957,
|
||||
"max_ms": 1166.4168701171875,
|
||||
"p50_ms": 6.675865173339844,
|
||||
"p95_ms": 59.150550842285156,
|
||||
"p99_ms": 1108.134521484375,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 2.83869483858017,
|
||||
"render": 2.5961379855321343,
|
||||
"tick": 33.04425997872419,
|
||||
"update": 1.6535338119387186
|
||||
},
|
||||
"seconds": 9.842669267654419,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 2.766425689061483,
|
||||
"render": 4.919464270273845,
|
||||
"tick": 1112.3431803385417,
|
||||
"update": 10.057182629903158
|
||||
}
|
||||
},
|
||||
"ocean": {
|
||||
"avg_fps": 67.01857611699128,
|
||||
"cpu": {
|
||||
"avg_ms": 14.669945189483148,
|
||||
"max_ms": 113.66764068603516,
|
||||
"p50_ms": 4.473541736602783,
|
||||
"p95_ms": 51.374263763427734,
|
||||
"p99_ms": 94.75230407714844
|
||||
},
|
||||
"frames": 802,
|
||||
"gpu": {
|
||||
"avg_ms": 2.9074973299021734,
|
||||
"max_ms": 22.526975631713867,
|
||||
"p50_ms": 1.8206720352172852,
|
||||
"p95_ms": 8.869888305664062,
|
||||
"p99_ms": 11.596799850463867
|
||||
},
|
||||
"low_1pct_fps": 10.111656570136391,
|
||||
"max_ms": 113.68443298339844,
|
||||
"p50_ms": 4.488469123840332,
|
||||
"p95_ms": 51.39033508300781,
|
||||
"p99_ms": 94.76870727539062,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 2.4291724538862556,
|
||||
"render": 2.242118674918006,
|
||||
"tick": 9.334139847333992,
|
||||
"update": 0.6651962902814967
|
||||
},
|
||||
"seconds": 11.966831384181976,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 2.7512698968251548,
|
||||
"render": 3.2687110900878906,
|
||||
"tick": 90.47323608398438,
|
||||
"update": 2.3865103986528187
|
||||
}
|
||||
},
|
||||
"mountain": {
|
||||
"avg_fps": 28.70327462003782,
|
||||
"cpu": {
|
||||
"avg_ms": 16.062305691622306,
|
||||
"max_ms": 67.72319793701172,
|
||||
"p50_ms": 10.215509414672852,
|
||||
"p95_ms": 45.747154235839844,
|
||||
"p99_ms": 63.64399337768555
|
||||
},
|
||||
"frames": 345,
|
||||
"gpu": {
|
||||
"avg_ms": 8.470281650363535,
|
||||
"max_ms": 21.519359588623047,
|
||||
"p50_ms": 8.845312118530273,
|
||||
"p95_ms": 15.868927955627441,
|
||||
"p99_ms": 17.557504653930664
|
||||
},
|
||||
"low_1pct_fps": 15.182693348952107,
|
||||
"max_ms": 67.74352264404297,
|
||||
"p50_ms": 33.36533737182617,
|
||||
"p95_ms": 46.64082336425781,
|
||||
"p99_ms": 63.65925979614258,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.8729904708654984,
|
||||
"render": 3.4296869785889337,
|
||||
"tick": 8.941314909811826,
|
||||
"update": 2.8190046198558116
|
||||
},
|
||||
"seconds": 12.01953451538086,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.8918330073356628,
|
||||
"render": 4.432150959968567,
|
||||
"tick": 50.667978286743164,
|
||||
"update": 9.854933142662048
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"load_before": [
|
||||
1.86328125,
|
||||
2.111328125,
|
||||
3.9072265625
|
||||
],
|
||||
"load_after": [
|
||||
8.4560546875,
|
||||
5.08544921875,
|
||||
4.68798828125
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
{
|
||||
"label": "after-lavapipe",
|
||||
"date": "2026-10-13",
|
||||
"commit": "9006566b",
|
||||
"host": "ludodesktop",
|
||||
"offscreen": false,
|
||||
"seed": "1234",
|
||||
"flight_seconds": 12.0,
|
||||
"scenes": {
|
||||
"village": {
|
||||
"from": [
|
||||
-960.0,
|
||||
125.0,
|
||||
80.0
|
||||
],
|
||||
"to": [
|
||||
-960.0,
|
||||
125.0,
|
||||
272.0
|
||||
],
|
||||
"pitch": 35.0
|
||||
},
|
||||
"forest": {
|
||||
"from": [
|
||||
-1100.0,
|
||||
120.0,
|
||||
-560.0
|
||||
],
|
||||
"to": [
|
||||
-1100.0,
|
||||
120.0,
|
||||
-368.0
|
||||
],
|
||||
"pitch": 25.0
|
||||
},
|
||||
"ocean": {
|
||||
"from": [
|
||||
-300.0,
|
||||
85.0,
|
||||
-420.0
|
||||
],
|
||||
"to": [
|
||||
-300.0,
|
||||
85.0,
|
||||
-228.0
|
||||
],
|
||||
"pitch": 10.0
|
||||
},
|
||||
"mountain": {
|
||||
"from": [
|
||||
560.0,
|
||||
260.0,
|
||||
860.0
|
||||
],
|
||||
"to": [
|
||||
752.0,
|
||||
260.0,
|
||||
860.0
|
||||
],
|
||||
"pitch": 30.0
|
||||
}
|
||||
},
|
||||
"distances": {
|
||||
"8": {
|
||||
"renderer": {
|
||||
"backend": "Vulkan",
|
||||
"drawn_sections": 156,
|
||||
"gpu": "llvmpipe (LLVM 21.1.8, 256 bits)",
|
||||
"present_mode": "Mailbox",
|
||||
"size": [
|
||||
1600,
|
||||
1000
|
||||
],
|
||||
"uploaded_bytes": 1278284288
|
||||
},
|
||||
"village": {
|
||||
"avg_fps": 58.3354572261441,
|
||||
"cpu": {
|
||||
"avg_ms": 11.363309368576322,
|
||||
"max_ms": 36.24496841430664,
|
||||
"p50_ms": 13.213258743286133,
|
||||
"p95_ms": 19.535802841186523,
|
||||
"p99_ms": 24.83148956298828
|
||||
},
|
||||
"frames": 700,
|
||||
"gpu": {
|
||||
"avg_ms": 13.518205827985492,
|
||||
"max_ms": 34.97639083862305,
|
||||
"p50_ms": 13.195829391479492,
|
||||
"p95_ms": 18.039182662963867,
|
||||
"p99_ms": 23.378856658935547
|
||||
},
|
||||
"low_1pct_fps": 31.312386939419746,
|
||||
"max_ms": 36.36259078979492,
|
||||
"p50_ms": 16.02706527709961,
|
||||
"p95_ms": 21.249738693237305,
|
||||
"p99_ms": 29.130090713500977,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.2306461141364915,
|
||||
"render": 9.720663217795746,
|
||||
"tick": 1.3112367039202113,
|
||||
"update": 0.10169148096282567
|
||||
},
|
||||
"seconds": 11.99956310081482,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.35282185673713684,
|
||||
"render": 25.05345916748047,
|
||||
"tick": 3.1022580585309436,
|
||||
"update": 0.38026727948869976
|
||||
}
|
||||
},
|
||||
"forest": {
|
||||
"avg_fps": 41.90001789962103,
|
||||
"cpu": {
|
||||
"avg_ms": 18.045572770611635,
|
||||
"max_ms": 36.546627044677734,
|
||||
"p50_ms": 20.543725967407227,
|
||||
"p95_ms": 30.5231990814209,
|
||||
"p99_ms": 34.017364501953125
|
||||
},
|
||||
"frames": 504,
|
||||
"gpu": {
|
||||
"avg_ms": 21.35815803588383,
|
||||
"max_ms": 36.23066329956055,
|
||||
"p50_ms": 19.825437545776367,
|
||||
"p95_ms": 29.274858474731445,
|
||||
"p99_ms": 33.32203674316406
|
||||
},
|
||||
"low_1pct_fps": 20.022920779459263,
|
||||
"max_ms": 99.08029174804688,
|
||||
"p50_ms": 22.576229095458984,
|
||||
"p95_ms": 32.772239685058594,
|
||||
"p99_ms": 36.67753982543945,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.22351377572686898,
|
||||
"render": 16.234788014183916,
|
||||
"tick": 1.4387834655649459,
|
||||
"update": 0.14943464463281017
|
||||
},
|
||||
"seconds": 12.028634479522704,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.2705736731489499,
|
||||
"render": 32.015567461649574,
|
||||
"tick": 2.741411516830946,
|
||||
"update": 0.3076988315830628
|
||||
}
|
||||
},
|
||||
"ocean": {
|
||||
"avg_fps": 60.413862302111134,
|
||||
"cpu": {
|
||||
"avg_ms": 10.874024665643429,
|
||||
"max_ms": 29.430055618286133,
|
||||
"p50_ms": 12.944002151489258,
|
||||
"p95_ms": 17.997587203979492,
|
||||
"p99_ms": 25.568126678466797
|
||||
},
|
||||
"frames": 725,
|
||||
"gpu": {
|
||||
"avg_ms": 12.736994123787715,
|
||||
"max_ms": 28.143625259399414,
|
||||
"p50_ms": 12.234904289245605,
|
||||
"p95_ms": 15.61594295501709,
|
||||
"p99_ms": 24.476089477539062
|
||||
},
|
||||
"low_1pct_fps": 34.82401357005238,
|
||||
"max_ms": 33.36631393432617,
|
||||
"p50_ms": 16.01299476623535,
|
||||
"p95_ms": 18.74709701538086,
|
||||
"p99_ms": 26.6020565032959,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.4042872669162421,
|
||||
"render": 9.401332649341946,
|
||||
"tick": 0.9126873913477977,
|
||||
"update": 0.15663292961387798
|
||||
},
|
||||
"seconds": 12.000557030677795,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.3962315060198307,
|
||||
"render": 26.506223678588867,
|
||||
"tick": 0.0020812499860767275,
|
||||
"update": 0.5454612672328949
|
||||
}
|
||||
},
|
||||
"mountain": {
|
||||
"avg_fps": 37.77761368324693,
|
||||
"cpu": {
|
||||
"avg_ms": 11.491443304826092,
|
||||
"max_ms": 34.474151611328125,
|
||||
"p50_ms": 13.871685981750488,
|
||||
"p95_ms": 20.351163864135742,
|
||||
"p99_ms": 23.77434539794922
|
||||
},
|
||||
"frames": 453,
|
||||
"gpu": {
|
||||
"avg_ms": 14.109076927302953,
|
||||
"max_ms": 35.115882873535156,
|
||||
"p50_ms": 13.555350303649902,
|
||||
"p95_ms": 18.336809158325195,
|
||||
"p99_ms": 30.865131378173828
|
||||
},
|
||||
"low_1pct_fps": 27.343844611059406,
|
||||
"max_ms": 39.0676155090332,
|
||||
"p50_ms": 33.33863830566406,
|
||||
"p95_ms": 33.36062240600586,
|
||||
"p99_ms": 34.297752380371094,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.22927819613457778,
|
||||
"render": 9.350584785242217,
|
||||
"tick": 1.7383085893987211,
|
||||
"update": 0.17420387428015405
|
||||
},
|
||||
"seconds": 11.991228556632995,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.2908208012580872,
|
||||
"render": 25.648976135253907,
|
||||
"tick": 1.9043863375205548,
|
||||
"update": 0.7624145835638046
|
||||
}
|
||||
}
|
||||
},
|
||||
"16": {
|
||||
"renderer": {
|
||||
"backend": "Vulkan",
|
||||
"drawn_sections": 0,
|
||||
"gpu": "llvmpipe (LLVM 21.1.8, 256 bits)",
|
||||
"present_mode": "Mailbox",
|
||||
"size": [
|
||||
1600,
|
||||
1000
|
||||
],
|
||||
"uploaded_bytes": 36807552
|
||||
},
|
||||
"village": {
|
||||
"avg_fps": 20.701493975921895,
|
||||
"cpu": {
|
||||
"avg_ms": 31.808356682459515,
|
||||
"max_ms": 74.47195434570312,
|
||||
"p50_ms": 41.86561584472656,
|
||||
"p95_ms": 56.75548553466797,
|
||||
"p99_ms": 73.00084686279297
|
||||
},
|
||||
"frames": 249,
|
||||
"gpu": {
|
||||
"avg_ms": 40.500388481140135,
|
||||
"max_ms": 67.22461700439453,
|
||||
"p50_ms": 40.38219451904297,
|
||||
"p95_ms": 50.01243209838867,
|
||||
"p99_ms": 62.00723648071289
|
||||
},
|
||||
"low_1pct_fps": 8.83914293384798,
|
||||
"max_ms": 133.2972869873047,
|
||||
"p50_ms": 47.37559509277344,
|
||||
"p95_ms": 61.3382682800293,
|
||||
"p99_ms": 74.61792755126953,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.30830518620081215,
|
||||
"render": 25.584145257750667,
|
||||
"tick": 5.116575629957185,
|
||||
"update": 0.8002672145165592
|
||||
},
|
||||
"seconds": 12.028117404937744,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.3641819953918457,
|
||||
"render": 44.77838750680288,
|
||||
"tick": 6.285618623097737,
|
||||
"update": 22.415442685286205
|
||||
}
|
||||
},
|
||||
"forest": {
|
||||
"avg_fps": 18.118065350089793,
|
||||
"cpu": {
|
||||
"avg_ms": 36.827518428137545,
|
||||
"max_ms": 85.7597427368164,
|
||||
"p50_ms": 49.536197662353516,
|
||||
"p95_ms": 62.31198501586914,
|
||||
"p99_ms": 66.591064453125
|
||||
},
|
||||
"frames": 218,
|
||||
"gpu": {
|
||||
"avg_ms": 47.82228247616269,
|
||||
"max_ms": 76.9173355102539,
|
||||
"p50_ms": 46.98957824707031,
|
||||
"p95_ms": 57.49673843383789,
|
||||
"p99_ms": 69.92066192626953
|
||||
},
|
||||
"low_1pct_fps": 11.528998473378772,
|
||||
"max_ms": 93.62928009033203,
|
||||
"p50_ms": 53.71598434448242,
|
||||
"p95_ms": 66.69908905029297,
|
||||
"p99_ms": 80.71000671386719,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.29564340877423595,
|
||||
"render": 30.678313442326466,
|
||||
"tick": 4.647872053591402,
|
||||
"update": 1.206680115073099
|
||||
},
|
||||
"seconds": 12.032189739227295,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.37793900569279987,
|
||||
"render": 66.15962092081706,
|
||||
"tick": 5.583541075388591,
|
||||
"update": 1.5741689602533977
|
||||
}
|
||||
},
|
||||
"ocean": {
|
||||
"avg_fps": 8.272097120557788,
|
||||
"cpu": {
|
||||
"avg_ms": 96.70907358733974,
|
||||
"max_ms": 208.84027099609375,
|
||||
"p50_ms": 89.04510498046875,
|
||||
"p95_ms": 169.3397979736328,
|
||||
"p99_ms": 208.84027099609375
|
||||
},
|
||||
"frames": 98,
|
||||
"gpu": {
|
||||
"avg_ms": 57.298700176939676,
|
||||
"max_ms": 66.62165832519531,
|
||||
"p50_ms": 57.89077377319336,
|
||||
"p95_ms": 65.73504638671875,
|
||||
"p99_ms": 66.62165832519531
|
||||
},
|
||||
"low_1pct_fps": 4.785729156618201,
|
||||
"max_ms": 208.95457458496094,
|
||||
"p50_ms": 140.0601348876953,
|
||||
"p95_ms": 178.57022094726562,
|
||||
"p99_ms": 208.95457458496094,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 3.3946731230434106,
|
||||
"render": 34.994675042982,
|
||||
"tick": 57.14290783599932,
|
||||
"update": 1.177816742536973
|
||||
},
|
||||
"seconds": 11.847056262969971,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 7.7776899337768555,
|
||||
"render": 70.36097717285156,
|
||||
"tick": 128.46734619140625,
|
||||
"update": 2.2351090908050537
|
||||
}
|
||||
},
|
||||
"mountain": {
|
||||
"avg_fps": 11.65883600782651,
|
||||
"cpu": {
|
||||
"avg_ms": 56.6912090659987,
|
||||
"max_ms": 107.11215209960938,
|
||||
"p50_ms": 77.04230499267578,
|
||||
"p95_ms": 97.60908508300781,
|
||||
"p99_ms": 104.08527374267578
|
||||
},
|
||||
"frames": 141,
|
||||
"gpu": {
|
||||
"avg_ms": 70.81221792731486,
|
||||
"max_ms": 96.33728790283203,
|
||||
"p50_ms": 69.16046905517578,
|
||||
"p95_ms": 86.61634063720703,
|
||||
"p99_ms": 92.56858825683594
|
||||
},
|
||||
"low_1pct_fps": 4.93309783391495,
|
||||
"max_ms": 216.64273071289062,
|
||||
"p50_ms": 81.59197235107422,
|
||||
"p95_ms": 104.20377349853516,
|
||||
"p99_ms": 188.7820281982422,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.31791428133105554,
|
||||
"render": 45.35119773817401,
|
||||
"tick": 9.391353438086544,
|
||||
"update": 1.6317692605104852
|
||||
},
|
||||
"seconds": 12.093831657409668,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.348672017455101,
|
||||
"render": 86.63534545898438,
|
||||
"tick": 13.065274715423584,
|
||||
"update": 5.5503233671188354
|
||||
}
|
||||
}
|
||||
},
|
||||
"32": {
|
||||
"renderer": {
|
||||
"backend": "Vulkan",
|
||||
"drawn_sections": 119,
|
||||
"gpu": "llvmpipe (LLVM 21.1.8, 256 bits)",
|
||||
"present_mode": "Mailbox",
|
||||
"size": [
|
||||
1600,
|
||||
1000
|
||||
],
|
||||
"uploaded_bytes": 222619264
|
||||
},
|
||||
"village": {
|
||||
"avg_fps": 5.310476400246329,
|
||||
"cpu": {
|
||||
"avg_ms": 111.2176975607872,
|
||||
"max_ms": 224.16696166992188,
|
||||
"p50_ms": 76.4469985961914,
|
||||
"p95_ms": 218.82273864746094,
|
||||
"p99_ms": 224.16696166992188
|
||||
},
|
||||
"frames": 64,
|
||||
"gpu": {
|
||||
"avg_ms": 121.35315322875977,
|
||||
"max_ms": 171.11207580566406,
|
||||
"p50_ms": 125.45780944824219,
|
||||
"p95_ms": 166.71322631835938,
|
||||
"p99_ms": 171.11207580566406
|
||||
},
|
||||
"low_1pct_fps": 3.59762228435401,
|
||||
"max_ms": 277.96136474609375,
|
||||
"p50_ms": 192.1078338623047,
|
||||
"p95_ms": 232.86318969726562,
|
||||
"p99_ms": 277.96136474609375,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.524940284434706,
|
||||
"render": 56.89460512250662,
|
||||
"tick": 38.0364211499691,
|
||||
"update": 15.762759290635586
|
||||
},
|
||||
"seconds": 12.051649452209473,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.5129500031471252,
|
||||
"render": 147.3928985595703,
|
||||
"tick": 62.813438415527344,
|
||||
"update": 13.448556900024414
|
||||
}
|
||||
},
|
||||
"forest": {
|
||||
"avg_fps": 3.899515654388116,
|
||||
"cpu": {
|
||||
"avg_ms": 155.46855975211935,
|
||||
"max_ms": 270.7467041015625,
|
||||
"p50_ms": 83.60679626464844,
|
||||
"p95_ms": 268.6576843261719,
|
||||
"p99_ms": 270.7467041015625
|
||||
},
|
||||
"frames": 47,
|
||||
"gpu": {
|
||||
"avg_ms": 192.77972445280656,
|
||||
"max_ms": 225.69723510742188,
|
||||
"p50_ms": 189.4962615966797,
|
||||
"p95_ms": 217.36190795898438,
|
||||
"p99_ms": 225.69723510742188
|
||||
},
|
||||
"low_1pct_fps": 3.3773198460417087,
|
||||
"max_ms": 296.0927734375,
|
||||
"p50_ms": 259.49017333984375,
|
||||
"p95_ms": 283.4007568359375,
|
||||
"p99_ms": 296.0927734375,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.5114446409205173,
|
||||
"render": 99.58217374821926,
|
||||
"tick": 43.03971359577585,
|
||||
"update": 12.336258025879555
|
||||
},
|
||||
"seconds": 12.052778900146484,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.5626839995384216,
|
||||
"render": 208.71124267578125,
|
||||
"tick": 53.47932052612305,
|
||||
"update": 7.994467258453369
|
||||
}
|
||||
},
|
||||
"ocean": {
|
||||
"avg_fps": 2.9455219461811217,
|
||||
"cpu": {
|
||||
"avg_ms": 287.98993862376494,
|
||||
"max_ms": 464.7646484375,
|
||||
"p50_ms": 286.4447326660156,
|
||||
"p95_ms": 444.6121520996094,
|
||||
"p99_ms": 464.7646484375
|
||||
},
|
||||
"frames": 34,
|
||||
"gpu": {
|
||||
"avg_ms": 116.96576734270369,
|
||||
"max_ms": 158.26394653320312,
|
||||
"p50_ms": 106.45819854736328,
|
||||
"p95_ms": 158.1454620361328,
|
||||
"p99_ms": 158.26394653320312
|
||||
},
|
||||
"low_1pct_fps": 2.151113067092828,
|
||||
"max_ms": 464.8756103515625,
|
||||
"p50_ms": 315.1367492675781,
|
||||
"p95_ms": 456.3304138183594,
|
||||
"p99_ms": 464.8756103515625,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 4.395756518139558,
|
||||
"render": 69.52432030789993,
|
||||
"tick": 206.13154108384077,
|
||||
"update": 7.93927874284632
|
||||
},
|
||||
"seconds": 11.542945739746093,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 4.144052982330322,
|
||||
"render": 166.14959716796875,
|
||||
"tick": 285.5132751464844,
|
||||
"update": 8.958683967590332
|
||||
}
|
||||
},
|
||||
"mountain": {
|
||||
"avg_fps": 3.983176689759444,
|
||||
"cpu": {
|
||||
"avg_ms": 171.41208145466257,
|
||||
"max_ms": 413.8367919921875,
|
||||
"p50_ms": 164.3318328857422,
|
||||
"p95_ms": 360.7731628417969,
|
||||
"p99_ms": 413.8367919921875
|
||||
},
|
||||
"frames": 47,
|
||||
"gpu": {
|
||||
"avg_ms": 166.80018520355225,
|
||||
"max_ms": 304.8785400390625,
|
||||
"p50_ms": 143.19728088378906,
|
||||
"p95_ms": 275.3953552246094,
|
||||
"p99_ms": 304.8785400390625
|
||||
},
|
||||
"low_1pct_fps": 2.418344167836982,
|
||||
"max_ms": 413.5060729980469,
|
||||
"p50_ms": 230.7030029296875,
|
||||
"p95_ms": 365.4028625488281,
|
||||
"p99_ms": 413.5060729980469,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.496992789684458,
|
||||
"render": 94.4349257743105,
|
||||
"tick": 61.33542941478973,
|
||||
"update": 15.14569672117842
|
||||
},
|
||||
"seconds": 11.799627197265625,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.4748440086841583,
|
||||
"render": 312.3215637207031,
|
||||
"tick": 86.37409973144531,
|
||||
"update": 14.667168617248535
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"load_before": [
|
||||
8.4560546875,
|
||||
5.08544921875,
|
||||
4.68798828125
|
||||
],
|
||||
"load_after": [
|
||||
10.98291015625,
|
||||
7.201171875,
|
||||
5.5615234375
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
{
|
||||
"label": "baseline-4080",
|
||||
"date": "2026-10-13",
|
||||
"commit": "27a32cc3",
|
||||
"host": "ludodesktop",
|
||||
"offscreen": true,
|
||||
"seed": "1234",
|
||||
"flight_seconds": 12.0,
|
||||
"scenes": {
|
||||
"village": {
|
||||
"from": [
|
||||
-960.0,
|
||||
125.0,
|
||||
80.0
|
||||
],
|
||||
"to": [
|
||||
-960.0,
|
||||
125.0,
|
||||
272.0
|
||||
],
|
||||
"pitch": 35.0
|
||||
},
|
||||
"forest": {
|
||||
"from": [
|
||||
-1100.0,
|
||||
120.0,
|
||||
-560.0
|
||||
],
|
||||
"to": [
|
||||
-1100.0,
|
||||
120.0,
|
||||
-368.0
|
||||
],
|
||||
"pitch": 25.0
|
||||
},
|
||||
"ocean": {
|
||||
"from": [
|
||||
-300.0,
|
||||
85.0,
|
||||
-420.0
|
||||
],
|
||||
"to": [
|
||||
-300.0,
|
||||
85.0,
|
||||
-228.0
|
||||
],
|
||||
"pitch": 10.0
|
||||
},
|
||||
"mountain": {
|
||||
"from": [
|
||||
560.0,
|
||||
260.0,
|
||||
860.0
|
||||
],
|
||||
"to": [
|
||||
752.0,
|
||||
260.0,
|
||||
860.0
|
||||
],
|
||||
"pitch": 30.0
|
||||
}
|
||||
},
|
||||
"distances": {
|
||||
"8": {
|
||||
"renderer": {
|
||||
"backend": "Vulkan",
|
||||
"drawn_sections": 158,
|
||||
"gpu": "NVIDIA GeForce RTX 4080",
|
||||
"present_mode": "Immediate",
|
||||
"size": [
|
||||
1280,
|
||||
720
|
||||
],
|
||||
"uploaded_bytes": 1137747200
|
||||
},
|
||||
"village": {
|
||||
"avg_fps": 362.94072531330323,
|
||||
"cpu": {
|
||||
"avg_ms": 2.734311161203731,
|
||||
"max_ms": 68.06377410888672,
|
||||
"p50_ms": 1.4820960760116577,
|
||||
"p95_ms": 7.922675609588623,
|
||||
"p99_ms": 35.78989028930664
|
||||
},
|
||||
"frames": 4373,
|
||||
"gpu": {
|
||||
"avg_ms": 0.4188921034758625,
|
||||
"max_ms": 2.5876479148864746,
|
||||
"p50_ms": 0.4106239974498749,
|
||||
"p95_ms": 0.5283839702606201,
|
||||
"p99_ms": 0.5427200198173523
|
||||
},
|
||||
"low_1pct_fps": 21.64332698060583,
|
||||
"max_ms": 68.08219146728516,
|
||||
"p50_ms": 1.4894599914550781,
|
||||
"p95_ms": 7.936410427093506,
|
||||
"p99_ms": 36.54151153564453,
|
||||
"seconds": 12.048799418210983
|
||||
},
|
||||
"forest": {
|
||||
"avg_fps": 613.1553113846195,
|
||||
"cpu": {
|
||||
"avg_ms": 1.6186122214214547,
|
||||
"max_ms": 216.98960876464844,
|
||||
"p50_ms": 1.10076105594635,
|
||||
"p95_ms": 3.0016868114471436,
|
||||
"p99_ms": 10.144570350646973
|
||||
},
|
||||
"frames": 6746,
|
||||
"gpu": {
|
||||
"avg_ms": 0.395751640401594,
|
||||
"max_ms": 1.99782395362854,
|
||||
"p50_ms": 0.3983359932899475,
|
||||
"p95_ms": 0.4833280146121979,
|
||||
"p99_ms": 0.5375999808311462
|
||||
},
|
||||
"low_1pct_fps": 49.639170314777054,
|
||||
"max_ms": 217.0211181640625,
|
||||
"p50_ms": 1.109616994857788,
|
||||
"p95_ms": 3.0161218643188477,
|
||||
"p99_ms": 10.163111686706543,
|
||||
"seconds": 11.002106439828873
|
||||
},
|
||||
"ocean": {
|
||||
"avg_fps": 323.23791637473414,
|
||||
"cpu": {
|
||||
"avg_ms": 2.7230400277200597,
|
||||
"max_ms": 4748.7744140625,
|
||||
"p50_ms": 0.9233829975128174,
|
||||
"p95_ms": 1.677724003791809,
|
||||
"p99_ms": 7.7397589683532715
|
||||
},
|
||||
"frames": 3870,
|
||||
"gpu": {
|
||||
"avg_ms": 0.24116999250656182,
|
||||
"max_ms": 0.954367995262146,
|
||||
"p50_ms": 0.2754560112953186,
|
||||
"p95_ms": 0.3389439880847931,
|
||||
"p99_ms": 0.3532800078392029
|
||||
},
|
||||
"low_1pct_fps": 5.421154869716093,
|
||||
"max_ms": 4748.80615234375,
|
||||
"p50_ms": 0.9285120368003845,
|
||||
"p95_ms": 1.7079449892044067,
|
||||
"p99_ms": 33.33257293701172,
|
||||
"seconds": 11.97260532861948
|
||||
},
|
||||
"mountain": {
|
||||
"avg_fps": 28.19080143244723,
|
||||
"cpu": {
|
||||
"avg_ms": 12.358911560308298,
|
||||
"max_ms": 187.3409881591797,
|
||||
"p50_ms": 9.513923645019531,
|
||||
"p95_ms": 25.783824920654297,
|
||||
"p99_ms": 110.71585845947266
|
||||
},
|
||||
"frames": 338,
|
||||
"gpu": {
|
||||
"avg_ms": 0.46002139662497143,
|
||||
"max_ms": 1.5626239776611328,
|
||||
"p50_ms": 0.4546560049057007,
|
||||
"p95_ms": 0.5140479803085327,
|
||||
"p99_ms": 0.5959680080413818
|
||||
},
|
||||
"low_1pct_fps": 7.428141780247131,
|
||||
"max_ms": 187.36033630371094,
|
||||
"p50_ms": 33.334835052490234,
|
||||
"p95_ms": 35.65903854370117,
|
||||
"p99_ms": 110.73509216308594,
|
||||
"seconds": 11.98972653579712
|
||||
}
|
||||
},
|
||||
"16": {
|
||||
"renderer": {
|
||||
"backend": "Vulkan",
|
||||
"drawn_sections": 1,
|
||||
"gpu": "NVIDIA GeForce RTX 4080",
|
||||
"present_mode": "Immediate",
|
||||
"size": [
|
||||
1280,
|
||||
720
|
||||
],
|
||||
"uploaded_bytes": 54284928
|
||||
},
|
||||
"village": {
|
||||
"avg_fps": 7.569165639843409,
|
||||
"cpu": {
|
||||
"avg_ms": 138.25648041658624,
|
||||
"max_ms": 877.6473999023438,
|
||||
"p50_ms": 103.97663879394531,
|
||||
"p95_ms": 318.5262145996094,
|
||||
"p99_ms": 877.6473999023438
|
||||
},
|
||||
"frames": 86,
|
||||
"gpu": {
|
||||
"avg_ms": 3.585262137790059,
|
||||
"max_ms": 8.415231704711914,
|
||||
"p50_ms": 3.299328088760376,
|
||||
"p95_ms": 7.171072006225586,
|
||||
"p99_ms": 8.415231704711914
|
||||
},
|
||||
"low_1pct_fps": 2.44461512120682,
|
||||
"max_ms": 409.0623474121094,
|
||||
"p50_ms": 104.01687622070312,
|
||||
"p95_ms": 318.5569763183594,
|
||||
"p99_ms": 409.0623474121094,
|
||||
"seconds": 11.361886381149292
|
||||
},
|
||||
"forest": {
|
||||
"avg_fps": 27.65529078792617,
|
||||
"cpu": {
|
||||
"avg_ms": 37.926895780927815,
|
||||
"max_ms": 3484.08349609375,
|
||||
"p50_ms": 3.398620128631592,
|
||||
"p95_ms": 44.56385040283203,
|
||||
"p99_ms": 684.4393920898438
|
||||
},
|
||||
"frames": 314,
|
||||
"gpu": {
|
||||
"avg_ms": 2.683724634467986,
|
||||
"max_ms": 9.881600379943848,
|
||||
"p50_ms": 1.8923519849777222,
|
||||
"p95_ms": 7.221248149871826,
|
||||
"p99_ms": 9.04089641571045
|
||||
},
|
||||
"low_1pct_fps": 0.4753349245465373,
|
||||
"max_ms": 3484.115234375,
|
||||
"p50_ms": 3.5420219898223877,
|
||||
"p95_ms": 44.591251373291016,
|
||||
"p99_ms": 159.3817901611328,
|
||||
"seconds": 11.354066113710404
|
||||
},
|
||||
"ocean": {
|
||||
"avg_fps": 59.16125632179682,
|
||||
"cpu": {
|
||||
"avg_ms": 16.807428855239674,
|
||||
"max_ms": 106.0348129272461,
|
||||
"p50_ms": 9.335607528686523,
|
||||
"p95_ms": 44.57380294799805,
|
||||
"p99_ms": 84.97154998779297
|
||||
},
|
||||
"frames": 712,
|
||||
"gpu": {
|
||||
"avg_ms": 1.958949391112736,
|
||||
"max_ms": 8.635392189025879,
|
||||
"p50_ms": 1.1182080507278442,
|
||||
"p95_ms": 5.493760108947754,
|
||||
"p99_ms": 7.108607769012451
|
||||
},
|
||||
"low_1pct_fps": 10.542641239411056,
|
||||
"max_ms": 106.06544494628906,
|
||||
"p50_ms": 9.364562034606934,
|
||||
"p95_ms": 45.14366912841797,
|
||||
"p99_ms": 84.9927749633789,
|
||||
"seconds": 12.034903317928315
|
||||
},
|
||||
"mountain": {
|
||||
"avg_fps": 26.78175419456072,
|
||||
"cpu": {
|
||||
"avg_ms": 20.230309660251326,
|
||||
"max_ms": 128.05508422851562,
|
||||
"p50_ms": 15.369550704956055,
|
||||
"p95_ms": 67.15973663330078,
|
||||
"p99_ms": 104.6961898803711
|
||||
},
|
||||
"frames": 325,
|
||||
"gpu": {
|
||||
"avg_ms": 7.143625645270714,
|
||||
"max_ms": 21.25823974609375,
|
||||
"p50_ms": 7.187456130981445,
|
||||
"p95_ms": 12.228608131408691,
|
||||
"p99_ms": 19.310592651367188
|
||||
},
|
||||
"low_1pct_fps": 7.777799947825389,
|
||||
"max_ms": 155.30685424804688,
|
||||
"p50_ms": 33.335182189941406,
|
||||
"p95_ms": 68.94293975830078,
|
||||
"p99_ms": 109.09304809570312,
|
||||
"seconds": 12.135127431869506
|
||||
}
|
||||
},
|
||||
"32": {
|
||||
"renderer": {
|
||||
"backend": "Vulkan",
|
||||
"drawn_sections": 0,
|
||||
"gpu": "NVIDIA GeForce RTX 4080",
|
||||
"present_mode": "Immediate",
|
||||
"size": [
|
||||
1280,
|
||||
720
|
||||
],
|
||||
"uploaded_bytes": 4829184
|
||||
},
|
||||
"village": {
|
||||
"avg_fps": 4.1477100728464,
|
||||
"cpu": {
|
||||
"avg_ms": 244.17284844846142,
|
||||
"max_ms": 386.7664489746094,
|
||||
"p50_ms": 244.8783416748047,
|
||||
"p95_ms": 363.0765380859375,
|
||||
"p99_ms": 386.7664489746094
|
||||
},
|
||||
"frames": 49,
|
||||
"gpu": {
|
||||
"avg_ms": 10.357822744213806,
|
||||
"max_ms": 23.010303497314453,
|
||||
"p50_ms": 9.227264404296875,
|
||||
"p95_ms": 19.309568405151367,
|
||||
"p99_ms": 23.010303497314453
|
||||
},
|
||||
"low_1pct_fps": 2.5853636290905797,
|
||||
"max_ms": 386.79278564453125,
|
||||
"p50_ms": 230.8652801513672,
|
||||
"p95_ms": 363.0941162109375,
|
||||
"p99_ms": 386.79278564453125,
|
||||
"seconds": 11.813747619628906
|
||||
},
|
||||
"forest": {
|
||||
"avg_fps": 8.3624969613463,
|
||||
"cpu": {
|
||||
"avg_ms": 118.74922356747165,
|
||||
"max_ms": 278.8187255859375,
|
||||
"p50_ms": 123.03407287597656,
|
||||
"p95_ms": 231.6608123779297,
|
||||
"p99_ms": 260.8450622558594
|
||||
},
|
||||
"frames": 101,
|
||||
"gpu": {
|
||||
"avg_ms": 15.898816661669477,
|
||||
"max_ms": 72.880126953125,
|
||||
"p50_ms": 20.789247512817383,
|
||||
"p95_ms": 28.441600799560547,
|
||||
"p99_ms": 60.816383361816406
|
||||
},
|
||||
"low_1pct_fps": 3.7057290596141406,
|
||||
"max_ms": 278.83917236328125,
|
||||
"p50_ms": 123.19290161132812,
|
||||
"p95_ms": 231.68026733398438,
|
||||
"p99_ms": 260.8656921386719,
|
||||
"seconds": 12.077732340812684
|
||||
},
|
||||
"ocean": {
|
||||
"avg_fps": 2.3954947141714045,
|
||||
"cpu": {
|
||||
"avg_ms": 424.7621285574777,
|
||||
"max_ms": 625.8532104492188,
|
||||
"p50_ms": 435.64044189453125,
|
||||
"p95_ms": 577.7593994140625,
|
||||
"p99_ms": 625.8532104492188
|
||||
},
|
||||
"frames": 28,
|
||||
"gpu": {
|
||||
"avg_ms": 9.483117733682905,
|
||||
"max_ms": 12.973055839538574,
|
||||
"p50_ms": 9.90719985961914,
|
||||
"p95_ms": 12.610560417175293,
|
||||
"p99_ms": 12.973055839538574
|
||||
},
|
||||
"low_1pct_fps": 1.5977632874291703,
|
||||
"max_ms": 625.8749389648438,
|
||||
"p50_ms": 433.9859619140625,
|
||||
"p95_ms": 577.7803955078125,
|
||||
"p99_ms": 625.8749389648438,
|
||||
"seconds": 11.688608551025391
|
||||
},
|
||||
"mountain": {
|
||||
"avg_fps": 3.3756775332390268,
|
||||
"cpu": {
|
||||
"avg_ms": 300.512197249975,
|
||||
"max_ms": 407.4363708496094,
|
||||
"p50_ms": 292.483642578125,
|
||||
"p95_ms": 394.8424377441406,
|
||||
"p99_ms": 407.4363708496094
|
||||
},
|
||||
"frames": 39,
|
||||
"gpu": {
|
||||
"avg_ms": 9.728866460995796,
|
||||
"max_ms": 18.930688858032227,
|
||||
"p50_ms": 8.820735931396484,
|
||||
"p95_ms": 17.399808883666992,
|
||||
"p99_ms": 18.930688858032227
|
||||
},
|
||||
"low_1pct_fps": 2.4541792422631716,
|
||||
"max_ms": 407.46820068359375,
|
||||
"p50_ms": 292.4177551269531,
|
||||
"p95_ms": 394.8649597167969,
|
||||
"p99_ms": 407.46820068359375,
|
||||
"seconds": 11.553236236572266
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
{
|
||||
"label": "baseline-lavapipe",
|
||||
"date": "2026-10-13",
|
||||
"commit": "27a32cc3",
|
||||
"host": "ludodesktop",
|
||||
"offscreen": false,
|
||||
"seed": "1234",
|
||||
"flight_seconds": 12.0,
|
||||
"scenes": {
|
||||
"village": {
|
||||
"from": [
|
||||
-960.0,
|
||||
125.0,
|
||||
80.0
|
||||
],
|
||||
"to": [
|
||||
-960.0,
|
||||
125.0,
|
||||
272.0
|
||||
],
|
||||
"pitch": 35.0
|
||||
},
|
||||
"forest": {
|
||||
"from": [
|
||||
-1100.0,
|
||||
120.0,
|
||||
-560.0
|
||||
],
|
||||
"to": [
|
||||
-1100.0,
|
||||
120.0,
|
||||
-368.0
|
||||
],
|
||||
"pitch": 25.0
|
||||
},
|
||||
"ocean": {
|
||||
"from": [
|
||||
-300.0,
|
||||
85.0,
|
||||
-420.0
|
||||
],
|
||||
"to": [
|
||||
-300.0,
|
||||
85.0,
|
||||
-228.0
|
||||
],
|
||||
"pitch": 10.0
|
||||
},
|
||||
"mountain": {
|
||||
"from": [
|
||||
560.0,
|
||||
260.0,
|
||||
860.0
|
||||
],
|
||||
"to": [
|
||||
752.0,
|
||||
260.0,
|
||||
860.0
|
||||
],
|
||||
"pitch": 30.0
|
||||
}
|
||||
},
|
||||
"distances": {
|
||||
"8": {
|
||||
"renderer": {
|
||||
"backend": "Vulkan",
|
||||
"drawn_sections": 146,
|
||||
"gpu": "llvmpipe (LLVM 21.1.8, 256 bits)",
|
||||
"present_mode": "Mailbox",
|
||||
"size": [
|
||||
1600,
|
||||
1000
|
||||
],
|
||||
"uploaded_bytes": 1288851072
|
||||
},
|
||||
"village": {
|
||||
"avg_fps": 16.38225374384963,
|
||||
"cpu": {
|
||||
"avg_ms": 48.17248754105975,
|
||||
"max_ms": 134.31028747558594,
|
||||
"p50_ms": 44.57072067260742,
|
||||
"p95_ms": 97.15985870361328,
|
||||
"p99_ms": 127.31073760986328
|
||||
},
|
||||
"frames": 199,
|
||||
"gpu": {
|
||||
"avg_ms": 26.732369270324707,
|
||||
"max_ms": 47.3107795715332,
|
||||
"p50_ms": 26.284116744995117,
|
||||
"p95_ms": 35.006874084472656,
|
||||
"p99_ms": 39.38005828857422
|
||||
},
|
||||
"low_1pct_fps": 6.569673692138871,
|
||||
"max_ms": 169.97352600097656,
|
||||
"p50_ms": 56.791988372802734,
|
||||
"p95_ms": 109.15220642089844,
|
||||
"p99_ms": 134.45559692382812,
|
||||
"seconds": 12.147290788650512
|
||||
},
|
||||
"forest": {
|
||||
"avg_fps": 19.917172468484612,
|
||||
"cpu": {
|
||||
"avg_ms": 31.039839970568817,
|
||||
"max_ms": 106.39506530761719,
|
||||
"p50_ms": 33.26064682006836,
|
||||
"p95_ms": 70.09876251220703,
|
||||
"p99_ms": 86.21255493164062
|
||||
},
|
||||
"frames": 240,
|
||||
"gpu": {
|
||||
"avg_ms": 37.272586759195285,
|
||||
"max_ms": 77.586669921875,
|
||||
"p50_ms": 34.38500213623047,
|
||||
"p95_ms": 59.649635314941406,
|
||||
"p99_ms": 66.52396392822266
|
||||
},
|
||||
"low_1pct_fps": 9.909291086862112,
|
||||
"max_ms": 106.57542419433594,
|
||||
"p50_ms": 46.05862808227539,
|
||||
"p95_ms": 80.38935852050781,
|
||||
"p99_ms": 94.94140625,
|
||||
"seconds": 12.049903186798096
|
||||
},
|
||||
"ocean": {
|
||||
"avg_fps": 27.969763129786234,
|
||||
"cpu": {
|
||||
"avg_ms": 25.69895526429727,
|
||||
"max_ms": 930.7354125976562,
|
||||
"p50_ms": 18.981081008911133,
|
||||
"p95_ms": 39.42117691040039,
|
||||
"p99_ms": 168.07225036621094
|
||||
},
|
||||
"frames": 336,
|
||||
"gpu": {
|
||||
"avg_ms": 18.856842384451912,
|
||||
"max_ms": 57.835872650146484,
|
||||
"p50_ms": 18.172868728637695,
|
||||
"p95_ms": 28.62451171875,
|
||||
"p99_ms": 44.414398193359375
|
||||
},
|
||||
"low_1pct_fps": 1.527565223414601,
|
||||
"max_ms": 930.8734741210938,
|
||||
"p50_ms": 26.45800018310547,
|
||||
"p95_ms": 50.053672790527344,
|
||||
"p99_ms": 180.92478942871094,
|
||||
"seconds": 12.012972667694092
|
||||
},
|
||||
"mountain": {
|
||||
"avg_fps": 26.5942562164059,
|
||||
"cpu": {
|
||||
"avg_ms": 22.482411369495093,
|
||||
"max_ms": 104.89199829101562,
|
||||
"p50_ms": 22.033512115478516,
|
||||
"p95_ms": 46.385555267333984,
|
||||
"p99_ms": 61.15697479248047
|
||||
},
|
||||
"frames": 320,
|
||||
"gpu": {
|
||||
"avg_ms": 23.762589550018312,
|
||||
"max_ms": 48.10239028930664,
|
||||
"p50_ms": 22.580347061157227,
|
||||
"p95_ms": 35.14606475830078,
|
||||
"p99_ms": 42.47938919067383
|
||||
},
|
||||
"low_1pct_fps": 11.197907022053611,
|
||||
"max_ms": 105.07585906982422,
|
||||
"p50_ms": 33.362815856933594,
|
||||
"p95_ms": 53.36695098876953,
|
||||
"p99_ms": 74.80219268798828,
|
||||
"seconds": 12.03267342376709
|
||||
}
|
||||
},
|
||||
"16": {
|
||||
"renderer": {
|
||||
"backend": "Vulkan",
|
||||
"drawn_sections": 1,
|
||||
"gpu": "llvmpipe (LLVM 21.1.8, 256 bits)",
|
||||
"present_mode": "Mailbox",
|
||||
"size": [
|
||||
1600,
|
||||
1000
|
||||
],
|
||||
"uploaded_bytes": 16106880
|
||||
},
|
||||
"village": {
|
||||
"avg_fps": 10.754658718707336,
|
||||
"cpu": {
|
||||
"avg_ms": 69.22208800682655,
|
||||
"max_ms": 193.21267700195312,
|
||||
"p50_ms": 66.95130157470703,
|
||||
"p95_ms": 154.44834899902344,
|
||||
"p99_ms": 191.29208374023438
|
||||
},
|
||||
"frames": 130,
|
||||
"gpu": {
|
||||
"avg_ms": 49.041961078495945,
|
||||
"max_ms": 73.83775329589844,
|
||||
"p50_ms": 48.90928268432617,
|
||||
"p95_ms": 60.60416793823242,
|
||||
"p99_ms": 66.1044921875
|
||||
},
|
||||
"low_1pct_fps": 4.171866301284614,
|
||||
"max_ms": 252.21458435058594,
|
||||
"p50_ms": 79.8940200805664,
|
||||
"p95_ms": 173.05947875976562,
|
||||
"p99_ms": 227.18716430664062,
|
||||
"seconds": 12.087784782409669
|
||||
},
|
||||
"forest": {
|
||||
"avg_fps": 12.167278778001634,
|
||||
"cpu": {
|
||||
"avg_ms": 58.366991678873696,
|
||||
"max_ms": 156.683349609375,
|
||||
"p50_ms": 69.24014282226562,
|
||||
"p95_ms": 110.83757019042969,
|
||||
"p99_ms": 134.16554260253906
|
||||
},
|
||||
"frames": 147,
|
||||
"gpu": {
|
||||
"avg_ms": 56.45229888606716,
|
||||
"max_ms": 78.26596069335938,
|
||||
"p50_ms": 55.92947006225586,
|
||||
"p95_ms": 63.54412841796875,
|
||||
"p99_ms": 66.5202407836914
|
||||
},
|
||||
"low_1pct_fps": 5.722095670390665,
|
||||
"max_ms": 212.99729919433594,
|
||||
"p50_ms": 77.36986541748047,
|
||||
"p95_ms": 122.84784698486328,
|
||||
"p99_ms": 136.52499389648438,
|
||||
"seconds": 12.081583950042724
|
||||
},
|
||||
"ocean": {
|
||||
"avg_fps": 7.681804845753147,
|
||||
"cpu": {
|
||||
"avg_ms": 98.35419226740743,
|
||||
"max_ms": 385.2493591308594,
|
||||
"p50_ms": 78.85044860839844,
|
||||
"p95_ms": 225.36231994628906,
|
||||
"p99_ms": 385.2493591308594
|
||||
},
|
||||
"frames": 91,
|
||||
"gpu": {
|
||||
"avg_ms": 71.51263585297957,
|
||||
"max_ms": 128.29212951660156,
|
||||
"p50_ms": 67.92412567138672,
|
||||
"p95_ms": 101.93219757080078,
|
||||
"p99_ms": 128.29212951660156
|
||||
},
|
||||
"low_1pct_fps": 2.5948566279945817,
|
||||
"max_ms": 385.37774658203125,
|
||||
"p50_ms": 123.91693878173828,
|
||||
"p95_ms": 225.49420166015625,
|
||||
"p99_ms": 385.37774658203125,
|
||||
"seconds": 11.846174411773681
|
||||
},
|
||||
"mountain": {
|
||||
"avg_fps": 5.886844433101332,
|
||||
"cpu": {
|
||||
"avg_ms": 124.87309415575484,
|
||||
"max_ms": 321.2157897949219,
|
||||
"p50_ms": 117.2811050415039,
|
||||
"p95_ms": 237.34304809570312,
|
||||
"p99_ms": 321.2157897949219
|
||||
},
|
||||
"frames": 71,
|
||||
"gpu": {
|
||||
"avg_ms": 95.46965434816148,
|
||||
"max_ms": 166.9488067626953,
|
||||
"p50_ms": 83.54202270507812,
|
||||
"p95_ms": 160.2403564453125,
|
||||
"p99_ms": 166.9488067626953
|
||||
},
|
||||
"low_1pct_fps": 3.111272818975726,
|
||||
"max_ms": 321.411865234375,
|
||||
"p50_ms": 153.0963134765625,
|
||||
"p95_ms": 275.37579345703125,
|
||||
"p99_ms": 321.411865234375,
|
||||
"seconds": 12.060790939331055
|
||||
}
|
||||
},
|
||||
"32": {
|
||||
"renderer": {
|
||||
"backend": "Vulkan",
|
||||
"drawn_sections": 1,
|
||||
"gpu": "llvmpipe (LLVM 21.1.8, 256 bits)",
|
||||
"present_mode": "Mailbox",
|
||||
"size": [
|
||||
1600,
|
||||
1000
|
||||
],
|
||||
"uploaded_bytes": 19154560
|
||||
},
|
||||
"village": {
|
||||
"avg_fps": 2.5956714101148957,
|
||||
"cpu": {
|
||||
"avg_ms": 345.63314671670236,
|
||||
"max_ms": 474.2013854980469,
|
||||
"p50_ms": 347.07415771484375,
|
||||
"p95_ms": 468.0090026855469,
|
||||
"p99_ms": 474.2013854980469
|
||||
},
|
||||
"frames": 31,
|
||||
"gpu": {
|
||||
"avg_ms": 85.50063645839691,
|
||||
"max_ms": 134.11460876464844,
|
||||
"p50_ms": 82.40946960449219,
|
||||
"p95_ms": 133.71929931640625,
|
||||
"p99_ms": 134.11460876464844
|
||||
},
|
||||
"low_1pct_fps": 2.01406006678458,
|
||||
"max_ms": 496.509521484375,
|
||||
"p50_ms": 390.230224609375,
|
||||
"p95_ms": 493.8070373535156,
|
||||
"p99_ms": 496.509521484375,
|
||||
"seconds": 11.942960067749024
|
||||
},
|
||||
"forest": {
|
||||
"avg_fps": 2.2283930626963917,
|
||||
"cpu": {
|
||||
"avg_ms": 354.7813931978666,
|
||||
"max_ms": 611.8099975585938,
|
||||
"p50_ms": 293.61376953125,
|
||||
"p95_ms": 601.3502197265625,
|
||||
"p99_ms": 611.8099975585938
|
||||
},
|
||||
"frames": 26,
|
||||
"gpu": {
|
||||
"avg_ms": 201.54420654296874,
|
||||
"max_ms": 357.0310363769531,
|
||||
"p50_ms": 172.65980529785156,
|
||||
"p95_ms": 341.8655700683594,
|
||||
"p99_ms": 357.0310363769531
|
||||
},
|
||||
"low_1pct_fps": 1.52339962842959,
|
||||
"max_ms": 656.4265747070312,
|
||||
"p50_ms": 407.1051330566406,
|
||||
"p95_ms": 611.97119140625,
|
||||
"p99_ms": 656.4265747070312,
|
||||
"seconds": 11.667600494384766
|
||||
},
|
||||
"ocean": {
|
||||
"avg_fps": 2.6137193379326398,
|
||||
"cpu": {
|
||||
"avg_ms": 330.783615620931,
|
||||
"max_ms": 730.7304077148438,
|
||||
"p50_ms": 296.65692138671875,
|
||||
"p95_ms": 680.8717041015625,
|
||||
"p99_ms": 730.7304077148438
|
||||
},
|
||||
"frames": 30,
|
||||
"gpu": {
|
||||
"avg_ms": 113.80178985595703,
|
||||
"max_ms": 216.57342529296875,
|
||||
"p50_ms": 104.23703002929688,
|
||||
"p95_ms": 193.67672729492188,
|
||||
"p99_ms": 216.57342529296875
|
||||
},
|
||||
"low_1pct_fps": 1.1784160273210693,
|
||||
"max_ms": 848.5967407226562,
|
||||
"p50_ms": 346.120849609375,
|
||||
"p95_ms": 681.0716552734375,
|
||||
"p99_ms": 848.5967407226562,
|
||||
"seconds": 11.477896484375
|
||||
},
|
||||
"mountain": {
|
||||
"avg_fps": 2.72441042065798,
|
||||
"cpu": {
|
||||
"avg_ms": 325.24547207740045,
|
||||
"max_ms": 578.7177124023438,
|
||||
"p50_ms": 295.25457763671875,
|
||||
"p95_ms": 569.8099975585938,
|
||||
"p99_ms": 578.7177124023438
|
||||
},
|
||||
"frames": 31,
|
||||
"gpu": {
|
||||
"avg_ms": 104.99177145957947,
|
||||
"max_ms": 163.95635986328125,
|
||||
"p50_ms": 99.96784210205078,
|
||||
"p95_ms": 160.04824829101562,
|
||||
"p99_ms": 163.95635986328125
|
||||
},
|
||||
"low_1pct_fps": 1.7544071931551564,
|
||||
"max_ms": 569.9931030273438,
|
||||
"p50_ms": 378.246337890625,
|
||||
"p95_ms": 555.5369873046875,
|
||||
"p99_ms": 569.9931030273438,
|
||||
"seconds": 11.378608657836914
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
# Reuse parsed model states
|
||||
|
||||
`models::prepare` parsed `model-states.json` to select blocks, then
|
||||
`StateModels::load` reopened and parsed the same document for binding and baking.
|
||||
`StateModels::from_facts` now borrows the first parsed map. The public `load`
|
||||
entry point still reads, parses and validates its own document. Both paths retain
|
||||
the `fs::read` + `serde_json::from_slice` strategy merged in PR #205.
|
||||
|
||||
The shared map uses `BTreeMap`, preserving the original catalog loader's JSON
|
||||
validation, including rejection of top-level `null`. The old selection map was
|
||||
`serde_json::Map`, also backed by a BTreeMap with the project's current features.
|
||||
Catalog binding, model selection and baking are unchanged. Preparation no longer
|
||||
holds a second complete JSON tree while constructing the catalog; no persistent
|
||||
cache or extra clones were added.
|
||||
|
||||
## Measurement
|
||||
|
||||
The committed `model_states_bench` example runs main's two-parse sequence after PR #205
|
||||
(first a serde JSON map, then `StateModels::load`) and the new one-parse sequence
|
||||
(first a BTreeMap, then `StateModels::from_facts`). Both select blocks from the
|
||||
same generated document with slice parsing and call the actual catalog binding/baking code. Timing
|
||||
includes file reads, parsing, filtering, binding, baking and result destruction.
|
||||
Fixture generation and equality checks run outside the timed region.
|
||||
|
||||
Prepared Minecraft resources were unavailable locally, so this is a synthetic
|
||||
2,836,696-byte document containing 843 block schemas and 53,952 states. Schemas
|
||||
combine integer and named properties. Blockstate resources use weighted and
|
||||
conditional multipart choices with rotations and UV locking, sharing one model
|
||||
with a baked face. Its absent texture uses the normal missing-sprite fallback.
|
||||
Both paths read identical files with a warm OS cache. The workload does not
|
||||
represent the full diversity of production model and texture assets.
|
||||
|
||||
On AMD Ryzen 5 5600X, Linux 7.2.8-zen1-2-zen x86_64, rustc 1.99.0
|
||||
(b940084d7 2026-09-28), Cargo `fast` profile, serial execution:
|
||||
|
||||
| Path | Median (15 samples) |
|
||||
| --- | ---: |
|
||||
| Before: parse twice, with #205 slice readers | 96.080 ms |
|
||||
| After: reuse first parse, with #205 slice readers | 73.585 ms |
|
||||
|
||||
**1.306x speedup; 23.41% less elapsed time** for this loading segment after PR #205. Each path
|
||||
has two warmups, sample order alternates, and inputs/results use `black_box`.
|
||||
This excludes gallery placements, GPU work and overall application startup.
|
||||
|
||||
These measurements supersede the original unbuffered-reader timings
|
||||
(2732.004 ms → 1386.060 ms, 1.971x) after rebasing onto main at
|
||||
`e96e1571`. The reviewer measured approximately 1.05x with real prepared resources;
|
||||
the generated fixture's balance of parsing and binding differs from production.
|
||||
The change still removes one complete document read, parse and JSON tree.
|
||||
|
||||
Equality validation compares every state's selected variants and baked geometry
|
||||
at three positions, plus sprite layers and opacity/translucency flags. The
|
||||
focused test also checks matching schema/registry errors, malformed/missing
|
||||
source files, rejection of `null`, and successful shared-facts loading after the
|
||||
source file has been deleted.
|
||||
|
||||
## Reproduce
|
||||
|
||||
```sh
|
||||
export CARGO_TARGET_DIR=/home/bob/Documents/horizon/target
|
||||
export CARGO_INCREMENTAL=0
|
||||
cargo run -p horizon-client --profile fast --example model_states_bench -- 15
|
||||
cargo test -p horizon-client --profile fast --example model_states_bench
|
||||
cargo clippy -p horizon-client --all-targets --profile fast -- -D warnings
|
||||
cargo fmt --all -- --check
|
||||
```
|
||||
|
||||
The target directory can be changed for another machine. The example creates and
|
||||
removes its fixtures in the system temporary directory.
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"date": "2026-10-15",
|
||||
"environment": "Linux x86_64/ext4/NVMe, Ryzen5 5600X, Rust1.99.0, Cargo fast",
|
||||
"scope": "actual resource_root vs original wrapper/builder; warmed storage; output equivalence outside timing",
|
||||
"warmups": 2,
|
||||
"median_samples": 11,
|
||||
"prepared_minecraft": "26.3",
|
||||
"prepared_schema": 7,
|
||||
"prepared_manifest_sha256": "fd03c686302748748e132ef3978b7b0bcf42024257e802985a5cc7175cc74298",
|
||||
"packs": [
|
||||
{
|
||||
"project": "faithful-32x",
|
||||
"version": "lDYpMiqk",
|
||||
"version_name": "[26.3] Faithful 32x \u2013 September 2026 Release",
|
||||
"publisher_page": "https://modrinth.com/resourcepack/faithful-32x/version/lDYpMiqk",
|
||||
"download_url": "https://cdn.modrinth.com/data/w0TnApzs/versions/lDYpMiqk/Faithful%2032x%20-%2026.3.zip",
|
||||
"zip_bytes": 13116019,
|
||||
"zip_sha512": "e90ec63af7f239dbb99aa20c98b23d830e76898f65d3e31b7f0b53bb36db12dcd8c38d6930c743237c9c3db7bf708423fa1d1b2fd497dd6c281dbe216b779db9",
|
||||
"asset_files": 5063,
|
||||
"assets_uncompressed_bytes": 12524266,
|
||||
"original_ms": 159.827,
|
||||
"cold_ms": 162.711,
|
||||
"hit_ms": 0.707
|
||||
},
|
||||
{
|
||||
"project": "default-hd-128x",
|
||||
"version": "rErHu3BE",
|
||||
"version_name": "Default HD 128x Demo/Alpha 1.8.2.7",
|
||||
"publisher_page": "https://modrinth.com/resourcepack/default-hd-128x/version/rErHu3BE",
|
||||
"download_url": "https://cdn.modrinth.com/data/orfTV6nk/versions/rErHu3BE/Default%20HD%20128x%20Demo%201.8.2.7.zip",
|
||||
"zip_bytes": 16876419,
|
||||
"zip_sha512": "f5f9606e92b986cd9c8b8e5ff2bb52db720cc7b38e013355bdc90089e01975a08d09750ef2fd727f753d88ec3fbcd067355179b490587f667b2995f8be81640f",
|
||||
"asset_files": 2318,
|
||||
"assets_uncompressed_bytes": 17315416,
|
||||
"original_ms": 111.501,
|
||||
"cold_ms": 114.386,
|
||||
"hit_ms": 0.31
|
||||
},
|
||||
{
|
||||
"project": "fpbr",
|
||||
"version": "dynf82xk",
|
||||
"version_name": "support 1.21.9 and newer",
|
||||
"publisher_page": "https://modrinth.com/resourcepack/fpbr/version/dynf82xk",
|
||||
"download_url": "https://cdn.modrinth.com/data/nvEpbd8t/versions/dynf82xk/FPBR-Main-1.21.9-R1.3.zip",
|
||||
"zip_bytes": 46737770,
|
||||
"zip_sha512": "64f1bbd036e205118f1d9c49a79558db2c1356ee988fb939c0023db1967d78e1bd05047f341ce6d65355717ba7ddb1330af57edbb4d5b8713c96ae62987274c5",
|
||||
"asset_files": 7001,
|
||||
"assets_uncompressed_bytes": 56356903,
|
||||
"original_ms": 347.639,
|
||||
"cold_ms": 348.012,
|
||||
"hit_ms": 0.448
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "cargo-horizon-mod"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.86"
|
||||
description = "cargo horizon-mod new/build/run: make Horizon mods in Rust (docs/mods.md)"
|
||||
|
||||
[dependencies]
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["pack"] }
|
||||
serde_json = "1.0.151"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,201 @@
|
||||
//! `cargo horizon-mod`: make Horizon mods in Rust (`docs/mods.md`).
|
||||
//!
|
||||
//! - `new <path> [--name "Name"] [--sdk-path <dir>]`: a mod crate from the template.
|
||||
//! - `build [--debug]`: the `.hmod` in `target/horizon-mod/`.
|
||||
//! - `run [--game <horizon-client>] [-- <game args>]`: build, then play with
|
||||
//! the mod; run `build` again and the game reloads it.
|
||||
use horizon_mods::{
|
||||
Manifest, Package,
|
||||
manifest::{MANIFEST, component_imports, componentize},
|
||||
};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, ExitCode, Stdio},
|
||||
};
|
||||
|
||||
const TARGET: &str = "wasm32-unknown-unknown";
|
||||
const SDK_GIT: &str = "{ git = \"https://github.com/ludovic111/horizon\" }";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
// Cargo runs `cargo-horizon-mod horizon-mod <args>`.
|
||||
let mut args: Vec<String> = std::env::args().skip(1).collect();
|
||||
if args.first().is_some_and(|a| a == "horizon-mod") {
|
||||
args.remove(0);
|
||||
}
|
||||
let result = match args.first().map(String::as_str) {
|
||||
Some("new") => new(&args[1..]),
|
||||
Some("build") => build(!args.iter().any(|a| a == "--debug")).map(|p| {
|
||||
println!("Built {}", p.display());
|
||||
}),
|
||||
Some("run") => run(&args[1..]),
|
||||
_ => Err("usage: cargo horizon-mod new <path> | build [--debug] | run [--game <horizon-client>] [-- <game args>]".into()),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("error: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The value after `flag` in `args`.
|
||||
fn flag<'a>(args: &'a [String], flag: &str) -> Option<&'a str> {
|
||||
let i = args.iter().position(|a| a == flag)?;
|
||||
args.get(i + 1).map(String::as_str)
|
||||
}
|
||||
|
||||
fn new(args: &[String]) -> Result<(), String> {
|
||||
let path = args
|
||||
.first()
|
||||
.filter(|a| !a.starts_with("--"))
|
||||
.ok_or("cargo horizon-mod new <path>")?;
|
||||
let dir = Path::new(path);
|
||||
if dir.exists() {
|
||||
return Err(format!("{} already exists", dir.display()));
|
||||
}
|
||||
let base = dir
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.ok_or("the path needs a folder name")?;
|
||||
let id: String = base
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
c.to_ascii_lowercase()
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let name = flag(args, "--name").map_or_else(|| base.replace(['-', '_'], " "), str::to_string);
|
||||
let sdk = match flag(args, "--sdk-path") {
|
||||
Some(p) => {
|
||||
let p = std::fs::canonicalize(p).map_err(|e| format!("--sdk-path {p}: {e}"))?;
|
||||
format!("{{ path = {:?} }}", p.display().to_string())
|
||||
}
|
||||
None => SDK_GIT.to_string(),
|
||||
};
|
||||
let fill = |t: &str| {
|
||||
t.replace("{crate}", &id.replace('_', "-"))
|
||||
.replace("{id}", &id)
|
||||
.replace("{name}", &name)
|
||||
.replace("{sdk}", &sdk)
|
||||
};
|
||||
let files = [
|
||||
("Cargo.toml", include_str!("../template/Cargo.toml.in")),
|
||||
(MANIFEST, include_str!("../template/horizon-mod.toml")),
|
||||
("src/lib.rs", include_str!("../template/src/lib.rs")),
|
||||
(".gitignore", include_str!("../template/gitignore")),
|
||||
];
|
||||
for (file, text) in files {
|
||||
let to = dir.join(file);
|
||||
std::fs::create_dir_all(to.parent().expect("a file in the mod"))
|
||||
.map_err(|e| e.to_string())?;
|
||||
std::fs::write(&to, fill(text)).map_err(|e| format!("{}: {e}", to.display()))?;
|
||||
}
|
||||
Manifest::parse(&std::fs::read_to_string(dir.join(MANIFEST)).map_err(|e| e.to_string())?)?;
|
||||
println!(
|
||||
"Created {} (mod id {id}). Next: cd {path} && cargo horizon-mod run",
|
||||
dir.display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Builds the crate in the current folder and packs `target/horizon-mod/<id>.hmod`.
|
||||
fn build(release: bool) -> Result<PathBuf, String> {
|
||||
let manifest_text = std::fs::read_to_string(MANIFEST)
|
||||
.map_err(|e| format!("{MANIFEST}: {e} (run this in a mod's folder)"))?;
|
||||
let manifest = Manifest::parse(&manifest_text)?;
|
||||
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
|
||||
let mut cmd = Command::new(cargo);
|
||||
cmd.args([
|
||||
"build",
|
||||
"--lib",
|
||||
"--target",
|
||||
TARGET,
|
||||
"--message-format=json-render-diagnostics",
|
||||
]);
|
||||
if release {
|
||||
cmd.arg("--release");
|
||||
}
|
||||
let out = cmd
|
||||
.stderr(Stdio::inherit())
|
||||
.output()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !out.status.success() {
|
||||
return Err(format!(
|
||||
"cargo build failed (is the target installed? rustup target add {TARGET})"
|
||||
));
|
||||
}
|
||||
let core = String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
|
||||
.filter(|m| m["reason"] == "compiler-artifact")
|
||||
.flat_map(|m| m["filenames"].as_array().cloned().unwrap_or_default())
|
||||
.filter_map(|f| f.as_str().map(PathBuf::from))
|
||||
.rfind(|f| f.extension().is_some_and(|e| e == "wasm"))
|
||||
.ok_or("the build made no .wasm: is the crate a cdylib?")?;
|
||||
let wasm = componentize(&std::fs::read(&core).map_err(|e| e.to_string())?)?;
|
||||
// The game refuses a component importing capabilities the manifest lacks.
|
||||
manifest.check_imports(component_imports(&wasm)?.iter().map(String::as_str))?;
|
||||
let target_dir = core.ancestors().nth(3).ok_or("unexpected target folder")?;
|
||||
let dir = target_dir.join("horizon-mod");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
let path = dir.join(format!("{}.hmod", manifest.id));
|
||||
// Write then rename, so a running game never reads half a file.
|
||||
let partial = path.with_extension("hmod.partial");
|
||||
std::fs::write(&partial, Package::write(&manifest_text, &wasm)?).map_err(|e| e.to_string())?;
|
||||
std::fs::rename(&partial, &path).map_err(|e| e.to_string())?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> Result<(), String> {
|
||||
let path = build(true)?;
|
||||
let game = flag(args, "--game")
|
||||
.map(str::to_string)
|
||||
.or_else(|| std::env::var("HORIZON_CLIENT").ok())
|
||||
.unwrap_or_else(|| "horizon-client".into());
|
||||
let extra = args.iter().skip_while(|a| *a != "--").skip(1);
|
||||
println!(
|
||||
"Starting {game} with {} (rebuild with `cargo horizon-mod build` to reload it)",
|
||||
path.display()
|
||||
);
|
||||
let status = Command::new(&game)
|
||||
.arg("--dev-mod")
|
||||
.arg(&path)
|
||||
.args(extra)
|
||||
.status()
|
||||
.map_err(|e| {
|
||||
format!("{game}: {e} (pass --game <path to horizon-client> or set HORIZON_CLIENT)")
|
||||
})?;
|
||||
status
|
||||
.success()
|
||||
.then_some(())
|
||||
.ok_or_else(|| format!("the game exited with {status}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_template_is_a_valid_mod() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mod-new-{}", std::process::id()));
|
||||
let path = dir.join("My-Mod");
|
||||
new(&[path.display().to_string(), "--name".into(), "My Mod".into()]).unwrap();
|
||||
let manifest =
|
||||
Manifest::parse(&std::fs::read_to_string(path.join(MANIFEST)).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
(manifest.id.as_str(), manifest.name.as_str()),
|
||||
("my_mod", "My Mod")
|
||||
);
|
||||
let cargo = std::fs::read_to_string(path.join("Cargo.toml")).unwrap();
|
||||
assert!(cargo.contains("name = \"my-mod\"") && cargo.contains(SDK_GIT));
|
||||
assert!(
|
||||
new(&[path.display().to_string()]).is_err(),
|
||||
"never overwrites"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "{crate}"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
horizon-mod-sdk = {sdk}
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
@@ -0,0 +1 @@
|
||||
/target
|
||||
@@ -0,0 +1,12 @@
|
||||
id = "{id}"
|
||||
name = "{name}"
|
||||
version = "0.1.0"
|
||||
description = ""
|
||||
authors = []
|
||||
# The Horizon mod API the SDK targets.
|
||||
api = "0.1"
|
||||
# client: runs in the player's game.
|
||||
side = "client"
|
||||
# What the mod may do, shown to players before they turn it on:
|
||||
# game-read (position, health, time), chat (show messages), hud (draw text).
|
||||
capabilities = ["chat"]
|
||||
@@ -0,0 +1,19 @@
|
||||
use horizon_mod_sdk::{Mod, Registration, chat};
|
||||
|
||||
struct MyMod {
|
||||
greeted: u32,
|
||||
}
|
||||
|
||||
impl Mod for MyMod {
|
||||
fn init() -> Result<(Self, Registration), String> {
|
||||
Ok((MyMod { greeted: 0 }, Registration::new().command("hello")))
|
||||
}
|
||||
|
||||
fn command(&mut self, _name: &str, args: &str) -> Result<Option<String>, String> {
|
||||
self.greeted += 1;
|
||||
chat::show(&format!("Hello {args}! ({} so far)", self.greeted));
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
horizon_mod_sdk::export_mod!(MyMod);
|
||||
@@ -50,29 +50,47 @@ impl ModelLibrary {
|
||||
/// A library that also knows `builtin/generated`, the parent of every
|
||||
/// flat item model; the native client registers it as a special model.
|
||||
pub fn with_item_generator(mut models: BTreeMap<String, CuboidModel>) -> Result<Self> {
|
||||
models.insert(
|
||||
GENERATED_MODEL.to_owned(),
|
||||
CuboidModel {
|
||||
parent: None,
|
||||
ambient_occlusion: None,
|
||||
gui_light: Some(GuiLight::Front),
|
||||
textures: BTreeMap::from([(
|
||||
"particle".to_owned(),
|
||||
TextureSlot::Reference("layer0".to_owned()),
|
||||
)]),
|
||||
elements: None,
|
||||
display: None,
|
||||
},
|
||||
);
|
||||
models.insert(GENERATED_MODEL.to_owned(), Self::item_generator_model());
|
||||
Self::new(models)
|
||||
}
|
||||
/// Register the item built-in while reusing already decoded resources.
|
||||
pub fn with_shared_item_generator(
|
||||
mut models: BTreeMap<String, Arc<CuboidModel>>,
|
||||
) -> Result<Self> {
|
||||
models.insert(
|
||||
GENERATED_MODEL.to_owned(),
|
||||
Arc::new(Self::item_generator_model()),
|
||||
);
|
||||
Self::from_shared(models)
|
||||
}
|
||||
fn item_generator_model() -> CuboidModel {
|
||||
CuboidModel {
|
||||
parent: None,
|
||||
ambient_occlusion: None,
|
||||
gui_light: Some(GuiLight::Front),
|
||||
textures: BTreeMap::from([(
|
||||
"particle".to_owned(),
|
||||
TextureSlot::Reference("layer0".to_owned()),
|
||||
)]),
|
||||
elements: None,
|
||||
display: None,
|
||||
}
|
||||
}
|
||||
pub fn new(models: BTreeMap<String, CuboidModel>) -> Result<Self> {
|
||||
Self::from_models(
|
||||
models
|
||||
.into_iter()
|
||||
.map(|(name, model)| (name, Arc::new(model))),
|
||||
)
|
||||
}
|
||||
/// Build an immutable resolution view without copying model geometry.
|
||||
pub fn from_shared(models: BTreeMap<String, Arc<CuboidModel>>) -> Result<Self> {
|
||||
Self::from_models(models)
|
||||
}
|
||||
fn from_models(models: impl IntoIterator<Item = (String, Arc<CuboidModel>)>) -> Result<Self> {
|
||||
let mut decoded = BTreeMap::new();
|
||||
for (name, model) in models {
|
||||
if decoded
|
||||
.insert(identifier(&name)?, Arc::new(model))
|
||||
.is_some()
|
||||
{
|
||||
if decoded.insert(identifier(&name)?, model).is_some() {
|
||||
return Err(error("duplicate normalized model identifier"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "horizon-bench"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
description = "The benchmark report format (docs/benchmark-report.md), shared by the game, the launcher and the upload"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
@@ -0,0 +1,259 @@
|
||||
//! The machine a report was measured on. CPU, RAM and OS are detected here; the GPU and the display
|
||||
//! come from the program that rendered (the game knows its adapter, the launcher its window).
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Hardware {
|
||||
/// `macos`, `linux`, `windows`.
|
||||
pub os: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub os_version: Option<String>,
|
||||
pub arch: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cpu: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cpu_threads: Option<usize>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ram_mb: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub gpu: Option<Gpu>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub display: Option<Display>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Gpu {
|
||||
pub name: String,
|
||||
pub vendor: String,
|
||||
pub driver: String,
|
||||
/// `metal`, `vulkan`, `dx12`, `gl`.
|
||||
pub backend: String,
|
||||
/// `integrated`, `discrete`, `virtual`, `cpu`, `other`.
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Display {
|
||||
/// Physical pixels.
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub scale: f64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub refresh_hz: Option<u32>,
|
||||
}
|
||||
|
||||
impl Hardware {
|
||||
pub fn detect() -> Hardware {
|
||||
Hardware {
|
||||
os: std::env::consts::OS.to_owned(),
|
||||
os_version: os_version(),
|
||||
arch: std::env::consts::ARCH.to_owned(),
|
||||
cpu: cpu(),
|
||||
cpu_threads: std::thread::available_parallelism().ok().map(|n| n.get()),
|
||||
ram_mb: ram_bytes().map(|b| b >> 20),
|
||||
gpu: None,
|
||||
display: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The one-minute load average: above about the number of threads the machine was busy.
|
||||
pub fn load_average() -> Option<f64> {
|
||||
if cfg!(target_os = "macos") {
|
||||
// "{ 1.23 1.10 1.00 }"
|
||||
output("sysctl", &["-n", "vm.loadavg"])?
|
||||
.trim_start_matches('{')
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.parse()
|
||||
.ok()
|
||||
} else {
|
||||
std::fs::read_to_string("/proc/loadavg")
|
||||
.ok()?
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.parse()
|
||||
.ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// `ac` or `battery`; `None` where it can't be read (and on machines without a battery on Linux).
|
||||
pub fn power_source() -> Option<&'static str> {
|
||||
if cfg!(target_os = "macos") {
|
||||
let batt = output("pmset", &["-g", "batt"])?;
|
||||
return Some(if batt.contains("'Battery Power'") {
|
||||
"battery"
|
||||
} else {
|
||||
"ac"
|
||||
});
|
||||
}
|
||||
let mut on_battery = None;
|
||||
for entry in std::fs::read_dir("/sys/class/power_supply").ok()?.flatten() {
|
||||
let read =
|
||||
|name: &str| std::fs::read_to_string(entry.path().join(name)).unwrap_or_default();
|
||||
match read("type").trim() {
|
||||
"Mains" if read("online").trim() == "1" => return Some("ac"),
|
||||
"Battery" => on_battery = Some("battery"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
on_battery
|
||||
}
|
||||
|
||||
/// Resident memory of a process, in bytes (macOS, Linux).
|
||||
pub fn rss_bytes(pid: u32) -> Option<u64> {
|
||||
if cfg!(target_os = "macos") {
|
||||
return output("ps", &["-o", "rss=", "-p", &pid.to_string()])?
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.map(|kb| kb * 1024);
|
||||
}
|
||||
let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
|
||||
let line = status.lines().find(|l| l.starts_with("VmRSS:"))?;
|
||||
line.split_whitespace()
|
||||
.nth(1)?
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.map(|kb| kb * 1024)
|
||||
}
|
||||
|
||||
/// CPU time a process has used, user and system, in seconds (macOS, Linux).
|
||||
pub fn cpu_seconds(pid: u32) -> Option<f64> {
|
||||
if cfg!(target_os = "macos") {
|
||||
// "[[dd-]hh:]mm:ss.cc"
|
||||
let time = output("ps", &["-o", "time=", "-p", &pid.to_string()])?;
|
||||
let (rest, days) = match time.split_once('-') {
|
||||
Some((d, rest)) => (rest.to_owned(), d.parse::<f64>().ok()?),
|
||||
None => (time, 0.0),
|
||||
};
|
||||
let seconds = rest.split(':').try_fold(0.0, |acc, part| {
|
||||
part.parse::<f64>().ok().map(|v| acc * 60.0 + v)
|
||||
})?;
|
||||
return Some(days * 86_400.0 + seconds);
|
||||
}
|
||||
// utime and stime, fields 14 and 15, in clock ticks (100 per second on Linux).
|
||||
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
|
||||
let fields: Vec<&str> = stat.rsplit_once(')')?.1.split_whitespace().collect();
|
||||
let ticks: f64 = fields.get(11)?.parse::<f64>().ok()? + fields.get(12)?.parse::<f64>().ok()?;
|
||||
Some(ticks / 100.0)
|
||||
}
|
||||
|
||||
fn output(program: &str, args: &[&str]) -> Option<String> {
|
||||
let out = Command::new(program).args(args).output().ok()?;
|
||||
let text = String::from_utf8(out.stdout).ok()?.trim().to_owned();
|
||||
(out.status.success() && !text.is_empty()).then_some(text)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn os_version() -> Option<String> {
|
||||
output("sw_vers", &["-productVersion"])
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn cpu() -> Option<String> {
|
||||
output("sysctl", &["-n", "machdep.cpu.brand_string"])
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
output("sysctl", &["-n", "hw.memsize"])?.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn os_version() -> Option<String> {
|
||||
let release = std::fs::read_to_string("/etc/os-release").ok()?;
|
||||
let name = key_value(&release, "PRETTY_NAME", '=')?
|
||||
.trim_matches('"')
|
||||
.to_owned();
|
||||
Some(match output("uname", &["-r"]) {
|
||||
Some(kernel) => format!("{name} (kernel {kernel})"),
|
||||
None => name,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn cpu() -> Option<String> {
|
||||
let info = std::fs::read_to_string("/proc/cpuinfo").ok()?;
|
||||
// x86 names the CPU per core; ARM boards name the machine once.
|
||||
key_value(&info, "model name", ':')
|
||||
.or_else(|| key_value(&info, "Model", ':'))
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
let info = std::fs::read_to_string("/proc/meminfo").ok()?;
|
||||
let kb = key_value(&info, "MemTotal", ':')?
|
||||
.trim_end_matches("kB")
|
||||
.trim();
|
||||
kb.parse::<u64>().ok().map(|kb| kb * 1024)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn key_value<'a>(text: &'a str, key: &str, sep: char) -> Option<&'a str> {
|
||||
text.lines().find_map(|line| {
|
||||
let (k, v) = line.split_once(sep)?;
|
||||
(k.trim() == key).then(|| v.trim())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn os_version() -> Option<String> {
|
||||
output("cmd", &["/C", "ver"])
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn cpu() -> Option<String> {
|
||||
std::env::var("PROCESSOR_IDENTIFIER").ok()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
let out = output(
|
||||
"powershell",
|
||||
&[
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory",
|
||||
],
|
||||
)?;
|
||||
out.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
fn os_version() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
fn cpu() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detects_this_machine() {
|
||||
let hw = Hardware::detect();
|
||||
assert_eq!(hw.os, std::env::consts::OS);
|
||||
assert!(hw.cpu_threads.unwrap() >= 1);
|
||||
if cfg!(any(target_os = "macos", target_os = "linux")) {
|
||||
assert!(hw.cpu.is_some(), "{hw:?}");
|
||||
assert!(hw.ram_mb.unwrap() > 256, "{hw:?}");
|
||||
assert!(hw.os_version.is_some(), "{hw:?}");
|
||||
assert!(load_average().is_some());
|
||||
assert!(rss_bytes(std::process::id()).unwrap() > 1 << 20);
|
||||
assert!(cpu_seconds(std::process::id()).unwrap() >= 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
//! The benchmark report format, version 1 (docs/benchmark-report.md).
|
||||
//!
|
||||
//! One JSON file per run, the same for the game, the launcher and `tools/perf_bench.py`: the local log
|
||||
//! a player can attach, and, with consent, what the telemetry upload sends ([`Report::for_upload`]).
|
||||
//! It holds summaries and fixed-edge histogram counts, never raw frame streams.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
mod hardware;
|
||||
|
||||
pub use hardware::{cpu_seconds, load_average, power_source, rss_bytes, Display, Gpu, Hardware};
|
||||
|
||||
pub const FORMAT: &str = "horizon-benchmark-report";
|
||||
pub const VERSION: u32 = 1;
|
||||
|
||||
/// Lower edges of the frame-time histogram buckets, in milliseconds; the last bucket is 1000 ms and
|
||||
/// over. Fixed by the format version so histograms from any number of reports add up.
|
||||
pub const HISTOGRAM_EDGES_MS: [f64; 24] = [
|
||||
0.0, 1.0, 2.0, 3.0, 4.17, 5.0, 6.0, 7.0, 8.33, 10.0, 12.0, 14.0, 16.67, 20.0, 25.0, 33.33,
|
||||
40.0, 50.0, 66.67, 100.0, 150.0, 250.0, 500.0, 1000.0,
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Kind {
|
||||
Game,
|
||||
Launcher,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct App {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub commit: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub mods: Vec<Mod>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Mod {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
/// What has to match for two reports to be compared. Every field is optional.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Conditions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub scenario: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub seed: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub render_distance: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub simulation_distance: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub window: Option<[u32; 2]>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub vsync: Option<bool>,
|
||||
/// Absent: unlimited.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub fps_cap: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub offscreen: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub repeats: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub warmup_s: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub power: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub load_avg_1m: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub backend: Option<String>,
|
||||
}
|
||||
|
||||
/// A measurement over a run's repeats.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Summary {
|
||||
pub n: usize,
|
||||
pub mean: f64,
|
||||
/// Sample standard deviation; `None` for a single value.
|
||||
pub stdev: Option<f64>,
|
||||
pub min: f64,
|
||||
pub max: f64,
|
||||
/// Half-width of the 95% confidence interval of the mean (Student's t); `None` for a single value.
|
||||
pub ci95: Option<f64>,
|
||||
}
|
||||
|
||||
impl Summary {
|
||||
/// `None` for no values.
|
||||
pub fn of(values: &[f64]) -> Option<Summary> {
|
||||
let n = values.len();
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
let mean = values.iter().sum::<f64>() / n as f64;
|
||||
let (stdev, ci95) = if n > 1 {
|
||||
let var = values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n - 1) as f64;
|
||||
let sd = var.sqrt();
|
||||
(Some(sd), Some(t95(n - 1) * sd / (n as f64).sqrt()))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
Some(Summary {
|
||||
n,
|
||||
mean: round(mean),
|
||||
stdev: stdev.map(round),
|
||||
min: round(values.iter().copied().fold(f64::INFINITY, f64::min)),
|
||||
max: round(values.iter().copied().fold(f64::NEG_INFINITY, f64::max)),
|
||||
ci95: ci95.map(round),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Two-sided 95% critical value of Student's t for `df` degrees of freedom.
|
||||
fn t95(df: usize) -> f64 {
|
||||
const TABLE: [f64; 30] = [
|
||||
12.706, 4.303, 3.182, 2.776, 2.571, 2.447, 2.365, 2.306, 2.262, 2.228, 2.201, 2.179, 2.160,
|
||||
2.145, 2.131, 2.120, 2.110, 2.101, 2.093, 2.086, 2.080, 2.074, 2.069, 2.064, 2.060, 2.056,
|
||||
2.052, 2.048, 2.045, 2.042,
|
||||
];
|
||||
TABLE.get(df.wrapping_sub(1)).copied().unwrap_or(1.96)
|
||||
}
|
||||
|
||||
/// One measured phase of one repeat.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Frames {
|
||||
pub phase: String,
|
||||
pub repeat: u32,
|
||||
pub frames: usize,
|
||||
pub seconds: f64,
|
||||
pub avg_fps: f64,
|
||||
pub low_1pct_fps: f64,
|
||||
pub low_01pct_fps: f64,
|
||||
pub p50_ms: f64,
|
||||
pub p95_ms: f64,
|
||||
pub p99_ms: f64,
|
||||
pub p999_ms: f64,
|
||||
pub max_ms: f64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cpu_p50_ms: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub gpu_p50_ms: Option<f64>,
|
||||
/// Frame counts per bucket of [`HISTOGRAM_EDGES_MS`].
|
||||
pub histogram: Vec<u64>,
|
||||
}
|
||||
|
||||
impl Frames {
|
||||
/// The figures of a phase from its frame times in milliseconds (time between the starts of
|
||||
/// consecutive frames); `None` for no frames. CPU and GPU times are per frame too, when measured.
|
||||
pub fn of(
|
||||
phase: &str,
|
||||
repeat: u32,
|
||||
frame_ms: &[f32],
|
||||
cpu_ms: &[f32],
|
||||
gpu_ms: &[f32],
|
||||
) -> Option<Frames> {
|
||||
let times = sorted(frame_ms);
|
||||
let n = times.len();
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
let total: f64 = times.iter().sum();
|
||||
// The average frame time of the slowest `share` of frames, as frames per second.
|
||||
let low = |share: f64| {
|
||||
let slowest = ×[n - ((n as f64 * share).ceil() as usize).max(1)..];
|
||||
1000.0 * slowest.len() as f64 / slowest.iter().sum::<f64>()
|
||||
};
|
||||
let mut histogram = vec![0; HISTOGRAM_EDGES_MS.len()];
|
||||
for &t in × {
|
||||
histogram[HISTOGRAM_EDGES_MS.partition_point(|&edge| edge <= t).max(1) - 1] += 1;
|
||||
}
|
||||
Some(Frames {
|
||||
phase: phase.to_owned(),
|
||||
repeat,
|
||||
frames: n,
|
||||
seconds: round(total / 1000.0),
|
||||
avg_fps: round(n as f64 * 1000.0 / total),
|
||||
low_1pct_fps: round(low(0.01)),
|
||||
low_01pct_fps: round(low(0.001)),
|
||||
p50_ms: round(percentile(×, 0.5)),
|
||||
p95_ms: round(percentile(×, 0.95)),
|
||||
p99_ms: round(percentile(×, 0.99)),
|
||||
p999_ms: round(percentile(×, 0.999)),
|
||||
max_ms: round(times[n - 1]),
|
||||
cpu_p50_ms: (!cpu_ms.is_empty()).then(|| round(percentile(&sorted(cpu_ms), 0.5))),
|
||||
gpu_p50_ms: (!gpu_ms.is_empty()).then(|| round(percentile(&sorted(gpu_ms), 0.5))),
|
||||
histogram,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn sorted(ms: &[f32]) -> Vec<f64> {
|
||||
let mut v: Vec<f64> = ms
|
||||
.iter()
|
||||
.map(|&m| f64::from(m))
|
||||
.filter(|m| m.is_finite())
|
||||
.collect();
|
||||
v.sort_by(f64::total_cmp);
|
||||
v
|
||||
}
|
||||
|
||||
/// Three decimals: plenty for milliseconds and FPS, short in JSON, and parsed back exactly.
|
||||
fn round(v: f64) -> f64 {
|
||||
(v * 1000.0).round() / 1000.0
|
||||
}
|
||||
|
||||
/// Nearest-rank percentile of sorted, non-empty values.
|
||||
fn percentile(sorted: &[f64], p: f64) -> f64 {
|
||||
let n = sorted.len();
|
||||
sorted[((n as f64 * p).ceil() as usize).clamp(1, n) - 1]
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Report {
|
||||
pub format: String,
|
||||
pub version: u32,
|
||||
pub kind: Kind,
|
||||
/// When the run ended, UTC, `YYYY-MM-DDTHH:MM:SSZ`.
|
||||
pub created: String,
|
||||
pub app: App,
|
||||
pub hardware: Hardware,
|
||||
#[serde(default)]
|
||||
pub conditions: Conditions,
|
||||
/// Scalar measurements by name; the unit is the name's suffix (`_ms`, `_mb`...).
|
||||
#[serde(default)]
|
||||
pub metrics: BTreeMap<String, Summary>,
|
||||
#[serde(default)]
|
||||
pub frames: Vec<Frames>,
|
||||
/// Local only: a name the person running it chose.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub label: Option<String>,
|
||||
/// Local only: logs the run wrote next to the report.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub local: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
/// An empty report for this machine, created now.
|
||||
pub fn new(kind: Kind, app: App) -> Report {
|
||||
Report {
|
||||
format: FORMAT.to_owned(),
|
||||
version: VERSION,
|
||||
kind,
|
||||
created: utc_now(),
|
||||
app,
|
||||
hardware: Hardware::detect(),
|
||||
conditions: Conditions::default(),
|
||||
metrics: BTreeMap::new(),
|
||||
frames: Vec::new(),
|
||||
label: None,
|
||||
local: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Records a measurement over the repeats; no values records nothing.
|
||||
pub fn metric(&mut self, name: &str, values: &[f64]) {
|
||||
if let Some(summary) = Summary::of(values) {
|
||||
self.metrics.insert(name.to_owned(), summary);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a report, rejecting other formats and newer versions.
|
||||
pub fn parse(json: &str) -> Result<Report, String> {
|
||||
let report: Report = serde_json::from_str(json).map_err(|e| e.to_string())?;
|
||||
if report.format != FORMAT {
|
||||
return Err(format!(
|
||||
"not a benchmark report: format {:?}",
|
||||
report.format
|
||||
));
|
||||
}
|
||||
if report.version > VERSION {
|
||||
return Err(format!(
|
||||
"report version {} is newer than {VERSION}",
|
||||
report.version
|
||||
));
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// The report without its local-only fields, dated to the day: what an upload sends.
|
||||
pub fn for_upload(&self) -> Report {
|
||||
let day = self.created.get(..10).unwrap_or_default();
|
||||
Report {
|
||||
label: None,
|
||||
local: Vec::new(),
|
||||
created: format!("{day}T00:00:00Z"),
|
||||
..self.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_json(&self) -> String {
|
||||
serde_json::to_string_pretty(self).expect("a report always serializes")
|
||||
}
|
||||
|
||||
/// Writes `<dir>/<kind>-<created>-<scenario>.json` and returns its path.
|
||||
pub fn write_to(&self, dir: &Path) -> io::Result<PathBuf> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let kind = match self.kind {
|
||||
Kind::Game => "game",
|
||||
Kind::Launcher => "launcher",
|
||||
};
|
||||
let stamp: String = self.created.chars().filter(char::is_ascii_digit).collect();
|
||||
let scenario: String = self
|
||||
.conditions
|
||||
.scenario
|
||||
.as_deref()
|
||||
.unwrap_or("run")
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '-' {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let path = dir.join(format!("{kind}-{stamp}-{scenario}.json"));
|
||||
std::fs::write(&path, self.to_json() + "\n")?;
|
||||
Ok(path)
|
||||
}
|
||||
}
|
||||
|
||||
/// The current time, UTC, `YYYY-MM-DDTHH:MM:SSZ`.
|
||||
pub fn utc_now() -> String {
|
||||
let secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs());
|
||||
utc(secs)
|
||||
}
|
||||
|
||||
fn utc(secs: u64) -> String {
|
||||
let (days, rem) = (secs / 86_400, secs % 86_400);
|
||||
// Civil date from days since 1970-01-01 (Howard Hinnant's algorithm).
|
||||
let z = days as i64 + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let day = doy - (153 * mp + 2) / 5 + 1;
|
||||
let month = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let year = yoe + era * 400 + i64::from(month <= 2);
|
||||
format!(
|
||||
"{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z",
|
||||
rem / 3600,
|
||||
rem / 60 % 60,
|
||||
rem % 60
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn utc_dates() {
|
||||
assert_eq!(utc(0), "1970-01-01T00:00:00Z");
|
||||
assert_eq!(utc(951_782_400), "2000-02-29T00:00:00Z");
|
||||
assert_eq!(utc(1_791_983_007), "2026-10-14T13:03:27Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_of_repeats() {
|
||||
assert_eq!(Summary::of(&[]), None);
|
||||
let one = Summary::of(&[5.0]).unwrap();
|
||||
assert_eq!((one.n, one.mean, one.stdev, one.ci95), (1, 5.0, None, None));
|
||||
let s = Summary::of(&[2.0, 4.0, 6.0]).unwrap();
|
||||
assert_eq!((s.mean, s.min, s.max), (4.0, 2.0, 6.0));
|
||||
assert_eq!(s.stdev, Some(2.0));
|
||||
assert_eq!(s.ci95, Some(4.969), "4.303 * 2 / sqrt(3)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_figures_and_histogram() {
|
||||
// 998 frames of 2 ms, one of 20 ms, one of 1500 ms.
|
||||
let mut ms = vec![2.0f32; 998];
|
||||
ms.extend([20.0, 1500.0]);
|
||||
let f = Frames::of("flight", 0, &ms, &[], &[]).unwrap();
|
||||
assert_eq!(f.frames, 1000);
|
||||
assert_eq!(f.seconds, 3.516);
|
||||
assert_eq!((f.p50_ms, f.p999_ms, f.max_ms), (2.0, 20.0, 1500.0));
|
||||
// Slowest 1% = 10 frames: 8 of 2 ms, 20 ms, 1500 ms.
|
||||
assert_eq!(f.low_1pct_fps, 6.51, "1000 * 10 / 1536");
|
||||
// Slowest 0.1% = the 1500 ms frame.
|
||||
assert_eq!(f.low_01pct_fps, 0.667);
|
||||
assert_eq!(f.histogram.len(), HISTOGRAM_EDGES_MS.len());
|
||||
assert_eq!(f.histogram[2], 998, "2 ms falls in [2, 3)");
|
||||
assert_eq!(f.histogram[13], 1, "20 ms falls in [20, 25)");
|
||||
assert_eq!(f.histogram[23], 1, "1500 ms falls in [1000, inf)");
|
||||
assert_eq!(f.histogram.iter().sum::<u64>(), 1000);
|
||||
assert_eq!((f.cpu_p50_ms, f.gpu_p50_ms), (None, None));
|
||||
assert_eq!(Frames::of("x", 0, &[], &[], &[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_and_upload_strips_local_fields() {
|
||||
let mut r = Report::new(
|
||||
Kind::Game,
|
||||
App {
|
||||
name: "horizon-client".into(),
|
||||
version: "0.1.0".into(),
|
||||
..App::default()
|
||||
},
|
||||
);
|
||||
r.conditions.scenario = Some("flight".into());
|
||||
r.metric("startup.title_ms", &[800.0, 820.0]);
|
||||
r.frames
|
||||
.extend(Frames::of("village", 0, &[2.0, 3.0], &[1.0], &[]));
|
||||
r.label = Some("my run".into());
|
||||
r.local.push("/home/someone/logs/latest.log".into());
|
||||
let back = Report::parse(&r.to_json()).unwrap();
|
||||
assert_eq!(back, r);
|
||||
r.created = "2026-10-15T14:03:27Z".into();
|
||||
let upload = r.for_upload();
|
||||
assert_eq!(upload.created, "2026-10-15T00:00:00Z", "the day only");
|
||||
let upload = upload.to_json();
|
||||
assert!(
|
||||
!upload.contains("my run") && !upload.contains("someone"),
|
||||
"{upload}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_other_formats_and_newer_versions() {
|
||||
let r = Report::new(Kind::Launcher, App::default());
|
||||
let newer = r.to_json().replace("\"version\": 1", "\"version\": 2");
|
||||
assert!(Report::parse(&newer).unwrap_err().contains("newer"));
|
||||
let other = r.to_json().replace(FORMAT, "something-else");
|
||||
assert!(Report::parse(&other).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_into_a_folder() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-bench-test-{}", std::process::id()));
|
||||
let mut r = Report::new(Kind::Launcher, App::default());
|
||||
r.created = "2026-10-15T14:03:27Z".into();
|
||||
r.conditions.scenario = Some("cold start".into());
|
||||
let path = r.write_to(&dir).unwrap();
|
||||
assert_eq!(
|
||||
path.file_name().unwrap(),
|
||||
"launcher-20261015140327-cold_start.json"
|
||||
);
|
||||
assert_eq!(
|
||||
Report::parse(&std::fs::read_to_string(&path).unwrap()).unwrap(),
|
||||
r
|
||||
);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "horizon-bots"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "Load-test bots for Minecraft Java Edition 26.3 servers (docs/server.md, section 9)"
|
||||
|
||||
[dependencies]
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
serde_json = "1.0.151"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,589 @@
|
||||
//! `horizon-bots`: load-test bots for any Minecraft Java Edition 26.3 server, vanilla, Paper or Horizon
|
||||
//! (docs/server.md, section 9). Each bot logs in offline, goes through configuration as vanilla's client
|
||||
//! 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`.
|
||||
//!
|
||||
//! horizon-bots --server 127.0.0.1:25565 --bots 25 --behaviour mixed --seconds 360 --report bots.json
|
||||
//!
|
||||
//! It prints one JSON line a second (bots online, chunks received) and writes a summary with `--report`.
|
||||
|
||||
use horizon_protocol::{
|
||||
PROTOCOL_VERSION,
|
||||
buf::{Reader, Writer},
|
||||
connection::{ConnWriter, Connection},
|
||||
crypto::offline_uuid,
|
||||
ids,
|
||||
packets::{
|
||||
Empty, configuration,
|
||||
handshake::{self, Intention},
|
||||
login::{self, ServerHello},
|
||||
play,
|
||||
},
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::{
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
const HELP: &str = "\
|
||||
horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline mode, creative)
|
||||
|
||||
--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
|
||||
--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
|
||||
--seed N default 12345
|
||||
--name PREFIX bot names: PREFIX0, PREFIX1...; default Bot
|
||||
--report FILE a JSON summary at the end
|
||||
";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Behaviour {
|
||||
Idle,
|
||||
Walk,
|
||||
Explore,
|
||||
}
|
||||
|
||||
impl Behaviour {
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Idle => "idle",
|
||||
Self::Walk => "walk",
|
||||
Self::Explore => "explore",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Options {
|
||||
host: String,
|
||||
port: u16,
|
||||
bots: usize,
|
||||
behaviour: String,
|
||||
join_every: f64,
|
||||
seconds: u64,
|
||||
view_distance: i8,
|
||||
seed: u64,
|
||||
name: String,
|
||||
report: Option<String>,
|
||||
}
|
||||
|
||||
fn options() -> Result<Options, String> {
|
||||
let mut o = Options {
|
||||
host: "127.0.0.1".into(),
|
||||
port: 25565,
|
||||
bots: 10,
|
||||
behaviour: "mixed".into(),
|
||||
join_every: 1.0,
|
||||
seconds: 60,
|
||||
view_distance: 10,
|
||||
seed: 12345,
|
||||
name: "Bot".into(),
|
||||
report: None,
|
||||
};
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
if arg == "-h" || arg == "--help" {
|
||||
print!("{HELP}");
|
||||
std::process::exit(0);
|
||||
}
|
||||
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() {
|
||||
"--server" => {
|
||||
let (host, port) = value.rsplit_once(':').unwrap_or((&value, "25565"));
|
||||
o.host = host.into();
|
||||
o.port = port.parse().map_err(|_| number("a port"))?;
|
||||
}
|
||||
"--bots" => o.bots = value.parse().map_err(|_| number("a number"))?,
|
||||
"--behaviour" => o.behaviour = value,
|
||||
"--join-every" => o.join_every = value.parse().map_err(|_| number("seconds"))?,
|
||||
"--seconds" => o.seconds = value.parse().map_err(|_| number("seconds"))?,
|
||||
"--view-distance" => o.view_distance = value.parse().map_err(|_| number("2 to 32"))?,
|
||||
"--seed" => o.seed = value.parse().map_err(|_| number("a number"))?,
|
||||
"--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()) {
|
||||
return Err(format!(
|
||||
"--behaviour {}: use idle, walk, explore or mixed",
|
||||
o.behaviour
|
||||
));
|
||||
}
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
/// What one bot is doing, shared between its reader and its ticks.
|
||||
#[derive(Default)]
|
||||
struct Bot {
|
||||
online: AtomicBool,
|
||||
chunks: AtomicU64,
|
||||
/// Where the server last put the bot (a teleport), to start from.
|
||||
teleport: Mutex<Option<[f64; 3]>>,
|
||||
error: Mutex<Option<String>>,
|
||||
joined_after_ms: AtomicU64,
|
||||
}
|
||||
|
||||
/// A small deterministic generator (SplitMix64), so every run moves the bots the same way.
|
||||
struct Random(u64);
|
||||
impl Random {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut z = self.0;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
fn unit(&mut self) -> f64 {
|
||||
(self.next() >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
}
|
||||
|
||||
fn raw(id: i32, write: impl FnOnce(&mut Writer)) -> Vec<u8> {
|
||||
let mut w = Writer::new();
|
||||
w.varint(id);
|
||||
write(&mut w);
|
||||
w.data
|
||||
}
|
||||
|
||||
fn err(e: impl std::fmt::Display) -> String {
|
||||
e.to_string()
|
||||
}
|
||||
|
||||
/// Handshake, offline login and configuration; returns the connection in the play state.
|
||||
fn join(o: &Options, name: &str) -> Result<Connection, String> {
|
||||
let mut c = Connection::connect(&o.host, o.port, Duration::from_secs(10)).map_err(err)?;
|
||||
c.send(
|
||||
&handshake::Serverbound::Intention(Intention {
|
||||
protocol: PROTOCOL_VERSION,
|
||||
host: o.host.clone(),
|
||||
port: o.port,
|
||||
intent: handshake::LOGIN,
|
||||
})
|
||||
.encode(),
|
||||
)
|
||||
.map_err(err)?;
|
||||
c.send(
|
||||
&login::Serverbound::Hello(ServerHello {
|
||||
name: name.into(),
|
||||
uuid: offline_uuid(name),
|
||||
})
|
||||
.encode(),
|
||||
)
|
||||
.map_err(err)?;
|
||||
loop {
|
||||
let (id, body) = c.recv().map_err(err)?;
|
||||
match login::Clientbound::decode(id, &body).map_err(err)? {
|
||||
login::Clientbound::Compression(p) => c.set_compression(p.threshold),
|
||||
login::Clientbound::LoginFinished(_) => {
|
||||
c.send(&login::Serverbound::LoginAcknowledged(Empty).encode())
|
||||
.map_err(err)?;
|
||||
break;
|
||||
}
|
||||
login::Clientbound::Disconnect(p) => return Err(format!("refused: {}", p.reason_json)),
|
||||
login::Clientbound::Hello(_) => {
|
||||
return Err("the server is in online mode; bots need online-mode=false".into());
|
||||
}
|
||||
login::Clientbound::CustomQuery(p) => c
|
||||
.send(
|
||||
&login::Serverbound::CustomQueryAnswer(login::CustomQueryAnswer {
|
||||
transaction: p.transaction,
|
||||
payload: None,
|
||||
})
|
||||
.encode(),
|
||||
)
|
||||
.map_err(err)?,
|
||||
login::Clientbound::CookieRequest(p) => c
|
||||
.send(
|
||||
&login::Serverbound::CookieResponse(login::CookieResponse {
|
||||
key: p.key,
|
||||
payload: None,
|
||||
})
|
||||
.encode(),
|
||||
)
|
||||
.map_err(err)?,
|
||||
login::Clientbound::Unknown(..) => {}
|
||||
}
|
||||
}
|
||||
use ids::configuration::clientbound as cb;
|
||||
c.send(
|
||||
&configuration::Serverbound::ClientInformation(configuration::ClientInformation::new(
|
||||
o.view_distance,
|
||||
))
|
||||
.encode(),
|
||||
)
|
||||
.map_err(err)?;
|
||||
loop {
|
||||
let (id, body) = c.recv().map_err(err)?;
|
||||
let reply = match id {
|
||||
cb::SELECT_KNOWN_PACKS
|
||||
| cb::KEEP_ALIVE
|
||||
| cb::PING
|
||||
| cb::CODE_OF_CONDUCT
|
||||
| cb::COOKIE_REQUEST
|
||||
| cb::DISCONNECT
|
||||
| cb::FINISH_CONFIGURATION => {
|
||||
match configuration::Clientbound::decode(id, &body).map_err(err)? {
|
||||
configuration::Clientbound::SelectKnownPacks(p) => {
|
||||
configuration::Serverbound::SelectKnownPacks(p)
|
||||
}
|
||||
configuration::Clientbound::KeepAlive(p) => {
|
||||
configuration::Serverbound::KeepAlive(p)
|
||||
}
|
||||
configuration::Clientbound::Ping(p) => configuration::Serverbound::Pong(p),
|
||||
configuration::Clientbound::CodeOfConduct(_) => {
|
||||
configuration::Serverbound::AcceptCodeOfConduct(Empty)
|
||||
}
|
||||
configuration::Clientbound::CookieRequest(p) => {
|
||||
configuration::Serverbound::CookieResponse(configuration::CookieResponse {
|
||||
key: p.key,
|
||||
payload: None,
|
||||
})
|
||||
}
|
||||
configuration::Clientbound::Disconnect(_) => {
|
||||
return Err("disconnected during configuration".into());
|
||||
}
|
||||
configuration::Clientbound::FinishConfiguration(_) => {
|
||||
c.send(&configuration::Serverbound::FinishConfiguration(Empty).encode())
|
||||
.map_err(err)?;
|
||||
return Ok(c);
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
c.send(&reply.encode()).map_err(err)?;
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the play state: answers what a client must answer, counts chunks, records teleports.
|
||||
fn read(
|
||||
mut reader: horizon_protocol::connection::ConnReader,
|
||||
writer: Arc<Mutex<ConnWriter>>,
|
||||
bot: Arc<Bot>,
|
||||
) {
|
||||
use ids::play::clientbound as cb;
|
||||
use ids::play::serverbound as sb;
|
||||
let send = |body: Vec<u8>| writer.lock().unwrap().send(&body);
|
||||
loop {
|
||||
let (id, body) = match reader.recv() {
|
||||
Ok(packet) => packet,
|
||||
Err(e) => {
|
||||
if bot.online.swap(false, Ordering::SeqCst) {
|
||||
*bot.error.lock().unwrap() = Some(e.to_string());
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut r = Reader::new(&body);
|
||||
let sent = match id {
|
||||
cb::KEEP_ALIVE => match r.i64() {
|
||||
Ok(v) => send(raw(sb::KEEP_ALIVE, |w| w.i64(v))),
|
||||
Err(_) => Ok(()),
|
||||
},
|
||||
cb::PING => match r.i32() {
|
||||
Ok(v) => send(raw(sb::PONG, |w| w.i32(v))),
|
||||
Err(_) => Ok(()),
|
||||
},
|
||||
cb::LEVEL_CHUNK_WITH_LIGHT => {
|
||||
bot.chunks.fetch_add(1, Ordering::Relaxed);
|
||||
Ok(())
|
||||
}
|
||||
cb::CHUNK_BATCH_FINISHED => send(
|
||||
play::Serverbound::ChunkBatchReceived(play::ChunkBatchReceived {
|
||||
chunks_per_tick: 25.0,
|
||||
})
|
||||
.encode(),
|
||||
),
|
||||
cb::PLAYER_POSITION => match play::Clientbound::decode(id, &body) {
|
||||
Ok(play::Clientbound::PlayerPosition(p)) => {
|
||||
let mut at = bot.teleport.lock().unwrap();
|
||||
let previous = at.unwrap_or([0.0; 3]);
|
||||
// Relative flags: X 1, Y 2, Z 4.
|
||||
let pos: [f64; 3] = std::array::from_fn(|i| {
|
||||
if p.relatives & (1 << i) != 0 {
|
||||
previous[i] + p.position[i]
|
||||
} else {
|
||||
p.position[i]
|
||||
}
|
||||
});
|
||||
*at = Some(pos);
|
||||
send(
|
||||
play::Serverbound::AcceptTeleportation(play::AcceptTeleportation {
|
||||
id: p.teleport,
|
||||
x: pos[0],
|
||||
y: pos[1],
|
||||
z: pos[2],
|
||||
yaw: p.yaw,
|
||||
pitch: p.pitch,
|
||||
})
|
||||
.encode(),
|
||||
)
|
||||
}
|
||||
_ => Ok(()),
|
||||
},
|
||||
cb::DISCONNECT | cb::START_CONFIGURATION => {
|
||||
bot.online.store(false, Ordering::SeqCst);
|
||||
*bot.error.lock().unwrap() = Some(if id == cb::DISCONNECT {
|
||||
format!(
|
||||
"disconnected: {}",
|
||||
String::from_utf8_lossy(&body)
|
||||
.chars()
|
||||
.filter(|c| !c.is_control())
|
||||
.collect::<String>()
|
||||
)
|
||||
} else {
|
||||
"sent back to configuration".into()
|
||||
});
|
||||
return;
|
||||
}
|
||||
_ => Ok(()),
|
||||
};
|
||||
if sent.is_err() {
|
||||
bot.online.store(false, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One bot: join, then move every 50 ms until `until`.
|
||||
fn run_bot(
|
||||
o: Arc<Options>,
|
||||
index: usize,
|
||||
behaviour: Behaviour,
|
||||
bot: Arc<Bot>,
|
||||
until: Arc<Mutex<Option<Instant>>>,
|
||||
) {
|
||||
let name = format!("{}{index}", o.name);
|
||||
let asked = Instant::now();
|
||||
let connection = match join(&o, &name) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
*bot.error.lock().unwrap() = Some(e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (reader, writer) = connection.split();
|
||||
let writer = Arc::new(Mutex::new(writer));
|
||||
bot.online.store(true, Ordering::SeqCst);
|
||||
{
|
||||
let (writer, bot) = (writer.clone(), bot.clone());
|
||||
thread::spawn(move || read(reader, writer, bot));
|
||||
}
|
||||
use ids::play::serverbound as sb;
|
||||
// Wait for the spawn position.
|
||||
let spawn = loop {
|
||||
if let Some(at) = *bot.teleport.lock().unwrap() {
|
||||
break at;
|
||||
}
|
||||
if !bot.online.load(Ordering::SeqCst) || asked.elapsed() > Duration::from_secs(60) {
|
||||
if bot.error.lock().unwrap().is_none() {
|
||||
*bot.error.lock().unwrap() = Some("no spawn position within 60 s".into());
|
||||
}
|
||||
return;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(20));
|
||||
};
|
||||
bot.joined_after_ms
|
||||
.store(asked.elapsed().as_millis() as u64, Ordering::SeqCst);
|
||||
let send = |body: Vec<u8>| writer.lock().unwrap().send(&body).is_ok();
|
||||
send(raw(sb::PLAYER_LOADED, |_| {}));
|
||||
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;
|
||||
let mut next = Instant::now();
|
||||
loop {
|
||||
if until.lock().unwrap().is_some_and(|u| Instant::now() >= u)
|
||||
|| !bot.online.load(Ordering::SeqCst)
|
||||
{
|
||||
break;
|
||||
}
|
||||
// A teleport from the server wins over where the bot thought it was.
|
||||
if let Some(at) = bot.teleport.lock().unwrap().take() {
|
||||
pos = at;
|
||||
}
|
||||
if pos[1] < cruise {
|
||||
pos[1] = (pos[1] + 0.5).min(cruise);
|
||||
} else {
|
||||
match behaviour {
|
||||
Behaviour::Idle => {}
|
||||
Behaviour::Walk => {
|
||||
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,
|
||||
cruise,
|
||||
spawn[2] + angle.sin() * reach,
|
||||
];
|
||||
} else {
|
||||
// Sprinting: 5.6 blocks a second.
|
||||
let step = 0.28_f64.min(distance);
|
||||
pos[0] += dx / distance * step;
|
||||
pos[2] += dz / distance * step;
|
||||
}
|
||||
}
|
||||
Behaviour::Explore => {
|
||||
// Flying fast in creative: about 22 blocks a second, which keeps new chunks coming.
|
||||
pos[0] += heading.cos() * 1.1;
|
||||
pos[2] += heading.sin() * 1.1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let ok = send(
|
||||
play::Serverbound::MovePlayerPos(play::MovePlayerPos {
|
||||
x: pos[0],
|
||||
y: pos[1],
|
||||
z: pos[2],
|
||||
flags: 0,
|
||||
})
|
||||
.encode(),
|
||||
) && send(raw(sb::CLIENT_TICK_END, |_| {}));
|
||||
if !ok {
|
||||
bot.online.store(false, Ordering::SeqCst);
|
||||
break;
|
||||
}
|
||||
next += Duration::from_millis(50);
|
||||
let now = Instant::now();
|
||||
if next > now {
|
||||
thread::sleep(next - now);
|
||||
} else if now - next > Duration::from_secs(1) {
|
||||
next = now;
|
||||
}
|
||||
}
|
||||
// Offline first, so the reader doesn't count the closed connection as a failure.
|
||||
bot.online.store(false, Ordering::SeqCst);
|
||||
writer.lock().unwrap().shutdown();
|
||||
}
|
||||
|
||||
fn behaviour_of(mode: &str, index: usize) -> Behaviour {
|
||||
match mode {
|
||||
"idle" => Behaviour::Idle,
|
||||
"walk" => Behaviour::Walk,
|
||||
"explore" => Behaviour::Explore,
|
||||
// mixed: of every five bots, three walk, one explores, one stands.
|
||||
_ => match index % 5 {
|
||||
0 | 2 | 4 => Behaviour::Walk,
|
||||
1 => Behaviour::Explore,
|
||||
_ => Behaviour::Idle,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let o = match options() {
|
||||
Ok(o) => Arc::new(o),
|
||||
Err(e) => {
|
||||
eprintln!("horizon-bots: {e}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
let start = Instant::now();
|
||||
let until: Arc<Mutex<Option<Instant>>> = Arc::new(Mutex::new(None));
|
||||
let bots: Vec<Arc<Bot>> = (0..o.bots).map(|_| Arc::new(Bot::default())).collect();
|
||||
let launcher = {
|
||||
let (o, bots, until) = (o.clone(), bots.clone(), until.clone());
|
||||
thread::spawn(move || {
|
||||
let mut handles = Vec::new();
|
||||
for (i, bot) in bots.iter().enumerate() {
|
||||
let (o2, bot, until) = (o.clone(), bot.clone(), until.clone());
|
||||
let behaviour = behaviour_of(&o.behaviour, i);
|
||||
handles.push(thread::spawn(move || run_bot(o2, i, behaviour, bot, until)));
|
||||
thread::sleep(Duration::from_secs_f64(o.join_every));
|
||||
}
|
||||
handles
|
||||
})
|
||||
};
|
||||
// The run lasts --seconds after the first bot is in.
|
||||
let mut previous_chunks = 0;
|
||||
let mut first = None;
|
||||
loop {
|
||||
thread::sleep(Duration::from_secs(1));
|
||||
let online = bots
|
||||
.iter()
|
||||
.filter(|b| b.online.load(Ordering::SeqCst))
|
||||
.count();
|
||||
let chunks: u64 = bots.iter().map(|b| b.chunks.load(Ordering::Relaxed)).sum();
|
||||
let failed = bots
|
||||
.iter()
|
||||
.filter(|b| b.error.lock().unwrap().is_some())
|
||||
.count();
|
||||
if first.is_none() && online > 0 {
|
||||
first = Some(Instant::now());
|
||||
*until.lock().unwrap() = Some(Instant::now() + Duration::from_secs(o.seconds));
|
||||
}
|
||||
println!(
|
||||
"{}",
|
||||
json!({
|
||||
"t": start.elapsed().as_secs(),
|
||||
"online": online,
|
||||
"failed": failed,
|
||||
"chunks": chunks,
|
||||
"chunks_per_second": chunks - previous_chunks,
|
||||
})
|
||||
);
|
||||
previous_chunks = chunks;
|
||||
let over = until.lock().unwrap().is_some_and(|u| Instant::now() >= u);
|
||||
let all_failed = failed == o.bots;
|
||||
if over || all_failed || (first.is_none() && start.elapsed() > Duration::from_secs(120)) {
|
||||
*until.lock().unwrap() = Some(Instant::now());
|
||||
break;
|
||||
}
|
||||
}
|
||||
for handle in launcher.join().unwrap_or_default() {
|
||||
let _ = handle.join();
|
||||
}
|
||||
let summary = json!({
|
||||
"server": format!("{}:{}", o.host, o.port),
|
||||
"bots": o.bots,
|
||||
"behaviour": o.behaviour,
|
||||
"seed": o.seed,
|
||||
"view_distance": o.view_distance,
|
||||
"seconds": o.seconds,
|
||||
"joined": bots.iter().filter(|b| b.joined_after_ms.load(Ordering::SeqCst) > 0).count(),
|
||||
"chunks": bots.iter().map(|b| b.chunks.load(Ordering::Relaxed)).sum::<u64>(),
|
||||
"per_bot": bots.iter().enumerate().map(|(i, b)| json!({
|
||||
"name": format!("{}{i}", o.name),
|
||||
"behaviour": behaviour_of(&o.behaviour, i).name(),
|
||||
"joined_after_ms": b.joined_after_ms.load(Ordering::SeqCst),
|
||||
"chunks": b.chunks.load(Ordering::Relaxed),
|
||||
"error": *b.error.lock().unwrap(),
|
||||
})).collect::<Vec<_>>(),
|
||||
});
|
||||
if let Some(path) = &o.report {
|
||||
if let Err(e) = std::fs::write(path, format!("{summary:#}\n")) {
|
||||
eprintln!("horizon-bots: {path}: {e}");
|
||||
}
|
||||
}
|
||||
let errors: Vec<String> = bots
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, b)| {
|
||||
b.error
|
||||
.lock()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.map(|e| format!("{}{i}: {e}", o.name))
|
||||
})
|
||||
.collect();
|
||||
for e in errors.iter().take(10) {
|
||||
eprintln!("horizon-bots: {e}");
|
||||
}
|
||||
std::process::exit(if errors.is_empty() { 0 } else { 1 });
|
||||
}
|
||||
@@ -12,16 +12,48 @@ horizon-assets = { path = "../horizon-assets" }
|
||||
horizon-core = { path = "../horizon-core" }
|
||||
horizon-world = { path = "../horizon-world" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
# server.properties, eula.txt and the user lists, shared with horizon-server.
|
||||
horizon-server-files = { path = "../horizon-server-files" }
|
||||
horizon-friends = { path = "../horizon-friends" }
|
||||
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
|
||||
horizon-mods = { path = "../horizon-mods" }
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl"] }
|
||||
winit = "0.30.13"
|
||||
pollster = "0.4"
|
||||
bytemuck = { version = "1.24", features = ["derive"] }
|
||||
glam = "0.30"
|
||||
png = "0.18"
|
||||
sha2 = "0.10.9"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1.0.151", features = ["raw_value"] }
|
||||
# Shader packs: pack.toml, and WGSL checked before it reaches wgpu.
|
||||
toml = "1.1"
|
||||
naga = { version = "30.0.1", features = ["wgsl-in"] }
|
||||
# Java (OptiFine/Iris) shader packs, translated to WGSL.
|
||||
horizon-shaderpack = { path = "../horizon-shaderpack" }
|
||||
cpal = "0.16"
|
||||
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
|
||||
miniz_oxide = "0.8"
|
||||
ab_glyph = "0.2.32"
|
||||
log = "0.4"
|
||||
chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"] }
|
||||
flate2 = { version = "1.1.10", default-features = false, features = ["rust_backend"] }
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md).
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
|
||||
horizon-bench = { path = "../horizon-bench" }
|
||||
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input).
|
||||
gilrs = "0.11"
|
||||
# The DualSense's rumble, adaptive triggers and lights over HID (gilrs only reads it).
|
||||
horizon-dualsense = { path = "../horizon-dualsense" }
|
||||
# HTTPS to the Minecraft services (online-mode session join).
|
||||
ureq = { version = "2", default-features = false, features = ["tls"] }
|
||||
|
||||
[target.'cfg(windows)'.build-dependencies]
|
||||
# The icon resource in the .exe (build.rs).
|
||||
winresource = "0.1"
|
||||
|
||||
[features]
|
||||
# Records the call sites of mob random draws (`mob_ai_probe` traces).
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
//! Embeds Horizon's icon (`packaging/icons/horizon.ico`) in the Windows executables, as icon
|
||||
//! resource 1: Explorer and the task bar show it, and the window takes it as its icon.
|
||||
fn main() {
|
||||
let dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
|
||||
let ico = format!("{dir}/../../packaging/icons/horizon.ico");
|
||||
println!("cargo:rerun-if-changed={ico}");
|
||||
#[cfg(windows)]
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
|
||||
let mut resource = winresource::WindowsResource::new();
|
||||
resource.set_icon(&ico);
|
||||
if let Err(e) = resource.compile() {
|
||||
panic!("embedding {ico}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,7 @@ fn main() {
|
||||
let tick = TickContext {
|
||||
player_underwater: ambience.was_underwater(),
|
||||
player_removed: removed,
|
||||
..TickContext::default()
|
||||
};
|
||||
engine.tick(false, &tick, &mut backend);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
//! Arrows over the client's resident scene (`arrows::tick`), for
|
||||
//! tools/test_arrow_parity.py: one line of state and one of events per operation.
|
||||
#![forbid(unsafe_code)]
|
||||
use horizon_client::{
|
||||
Result,
|
||||
arrows::{
|
||||
self, ArrowEvent, ArrowParticle, ArrowScene, ArrowSound, BlockReaction, EntityHit, Pickup,
|
||||
Touch,
|
||||
},
|
||||
assets::Assets,
|
||||
blocks::BlockFacts,
|
||||
collisions,
|
||||
items::ItemStack,
|
||||
mobs::Who,
|
||||
motion::{LAVA, MotionResources, WATER},
|
||||
projectiles::{Hit, Projectile, ProjectileKind},
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
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()
|
||||
)
|
||||
}
|
||||
fn seed(v: &Value) -> i64 {
|
||||
v.as_str().unwrap().parse().unwrap()
|
||||
}
|
||||
fn particle(k: ArrowParticle) -> &'static str {
|
||||
match k {
|
||||
ArrowParticle::Bubble => "bubble",
|
||||
ArrowParticle::Crit => "crit",
|
||||
ArrowParticle::Splash => "splash",
|
||||
}
|
||||
}
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<_> = std::env::args().skip(1).collect();
|
||||
if args.len() != 3 {
|
||||
return Err("usage: arrow_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 mut scene = World::test_scene(&assets, 1)?;
|
||||
fs::write(
|
||||
dir.join("scene.json"),
|
||||
serde_json::to_vec(
|
||||
&serde_json::json!({"radius":1,"resident_chunks":scene.chunks.len(),"model_gallery":assets.model_gallery}),
|
||||
)?,
|
||||
)?;
|
||||
if args[2] == "--describe" {
|
||||
return Ok(());
|
||||
}
|
||||
let binding = collisions::load(&root, assets.registry)?;
|
||||
let motion = MotionResources::load(&root, &assets)?;
|
||||
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")))?);
|
||||
let mut level_random = LegacyRandom::new(0);
|
||||
for line in std::io::BufReader::new(fs::File::open(dir.join("inputs.txt"))?).lines() {
|
||||
let f: Value = serde_json::from_str(&line?)?;
|
||||
let kind = ProjectileKind::Arrow {
|
||||
base_damage: 2.0,
|
||||
critical: f["crit"].as_bool().unwrap(),
|
||||
effect: None,
|
||||
};
|
||||
let mut arrow = Projectile::new(kind, vector(&f["position"]), None)
|
||||
.with_arrow_random(LegacyRandom::new(seed(&f["seed"])));
|
||||
arrow.velocity = vector(&f["velocity"]);
|
||||
arrow.arrow.as_mut().unwrap().no_physics = f["no_physics"].as_bool().unwrap();
|
||||
// ProjectileWeaponItem.useAmmo's stack, intangible when no ammo was used.
|
||||
arrow.arrow.as_mut().unwrap().set_item(&arrows::drawn_ammo(
|
||||
&ItemStack::new("minecraft:arrow", 1),
|
||||
f["intangible"].as_bool().unwrap(),
|
||||
));
|
||||
// The entity in the way: its box and whether it takes the damage.
|
||||
let mut target: Option<(Aabb, bool)> = None;
|
||||
for op in f["ops"].as_array().unwrap() {
|
||||
let mut events = Vec::new();
|
||||
let mut touched = "-".to_string();
|
||||
match op["mode"].as_str().unwrap() {
|
||||
"tick" => {
|
||||
// The game's part of an entity hit: the damage it deals
|
||||
// (recorded after the tick's own events).
|
||||
let mut hurt = None;
|
||||
if !arrow.arrow.as_ref().unwrap().removed {
|
||||
let no_rain = |_| false;
|
||||
let view = ArrowScene {
|
||||
world: &scene,
|
||||
registry: assets.registry,
|
||||
facts: &facts,
|
||||
collision: &binding,
|
||||
fluids: &motion.fluids,
|
||||
raining: &no_rain,
|
||||
mob_griefing: true,
|
||||
tnt_explodes: true,
|
||||
};
|
||||
let targets: Vec<_> = target.iter().map(|t| (Who::Mob(2), t.0)).collect();
|
||||
let (_, hit) = arrows::tick(&mut arrow, &view, &targets);
|
||||
if let (Hit::Entity { .. }, Some((_, takes))) = (hit, target) {
|
||||
let mut st = arrow.arrow.take().unwrap();
|
||||
let damage = arrow.arrow_damage(&mut st.random);
|
||||
hurt = Some((st.events.len(), damage));
|
||||
arrow.arrow = Some(st);
|
||||
let outcome = if takes {
|
||||
EntityHit::Hurt
|
||||
} else {
|
||||
EntityHit::Refused
|
||||
};
|
||||
arrows::finish_entity_hit(&mut arrow, &view, outcome);
|
||||
}
|
||||
}
|
||||
let a = arrow.arrow.as_mut().unwrap();
|
||||
let (drop_at, dropped) = (arrow.position, a.pickup_item.count);
|
||||
let raised = std::mem::take(&mut a.events);
|
||||
let count = raised.len();
|
||||
for (i, event) in raised.into_iter().map(Some).chain([None]).enumerate() {
|
||||
if let Some((at, damage)) = hurt
|
||||
&& at == i
|
||||
{
|
||||
events.push(format!("hurt {:08x}", damage.to_bits()));
|
||||
}
|
||||
let Some(event) = event else {
|
||||
debug_assert_eq!(i, count);
|
||||
break;
|
||||
};
|
||||
match event {
|
||||
ArrowEvent::Particle {
|
||||
kind,
|
||||
position,
|
||||
velocity,
|
||||
} => events.push(format!(
|
||||
"particle {} {} {}",
|
||||
particle(kind),
|
||||
bits(position),
|
||||
bits(velocity)
|
||||
)),
|
||||
ArrowEvent::Sound {
|
||||
sound,
|
||||
volume,
|
||||
pitch,
|
||||
} => events.push(format!(
|
||||
"sound {} {:08x} {:08x}",
|
||||
match sound {
|
||||
ArrowSound::HitGround => "hit",
|
||||
ArrowSound::Splash => "splash",
|
||||
ArrowSound::Extinguish => "extinguish",
|
||||
},
|
||||
volume.to_bits(),
|
||||
pitch.to_bits()
|
||||
)),
|
||||
ArrowEvent::BubbleColumnParticles { pos } => {
|
||||
for _ in 0..2 {
|
||||
let x = f64::from(pos.x) + level_random.next_double();
|
||||
let z = f64::from(pos.z) + level_random.next_double();
|
||||
let at = Vec3::new(x, f64::from(pos.y + 1), z);
|
||||
events.push(format!(
|
||||
"sent splash {} {} {:016x}",
|
||||
bits(at),
|
||||
bits(Vec3::ZERO),
|
||||
1.0f64.to_bits()
|
||||
));
|
||||
let x = f64::from(pos.x) + level_random.next_double();
|
||||
let z = f64::from(pos.z) + level_random.next_double();
|
||||
let at = Vec3::new(x, f64::from(pos.y + 1), z);
|
||||
events.push(format!(
|
||||
"sent bubble {} {} {:016x}",
|
||||
bits(at),
|
||||
bits(Vec3::new(0.0, 0.01, 0.0)),
|
||||
0.2f64.to_bits()
|
||||
));
|
||||
}
|
||||
}
|
||||
ArrowEvent::Block { pos, reaction } => match reaction {
|
||||
BlockReaction::PrimeTnt => {
|
||||
// TntBlock.prime: PrimedTnt draws its push from the
|
||||
// level random, then removeBlock(pos, false).
|
||||
let _ = level_random.next_double();
|
||||
let at = Vec3::new(
|
||||
f64::from(pos.x) + 0.5,
|
||||
f64::from(pos.y),
|
||||
f64::from(pos.z) + 0.5,
|
||||
);
|
||||
events.push(format!("spawn tnt {} 80", bits(at)));
|
||||
events.push(format!(
|
||||
"sound tnt_primed {:08x} {:08x}",
|
||||
1.0f32.to_bits(),
|
||||
1.0f32.to_bits()
|
||||
));
|
||||
events.push("prime-event".into());
|
||||
let air =
|
||||
assets.registry.default_state("minecraft:air").unwrap();
|
||||
scene.set(pos.x, pos.y, pos.z, air)?;
|
||||
events.push(format!(
|
||||
"write {},{},{} {} 3",
|
||||
pos.x, pos.y, pos.z, air.0
|
||||
));
|
||||
}
|
||||
BlockReaction::Light(lit) => {
|
||||
scene.set(pos.x, pos.y, pos.z, lit)?;
|
||||
events.push(format!(
|
||||
"write {},{},{} {} 11",
|
||||
pos.x, pos.y, pos.z, lit.0
|
||||
));
|
||||
}
|
||||
},
|
||||
ArrowEvent::SplashEvent => events.push("splash-event".into()),
|
||||
ArrowEvent::ShootEvent => events.push("shoot-event".into()),
|
||||
ArrowEvent::Drop => events.push(format!(
|
||||
"drop {} {dropped}",
|
||||
bits(Vec3::new(
|
||||
drop_at.x,
|
||||
drop_at.y + f64::from(0.1f32),
|
||||
drop_at.z
|
||||
))
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
"target" => {
|
||||
// EntityDimensions.scalable(0.6, 1.8).makeBoundingBox.
|
||||
let at = vector(&op["position"]);
|
||||
let w = f64::from(0.6f32) / 2.0;
|
||||
let h = f64::from(1.8f32);
|
||||
let b = Aabb::new(
|
||||
Vec3::new(at.x - w, at.y, at.z - w),
|
||||
Vec3::new(at.x + w, at.y + h, at.z + w),
|
||||
);
|
||||
target = Some((b, op["takes"].as_bool().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,
|
||||
)?;
|
||||
}
|
||||
"velocity" => arrow.velocity = vector(&op["value"]),
|
||||
"ignite" => arrow
|
||||
.arrow
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.ignite_for_seconds(f32::from_bits(
|
||||
u32::from_str_radix(op["value"].as_str().unwrap(), 16).unwrap(),
|
||||
)),
|
||||
"level_seed" => level_random.set_seed(seed(&op["value"])),
|
||||
"owner" => arrow
|
||||
.arrow
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.set_owner(match op["value"].as_str() {
|
||||
Some("player") => Some(Who::Player),
|
||||
Some("mob") => Some(Who::Mob(1)),
|
||||
_ => None,
|
||||
}),
|
||||
"pickup" => {
|
||||
arrow.arrow.as_mut().unwrap().pickup =
|
||||
Pickup::from_byte(op["value"].as_i64().unwrap())
|
||||
}
|
||||
// The game's touch: the inventory takes the arrow unless full,
|
||||
// and a creative player takes it anyway.
|
||||
"touch" => {
|
||||
let creative = op["creative"].as_bool().unwrap();
|
||||
let full = op["inventory"] == "full";
|
||||
let a = arrow.arrow.as_mut().unwrap();
|
||||
let (taken, arrows) = match a.player_touch(creative) {
|
||||
Touch::Ignore => (false, 0),
|
||||
Touch::Take => (true, 0),
|
||||
Touch::Give(_) => (!full || creative, u32::from(!full)),
|
||||
};
|
||||
if taken {
|
||||
a.removed = true;
|
||||
}
|
||||
touched = format!("{taken} {arrows}");
|
||||
}
|
||||
other => return Err(format!("unknown operation {other}").into()),
|
||||
}
|
||||
let a = arrow.arrow.as_ref().unwrap();
|
||||
writeln!(
|
||||
out,
|
||||
"arrow {} {} {:08x} {:08x} {} {} {} {} {} {} {} {} {} {:016x} {:016x} {} {} {:016x} {}",
|
||||
bits(arrow.position),
|
||||
bits(arrow.velocity),
|
||||
arrow.pitch.to_bits(),
|
||||
arrow.yaw.to_bits(),
|
||||
a.in_ground,
|
||||
a.in_ground_time,
|
||||
a.life,
|
||||
a.shake,
|
||||
a.last_state.map_or("none".into(), |s| s.0.to_string()),
|
||||
a.fire_ticks,
|
||||
a.was_touching_water,
|
||||
a.was_eye_in_water,
|
||||
a.first_tick,
|
||||
a.fluids.fluid_height(&Tags, WATER).to_bits(),
|
||||
a.fluids.fluid_height(&Tags, LAVA).to_bits(),
|
||||
a.removed,
|
||||
arrow.critical(),
|
||||
a.fall_distance.to_bits(),
|
||||
bits(arrow.previous)
|
||||
)?;
|
||||
writeln!(out, "events {}", events.join(" | "))?;
|
||||
let a = arrow.arrow.as_ref().unwrap();
|
||||
let intangible = a
|
||||
.pickup_item
|
||||
.data()
|
||||
.and_then(|d| d.other(arrows::INTANGIBLE))
|
||||
.is_some();
|
||||
writeln!(out, "pickup {} {intangible} {touched}", a.pickup.byte())?;
|
||||
}
|
||||
writeln!(out, "end")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
struct Tags;
|
||||
impl horizon_world::entity_fluid::FluidMembership for Tags {
|
||||
fn fluid_in_tag(
|
||||
&self,
|
||||
kind: horizon_world::fluid::FluidKind,
|
||||
tag: horizon_world::entity_fluid::FluidTagId,
|
||||
) -> bool {
|
||||
use horizon_world::fluid::FluidKind;
|
||||
if tag == WATER {
|
||||
matches!(kind, FluidKind::Water | FluidKind::FlowingWater)
|
||||
} else {
|
||||
matches!(kind, FluidKind::Lava | FluidKind::FlowingLava)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,7 @@ fn main() -> Result<()> {
|
||||
player.auto_jump_enabled = p["enabled"].as_bool().unwrap();
|
||||
player.auto_jump_time = p["time"].as_i64().unwrap() as i32;
|
||||
MotionWorld {
|
||||
entities: &[],
|
||||
player: &mut player,
|
||||
scene: &mut scene,
|
||||
assets: &assets,
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Full production fact loading, including file reads, interning and temporary destruction.
|
||||
//! cargo run --profile fast -p horizon-client --example block_facts_bench -- ROOT [SAMPLES]
|
||||
//! Use SAMPLES=1 with an OS peak-RSS wrapper; `digest` checks all facts
|
||||
//! and the shape sharing partition (including sharing between outlines and edges).
|
||||
use horizon_client::blocks::BlockFacts;
|
||||
use horizon_world::{DATA_VERSION, nbt::Tag, registry::BlockRegistry};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::{HashMap, hash_map::DefaultHasher},
|
||||
fs,
|
||||
hash::{Hash, Hasher},
|
||||
hint::black_box,
|
||||
path::Path,
|
||||
time::Instant,
|
||||
};
|
||||
type Result<T> = horizon_client::Result<T>;
|
||||
|
||||
fn tag(value: &Value) -> Result<Tag> {
|
||||
Ok(match value {
|
||||
Value::String(s) => Tag::String(s.as_str().into()),
|
||||
Value::Number(n) => Tag::Int(i32::try_from(n.as_i64().ok_or("registry number")?)?),
|
||||
Value::Array(a) => Tag::List(a.iter().map(tag).collect::<Result<_>>()?),
|
||||
Value::Object(o) => Tag::Compound(
|
||||
o.iter()
|
||||
.map(|(k, v)| Ok((k.as_str().into(), tag(v)?)))
|
||||
.collect::<Result<_>>()?,
|
||||
),
|
||||
_ => return Err("registry value".into()),
|
||||
})
|
||||
}
|
||||
fn registry(root: &Path) -> Result<BlockRegistry> {
|
||||
let facts: Value = serde_json::from_slice(&fs::read(root.join("facts.json"))?)?;
|
||||
Ok(BlockRegistry::from_nbt(&Tag::compound([
|
||||
("DataVersion", Tag::Int(DATA_VERSION)),
|
||||
(
|
||||
"states",
|
||||
Tag::List(
|
||||
facts["states"]
|
||||
.as_array()
|
||||
.ok_or("registry states")?
|
||||
.iter()
|
||||
.map(|s| tag(&s[4]))
|
||||
.collect::<Result<_>>()?,
|
||||
),
|
||||
),
|
||||
("defaults", tag(&facts["defaults"])?),
|
||||
]))?)
|
||||
}
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<_> = std::env::args().skip(1).collect();
|
||||
let root = Path::new(
|
||||
args.first()
|
||||
.ok_or("usage: block_facts_bench ROOT [SAMPLES|digest]")?,
|
||||
);
|
||||
let setup = Instant::now();
|
||||
let registry = registry(root)?;
|
||||
println!(
|
||||
"registry_setup_ms={:.3}",
|
||||
setup.elapsed().as_secs_f64() * 1000.0
|
||||
);
|
||||
if args.get(1).is_some_and(|s| s == "digest") {
|
||||
let facts = BlockFacts::load(root, ®istry)?;
|
||||
let mut hash = DefaultHasher::new();
|
||||
let mut shapes = HashMap::new();
|
||||
facts.air.hash(&mut hash);
|
||||
for state in &facts.states {
|
||||
format!("{state:?}").hash(&mut hash);
|
||||
for shape in [&state.outline, &state.outline_edges] {
|
||||
let next = shapes.len();
|
||||
shapes
|
||||
.entry((shape.as_ptr(), shape.len()))
|
||||
.or_insert(next)
|
||||
.hash(&mut hash);
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"states={} shared_shapes={} digest={:016x}",
|
||||
facts.states.len(),
|
||||
shapes.len(),
|
||||
hash.finish()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
let samples: usize = args.get(1).map(|s| s.parse()).transpose()?.unwrap_or(9);
|
||||
assert!(samples > 0);
|
||||
// First load is reported separately; OS file caches are not forcibly evicted.
|
||||
let load = || -> Result<f64> {
|
||||
let start = Instant::now();
|
||||
let facts = black_box(BlockFacts::load(black_box(root), black_box(®istry))?);
|
||||
let ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||
drop(facts);
|
||||
Ok(ms)
|
||||
};
|
||||
println!("first_load_ms={:.3}", load()?);
|
||||
if samples > 1 {
|
||||
load()?;
|
||||
load()?;
|
||||
let mut times = (0..samples).map(|_| load()).collect::<Result<Vec<_>>>()?;
|
||||
println!("samples_ms={times:.3?}");
|
||||
times.sort_by(f64::total_cmp);
|
||||
println!("median_ms={:.3}", times[times.len() / 2]);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//! Transport for ClientLevel shape-aware terrain-particle placement.
|
||||
use horizon_client::{assets::Assets, blocks::BlockFacts, mesher::MeshTables, models, particles};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
fs,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
path::Path,
|
||||
};
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<_> = std::env::args().collect();
|
||||
let root = Path::new(&args[1]);
|
||||
let assets = Assets::load(root)?;
|
||||
let facts = BlockFacts::load(root, assets.registry)?;
|
||||
let tables = MeshTables::new(
|
||||
&assets,
|
||||
&facts,
|
||||
models::load_visibility(root, assets.registry)?,
|
||||
)?;
|
||||
let mut output = BufWriter::new(fs::File::create(&args[3])?);
|
||||
for line in BufReader::new(fs::File::open(&args[2])?).lines() {
|
||||
let row: Value = serde_json::from_str(&line?)?;
|
||||
let state = BlockStateId(row["state"].as_u64().unwrap() as u32);
|
||||
let pos: [i32; 3] = serde_json::from_value(row["pos"].clone())?;
|
||||
let offset = if facts.get(state).outline_offset {
|
||||
tables.offset(state, pos[0], pos[2]).unwrap_or([0.0; 3])
|
||||
} else {
|
||||
[0.0; 3]
|
||||
};
|
||||
let boxes: Vec<[f64; 6]> = facts
|
||||
.get(state)
|
||||
.outline
|
||||
.iter()
|
||||
.map(|b| std::array::from_fn(|i| b[i] + offset[i % 3]))
|
||||
.collect();
|
||||
let mut hits = Vec::new();
|
||||
let mut next = Vec::new();
|
||||
for face in [
|
||||
[0, -1, 0],
|
||||
[0, 1, 0],
|
||||
[0, 0, -1],
|
||||
[0, 0, 1],
|
||||
[-1, 0, 0],
|
||||
[1, 0, 0],
|
||||
] {
|
||||
let mut random = LegacyRandom::new(row["seed"].as_i64().unwrap());
|
||||
hits.push(particles::breaking_position(&mut random, pos, face, &boxes));
|
||||
next.push(random.next_long());
|
||||
}
|
||||
let mut destroy = Vec::new();
|
||||
particles::destroy_positions(pos, &boxes, |p, _| destroy.push(p));
|
||||
serde_json::to_writer(
|
||||
&mut output,
|
||||
&json!({"case":row["case"],"hits":hits,"next":next,"destroy":destroy}),
|
||||
)?;
|
||||
output.write_all(&[10])?;
|
||||
}
|
||||
output.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Headless transport for real model-shaped breaking overlays.
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
blocks::{BlockFacts, RenderShape},
|
||||
breaking,
|
||||
mesher::MeshTables,
|
||||
models,
|
||||
};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
fs,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
path::Path,
|
||||
};
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<_> = std::env::args().collect();
|
||||
let root = Path::new(&args[1]);
|
||||
let assets = Assets::load(root)?;
|
||||
let facts = BlockFacts::load(root, assets.registry)?;
|
||||
let tables = MeshTables::new(
|
||||
&assets,
|
||||
&facts,
|
||||
models::load_visibility(root, assets.registry)?,
|
||||
)?;
|
||||
let mut output = BufWriter::new(fs::File::create(&args[3])?);
|
||||
for line in BufReader::new(fs::File::open(&args[2])?).lines() {
|
||||
let row: Value = serde_json::from_str(&line?)?;
|
||||
let state = BlockStateId(row["state"].as_u64().unwrap() as u32);
|
||||
let p: [i32; 3] = serde_json::from_value(row["pos"].clone())?;
|
||||
let camera: [f64; 3] = serde_json::from_value(row["camera"].clone())?;
|
||||
let mut vertices = Vec::new();
|
||||
let mut translucent = false;
|
||||
if facts.get(state).render_shape == RenderShape::Model {
|
||||
let models = assets
|
||||
.model_catalog
|
||||
.collect(state, BlockPos::new(p[0], p[1], p[2]))?;
|
||||
let offset = tables.offset(state, p[0], p[2]).unwrap_or([0.0; 3]);
|
||||
let origin = (glam::DVec3::new(f64::from(p[0]), f64::from(p[1]), f64::from(p[2]))
|
||||
- glam::DVec3::from(camera))
|
||||
.as_vec3()
|
||||
+ glam::DVec3::from(offset).as_vec3();
|
||||
translucent = models
|
||||
.iter()
|
||||
.any(|(_, m)| m.faces.iter().any(|f| f.translucent));
|
||||
for (_, m) in models {
|
||||
breaking::geometry(m, origin, translucent, &mut vertices);
|
||||
}
|
||||
}
|
||||
let quads: Vec<_> = vertices
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|q| {
|
||||
q.iter()
|
||||
.map(|v| {
|
||||
assert_eq!(v.color, 0xffff_ffff);
|
||||
assert_eq!(v.light, 240 | (240 << 8));
|
||||
assert_eq!(v.layer, if translucent { 12 } else { 11 });
|
||||
[v.pos[0], v.pos[1], v.pos[2], v.uv[0], v.uv[1]]
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect();
|
||||
serde_json::to_writer(
|
||||
&mut output,
|
||||
&json!({"case":row["case"],"translucent":translucent,"quads":quads}),
|
||||
)?;
|
||||
output.write_all(&[10])?;
|
||||
}
|
||||
output.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -87,6 +87,7 @@ fn main() -> horizon_client::Result<()> {
|
||||
recipes: &recipes,
|
||||
brewing: &brewing,
|
||||
banners: &banners,
|
||||
trade: None,
|
||||
};
|
||||
let mut out = String::new();
|
||||
for line in input.lines() {
|
||||
|
||||
@@ -9,7 +9,7 @@ use horizon_client::{
|
||||
biome::Biomes,
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, MIN_Y, SECTIONS},
|
||||
light::{LightTables, light_chunk},
|
||||
light::{LightTables, apply_lighting, light_chunk},
|
||||
mesher::{MeshTables, Mesher},
|
||||
models,
|
||||
streaming::{ChunkGenerator, Proto},
|
||||
@@ -105,7 +105,7 @@ fn main() -> Result<()> {
|
||||
);
|
||||
let mut chunks: Vec<Chunk> = chunks.iter().map(|c| (**c).clone()).collect();
|
||||
for ((x, z), sections) in lit {
|
||||
chunks[((z - 1) * inner + x - 1) as usize].sections = sections;
|
||||
apply_lighting(&mut chunks[((z - 1) * inner + x - 1) as usize], sections);
|
||||
}
|
||||
let chunks: Vec<Arc<Chunk>> = chunks.into_iter().map(Arc::new).collect();
|
||||
let at = |x: i32, z: i32| chunks[((z - 1) * inner + x - 1) as usize].clone();
|
||||
@@ -139,6 +139,7 @@ fn main() -> Result<()> {
|
||||
blend_radius: 2,
|
||||
smooth_lighting: true,
|
||||
cutout_leaves: true,
|
||||
cull_leaves: false,
|
||||
};
|
||||
let mut best = f64::MAX;
|
||||
let mut quads = 0usize;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
//! CloudRenderer face selection and mesh traversal, without a GPU.
|
||||
use horizon_client::clouds;
|
||||
use std::{fmt::Write, fs};
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<_> = std::env::args().collect();
|
||||
let mut output = String::new();
|
||||
for (case, line) in fs::read_to_string(&args[1])?.lines().enumerate() {
|
||||
let values: Vec<i32> = line
|
||||
.split_whitespace()
|
||||
.map(|v| v.parse().unwrap())
|
||||
.collect();
|
||||
let [kind, x, z, bottom, neighbors] = values.as_slice() else {
|
||||
panic!("invalid case")
|
||||
};
|
||||
write!(output, "{case}")?;
|
||||
let mut cell = |x, z, neighbors| {
|
||||
let empty = std::array::from_fn(|i| neighbors & (1 << i) != 0);
|
||||
for (direction, inside) in clouds::faces(clouds::face_mask(x, z, *bottom as f32, empty))
|
||||
{
|
||||
write!(output, " {x},{z},{direction},{}", u8::from(inside)).unwrap();
|
||||
}
|
||||
};
|
||||
let radius = if *kind == 2 {
|
||||
clouds::radius_cells(*x)
|
||||
} else {
|
||||
*x
|
||||
};
|
||||
if *kind == 0 {
|
||||
cell(*x, *z, *neighbors);
|
||||
} else {
|
||||
for (x, z) in clouds::cells(radius) {
|
||||
cell(x, z, 0);
|
||||
}
|
||||
}
|
||||
output.push('\n');
|
||||
}
|
||||
fs::write(&args[2], output)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! Runs tools/test_container_screen_parity.py's screens and mouse events
|
||||
//! through `horizon_client::container_screen`.
|
||||
use horizon_client::{
|
||||
Result,
|
||||
container_screen::{Book, Button, Clicks, Image, Release, Target, clicked_outside},
|
||||
menu::Input,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
};
|
||||
|
||||
fn input_name(input: Input) -> &'static str {
|
||||
match input {
|
||||
Input::Pickup => "PICKUP",
|
||||
Input::QuickMove => "QUICK_MOVE",
|
||||
Input::Swap => "SWAP",
|
||||
Input::Clone => "CLONE",
|
||||
Input::Throw => "THROW",
|
||||
Input::QuickCraft => "QUICK_CRAFT",
|
||||
Input::PickupAll => "PICKUP_ALL",
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let input = BufReader::new(File::open(&args[1])?);
|
||||
let mut out = BufWriter::new(File::create(&args[2])?);
|
||||
for (n, line) in input.lines().enumerate() {
|
||||
let c: Value = serde_json::from_str(&line?)?;
|
||||
writeln!(out, "== {n}")?;
|
||||
let int = |v: &Value| v.as_i64().unwrap() as i32;
|
||||
let image = Image {
|
||||
left: int(&c["image"][0]),
|
||||
top: int(&c["image"][1]),
|
||||
width: int(&c["image"][2]),
|
||||
height: int(&c["image"][3]),
|
||||
};
|
||||
let book = c.get("book").map(|b| Book {
|
||||
visible: b[0].as_bool().unwrap(),
|
||||
selected_tab_hovered: b[1].as_bool().unwrap(),
|
||||
});
|
||||
let mut clicks = Clicks::default();
|
||||
for ev in c["events"].as_array().unwrap() {
|
||||
let name = ev[0].as_str().unwrap();
|
||||
let (x, y) = (ev[1].as_f64().unwrap(), ev[2].as_f64().unwrap());
|
||||
let outside = clicked_outside(x, y, image, book);
|
||||
let mut line = format!("{name} {outside}");
|
||||
if name != "point" {
|
||||
// The case says whether the event is over the menu's slot (7).
|
||||
let on_slot = ev[6].as_bool().unwrap();
|
||||
line += &format!(" {on_slot}");
|
||||
let target = Target::of(on_slot.then_some(7), outside);
|
||||
let button = match ev[3].as_i64().unwrap() {
|
||||
1 => Button::Left,
|
||||
3 => Button::Right,
|
||||
_ => Button::Middle,
|
||||
};
|
||||
let shift = ev[4].as_i64().unwrap() & 3 != 0;
|
||||
let carried_empty = !ev[5].as_bool().unwrap();
|
||||
let click = if name == "press" {
|
||||
clicks.press(&target, button, carried_empty, shift, false)
|
||||
} else {
|
||||
match clicks.release(&target, button, carried_empty, false, shift, false) {
|
||||
Release::Click(input) => Some(input),
|
||||
Release::QuickCraft => panic!("no drags in these cases"),
|
||||
Release::Nothing => None,
|
||||
}
|
||||
};
|
||||
if let Some(input) = click {
|
||||
let id = match target {
|
||||
Target::Slot(s) => s,
|
||||
Target::Outside => -999,
|
||||
Target::Nothing => -1,
|
||||
};
|
||||
line += &format!(" click {id} {} {}", button.menu_button(), input_name(input));
|
||||
}
|
||||
}
|
||||
writeln!(out, "{line}")?;
|
||||
}
|
||||
}
|
||||
out.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT
|
||||
use horizon_client::{
|
||||
creative::{Creative, Names},
|
||||
item_data::canon_stack,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{fs, path::Path};
|
||||
|
||||
fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let usage = "usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT";
|
||||
let names = Names::load(Path::new(args.get(1).ok_or(usage)?));
|
||||
let creative = Creative::from_json(&serde_json::from_slice(&fs::read(
|
||||
args.get(2).ok_or(usage)?,
|
||||
)?)?)?;
|
||||
let vanilla: Value = serde_json::from_slice(&fs::read(args.get(3).ok_or(usage)?)?)?;
|
||||
let tabs: Vec<Value> = creative
|
||||
.tabs
|
||||
.iter()
|
||||
.map(|t| {
|
||||
json!({
|
||||
"id": t.id,
|
||||
"title": t.title,
|
||||
"icon": t.icon,
|
||||
"row": if t.top { "TOP" } else { "BOTTOM" },
|
||||
"column": t.column,
|
||||
"items": t.items.iter().map(|s| canon_stack(Some(s))).collect::<Vec<_>>(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let searches: Vec<Value> = vanilla["searches"]
|
||||
.as_array()
|
||||
.ok_or("searches")?
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let query = s["query"].as_str().unwrap_or_default();
|
||||
json!({"query": query, "results": creative.search_results(query, &names)})
|
||||
})
|
||||
.collect();
|
||||
let search: Vec<String> = creative
|
||||
.search_items()
|
||||
.iter()
|
||||
.map(|s| canon_stack(Some(s)))
|
||||
.collect();
|
||||
let out = json!({"tabs": tabs, "search": search, "searches": searches});
|
||||
fs::write(args.get(4).ok_or(usage)?, out.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! Runs tools/test_emitter_particles_parity.py's emitters through
|
||||
//! `particles::on_block_faces`, `particles::along_axis` and
|
||||
//! `particles::item_use_particles`.
|
||||
use horizon_client::{Result, particles};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
};
|
||||
|
||||
fn d(v: f64) -> String {
|
||||
format!("{:016x}", v.to_bits())
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let mut out = BufWriter::new(File::create(&args[2])?);
|
||||
for (n, line) in BufReader::new(File::open(&args[1])?).lines().enumerate() {
|
||||
let c: Value = serde_json::from_str(&line?)?;
|
||||
writeln!(out, "== {n}")?;
|
||||
let mut random = LegacyRandom::new(c["seed"].as_i64().ok_or("seed")?);
|
||||
let mut lines = Vec::new();
|
||||
let record = |kind: &str| {
|
||||
let kind = kind.to_owned();
|
||||
move |p: [f64; 3], v: [f64; 3]| {
|
||||
format!(
|
||||
"+ {kind} {} {} {} {} {} {}",
|
||||
d(p[0]),
|
||||
d(p[1]),
|
||||
d(p[2]),
|
||||
d(v[0]),
|
||||
d(v[1]),
|
||||
d(v[2])
|
||||
)
|
||||
}
|
||||
};
|
||||
if c["kind"] == "item_use" {
|
||||
let p: [f64; 3] = std::array::from_fn(|i| c["pos"][i].as_f64().unwrap_or(0.0));
|
||||
let f = |k: &str| c[k].as_f64().unwrap_or(0.0) as f32;
|
||||
let line = record("item");
|
||||
particles::item_use_particles(
|
||||
&mut random,
|
||||
f("pitch"),
|
||||
f("yaw"),
|
||||
p,
|
||||
p[1] + f64::from(f("eye")),
|
||||
c["count"].as_u64().unwrap_or(0) as u32,
|
||||
|p, v| lines.push(line(p, v)),
|
||||
);
|
||||
} else {
|
||||
let p: [i32; 3] = std::array::from_fn(|i| c["pos"][i].as_i64().unwrap_or(0) as i32);
|
||||
match c["axis"].as_i64() {
|
||||
Some(axis @ 0..=2) => {
|
||||
let line = record("electric_spark");
|
||||
particles::along_axis(&mut random, axis as usize, p, |p, v| {
|
||||
lines.push(line(p, v))
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
let line = record("wax_on");
|
||||
particles::on_block_faces(&mut random, p, |p, v| lines.push(line(p, v)));
|
||||
}
|
||||
}
|
||||
}
|
||||
for l in lines {
|
||||
writeln!(out, "{l}")?;
|
||||
}
|
||||
writeln!(out, "next {}", random.next_long())?;
|
||||
}
|
||||
out.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
//! Replays the render states of the vanilla entity-animation oracle through
|
||||
//! Horizon's `mobs::anim::setup` and writes every part's pose.
|
||||
//! Horizon's `mobs::anim::setup` and writes every part's pose, then draws the
|
||||
//! posed model with `mobs::render::draw` under the case's living-entity root
|
||||
//! transform and writes each drawn part's packed vertex normals.
|
||||
//!
|
||||
//! cargo run --profile fast -p horizon-client --example entity_anim_fixture -- \
|
||||
//! RESOURCES VANILLA.jsonl OUT.jsonl
|
||||
use horizon_client::mesher::Vertex;
|
||||
use horizon_client::mobs::{
|
||||
Mob, MobData, MobTypes, Part, Species, allay,
|
||||
anim::{self, Poses, State},
|
||||
aquatic, body_anim as b_anim, equine, guardian,
|
||||
keyframes::AnimationState,
|
||||
shulker, sniffer, species, tame,
|
||||
render, shulker, sniffer, species, tame,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use serde_json::{Value, json};
|
||||
@@ -44,6 +47,61 @@ fn walk(part: &Part, path: &str, poses: &Poses, out: &mut BTreeMap<String, Value
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits the vertices `render::draw` emitted for `part` into its drawn parts,
|
||||
/// following draw's walk: a hidden or zero-scaled part ends its branch, a
|
||||
/// skip-draw part keeps its children. Per part: n = packed normal bytes per
|
||||
/// vertex, p = sum of x + 2y + 3z over the positions.
|
||||
fn drawn(
|
||||
part: &Part,
|
||||
path: &str,
|
||||
poses: &Poses,
|
||||
vertices: &[Vertex],
|
||||
cursor: &mut usize,
|
||||
out: &mut BTreeMap<String, Value>,
|
||||
) {
|
||||
let p = poses.pose(part);
|
||||
if poses.is_hidden(part) || p[6] == 0.0 && p[7] == 0.0 && p[8] == 0.0 {
|
||||
return;
|
||||
}
|
||||
let count = if poses.skips(part) {
|
||||
0
|
||||
} else {
|
||||
part.polygons.len() * 4
|
||||
};
|
||||
if count > 0 {
|
||||
let own = &vertices[*cursor..*cursor + count];
|
||||
let normals: Vec<i8> = own.iter().flat_map(|v| v.normal[..3].to_vec()).collect();
|
||||
let sum: f64 = own
|
||||
.iter()
|
||||
.map(|v| f64::from(v.pos[0]) + 2.0 * f64::from(v.pos[1]) + 3.0 * f64::from(v.pos[2]))
|
||||
.sum();
|
||||
out.insert(path.to_owned(), json!({"n": normals, "p": sum}));
|
||||
}
|
||||
*cursor += count;
|
||||
for c in &part.children {
|
||||
let child = if path.is_empty() {
|
||||
c.name.clone()
|
||||
} else {
|
||||
format!("{path}/{}", c.name)
|
||||
};
|
||||
drawn(c, &child, poses, vertices, cursor, out);
|
||||
}
|
||||
}
|
||||
|
||||
/// LivingEntityRenderer's root transform without the entity's position:
|
||||
/// rotate 180 - yaw about Y, scale(-1, -1, 1), the renderer's own scale, then
|
||||
/// translate(0, -1.501, 0) (as `render::base_transform`).
|
||||
fn root_transform(case: &Value) -> glam::Mat4 {
|
||||
let yaw = case["@rootYaw"].as_f64().unwrap_or(0.0) as f32;
|
||||
let scale = case["@rootScale"].as_array().map_or(glam::Vec3::ONE, |s| {
|
||||
glam::Vec3::from_array(std::array::from_fn(|i| s[i].as_f64().unwrap_or(1.0) as f32))
|
||||
});
|
||||
glam::Mat4::from_rotation_y((180.0 - yaw).to_radians())
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(-1.0, -1.0, 1.0))
|
||||
* glam::Mat4::from_scale(scale)
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, -1.501, 0.0))
|
||||
}
|
||||
|
||||
/// The entity-side values behind the render-state fields the case sets, for
|
||||
/// the species whose Horizon animation reads them from the `Mob`.
|
||||
fn entity_state(mob: &mut Mob, species: Species, case: &Value) {
|
||||
@@ -236,12 +294,26 @@ fn main() -> horizon_client::Result<()> {
|
||||
let types = MobTypes::load(&root)?;
|
||||
let animations = MobData::load_animations(&root);
|
||||
let mut out = String::new();
|
||||
let crystal = horizon_client::mobs::load_model(&root, "minecraft:end_crystal#main");
|
||||
for line in fs::read_to_string(&args[1])?.lines() {
|
||||
let v: Value = serde_json::from_str(line)?;
|
||||
let layer = v["layer"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.trim_start_matches("minecraft:");
|
||||
// EndCrystalModel is not a mob's: horizon_client::end_crystal poses it.
|
||||
if layer == "end_crystal#main"
|
||||
&& let Some(model) = &crystal
|
||||
{
|
||||
let case = &v["case"];
|
||||
let mut poses = horizon_client::end_crystal::poses(
|
||||
model,
|
||||
case["ageInTicks"].as_f64().unwrap_or(0.0) as f32,
|
||||
case["showsBottom"].as_bool().unwrap_or(false),
|
||||
);
|
||||
out.push_str(&format!("{}\n", emit(model, &mut poses, case, layer)));
|
||||
continue;
|
||||
}
|
||||
let baby = layer.contains("_baby#");
|
||||
let adult = layer.replace("_baby#", "#");
|
||||
// Variant layers (sizes, hatless villagers, climate cows...) animate
|
||||
@@ -518,10 +590,38 @@ fn main() -> horizon_client::Result<()> {
|
||||
}
|
||||
_ => anim::setup(&mob, s, p, &animations),
|
||||
}
|
||||
let mut parts = BTreeMap::new();
|
||||
walk(&model, "", &poses, &mut parts);
|
||||
out.push_str(&format!("{}\n", json!({"parts": parts})));
|
||||
out.push_str(&format!("{}\n", emit(&model, &mut poses, case, layer)));
|
||||
}
|
||||
fs::write(&args[2], out)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every part's pose, and the vertices drawn under the harness's root transform.
|
||||
fn emit(model: &Part, poses: &mut Poses, case: &Value, layer: &str) -> Value {
|
||||
// The harness's part-scale override, set after setupAnim as the oracle does.
|
||||
if let Some(scale) = case["@partScale"].as_array() {
|
||||
let pose = poses.get(scale[0].as_str().unwrap_or_default());
|
||||
for i in 0..3 {
|
||||
pose[6 + i] = scale[1 + i].as_f64().unwrap_or(1.0) as f32;
|
||||
}
|
||||
}
|
||||
let poses = &*poses;
|
||||
let mut parts = BTreeMap::new();
|
||||
walk(model, "", poses, &mut parts);
|
||||
let mut emitted = Vec::new();
|
||||
render::draw(
|
||||
model,
|
||||
root_transform(case),
|
||||
poses,
|
||||
[0.0, 0.0, 1.0, 1.0],
|
||||
0xFFFF_FFFF,
|
||||
240 | 240 << 8,
|
||||
0,
|
||||
&mut emitted,
|
||||
);
|
||||
let mut vertices = BTreeMap::new();
|
||||
let mut cursor = 0;
|
||||
drawn(model, "", poses, &emitted, &mut cursor, &mut vertices);
|
||||
assert_eq!(cursor, emitted.len(), "{layer}: draw's walk changed");
|
||||
json!({"parts": parts, "vertices": vertices})
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! Runs tools/test_entity_components_parity.py's entities and advancement
|
||||
//! entity predicates through `horizon_client::advancements::entity`, with the
|
||||
//! variant component `mobs::finalize::species_variant_component` reports.
|
||||
use horizon_client::{
|
||||
Result,
|
||||
advancements::{EntityView, World, entity},
|
||||
mobs::{
|
||||
Species,
|
||||
finalize::{species_variant_component, variant_index},
|
||||
},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
};
|
||||
|
||||
/// Nothing but the entity: these predicates read only its type and components.
|
||||
struct Empty;
|
||||
impl World for Empty {
|
||||
fn dimension(&self) -> &str {
|
||||
"minecraft:overworld"
|
||||
}
|
||||
fn biome(&self, _: [i32; 3]) -> String {
|
||||
"minecraft:plains".into()
|
||||
}
|
||||
fn structures(&self, _: [i32; 3]) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
fn block(&self, _: [i32; 3]) -> (String, BTreeMap<String, String>) {
|
||||
("minecraft:air".into(), BTreeMap::new())
|
||||
}
|
||||
fn fluid(&self, _: [i32; 3]) -> Option<String> {
|
||||
None
|
||||
}
|
||||
fn can_see_sky(&self, _: [i32; 3]) -> bool {
|
||||
true
|
||||
}
|
||||
fn light(&self, _: [i32; 3]) -> i32 {
|
||||
15
|
||||
}
|
||||
fn smokey(&self, _: [i32; 3]) -> bool {
|
||||
false
|
||||
}
|
||||
fn in_tag(&self, _: &str, _: &str, _: &str) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn species(id: &str) -> Species {
|
||||
match id {
|
||||
"minecraft:wolf" => Species::Wolf,
|
||||
"minecraft:cat" => Species::Cat,
|
||||
"minecraft:frog" => Species::Frog,
|
||||
"minecraft:pig" => Species::Pig,
|
||||
"minecraft:cow" => Species::Cow,
|
||||
"minecraft:chicken" => Species::Chicken,
|
||||
other => panic!("no species for {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let input = BufReader::new(File::open(&args[1])?);
|
||||
let mut out = BufWriter::new(File::create(&args[2])?);
|
||||
for (n, line) in input.lines().enumerate() {
|
||||
let c: Value = serde_json::from_str(&line?)?;
|
||||
writeln!(out, "== {n}")?;
|
||||
let kind = c["type"].as_str().unwrap();
|
||||
let s = species(kind);
|
||||
let variant = variant_index(s, c["variant"].as_str().unwrap());
|
||||
let view = EntityView {
|
||||
kind: kind.to_owned(),
|
||||
position: [0.5, 64.0, 0.5],
|
||||
components: species_variant_component(s, variant)
|
||||
.map(|(id, v)| (id.to_owned(), v))
|
||||
.into_iter()
|
||||
.collect(),
|
||||
..Default::default()
|
||||
};
|
||||
for p in c["predicates"].as_array().unwrap() {
|
||||
writeln!(out, "{}", entity(p, &view, &Empty, [2.5, 64.0, 0.5]))?;
|
||||
}
|
||||
}
|
||||
out.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -21,6 +21,7 @@ fn stack(spec: &str) -> LootStack {
|
||||
enchantments: Vec::new(),
|
||||
potion: None,
|
||||
map: false,
|
||||
exploration: None,
|
||||
ominous_amplifier: None,
|
||||
};
|
||||
for e in parts {
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
//! Isolated production mob-model builder benchmark; no assets, Game, GPU or full-frame work.
|
||||
//! CARGO_TARGET_DIR=/home/bob/Documents/horizon/target CARGO_INCREMENTAL=0 \
|
||||
//! cargo run --profile fast -p horizon-client --example entity_geometry_bench
|
||||
use horizon_client::{
|
||||
mesher::Vertex,
|
||||
mobs::{Part, Polygon, anim::Poses, render},
|
||||
};
|
||||
use std::{hint::black_box, time::Instant};
|
||||
|
||||
fn cube(name: String, position: f32) -> Part {
|
||||
let polygons = (0..6)
|
||||
.map(|face| {
|
||||
let axis = face / 2;
|
||||
let side = (face % 2) as f32;
|
||||
let mut normal = [0.0; 3];
|
||||
normal[axis] = side * 2.0 - 1.0;
|
||||
Polygon {
|
||||
normal,
|
||||
vertices: [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]].map(|uv| {
|
||||
let mut vertex = [0.0; 5];
|
||||
vertex[axis] = side * 8.0;
|
||||
vertex[(axis + 1) % 3] = uv[0] * 8.0;
|
||||
vertex[(axis + 2) % 3] = uv[1] * 8.0;
|
||||
vertex[3] = uv[0];
|
||||
vertex[4] = uv[1];
|
||||
vertex
|
||||
}),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Part {
|
||||
name,
|
||||
pose: [position, 0.0, 0.0, 0.1, 0.2, 0.0, 1.0, 1.0, 1.0],
|
||||
polygons,
|
||||
children: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build(model: &Part, poses: &Poses, count: usize, out: &mut Vec<Vertex>) {
|
||||
for i in 0..count {
|
||||
render::draw(
|
||||
black_box(model),
|
||||
glam::Mat4::from_translation(glam::Vec3::new((i % 32) as f32, 0.0, (i / 32) as f32)),
|
||||
black_box(poses),
|
||||
[0.125, 0.25, 0.5, 0.75],
|
||||
0xffc0_d0e0,
|
||||
0xf0f0,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
}
|
||||
black_box(out.as_slice());
|
||||
}
|
||||
|
||||
fn measure(model: &Part, poses: &Poses, count: usize, iterations: usize, reuse: bool) -> f64 {
|
||||
let mut retained = Vec::new();
|
||||
build(model, poses, count, &mut retained); // Untimed warmup.
|
||||
let start = Instant::now();
|
||||
for _ in 0..iterations {
|
||||
let mut out = if reuse {
|
||||
std::mem::take(&mut retained)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
out.clear();
|
||||
build(model, poses, count, &mut out);
|
||||
if reuse {
|
||||
retained = out;
|
||||
}
|
||||
}
|
||||
start.elapsed().as_secs_f64() * 1e6 / iterations as f64
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut model = cube("body".into(), 0.0);
|
||||
model.children = (1..6).map(|i| cube(format!("part{i}"), i as f32)).collect();
|
||||
let poses = Poses::new(&model);
|
||||
for count in [32, 256, 1024, 4096, 8192] {
|
||||
let mut expected = Vec::new();
|
||||
build(&model, &poses, count, &mut expected);
|
||||
let mut retained = Vec::new();
|
||||
build(&model, &poses, count, &mut retained);
|
||||
let (pointer, capacity) = (retained.as_ptr(), retained.capacity());
|
||||
// Populated -> empty/reset -> smaller -> populated must never leave stale vertices.
|
||||
for n in [0, count / 2, count, count] {
|
||||
retained.clear();
|
||||
build(&model, &poses, n, &mut retained);
|
||||
let mut fresh = Vec::new();
|
||||
build(&model, &poses, n, &mut fresh);
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<Vertex, u8>(&retained),
|
||||
bytemuck::cast_slice::<Vertex, u8>(&fresh)
|
||||
);
|
||||
assert_eq!(
|
||||
(retained.as_ptr(), retained.capacity()),
|
||||
(pointer, capacity)
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<Vertex, u8>(&retained),
|
||||
bytemuck::cast_slice::<Vertex, u8>(&expected)
|
||||
);
|
||||
let iterations = (100_000 / count).clamp(40, 1000);
|
||||
let mut fresh = Vec::new();
|
||||
let mut reused = Vec::new();
|
||||
let mut paired_speedups = Vec::new();
|
||||
// Alternate order, reporting medians over eleven warmed batches.
|
||||
for batch in 0..11 {
|
||||
let mut pair = [0.0; 2];
|
||||
for reuse in if batch % 2 == 0 {
|
||||
[false, true]
|
||||
} else {
|
||||
[true, false]
|
||||
} {
|
||||
let time = measure(&model, &poses, count, iterations, reuse);
|
||||
pair[usize::from(reuse)] = time;
|
||||
if reuse {
|
||||
reused.push(time);
|
||||
} else {
|
||||
fresh.push(time);
|
||||
}
|
||||
}
|
||||
paired_speedups.push(pair[0] / pair[1]);
|
||||
}
|
||||
paired_speedups.sort_by(f64::total_cmp);
|
||||
fresh.sort_by(f64::total_cmp);
|
||||
reused.sort_by(f64::total_cmp);
|
||||
println!(
|
||||
"entities={count} vertices={} capacity={capacity} retained_bytes={} fresh_us={:.3} reused_us={:.3} speedup={:.3}x paired_speedup_median={:.3}x paired_speedup_range={:.3}..{:.3}x iterations={iterations} batches=11 byte_equivalence=pass pointer_capacity_reuse=pass",
|
||||
retained.len(),
|
||||
capacity * size_of::<Vertex>(),
|
||||
fresh[5],
|
||||
reused[5],
|
||||
fresh[5] / reused[5],
|
||||
paired_speedups[5],
|
||||
paired_speedups[0],
|
||||
paired_speedups[10]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! Runs tools/test_entity_particles_parity.py's entity events through
|
||||
//! `particles::entity_event_burst` and `particles::around_self`.
|
||||
use horizon_client::{Result, particles};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
};
|
||||
|
||||
fn d(v: f64) -> String {
|
||||
format!("{:016x}", v.to_bits())
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let mut out = BufWriter::new(File::create(&args[2])?);
|
||||
for (n, line) in BufReader::new(File::open(&args[1])?).lines().enumerate() {
|
||||
let c: Value = serde_json::from_str(&line?)?;
|
||||
writeln!(out, "== {n}")?;
|
||||
let mut random = LegacyRandom::new(c["seed"].as_i64().ok_or("seed")?);
|
||||
let feet: [f64; 3] = std::array::from_fn(|i| c["feet"][i].as_f64().unwrap_or(0.0));
|
||||
// EntityDimensions hold floats.
|
||||
let width = f64::from(c["width"].as_f64().unwrap_or(0.0) as f32);
|
||||
let height = f64::from(c["height"].as_f64().unwrap_or(0.0) as f32);
|
||||
let entity = c["entity"].as_str().unwrap_or("");
|
||||
if let Some((kind, burst)) =
|
||||
particles::entity_event_burst(entity, c["event"].as_u64().unwrap_or(0) as u8)
|
||||
{
|
||||
let mut lines = Vec::new();
|
||||
particles::around_self(&mut random, feet, width, height, burst, |p, v| {
|
||||
lines.push(format!(
|
||||
"+ {kind} {} {} {} {} {} {}",
|
||||
d(p[0]),
|
||||
d(p[1]),
|
||||
d(p[2]),
|
||||
d(v[0]),
|
||||
d(v[1]),
|
||||
d(v[2])
|
||||
));
|
||||
});
|
||||
for l in lines {
|
||||
writeln!(out, "{l}")?;
|
||||
}
|
||||
}
|
||||
writeln!(out, "next {}", random.next_long())?;
|
||||
}
|
||||
out.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -64,6 +64,10 @@ fn main() -> horizon_client::Result<()> {
|
||||
);
|
||||
put("visual/fog_start_distance", format!("{:?}", sky.fog_start));
|
||||
put("visual/fog_end_distance", format!("{:?}", sky.fog_end));
|
||||
put(
|
||||
"visual/cloud_fog_end_distance",
|
||||
format!("{:?}", sky.cloud_fog_end),
|
||||
);
|
||||
out.push_str(&row);
|
||||
out.push_str("}\n");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! Runs tools/test_fire_starter_sound_parity.py's level random seeds through
|
||||
//! the fire charge's use sound (`horizon_client::items::fire_starter_pitch`).
|
||||
use horizon_client::{
|
||||
Result,
|
||||
items::{fire_starter_pitch, fire_starter_sound},
|
||||
};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
};
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let input = BufReader::new(File::open(&args[1])?);
|
||||
let mut out = BufWriter::new(File::create(&args[2])?);
|
||||
for line in input.lines() {
|
||||
let mut random = LegacyRandom::new(line?.trim().parse()?);
|
||||
let pitch = fire_starter_pitch(true, &mut random);
|
||||
writeln!(
|
||||
out,
|
||||
"{} {} {} {}",
|
||||
fire_starter_sound(true),
|
||||
1.0f32.to_bits() as i32,
|
||||
pitch.to_bits() as i32,
|
||||
random.next_long()
|
||||
)?;
|
||||
}
|
||||
out.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use horizon_client::{
|
||||
Result,
|
||||
attack_strength::{AttackStrength, HeldStack},
|
||||
first_person::{
|
||||
Clicks, Hand, HandHeights, HandWorld, Hit, SwingAnimation, SwingState, UseResult,
|
||||
Clicks, Hand, HandHeights, HandSway, HandWorld, Hit, SwingAnimation, SwingState, UseResult,
|
||||
swing_duration,
|
||||
},
|
||||
};
|
||||
@@ -308,6 +308,9 @@ fn run(case: &Value, out: &mut impl Write) -> Result<()> {
|
||||
let mut strength = AttackStrength::default();
|
||||
// FirstPersonHandsAndItems' visible items start as ItemStack.EMPTY.
|
||||
let mut visible = [0usize, 0usize];
|
||||
// The player's xRot and yRot, and the hands' lag behind them.
|
||||
let mut rot = [0.0f32; 2];
|
||||
let mut sway = HandSway::default();
|
||||
for (s, step) in case["steps"].as_array().unwrap().iter().enumerate() {
|
||||
probe.step = step;
|
||||
probe.events.clear();
|
||||
@@ -327,6 +330,10 @@ fn run(case: &Value, out: &mut impl Write) -> Result<()> {
|
||||
step["ud"].as_bool().unwrap(),
|
||||
);
|
||||
probe.swing.tick();
|
||||
if let Some(r) = step["rot"].as_array() {
|
||||
rot = [fl(&r[0]), fl(&r[1])];
|
||||
}
|
||||
sway.tick(rot[0], rot[1]);
|
||||
let main = probe.stack(Hand::Main).clone();
|
||||
strength.tick(held(&main));
|
||||
// FirstPersonHandsAndItems.tick: shouldInstantlyReplaceVisibleItem
|
||||
@@ -363,7 +370,7 @@ fn run(case: &Value, out: &mut impl Write) -> Result<()> {
|
||||
let d = &clicks.destroying;
|
||||
writeln!(
|
||||
out,
|
||||
"tick {s} {current} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {}",
|
||||
"tick {s} {current} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {}",
|
||||
sw.ticks,
|
||||
fb(sw.animation),
|
||||
fb(sw.old_animation),
|
||||
@@ -379,7 +386,11 @@ fn run(case: &Value, out: &mut impl Write) -> Result<()> {
|
||||
clicks.right_click_delay,
|
||||
d.delay,
|
||||
d.active,
|
||||
fb(d.progress)
|
||||
fb(d.progress),
|
||||
fb(sway.x_bob),
|
||||
fb(sway.y_bob),
|
||||
fb(sway.x_bob_o),
|
||||
fb(sway.y_bob_o)
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -93,8 +93,9 @@ fn encode(draws: &[GuiDraw]) -> Vec<Value> {
|
||||
h,
|
||||
region,
|
||||
color,
|
||||
blend,
|
||||
} => {
|
||||
json!({"type": "sprite", "sprite": sprite, "x": x, "y": y, "w": w, "h": h, "region": region, "color": *color as i32})
|
||||
json!({"type": "sprite", "sprite": sprite, "x": x, "y": y, "w": w, "h": h, "region": region, "color": *color as i32, "pipeline": blend.pipeline()})
|
||||
}
|
||||
GuiDraw::AtlasSprite {
|
||||
sprite,
|
||||
@@ -190,7 +191,12 @@ fn decorations(c: &Value, atlas: &HudAtlas) -> Vec<GuiDraw> {
|
||||
// Spectators only see it over a spectatable target (none here).
|
||||
if c["crosshair"].as_bool().unwrap_or(false) && mode != "spectator" {
|
||||
let attack = (indicator == "CROSSHAIR").then(|| float("attackScale", 1.0));
|
||||
draws.extend(hud_layout::crosshair(w, h, attack, false));
|
||||
// crosshairPickEntity: absent, or a living entity alive or not.
|
||||
let target = c["target"].as_bool();
|
||||
let full = attack.is_some_and(|a| {
|
||||
hud_layout::full_attack_indicator(a, float("attackDelay", 5.0), target)
|
||||
});
|
||||
draws.extend(hud_layout::crosshair(w, h, attack, full));
|
||||
}
|
||||
if mode != "spectator" {
|
||||
let pop = |v: &Value| v.as_f64().map(|p| p as f32 - partial);
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Compare the read + Value parse stages in BlockFacts::load and Assets::load.
|
||||
//! Usage: json_load_bench RESOURCE_ROOT [REPETITIONS]; see tools/json_load_fixtures.py.
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs::{self, File},
|
||||
hint::black_box,
|
||||
io::BufReader,
|
||||
path::Path,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
fn reader(path: &Path) -> Result<Value> {
|
||||
Ok(serde_json::from_reader(BufReader::new(File::open(path)?))?)
|
||||
}
|
||||
|
||||
fn slice(path: &Path) -> Result<Value> {
|
||||
Ok(serde_json::from_slice(&fs::read(path)?)?)
|
||||
}
|
||||
|
||||
fn measure(path: &Path, load: fn(&Path) -> Result<Value>) -> Result<Duration> {
|
||||
let start = Instant::now();
|
||||
let value = black_box(load(black_box(path))?);
|
||||
let elapsed = start.elapsed();
|
||||
// Value destruction is excluded; the temporary byte buffer is released by slice().
|
||||
drop(value);
|
||||
Ok(elapsed)
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let root = args
|
||||
.next()
|
||||
.ok_or("usage: json_load_bench ROOT [REPETITIONS]")?;
|
||||
let repetitions: usize = args.next().map_or(Ok(15), |s| s.parse())?;
|
||||
if repetitions == 0 {
|
||||
return Err("REPETITIONS must be positive".into());
|
||||
}
|
||||
for name in ["gameplay-facts.json", "facts.json"] {
|
||||
let path = Path::new(&root).join(name);
|
||||
// Validate complete parsed trees on the exact files being timed.
|
||||
assert_eq!(reader(&path)?, slice(&path)?);
|
||||
for _ in 0..2 {
|
||||
measure(&path, reader)?;
|
||||
measure(&path, slice)?;
|
||||
}
|
||||
let (mut before, mut after) = (Vec::new(), Vec::new());
|
||||
for i in 0..repetitions {
|
||||
// Alternate order to reduce filesystem cache / frequency bias.
|
||||
if i % 2 == 0 {
|
||||
before.push(measure(&path, reader)?);
|
||||
after.push(measure(&path, slice)?);
|
||||
} else {
|
||||
after.push(measure(&path, slice)?);
|
||||
before.push(measure(&path, reader)?);
|
||||
}
|
||||
}
|
||||
before.sort_unstable();
|
||||
after.sort_unstable();
|
||||
let median = |samples: &[Duration]| {
|
||||
(samples[(samples.len() - 1) / 2].as_secs_f64()
|
||||
+ samples[samples.len() / 2].as_secs_f64())
|
||||
* 500.0
|
||||
};
|
||||
let (before, after) = (median(&before), median(&after));
|
||||
println!(
|
||||
"{name}: {} bytes; {repetitions} samples; reader {before:.3} ms; slice {after:.3} ms; {:.2}x",
|
||||
fs::metadata(&path)?.len(),
|
||||
before / after
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reader_and_slice_preserve_values_and_error_categories() -> Result<()> {
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("horizon-json-load-{}.json", std::process::id()));
|
||||
let check = || -> Result<()> {
|
||||
fs::write(
|
||||
&path,
|
||||
br#"{"states":[{"id":0,"hardness":-1.5,"name":"escaped\n\u2603","outline":[[0,0,0,1,1,1]],"enabled":true}],"defaults":null}"#,
|
||||
)?;
|
||||
assert_eq!(reader(&path)?, slice(&path)?);
|
||||
for invalid in [
|
||||
&b"{\"states\":["[..],
|
||||
&b"{\"states\":tru}"[..],
|
||||
&b"{} trailing"[..],
|
||||
&b"{\"name\":\"\xff\"}"[..],
|
||||
] {
|
||||
fs::write(&path, invalid)?;
|
||||
let before = reader(&path).unwrap_err();
|
||||
let after = slice(&path).unwrap_err();
|
||||
assert_eq!(
|
||||
before
|
||||
.downcast_ref::<serde_json::Error>()
|
||||
.unwrap()
|
||||
.classify(),
|
||||
after
|
||||
.downcast_ref::<serde_json::Error>()
|
||||
.unwrap()
|
||||
.classify()
|
||||
);
|
||||
}
|
||||
fs::remove_file(&path)?;
|
||||
for load in [reader, slice] {
|
||||
let error = load(&path).unwrap_err();
|
||||
assert_eq!(
|
||||
error.downcast_ref::<std::io::Error>().unwrap().kind(),
|
||||
std::io::ErrorKind::NotFound
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
let result = check();
|
||||
let _ = fs::remove_file(path);
|
||||
result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Compare the exact read/deserialize sequences used by the model loaders.
|
||||
//! Run with `cargo run -p horizon-client --profile fast --example json_readers_bench`.
|
||||
//! Optional arguments: resource directory, repetitions (default 9).
|
||||
use serde_json::{Map, Value, json};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fmt::Debug,
|
||||
fs::{self, File},
|
||||
hint::black_box,
|
||||
io::BufReader,
|
||||
path::{Path, PathBuf},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
type Result<T> = horizon_client::Result<T>;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Reader {
|
||||
File,
|
||||
Buffered,
|
||||
Slice,
|
||||
}
|
||||
const READERS: [Reader; 3] = [Reader::File, Reader::Buffered, Reader::Slice];
|
||||
|
||||
// These match the map types and reader expressions at all three production sites.
|
||||
macro_rules! reader {
|
||||
($name:ident, $map:ty) => {
|
||||
fn $name(path: &Path, reader: Reader) -> Result<$map> {
|
||||
Ok(match reader {
|
||||
Reader::File => serde_json::from_reader(File::open(path)?)?,
|
||||
Reader::Buffered => serde_json::from_reader(BufReader::new(File::open(path)?))?,
|
||||
Reader::Slice => serde_json::from_slice(&fs::read(path)?)?,
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
reader!(prepare_facts, Map<String, Value>);
|
||||
reader!(catalog_facts, BTreeMap<String, Value>);
|
||||
|
||||
struct Fixture(PathBuf);
|
||||
impl Fixture {
|
||||
fn new() -> Result<Self> {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-json-readers-{}", std::process::id()));
|
||||
fs::create_dir(&root)?;
|
||||
Ok(Self(root))
|
||||
}
|
||||
}
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn fixtures(root: &Path) -> Result<()> {
|
||||
let states: Vec<_> = (0..56)
|
||||
.map(|i| {
|
||||
json!({"facing": (["north", "east", "south", "west"][i % 4]),
|
||||
"powered": if i / 4 % 2 == 0 { "false" } else { "true" },
|
||||
"age": (i / 8).to_string()})
|
||||
})
|
||||
.collect();
|
||||
let mut models = Map::new();
|
||||
let mut render = Map::new();
|
||||
for i in 0..1024 {
|
||||
let name = format!("minecraft:fixture_block_{i:04}");
|
||||
models.insert(
|
||||
name.clone(),
|
||||
json!({
|
||||
"schema": {"facing": {"named": ["north", "east", "south", "west"]},
|
||||
"powered": {"named": ["false", "true"]}, "age": {"integer": [0, 6]}},
|
||||
"states": states
|
||||
}),
|
||||
);
|
||||
render.insert(
|
||||
name,
|
||||
json!({"rule": "never", "can_occlude": true, "bars_tag": false}),
|
||||
);
|
||||
}
|
||||
fs::write(root.join("model-states.json"), serde_json::to_vec(&models)?)?;
|
||||
fs::write(
|
||||
root.join("render-definitions.json"),
|
||||
serde_json::to_vec(&render)?,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn benchmark<T: PartialEq + Debug>(
|
||||
label: &str,
|
||||
path: &Path,
|
||||
repetitions: usize,
|
||||
read: impl Fn(&Path, Reader) -> Result<T>,
|
||||
) -> Result<()> {
|
||||
let expected = read(path, Reader::File)?;
|
||||
for reader in READERS {
|
||||
assert_eq!(expected, read(path, reader)?);
|
||||
for _ in 0..2 {
|
||||
black_box(read(black_box(path), reader)?);
|
||||
}
|
||||
}
|
||||
drop(expected);
|
||||
let mut samples: [Vec<f64>; 3] = std::array::from_fn(|_| Vec::new());
|
||||
for repetition in 0..repetitions {
|
||||
// Rotate reader order to distribute drift across implementations.
|
||||
for offset in 0..3 {
|
||||
let index = (repetition + offset) % 3;
|
||||
let start = Instant::now();
|
||||
let value = black_box(read(black_box(path), READERS[index])?);
|
||||
samples[index].push(start.elapsed().as_secs_f64() * 1000.0);
|
||||
drop(value); // Match the loaders: retain the parsed tree after loading.
|
||||
}
|
||||
}
|
||||
let median: Vec<_> = samples
|
||||
.iter_mut()
|
||||
.map(|s| {
|
||||
s.sort_by(f64::total_cmp);
|
||||
s[s.len() / 2]
|
||||
})
|
||||
.collect();
|
||||
println!(
|
||||
"{label}: {} bytes, {repetitions} samples/reader, median ms: File={:.3}, BufReader={:.3}, read+slice={:.3}; slice speedup={:.2}x",
|
||||
fs::metadata(path)?.len(),
|
||||
median[0],
|
||||
median[1],
|
||||
median[2],
|
||||
median[0] / median[2]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<_> = std::env::args().skip(1).collect();
|
||||
let fixture = if args.is_empty() {
|
||||
Some(Fixture::new()?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let root = fixture
|
||||
.as_ref()
|
||||
.map(|f| f.0.clone())
|
||||
.unwrap_or_else(|| PathBuf::from(&args[0]));
|
||||
if fixture.is_some() {
|
||||
fixtures(&root)?;
|
||||
println!(
|
||||
"Generated representative fixtures: 1024 blocks, 57344 model states; warm filesystem cache."
|
||||
);
|
||||
}
|
||||
let repetitions = args.get(1).map(|s| s.parse()).transpose()?.unwrap_or(9);
|
||||
assert!(repetitions > 0, "repetitions must be positive");
|
||||
benchmark(
|
||||
"prepare model states",
|
||||
&root.join("model-states.json"),
|
||||
repetitions,
|
||||
prepare_facts,
|
||||
)?;
|
||||
benchmark(
|
||||
"catalog model states",
|
||||
&root.join("model-states.json"),
|
||||
repetitions,
|
||||
catalog_facts,
|
||||
)?;
|
||||
benchmark(
|
||||
"render definitions",
|
||||
&root.join("render-definitions.json"),
|
||||
repetitions,
|
||||
catalog_facts,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reader_equivalence_and_errors() -> Result<()> {
|
||||
let fixture = Fixture::new()?;
|
||||
let path = fixture.0.join("test.json");
|
||||
fs::write(&path, br#"{"minecraft:stone":{"schema":{},"states":[{}]},"unicode":"\u2603","duplicate":1,"duplicate":2}"#)?;
|
||||
let expected = prepare_facts(&path, Reader::File)?;
|
||||
for reader in READERS {
|
||||
assert_eq!(expected, prepare_facts(&path, reader)?);
|
||||
assert_eq!(
|
||||
expected.iter().collect::<BTreeMap<_, _>>(),
|
||||
catalog_facts(&path, reader)?.iter().collect()
|
||||
);
|
||||
}
|
||||
for invalid in [b"{\"a\": ".as_slice(), b"{} trailing", b"{\"a\":\"\xff\"}"] {
|
||||
fs::write(&path, invalid)?;
|
||||
for reader in READERS {
|
||||
let error = prepare_facts(&path, reader).unwrap_err();
|
||||
assert!(error.downcast_ref::<serde_json::Error>().is_some());
|
||||
assert!(catalog_facts(&path, reader).is_err());
|
||||
}
|
||||
}
|
||||
fs::remove_file(&path)?;
|
||||
for reader in READERS {
|
||||
let error = prepare_facts(&path, reader).unwrap_err();
|
||||
assert_eq!(
|
||||
error.downcast_ref::<std::io::Error>().unwrap().kind(),
|
||||
std::io::ErrorKind::NotFound
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ use horizon_client::{
|
||||
biome::Biomes,
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, MIN_Y, SECTIONS, index},
|
||||
light::{LightTables, light_chunk},
|
||||
light::{LightTables, apply_lighting, light_chunk},
|
||||
save,
|
||||
};
|
||||
use horizon_core::position::ChunkPos;
|
||||
@@ -101,7 +101,7 @@ fn main() -> horizon_client::Result<()> {
|
||||
.unwrap_or((None, None));
|
||||
let nib = |a: &Option<Vec<u8>>| a.as_ref().map(|a| (a[i >> 1] >> ((i & 1) * 4)) & 15);
|
||||
let id = chunks[&(x, z)].get(wx & 15, wy, wz & 15);
|
||||
let packed = lit[si].light(i);
|
||||
let packed = lit[si].get(i);
|
||||
println!(
|
||||
"{wy}: {} horizon sky {} block {} vanilla sky {:?} block {:?}",
|
||||
facts.states[id as usize].name,
|
||||
@@ -157,7 +157,7 @@ fn main() -> horizon_client::Result<()> {
|
||||
.map_or(15, |a| (a[(i & 255) >> 1] >> ((i & 1) * 4)) & 15),
|
||||
};
|
||||
let vb = nibble(&block, 0);
|
||||
let packed = section.light(i);
|
||||
let packed = section.get(i);
|
||||
let (hs, hb) = (packed >> 4, packed & 15);
|
||||
total += 1;
|
||||
if hs != vs {
|
||||
@@ -228,7 +228,7 @@ fn incremental(
|
||||
std::array::from_fn(|dx| chunks.get(&(x + dx as i32 - 1, z + dz as i32 - 1)))
|
||||
});
|
||||
let mut c = chunks[&(x, z)].clone();
|
||||
c.sections = light_chunk(®ion, tables, sky_light);
|
||||
apply_lighting(&mut c, light_chunk(®ion, tables, sky_light));
|
||||
c.lit = true;
|
||||
world.insert((x, z), Arc::new(c));
|
||||
}
|
||||
@@ -299,7 +299,7 @@ fn incremental(
|
||||
let full = light_chunk(®ion, tables, sky_light);
|
||||
for (si, s) in full.iter().enumerate() {
|
||||
for i in 0..4096 {
|
||||
let (a, b) = (s.light(i), world[&(kx, kz)].sections[si].light(i));
|
||||
let (a, b) = (s.get(i), world[&(kx, kz)].sections[si].light(i));
|
||||
if a != b {
|
||||
bad += 1;
|
||||
if bad < 20 {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
//! Menu clicks for tools/test_menu_click_parity.py: reads one JSON case per
|
||||
//! line (menu, creative, slots, carried, data, ops) and prints the oracle's
|
||||
//! state lines after every click.
|
||||
//! state lines after every click. The `*_be` furnace menus run over a furnace
|
||||
//! block entity: its data slots follow `Furnace::input_clicked`.
|
||||
//!
|
||||
//! usage: menu_fixture RESOURCES INPUT OUTPUT
|
||||
use horizon_client::{
|
||||
containers::{Furnace, FurnaceKind},
|
||||
crafting::Recipes,
|
||||
item_data::{canon_stack, parse_stack},
|
||||
items::Items,
|
||||
@@ -41,11 +43,25 @@ fn main() -> horizon_client::Result<()> {
|
||||
recipes: &recipes,
|
||||
brewing: &brewing,
|
||||
banners: &banners,
|
||||
trade: None,
|
||||
};
|
||||
let mut out = String::new();
|
||||
for line in input.lines() {
|
||||
let case: Value = serde_json::from_str(line)?;
|
||||
let kind = Kind::parse(case["menu"].as_str().unwrap_or_default()).ok_or("menu kind")?;
|
||||
let name = case["menu"].as_str().unwrap_or_default();
|
||||
// FurnaceMenu over the server's AbstractFurnaceBlockEntity.
|
||||
let furnace_kind = match name {
|
||||
"furnace_be" => Some(FurnaceKind::Furnace),
|
||||
"blast_furnace_be" => Some(FurnaceKind::BlastFurnace),
|
||||
"smoker_be" => Some(FurnaceKind::Smoker),
|
||||
_ => None,
|
||||
};
|
||||
let kind = Kind::parse(
|
||||
name.strip_suffix("_be")
|
||||
.filter(|_| furnace_kind.is_some())
|
||||
.unwrap_or(name),
|
||||
)
|
||||
.ok_or("menu kind")?;
|
||||
let mut menu = Menu::new(kind);
|
||||
menu.creative = case["creative"].as_bool().unwrap_or(false);
|
||||
for (i, slot) in case["slots"].as_array().ok_or("slots")?.iter().enumerate() {
|
||||
@@ -56,6 +72,15 @@ fn main() -> horizon_client::Result<()> {
|
||||
for (i, v) in case["data"].as_array().ok_or("data")?.iter().enumerate() {
|
||||
menu.data[i] = v.as_i64().unwrap_or(0) as i32;
|
||||
}
|
||||
let mut furnace = furnace_kind.map(|kind| Furnace {
|
||||
kind,
|
||||
items: [0, 1, 2].map(|i| menu.slot(i).cloned()),
|
||||
lit_remaining: menu.data[0],
|
||||
lit_total: menu.data[1],
|
||||
cooking_timer: menu.data[2],
|
||||
cooking_total: menu.data[3],
|
||||
speed: 1.0,
|
||||
});
|
||||
menu.drops.clear();
|
||||
out += &format!("init {}\n", state(&menu));
|
||||
for op in case["ops"].as_array().ok_or("ops")? {
|
||||
@@ -78,8 +103,25 @@ fn main() -> horizon_client::Result<()> {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let kind = Input::parse(op[2].as_str().unwrap_or_default()).ok_or("input")?;
|
||||
match menu.click(&env, slot, button, kind) {
|
||||
let clicked = if op[2] == "QUICK_MOVE_ALL" {
|
||||
// A Shift double click: lastQuickMoved is the op's fourth field.
|
||||
let moved = parse_stack(op[3].as_str().unwrap_or("-"))?;
|
||||
match moved {
|
||||
Some(moved) => menu.quick_move_all(&env, slot as usize, &moved),
|
||||
None => Ok(()),
|
||||
}
|
||||
} else {
|
||||
let kind = Input::parse(op[2].as_str().unwrap_or_default()).ok_or("input")?;
|
||||
menu.click(&env, slot, button, kind)
|
||||
};
|
||||
if let Some(f) = furnace.as_mut() {
|
||||
let before = f.items[0].clone();
|
||||
f.items = [0, 1, 2].map(|i| menu.slot(i).cloned());
|
||||
f.input_clicked(&recipes, before);
|
||||
menu.data[2] = f.cooking_timer;
|
||||
menu.data[3] = f.cooking_total;
|
||||
}
|
||||
match clicked {
|
||||
Ok(()) => out += &format!("{}\n", state(&menu)),
|
||||
Err(name) => out += &format!("error {name}\n"),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
//! Horizon's sprite mip levels for tools/test_mipmap_parity.py: each sprite
|
||||
//! loads through `sprites::Sprites` (its .mcmeta settings and transparency)
|
||||
//! and `mipmap::generate` makes its levels down to 1x1, printed as RGBA hex
|
||||
//! rows like VanillaMipmapOracle.
|
||||
use std::{fmt::Write as _, fs, path::Path};
|
||||
|
||||
fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let input = fs::read_to_string(&args[0])?;
|
||||
let mut lines = input.lines();
|
||||
let root = Path::new(lines.next().ok_or("no resources root")?);
|
||||
let names: Vec<&str> = lines.collect();
|
||||
let mut sprites = horizon_client::sprites::Sprites::default();
|
||||
let layers = names
|
||||
.iter()
|
||||
.map(|name| sprites.insert(root, name))
|
||||
.collect::<horizon_client::Result<Vec<u32>>>()?;
|
||||
sprites.finish();
|
||||
let size = sprites.size;
|
||||
let bytes = (size * size * 4) as usize;
|
||||
let mut out = String::new();
|
||||
for (name, layer) in names.iter().zip(layers) {
|
||||
let start = layer as usize * bytes;
|
||||
let base = horizon_client::mipmap::Image {
|
||||
width: size,
|
||||
height: size,
|
||||
pixels: sprites.pixels[start..start + bytes]
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.to_vec(),
|
||||
};
|
||||
let s = sprites.mips[layer as usize];
|
||||
writeln!(out, "== {name}")?;
|
||||
for mip in horizon_client::mipmap::generate(base, size.trailing_zeros(), s) {
|
||||
write!(out, "{}x{}", mip.width, mip.height)?;
|
||||
for row in mip.pixels.chunks(mip.width as usize) {
|
||||
out.push(' ');
|
||||
for p in row {
|
||||
write!(out, "{:02x}{:02x}{:02x}{:02x}", p[0], p[1], p[2], p[3])?;
|
||||
}
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
fs::write(&args[1], out)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -5,20 +5,21 @@
|
||||
//! usage: mob_ai_probe RESOURCES SCENARIOS OUTPUT
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
beehive::HiveHost,
|
||||
biome::Biomes,
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, HEIGHT, MIN_Y},
|
||||
collisions,
|
||||
containers::{BlockEntity, CreakingHeart},
|
||||
items::{ItemStack, Items},
|
||||
light::{LightTables, light_chunk},
|
||||
light::{LightTables, apply_lighting, light_chunk},
|
||||
mobs::{
|
||||
Damage, MobType, creaking,
|
||||
creaking_heart::{self, HeartEvent, HeartHost},
|
||||
},
|
||||
mobs::{
|
||||
Mob, MobData, MobEvent, MobTypes, MobWorld, PlayerView, Seen, Species, Who,
|
||||
control::Operation, spawning::LocalDifficulty,
|
||||
control::Operation, seen_grid::SeenGrid, spawning::LocalDifficulty,
|
||||
},
|
||||
world::World,
|
||||
};
|
||||
@@ -27,7 +28,13 @@ use horizon_core::{
|
||||
random::LegacyRandom,
|
||||
};
|
||||
use horizon_world::{chunk::BlockState, registry::BlockRegistry, registry::BlockStateId};
|
||||
use std::{collections::BTreeMap, fmt::Write as _, fs, path::Path, sync::Arc};
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
fmt::Write as _,
|
||||
fs,
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
struct MobSpec {
|
||||
kind: String,
|
||||
@@ -54,8 +61,13 @@ struct Scenario {
|
||||
hearts: Vec<Vec<String>>,
|
||||
/// `ServerLevel.isSpawningMonsters`.
|
||||
spawning: bool,
|
||||
/// The day time moves on with every tick.
|
||||
daycycle: bool,
|
||||
/// The player hits a mob for 1 at the start of a tick: (tick, mob index).
|
||||
hurts: Vec<(i32, usize)>,
|
||||
/// The player feeds an animal at the start of a tick (Animal.mobInteract):
|
||||
/// (tick, mob index); the scenario only feeds each animal its food.
|
||||
feeds: Vec<(i32, usize)>,
|
||||
hand: Option<String>,
|
||||
mobs: Vec<MobSpec>,
|
||||
}
|
||||
@@ -82,6 +94,7 @@ fn parse(text: &str) -> Vec<Scenario> {
|
||||
"thunder" => s.thunder = true,
|
||||
"levelseed" => {}
|
||||
"spawning" => s.spawning = true,
|
||||
"daycycle" => s.daycycle = true,
|
||||
_ => panic!("unknown scenario option {kv}"),
|
||||
}
|
||||
}
|
||||
@@ -92,6 +105,7 @@ fn parse(text: &str) -> Vec<Scenario> {
|
||||
}
|
||||
"heart" => s.hearts.push(f.iter().map(|x| (*x).to_owned()).collect()),
|
||||
"hurt" => s.hurts.push((f[1].parse().unwrap(), f[2].parse().unwrap())),
|
||||
"feed" => s.feeds.push((f[1].parse().unwrap(), f[2].parse().unwrap())),
|
||||
"player" => {
|
||||
s.player = Some([
|
||||
f[1].parse().unwrap(),
|
||||
@@ -403,6 +417,65 @@ impl HeartHost for Host<'_> {
|
||||
self.spawning
|
||||
}
|
||||
}
|
||||
/// A scenario's beehives as `BeehiveBlockEntity.serverTick` sees the level:
|
||||
/// the bees let out are newcomers seeded as the oracle seeds them.
|
||||
struct ProbeHive<'a, 'w> {
|
||||
w: &'a MobWorld<'w>,
|
||||
random: &'a mut LegacyRandom,
|
||||
types: &'a HashMap<String, Arc<MobType>>,
|
||||
/// The index of the first newcomer of this tick.
|
||||
first: usize,
|
||||
added: &'a mut Vec<Mob>,
|
||||
honey: &'a mut Vec<([i32; 3], i32)>,
|
||||
events: &'a mut Vec<MobEvent>,
|
||||
}
|
||||
impl HiveHost for ProbeHive<'_, '_> {
|
||||
fn random(&mut self) -> &mut LegacyRandom {
|
||||
self.random
|
||||
}
|
||||
fn bees_stay_in_hive(&self, _at: [i32; 3]) -> bool {
|
||||
horizon_client::mobs::bee::bees_stay_in_hive(self.w)
|
||||
}
|
||||
fn blocked(&self, at: [i32; 3]) -> bool {
|
||||
!self.w.block_collision_boxes(at[0], at[1], at[2]).is_empty()
|
||||
}
|
||||
fn set_honey_level(&mut self, at: [i32; 3], level: i32) {
|
||||
self.honey.push((at, level));
|
||||
}
|
||||
fn load(&mut self, tag: &horizon_world::nbt::Tag) -> Option<Mob> {
|
||||
let index = self.first + self.added.len();
|
||||
let mut m =
|
||||
horizon_client::entity_nbt::mob_from_nbt(tag, self.types, 100_000 + index as u32)?;
|
||||
if m.species() != Species::Bee {
|
||||
return None;
|
||||
}
|
||||
m.random = LegacyRandom::new(spawn_seed(index));
|
||||
horizon_client::mobs::bee::draw_cooldowns(&mut m);
|
||||
Some(m)
|
||||
}
|
||||
fn add(&mut self, m: Mob) -> bool {
|
||||
self.added.push(m);
|
||||
true
|
||||
}
|
||||
fn sound(&mut self, name: &'static str, at: [f64; 3]) {
|
||||
self.events.push(MobEvent::Sound(name, at, 1.0, 1.0));
|
||||
}
|
||||
}
|
||||
/// The hive's honey level and front from its block state.
|
||||
fn hive_state(registry: &BlockRegistry, world: &World, at: [i32; 3]) -> (Option<i32>, [i32; 2]) {
|
||||
let props = registry
|
||||
.state(world.block(at[0], at[1], at[2]))
|
||||
.map(|s| s.properties.clone())
|
||||
.unwrap_or_default();
|
||||
let honey = props.get("honey_level").and_then(|v| v.parse().ok());
|
||||
let facing = horizon_client::beehive::facing_step(props.get("facing").map(String::as_str));
|
||||
(honey, facing)
|
||||
}
|
||||
fn is_hive(b: &[String]) -> bool {
|
||||
b[0] == "block"
|
||||
&& (b[4].starts_with("minecraft:beehive") || b[4].starts_with("minecraft:bee_nest"))
|
||||
}
|
||||
|
||||
/// The heart's state, block entity and the level random, as the oracle
|
||||
/// prints them.
|
||||
fn print_heart(
|
||||
@@ -466,7 +539,7 @@ fn player_hurt(player: Option<&PlayerView>, e: &MobEvent) -> bool {
|
||||
|
||||
/// `player_down`: the scenario's player is no valid target (creative, or
|
||||
/// killed this tick).
|
||||
fn print(out: &mut String, t: i32, i: usize, m: &Mob, player_down: bool) {
|
||||
fn print(out: &mut String, t: i32, i: usize, m: &Mob, player_down: bool, now: i64) {
|
||||
let _ = write!(out, "{t} {i}");
|
||||
if m.removed {
|
||||
// Entity.RemovalReason: the end of the death animation, else a discard.
|
||||
@@ -585,6 +658,21 @@ fn print(out: &mut String, t: i32, i: usize, m: &Mob, player_down: bool) {
|
||||
.map_or("-".into(), |p| format!("{},{},{}", p[0], p[1], p[2]));
|
||||
let _ = write!(out, "\tcg {} {held} {opened}", m.extra.variant3);
|
||||
}
|
||||
if let Some(b) = horizon_client::mobs::bee::get(m) {
|
||||
let anger = if b.anger_end > 0 {
|
||||
b.anger_end - now
|
||||
} else {
|
||||
-1
|
||||
};
|
||||
let flower = b
|
||||
.flower
|
||||
.map_or("-".into(), |p| format!("{},{},{}", p[0], p[1], p[2]));
|
||||
let _ = write!(
|
||||
out,
|
||||
"\tbee {} {anger} {} {} {flower}",
|
||||
b.flags, b.ticks_without_nectar, b.flower_cooldown
|
||||
);
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
@@ -680,8 +768,15 @@ fn main() -> horizon_client::Result<()> {
|
||||
let assets = Assets::load(root)?;
|
||||
let registry = assets.registry;
|
||||
let facts = BlockFacts::load(root, registry)?;
|
||||
let motion = horizon_client::motion::MotionResources::load(root, &assets)?;
|
||||
let data = MobData::load(root, &facts);
|
||||
let types = MobTypes::load(root)?;
|
||||
// The types a bee leaving its hive is loaded with (EntityType.loadEntityRecursive).
|
||||
let arc_types: HashMap<String, Arc<MobType>> = types
|
||||
.types
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
|
||||
.collect();
|
||||
let biomes = Biomes::load(root)?;
|
||||
let items = Items::load(root, registry, &facts)?;
|
||||
let tables = LightTables::load(root, registry, &facts)?;
|
||||
@@ -724,6 +819,11 @@ fn main() -> horizon_client::Result<()> {
|
||||
let n = |i: usize| b[i].parse::<i32>().unwrap();
|
||||
if b[0] == "block" || b[0] == "chest" {
|
||||
world.set(n(1), n(2), n(3), state(registry, &b[4]))?;
|
||||
if is_hive(b) {
|
||||
world
|
||||
.block_entities
|
||||
.insert((n(1), n(2), n(3)), BlockEntity::Beehive(Box::default()));
|
||||
}
|
||||
if b[0] == "chest" {
|
||||
// chest X Y Z STATE SLOTS: item*count or - per slot.
|
||||
let mut slots = vec![None; 27];
|
||||
@@ -774,7 +874,7 @@ fn main() -> horizon_client::Result<()> {
|
||||
})
|
||||
});
|
||||
let mut c = (*world.chunks[&(x, z)]).clone();
|
||||
c.sections = light_chunk(®ion, &tables, true);
|
||||
apply_lighting(&mut c, light_chunk(®ion, &tables, true));
|
||||
c.lit = true;
|
||||
((x, z), c)
|
||||
})
|
||||
@@ -802,6 +902,10 @@ fn main() -> horizon_client::Result<()> {
|
||||
for flag in &spec.flags {
|
||||
match flag.as_str() {
|
||||
"persistent" => m.persistent = true,
|
||||
"ridden" => {
|
||||
m.extra.head = Some("minecraft:red_harness".into());
|
||||
m.rider = Some(horizon_client::mobs::equine::Rider::default());
|
||||
}
|
||||
"noai" => m.no_ai = true,
|
||||
// Warden.finalizeSpawn's own part.
|
||||
"digcooldown" | "emerge" => horizon_client::mobs::warden::finalize(
|
||||
@@ -810,6 +914,7 @@ fn main() -> horizon_client::Result<()> {
|
||||
&mut Vec::new(),
|
||||
),
|
||||
"inlove" => m.extra.in_love = 600,
|
||||
"nectar" => horizon_client::mobs::bee::set_has_nectar(&mut m, true),
|
||||
"tame" => m.extra.tame = true,
|
||||
x if x.starts_with("age=") => m.extra.age = x[4..].parse().unwrap(),
|
||||
x if x.starts_with("strength=") => m.extra.variant2 = x[9..].parse().unwrap(),
|
||||
@@ -830,6 +935,10 @@ fn main() -> horizon_client::Result<()> {
|
||||
}
|
||||
}
|
||||
m.random = LegacyRandom::new(spec.seed);
|
||||
if m.species() == Species::Bee {
|
||||
// The oracle draws the constructor's cooldowns again once seeded.
|
||||
horizon_client::mobs::bee::draw_cooldowns(&mut m);
|
||||
}
|
||||
if horizon_client::mobs::monster_brain::has_brain(m.species()) {
|
||||
// The oracle delays the sensors again with the seeded random
|
||||
// and seeds RunOne's shuffles with the mob's seed.
|
||||
@@ -893,22 +1002,30 @@ fn main() -> horizon_client::Result<()> {
|
||||
}
|
||||
});
|
||||
let mut scratch = LegacyRandom::new(0);
|
||||
// The level's random for eating sounds: only goats and sniffers draw
|
||||
// from it, and the scenarios feed neither.
|
||||
let mut level_random = LegacyRandom::new(0);
|
||||
for (i, m) in mobs.iter().enumerate() {
|
||||
print(&mut out, 0, i, m, s.creative);
|
||||
print(&mut out, 0, i, m, s.creative, world.game_time);
|
||||
}
|
||||
// The MobWorld every step sees, over a snapshot of the mobs.
|
||||
macro_rules! mob_world {
|
||||
($snapshot:expr) => {
|
||||
mob_world!($snapshot, None)
|
||||
};
|
||||
($snapshot:expr, $grid:expr) => {
|
||||
MobWorld {
|
||||
teams: Default::default(),
|
||||
world: &world,
|
||||
registry,
|
||||
facts: &facts,
|
||||
movement: &motion.blocks,
|
||||
collision: &collision,
|
||||
data: &data,
|
||||
player: player.clone(),
|
||||
others: $snapshot,
|
||||
daylight: (s.time.rem_euclid(24000)) < 12000,
|
||||
others_grid: $grid,
|
||||
daylight: (world.day_time.rem_euclid(24000)) < 12000,
|
||||
game_time: world.game_time,
|
||||
day_time: world.day_time,
|
||||
raining: s.rain,
|
||||
@@ -928,6 +1045,9 @@ fn main() -> horizon_client::Result<()> {
|
||||
}
|
||||
for t in 1..=s.ticks {
|
||||
world.game_time += 1;
|
||||
if s.daycycle {
|
||||
world.day_time = s.time + i64::from(t);
|
||||
}
|
||||
let mut heart_events_later: Vec<HeartEvent> = Vec::new();
|
||||
// Creaking.tick reads the heart's block entity.
|
||||
for (at, h) in &hearts {
|
||||
@@ -949,6 +1069,8 @@ fn main() -> horizon_client::Result<()> {
|
||||
p.yaw = yaw;
|
||||
}
|
||||
let mut snapshot: Vec<Seen> = mobs.iter().filter(|m| !m.removed).map(seen).collect();
|
||||
// The snapshot by position, kept as the game keeps it.
|
||||
let mut grid = SeenGrid::new(snapshot.iter().map(|e| e.bounds));
|
||||
let w = mob_world!(&snapshot);
|
||||
let mut events = Vec::new();
|
||||
let mut heart_events = Vec::new();
|
||||
@@ -996,6 +1118,16 @@ fn main() -> horizon_client::Result<()> {
|
||||
}
|
||||
events.append(&mut mobs[i].outbox);
|
||||
}
|
||||
for &(tick, i) in &s.feeds {
|
||||
if tick != t || i >= mobs.len() || mobs[i].removed {
|
||||
continue;
|
||||
}
|
||||
if let Some(Some((name, volume, pitch))) =
|
||||
horizon_client::mobs::ai::feed_animal(&mut mobs[i], &mut level_random)
|
||||
{
|
||||
events.push(MobEvent::Sound(name, [0.0; 3], volume, pitch));
|
||||
}
|
||||
}
|
||||
#[allow(unused_mut)] // filled only with the rng-trace feature
|
||||
let mut traces: Vec<String> = Vec::new();
|
||||
let mut pushes = Vec::new();
|
||||
@@ -1004,7 +1136,7 @@ fn main() -> horizon_client::Result<()> {
|
||||
if mobs[i].removed {
|
||||
continue;
|
||||
}
|
||||
let w = mob_world!(&snapshot);
|
||||
let w = mob_world!(&snapshot, Some(&grid));
|
||||
let m = &mut mobs[i];
|
||||
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
|
||||
let from = events.len();
|
||||
@@ -1083,11 +1215,47 @@ fn main() -> horizon_client::Result<()> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// BeeEnterHiveGoal.start: BeehiveBlockEntity.addOccupant.
|
||||
let entering: Vec<[i32; 3]> = events[from.min(events.len())..]
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
MobEvent::EnterHive { hive, .. } => Some(*hive),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for at in entering {
|
||||
let key = (at[0], at[1], at[2]);
|
||||
let Some(BlockEntity::Beehive(mut hive)) = world.block_entities.remove(&key)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let tag = horizon_client::entity_nbt::mob_to_nbt(&mobs[i], [0; 4], None);
|
||||
let flower = horizon_client::mobs::bee::get(&mobs[i]).and_then(|b| b.flower);
|
||||
let mut honey = Vec::new();
|
||||
let stored = {
|
||||
let w = mob_world!(&snapshot);
|
||||
let mut host = ProbeHive {
|
||||
w: &w,
|
||||
random: &mut scratch,
|
||||
types: &arc_types,
|
||||
first: 0,
|
||||
added: &mut Vec::new(),
|
||||
honey: &mut honey,
|
||||
events: &mut events,
|
||||
};
|
||||
hive.add_occupant(at, tag, flower, &mut host)
|
||||
};
|
||||
world.block_entities.insert(key, BlockEntity::Beehive(hive));
|
||||
if stored {
|
||||
mobs[i].removed = true;
|
||||
}
|
||||
}
|
||||
// ServerLevel ticks entities one after another: the ones
|
||||
// after this mob see it as it is now.
|
||||
if let Some(e) = snapshot.iter_mut().find(|e| e.id == mobs[i].id) {
|
||||
*e = seen(&mobs[i]);
|
||||
e.alive &= !mobs[i].removed;
|
||||
if let Some(k) = snapshot.iter().position(|e| e.id == mobs[i].id) {
|
||||
snapshot[k] = seen(&mobs[i]);
|
||||
snapshot[k].alive &= !mobs[i].removed;
|
||||
grid.update(k, snapshot[k].bounds);
|
||||
}
|
||||
}
|
||||
for m in &mut mobs {
|
||||
@@ -1115,6 +1283,43 @@ fn main() -> horizon_client::Result<()> {
|
||||
added.append(&mut host.added);
|
||||
}
|
||||
drop(w);
|
||||
// Then the hives (BeehiveBlockEntity.serverTick).
|
||||
let mut hive_keys: Vec<(i32, i32, i32)> = world
|
||||
.block_entities
|
||||
.iter()
|
||||
.filter(|(_, e)| matches!(e, BlockEntity::Beehive(_)))
|
||||
.map(|(k, _)| *k)
|
||||
.collect();
|
||||
hive_keys.sort_unstable();
|
||||
let mut honey = Vec::new();
|
||||
for key in hive_keys {
|
||||
let at = [key.0, key.1, key.2];
|
||||
let (level, facing) = hive_state(registry, &world, at);
|
||||
let Some(BlockEntity::Beehive(mut hive)) = world.block_entities.remove(&key) else {
|
||||
continue;
|
||||
};
|
||||
{
|
||||
let w = mob_world!(&snapshot);
|
||||
let mut host = ProbeHive {
|
||||
w: &w,
|
||||
random: &mut scratch,
|
||||
types: &arc_types,
|
||||
first: mobs.len() + born.len(),
|
||||
added: &mut added,
|
||||
honey: &mut honey,
|
||||
events: &mut events,
|
||||
};
|
||||
hive.server_tick(at, facing, level, &mut host);
|
||||
}
|
||||
world.block_entities.insert(key, BlockEntity::Beehive(hive));
|
||||
}
|
||||
for (at, level) in honey {
|
||||
if let Some(mut st) = registry.state(world.block(at[0], at[1], at[2])).cloned() {
|
||||
st.properties
|
||||
.insert("honey_level".to_owned(), level.to_string());
|
||||
world.set(at[0], at[1], at[2], registry.resolve(&st)?)?;
|
||||
}
|
||||
}
|
||||
mobs.append(&mut born);
|
||||
mobs.append(&mut added);
|
||||
for e in &heart_events {
|
||||
@@ -1133,7 +1338,14 @@ fn main() -> horizon_client::Result<()> {
|
||||
.iter()
|
||||
.any(|e| player_hurt(player.as_ref(), e) && player_damage(e) >= 20.0);
|
||||
for (i, m) in mobs.iter().enumerate() {
|
||||
print(&mut out, t, i, m, s.creative || player_killed);
|
||||
print(
|
||||
&mut out,
|
||||
t,
|
||||
i,
|
||||
m,
|
||||
s.creative || player_killed,
|
||||
world.game_time,
|
||||
);
|
||||
}
|
||||
for e in &events {
|
||||
if let MobEvent::Sound(name, _, volume, pitch) = e {
|
||||
@@ -1190,6 +1402,35 @@ fn main() -> horizon_client::Result<()> {
|
||||
}
|
||||
);
|
||||
}
|
||||
for b in s.blocks.iter().filter(|b| is_hive(b)) {
|
||||
let n = |i: usize| b[i].parse::<i32>().unwrap();
|
||||
let Some(BlockEntity::Beehive(hive)) =
|
||||
world.block_entities.get(&(n(1), n(2), n(3)))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let (level, _) = hive_state(registry, &world, [n(1), n(2), n(3)]);
|
||||
let bees: Vec<String> = hive
|
||||
.bees
|
||||
.iter()
|
||||
.map(|b| format!("{}/{}", b.ticks_in_hive, b.min_ticks_in_hive))
|
||||
.collect();
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"{t} hive {},{},{} honey {} bees {} flower {}",
|
||||
b[1],
|
||||
b[2],
|
||||
b[3],
|
||||
level.unwrap_or(-1),
|
||||
if bees.is_empty() {
|
||||
"-".into()
|
||||
} else {
|
||||
bees.join(",")
|
||||
},
|
||||
hive.flower
|
||||
.map_or("-".into(), |f| format!("{},{},{}", f[0], f[1], f[2]))
|
||||
);
|
||||
}
|
||||
for line in traces {
|
||||
let _ = writeln!(out, "{line}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
//! Full block/assets + item startup. Compile with HORIZON_BENCH_COUNT_ALLOCATIONS=1
|
||||
//! for live requested heap bytes; leave it unset for latency measurements.
|
||||
//! cargo run --profile fast -p horizon-client --example model_library_bench -- ROOT [--parity]
|
||||
//! Run fresh processes, warm up twice, then compare multiple alternating baseline/candidate runs.
|
||||
use horizon_client::{assets::Assets, item_render::ItemRenderer};
|
||||
use std::{
|
||||
alloc::{GlobalAlloc, Layout, System},
|
||||
hash::{Hash, Hasher},
|
||||
path::Path,
|
||||
sync::atomic::{AtomicUsize, Ordering::Relaxed},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
struct CountingAllocator;
|
||||
const TRACK: bool = option_env!("HORIZON_BENCH_COUNT_ALLOCATIONS").is_some();
|
||||
static LIVE: AtomicUsize = AtomicUsize::new(0);
|
||||
// The allocator delegates every operation and layout unchanged to System.
|
||||
unsafe impl GlobalAlloc for CountingAllocator {
|
||||
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
|
||||
let ptr = unsafe { System.alloc(layout) };
|
||||
if TRACK && !ptr.is_null() {
|
||||
LIVE.fetch_add(layout.size(), Relaxed);
|
||||
}
|
||||
ptr
|
||||
}
|
||||
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
|
||||
let ptr = unsafe { System.alloc_zeroed(layout) };
|
||||
if TRACK && !ptr.is_null() {
|
||||
LIVE.fetch_add(layout.size(), Relaxed);
|
||||
}
|
||||
ptr
|
||||
}
|
||||
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
|
||||
if TRACK {
|
||||
LIVE.fetch_sub(layout.size(), Relaxed);
|
||||
}
|
||||
unsafe { System.dealloc(ptr, layout) };
|
||||
}
|
||||
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, size: usize) -> *mut u8 {
|
||||
let out = unsafe { System.realloc(ptr, layout, size) };
|
||||
if TRACK && !out.is_null() {
|
||||
LIVE.fetch_sub(layout.size(), Relaxed);
|
||||
LIVE.fetch_add(size, Relaxed);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
#[global_allocator]
|
||||
static ALLOCATOR: CountingAllocator = CountingAllocator;
|
||||
|
||||
fn main() -> horizon_client::Result<()> {
|
||||
let root = std::env::args()
|
||||
.nth(1)
|
||||
.ok_or("usage: model_library_bench ROOT [--parity]")?;
|
||||
let root = Path::new(&root);
|
||||
let initial = LIVE.load(Relaxed);
|
||||
let start = Instant::now();
|
||||
let assets = Assets::load(root)?;
|
||||
let block_ms = start.elapsed().as_secs_f64() * 1000.;
|
||||
let block_retained = LIVE.load(Relaxed) - initial;
|
||||
let start_items = Instant::now();
|
||||
let items = ItemRenderer::load_with_library(root, assets.model_library.clone())?;
|
||||
let item_ms = start_items.elapsed().as_secs_f64() * 1000.;
|
||||
let total_ms = start.elapsed().as_secs_f64() * 1000.;
|
||||
let retained = LIVE.load(Relaxed) - initial;
|
||||
println!(
|
||||
"block_ms={block_ms:.3} item_ms={item_ms:.3} total_ms={total_ms:.3} retained_bytes={retained} block_retained_bytes={block_retained}"
|
||||
);
|
||||
if std::env::args().any(|a| a == "--parity") {
|
||||
let mut hash = std::collections::hash_map::DefaultHasher::new();
|
||||
for id in 0..assets.registry.len() {
|
||||
let id = horizon_world::registry::BlockStateId(id as u32);
|
||||
for pos in [
|
||||
horizon_core::position::BlockPos::new(0, 0, 0),
|
||||
horizon_core::position::BlockPos::new(-17, 65, 31),
|
||||
] {
|
||||
if let Ok(selected) = assets.model_catalog.collect(id, pos) {
|
||||
for (variant, model) in selected {
|
||||
format!("{variant:?}").hash(&mut hash);
|
||||
model.ambient_occlusion.hash(&mut hash);
|
||||
for face in &model.faces {
|
||||
format!("{:?}", face.quad).hash(&mut hash);
|
||||
face.layer.hash(&mut hash);
|
||||
face.opaque.hash(&mut hash);
|
||||
face.translucent.hash(&mut hash);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut names = Vec::new();
|
||||
for ns in std::fs::read_dir(root.join("assets"))? {
|
||||
let ns = ns?;
|
||||
let base = ns.path().join("models");
|
||||
let mut stack = vec![base.clone()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries {
|
||||
let path = entry?.path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
} else if path.extension().is_some_and(|e| e == "json") {
|
||||
names.push(format!(
|
||||
"{}:{}",
|
||||
ns.file_name().to_string_lossy(),
|
||||
path.strip_prefix(&base)?
|
||||
.with_extension("")
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/")
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
names.sort();
|
||||
for name in &names {
|
||||
format!("{:?}", items.bake(name)).hash(&mut hash);
|
||||
}
|
||||
println!(
|
||||
"parity_models={} fingerprint={:016x}",
|
||||
names.len(),
|
||||
hash.finish()
|
||||
);
|
||||
}
|
||||
std::hint::black_box((&assets, &items));
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
//! Compare the old two-parse loading path with catalog construction from shared facts.
|
||||
//! Run: cargo run -p horizon-client --profile fast --example model_states_bench -- 15
|
||||
use horizon_client::{Result, models::StateModels, sprites::Sprites};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_world::{
|
||||
DATA_VERSION,
|
||||
chunk::BlockState,
|
||||
nbt::Tag,
|
||||
registry::{BlockRegistry, BlockStateId},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{collections::BTreeMap, fs, hint::black_box, path::PathBuf, time::Instant};
|
||||
|
||||
struct Fixture {
|
||||
root: PathBuf,
|
||||
registry: BlockRegistry,
|
||||
bytes: usize,
|
||||
blocks: usize,
|
||||
}
|
||||
impl Fixture {
|
||||
fn new(target_bytes: usize) -> Result<Self> {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-model-states-{}", std::process::id()));
|
||||
fs::create_dir_all(root.join("assets/minecraft/blockstates"))?;
|
||||
fs::create_dir_all(root.join("assets/minecraft/models/block"))?;
|
||||
let mut possible = Vec::new();
|
||||
for age in 0..8 {
|
||||
for facing in ["north", "south", "east", "west"] {
|
||||
for waterlogged in ["false", "true"] {
|
||||
possible.push(
|
||||
json!({"age":age.to_string(), "facing":facing, "waterlogged":waterlogged}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let fact = json!({"schema":{
|
||||
"age":{"integer":[0,7]},
|
||||
"facing":{"named":["north","south","east","west"]},
|
||||
"waterlogged":{"named":["false","true"]}
|
||||
},"states":possible});
|
||||
let blocks = (target_bytes / (serde_json::to_vec(&fact)?.len() + 26)).max(1);
|
||||
let mut facts = BTreeMap::new();
|
||||
let mut states = Vec::new();
|
||||
let mut defaults = BTreeMap::new();
|
||||
for i in 0..blocks {
|
||||
let block = format!("fixture_{i:04}");
|
||||
let name = format!("minecraft:{block}");
|
||||
defaults.insert(name.as_str().into(), Tag::Int(states.len() as i32));
|
||||
for properties in &possible {
|
||||
let state = BlockState {
|
||||
name: name.clone(),
|
||||
properties: serde_json::from_value(properties.clone())?,
|
||||
};
|
||||
states.push(state.to_nbt());
|
||||
}
|
||||
facts.insert(name, fact.clone());
|
||||
fs::write(
|
||||
root.join(format!("assets/minecraft/blockstates/{block}.json")),
|
||||
r#"{"multipart":[{"apply":[{"model":"minecraft:block/fixture","weight":2},{"model":"minecraft:block/fixture","y":90,"weight":1}]},{"when":{"waterlogged":"true"},"apply":{"model":"minecraft:block/fixture","y":180,"uvlock":true}}]}"#,
|
||||
)?;
|
||||
}
|
||||
fs::write(
|
||||
root.join("assets/minecraft/models/block/fixture.json"),
|
||||
r##"{"textures":{"all":"minecraft:block/fixture"},"elements":[{"from":[0,0,0],"to":[16,16,16],"faces":{"north":{"texture":"#all","cullface":"north"}}}]}"##,
|
||||
)?;
|
||||
let document = serde_json::to_vec(&facts)?;
|
||||
let bytes = document.len();
|
||||
fs::write(root.join("model-states.json"), document)?;
|
||||
let registry = BlockRegistry::from_nbt(&Tag::compound([
|
||||
("DataVersion", Tag::Int(DATA_VERSION)),
|
||||
("states", Tag::List(states)),
|
||||
("defaults", Tag::Compound(defaults)),
|
||||
]))?;
|
||||
Ok(Self {
|
||||
root,
|
||||
registry,
|
||||
bytes,
|
||||
blocks,
|
||||
})
|
||||
}
|
||||
fn facts(&self) -> Result<BTreeMap<String, Value>> {
|
||||
Ok(serde_json::from_slice(&fs::read(
|
||||
self.root.join("model-states.json"),
|
||||
)?)?)
|
||||
}
|
||||
fn block_names<'a>(&self, names: impl Iterator<Item = &'a String>) -> Vec<&'a str> {
|
||||
names
|
||||
.filter(|name| self.registry.default_state(name).is_some())
|
||||
.filter_map(|name| name.strip_prefix("minecraft:"))
|
||||
.collect()
|
||||
}
|
||||
fn load(&self, reuse: bool) -> Result<(StateModels, Sprites)> {
|
||||
// The first parse and block filtering match models::prepare in both paths.
|
||||
let mut sprites = Sprites::default();
|
||||
let catalog = if reuse {
|
||||
let facts = self.facts()?;
|
||||
let blocks = self.block_names(facts.keys());
|
||||
StateModels::from_facts(&self.root, &self.registry, &blocks, &mut sprites, &facts)?
|
||||
} else {
|
||||
let facts: serde_json::Map<String, Value> =
|
||||
serde_json::from_slice(&fs::read(self.root.join("model-states.json"))?)?;
|
||||
let blocks = self.block_names(facts.keys());
|
||||
StateModels::load(&self.root, &self.registry, &blocks, &mut sprites)?
|
||||
};
|
||||
Ok((catalog, sprites))
|
||||
}
|
||||
fn verify(&self) -> Result<()> {
|
||||
let (before, before_sprites) = self.load(false)?;
|
||||
let (after, after_sprites) = self.load(true)?;
|
||||
assert_eq!(before_sprites.layers, after_sprites.layers);
|
||||
assert_eq!(before_sprites.opaque, after_sprites.opaque);
|
||||
assert_eq!(before_sprites.translucent, after_sprites.translucent);
|
||||
for id in 0..self.registry.len() {
|
||||
for position in [
|
||||
BlockPos::new(0, 0, 0),
|
||||
BlockPos::new(12, -4, 7),
|
||||
BlockPos::new(42, 9, -19),
|
||||
] {
|
||||
let a = before.collect(BlockStateId(id as u32), position)?;
|
||||
let b = after.collect(BlockStateId(id as u32), position)?;
|
||||
assert_eq!(a.len(), b.len());
|
||||
for ((av, am), (bv, bm)) in a.into_iter().zip(b) {
|
||||
assert_eq!(av, bv);
|
||||
assert_eq!(am.ambient_occlusion, bm.ambient_occlusion);
|
||||
assert_eq!(am.cube, bm.cube);
|
||||
assert_eq!(am.faces.len(), bm.faces.len());
|
||||
for (af, bf) in am.faces.iter().zip(&bm.faces) {
|
||||
assert_eq!(af.quad, bf.quad);
|
||||
assert_eq!(
|
||||
(af.layer, af.opaque, af.translucent),
|
||||
(bf.layer, bf.opaque, bf.translucent)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.root);
|
||||
}
|
||||
}
|
||||
fn median(values: &mut [f64]) -> f64 {
|
||||
values.sort_by(f64::total_cmp);
|
||||
values[values.len() / 2]
|
||||
}
|
||||
fn main() -> Result<()> {
|
||||
let repetitions: usize = std::env::args()
|
||||
.nth(1)
|
||||
.map(|s| s.parse())
|
||||
.transpose()?
|
||||
.unwrap_or(15);
|
||||
if repetitions == 0 {
|
||||
return Err("repetitions must be positive".into());
|
||||
}
|
||||
let fixture = Fixture::new(2_840_000)?;
|
||||
fixture.verify()?;
|
||||
for _ in 0..2 {
|
||||
for reuse in [false, true] {
|
||||
drop(black_box(fixture.load(reuse)?));
|
||||
}
|
||||
}
|
||||
let mut before = Vec::new();
|
||||
let mut after = Vec::new();
|
||||
for iteration in 0..repetitions {
|
||||
// Alternate order to limit cache/order bias. Both paths use slice parsing.
|
||||
for reuse in if iteration % 2 == 0 {
|
||||
[false, true]
|
||||
} else {
|
||||
[true, false]
|
||||
} {
|
||||
let started = Instant::now();
|
||||
drop(black_box(fixture.load(black_box(reuse))?));
|
||||
let ms = started.elapsed().as_secs_f64() * 1000.0;
|
||||
if reuse {
|
||||
after.push(ms);
|
||||
} else {
|
||||
before.push(ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
let before = median(&mut before);
|
||||
let after = median(&mut after);
|
||||
println!(
|
||||
"fixture: {} bytes, {} blocks, {} states; equality: all states at 3 positions",
|
||||
fixture.bytes,
|
||||
fixture.blocks,
|
||||
fixture.registry.len()
|
||||
);
|
||||
println!(
|
||||
"2 warmups/path, {repetitions} samples/path; median before {before:.3} ms, after {after:.3} ms; {:.3}x speedup ({:.2}% less time)",
|
||||
before / after,
|
||||
(1.0 - after / before) * 100.0
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn shared_facts_match_load_and_preserve_errors() -> Result<()> {
|
||||
let fixture = Fixture::new(1)?;
|
||||
fixture.verify()?;
|
||||
let mut facts = fixture.facts()?;
|
||||
let blocks = ["fixture_0000"];
|
||||
let load_error = |facts: &BTreeMap<String, Value>| -> Result<String> {
|
||||
fs::write(
|
||||
fixture.root.join("model-states.json"),
|
||||
serde_json::to_vec(facts)?,
|
||||
)?;
|
||||
let a = StateModels::load(
|
||||
&fixture.root,
|
||||
&fixture.registry,
|
||||
&blocks,
|
||||
&mut Sprites::default(),
|
||||
)
|
||||
.err()
|
||||
.ok_or("expected load error")?
|
||||
.to_string();
|
||||
let b = StateModels::from_facts(
|
||||
&fixture.root,
|
||||
&fixture.registry,
|
||||
&blocks,
|
||||
&mut Sprites::default(),
|
||||
facts,
|
||||
)
|
||||
.err()
|
||||
.ok_or("expected shared facts error")?
|
||||
.to_string();
|
||||
assert_eq!(a, b);
|
||||
Ok(a)
|
||||
};
|
||||
let good = facts.clone();
|
||||
facts.clear();
|
||||
assert_eq!(load_error(&facts)?, "missing block model state schema");
|
||||
facts = good.clone();
|
||||
facts.get_mut("minecraft:fixture_0000").unwrap()["schema"] = Value::Null;
|
||||
assert_eq!(load_error(&facts)?, "invalid model schema");
|
||||
facts = good.clone();
|
||||
facts.get_mut("minecraft:fixture_0000").unwrap()["states"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.reverse();
|
||||
assert_eq!(
|
||||
load_error(&facts)?,
|
||||
"model state definitions disagree with the imported block registry"
|
||||
);
|
||||
fs::remove_file(fixture.root.join("model-states.json"))?;
|
||||
// A parsed document remains usable without reopening its source file.
|
||||
StateModels::from_facts(
|
||||
&fixture.root,
|
||||
&fixture.registry,
|
||||
&blocks,
|
||||
&mut Sprites::default(),
|
||||
&good,
|
||||
)?;
|
||||
assert!(
|
||||
StateModels::load(
|
||||
&fixture.root,
|
||||
&fixture.registry,
|
||||
&blocks,
|
||||
&mut Sprites::default()
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
fs::write(fixture.root.join("model-states.json"), b"null")?;
|
||||
assert!(
|
||||
StateModels::load(
|
||||
&fixture.root,
|
||||
&fixture.registry,
|
||||
&blocks,
|
||||
&mut Sprites::default()
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
fs::write(fixture.root.join("model-states.json"), b"{")?;
|
||||
assert!(
|
||||
StateModels::load(
|
||||
&fixture.root,
|
||||
&fixture.registry,
|
||||
&blocks,
|
||||
&mut Sprites::default()
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -9,13 +9,24 @@
|
||||
//! 1.0), foxes and axolotls from the third, sulfur cubes have size 2, salmon
|
||||
//! are small, slimes and magma cubes keep size 1, tropical fish school.
|
||||
//!
|
||||
//! Spawner cases (`mode=spawner`, tools/test_spawner_parity.py) tick a
|
||||
//! `mobs::spawner::Spawner` loaded from the case's SNBT on the same world and
|
||||
//! print its events tick by tick.
|
||||
//!
|
||||
//! Trial spawner cases (`mode=trial`, tools/test_trial_spawner_parity.py) tick
|
||||
//! a `mobs::trial_spawner::TrialSpawner` with the game time advancing and
|
||||
//! print its states, events, spawns and ejected items.
|
||||
//!
|
||||
//! Vault cases (`mode=vault`, tools/test_vault_parity.py) tick a
|
||||
//! `vault::Vault` the same way, with players moving and using keys on it.
|
||||
//!
|
||||
//! usage: natural_spawn_fixture RESOURCES INPUT OUTPUT
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
biome::{Biomes, SpawnEntry},
|
||||
blocks::BlockFacts,
|
||||
collisions::{self, LookupCollisions, no_collision_empty},
|
||||
items::Items,
|
||||
items::{ItemStack, Items},
|
||||
mobs::{
|
||||
MobTypes, Species,
|
||||
data::MobData,
|
||||
@@ -25,19 +36,25 @@ use horizon_client::{
|
||||
SpawnPlayer, SpawnRequest, SpawnState, Spawned, StructureAt, StructureSpawns,
|
||||
spawn_box,
|
||||
},
|
||||
spawner::{Spawner, SpawnerAdded, SpawnerEvent, SpawnerLevel, SpawnerSpawn},
|
||||
species::{self, Baby},
|
||||
trial_spawner::{
|
||||
Omen, TRIAL_OMEN_TICKS_PER_LEVEL, TrialEvent, TrialPlayer, TrialSpawner,
|
||||
TrialSpawnerLevel, TrialState,
|
||||
},
|
||||
},
|
||||
vault::{Vault, VaultEvent, VaultLevel, VaultPlayer, VaultState},
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::Aabb,
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
use horizon_world::{
|
||||
block_collision::BlockCollisionRegistry, chunk::BlockState, registry::BlockStateId,
|
||||
block_collision::BlockCollisionRegistry, chunk::BlockState, nbt::Tag, registry::BlockStateId,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
cell::{Cell, RefCell},
|
||||
collections::{BTreeMap, HashMap, HashSet},
|
||||
fmt::Write as _,
|
||||
fs,
|
||||
@@ -73,6 +90,8 @@ struct Shared<'a> {
|
||||
structures: &'a StructureSpawns,
|
||||
collision: &'a BlockCollisionRegistry<'a>,
|
||||
air: BlockStateId,
|
||||
/// Every entity type id (`entity-types.json`).
|
||||
entity_types: &'a HashSet<String>,
|
||||
}
|
||||
struct World<'a> {
|
||||
s: &'a Shared<'a>,
|
||||
@@ -95,8 +114,22 @@ struct World<'a> {
|
||||
respawn: Option<[i32; 3]>,
|
||||
unloaded: HashSet<(i32, i32)>,
|
||||
spawn_mobs: bool,
|
||||
/// Boxes of living entities (case entities, then spawned mobs).
|
||||
entities: RefCell<Vec<Aabb>>,
|
||||
/// The `spawner_blocks_work` game rule.
|
||||
spawners_work: bool,
|
||||
/// Types and boxes of living entities (case entities, then spawned mobs).
|
||||
entities: RefCell<Vec<(String, Aabb)>>,
|
||||
/// Blocks set since the case started (a trial spawner's states).
|
||||
placed: RefCell<Vec<([i32; 3], BlockStateId)>>,
|
||||
/// The game time (trial cases advance it).
|
||||
time: Cell<i64>,
|
||||
/// The mobs a trial spawner added: UUID, position, height and box.
|
||||
trial_mobs: RefCell<Vec<([i32; 4], [f64; 3], f32, Aabb)>>,
|
||||
/// How many mobs a trial spawner added (their UUIDs count up).
|
||||
trial_count: Cell<i32>,
|
||||
/// Each player's omen and its amplifier (`/trial_omen:A`, `/bad_omen:A`).
|
||||
omens: RefCell<Vec<Option<(Omen, i32)>>>,
|
||||
/// Loot tables (trial cases).
|
||||
loot: Option<std::sync::Arc<horizon_world::loot::LootData>>,
|
||||
}
|
||||
impl World<'_> {
|
||||
fn biome_at(&self, x: i32, y: i32, z: i32) -> &str {
|
||||
@@ -120,6 +153,15 @@ impl World<'_> {
|
||||
}
|
||||
impl SpawnLevel for World<'_> {
|
||||
fn state(&self, x: i32, y: i32, z: i32) -> BlockStateId {
|
||||
if let Some((_, s)) = self
|
||||
.placed
|
||||
.borrow()
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|(p, _)| *p == [x, y, z])
|
||||
{
|
||||
return *s;
|
||||
}
|
||||
self.blocks
|
||||
.iter()
|
||||
.rev()
|
||||
@@ -280,10 +322,21 @@ impl NaturalLevel for World<'_> {
|
||||
block: &lookup,
|
||||
binding: self.s.collision,
|
||||
};
|
||||
// getEntityCollisions(null, box): living shulkers are solid.
|
||||
let grown = bounds.inflate(horizon_core::geometry::Vec3::new(1e-7, 1e-7, 1e-7));
|
||||
no_collision_empty(&mut world, bounds)
|
||||
&& !self
|
||||
.entities
|
||||
.borrow()
|
||||
.iter()
|
||||
.any(|(id, b)| id == "minecraft:shulker" && b.intersects(grown))
|
||||
}
|
||||
fn unobstructed(&self, bounds: Aabb) -> bool {
|
||||
!self.entities.borrow().iter().any(|b| b.intersects(bounds))
|
||||
!self
|
||||
.entities
|
||||
.borrow()
|
||||
.iter()
|
||||
.any(|(_, b)| b.intersects(bounds))
|
||||
}
|
||||
fn spawn_mobs(&self) -> bool {
|
||||
self.spawn_mobs
|
||||
@@ -299,6 +352,111 @@ impl NaturalLevel for World<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
impl SpawnerLevel for World<'_> {
|
||||
fn alive_player_near(&self, x: f64, y: f64, z: f64, range: f64) -> bool {
|
||||
self.players.iter().any(|(p, mode)| {
|
||||
let d = (p[0] - x).powi(2) + (p[1] - y).powi(2) + (p[2] - z).powi(2);
|
||||
mode != "spectator" && (range < 0.0 || d < range * range)
|
||||
})
|
||||
}
|
||||
fn spawner_blocks_work(&self) -> bool {
|
||||
self.spawners_work
|
||||
}
|
||||
fn entity_exists(&self, id: &str) -> bool {
|
||||
self.s.types.types.contains_key(id) || self.s.entity_types.contains(id)
|
||||
}
|
||||
fn count_entities(&self, id: &str, bounds: Aabb) -> usize {
|
||||
self.entities
|
||||
.borrow()
|
||||
.iter()
|
||||
.filter(|(k, b)| k == id && b.intersects(bounds))
|
||||
.count()
|
||||
}
|
||||
}
|
||||
|
||||
impl TrialSpawnerLevel for World<'_> {
|
||||
fn game_time(&self) -> i64 {
|
||||
self.time.get()
|
||||
}
|
||||
fn mob_spawning(&self) -> bool {
|
||||
self.spawn_mobs
|
||||
}
|
||||
fn players(&self) -> Vec<TrialPlayer> {
|
||||
self.players
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (p, mode))| TrialPlayer {
|
||||
uuid: [0, 0, 0, i as i32 + 1],
|
||||
block: p.map(|v| v.floor() as i32),
|
||||
eye: [p[0], p[1] + f64::from(1.62f32), p[2]],
|
||||
creative: mode == "creative",
|
||||
spectator: mode == "spectator",
|
||||
position: *p,
|
||||
height: 1.8,
|
||||
alive: true,
|
||||
omen: self
|
||||
.omens
|
||||
.borrow()
|
||||
.get(i)
|
||||
.copied()
|
||||
.flatten()
|
||||
.map(|(o, _)| o),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn visual_hit(&self, from: [f64; 3], to: [f64; 3]) -> Option<[i32; 3]> {
|
||||
let lookup = |x: i32, y: i32, z: i32| self.state(x, y, z);
|
||||
let mut scene = LookupCollisions {
|
||||
block: &lookup,
|
||||
binding: self.s.collision,
|
||||
};
|
||||
let v = |p: [f64; 3]| horizon_core::geometry::Vec3::new(p[0], p[1], p[2]);
|
||||
horizon_client::mobs::visual_clip(
|
||||
self.s.facts,
|
||||
self.s.collision,
|
||||
&mut scene,
|
||||
&lookup,
|
||||
v(from),
|
||||
v(to),
|
||||
)
|
||||
.flatten()
|
||||
}
|
||||
fn entity_block(&self, uuid: [i32; 4]) -> Option<[i32; 3]> {
|
||||
let mobs = self.trial_mobs.borrow();
|
||||
let (_, p, ..) = mobs.iter().find(|(u, ..)| *u == uuid)?;
|
||||
Some(p.map(|v| v.floor() as i32))
|
||||
}
|
||||
fn entity_box(&self, uuid: [i32; 4]) -> Option<([f64; 3], f32)> {
|
||||
let mobs = self.trial_mobs.borrow();
|
||||
let (_, p, h, _) = mobs.iter().find(|(u, ..)| *u == uuid)?;
|
||||
Some((*p, *h))
|
||||
}
|
||||
fn no_block_collision(&self, block: [i32; 3]) -> bool {
|
||||
let lookup = |x: i32, y: i32, z: i32| self.state(x, y, z);
|
||||
let mut scene = LookupCollisions {
|
||||
block: &lookup,
|
||||
binding: self.s.collision,
|
||||
};
|
||||
let state = self.state(block[0], block[1], block[2]);
|
||||
self.s
|
||||
.collision
|
||||
.collision_shape(&mut scene, state, block)
|
||||
.map_or(true, |shape| shape.is_empty())
|
||||
}
|
||||
fn loot_items(&self, table: &str, seed: i64) -> Vec<horizon_client::items::ItemStack> {
|
||||
let Some(loot) = &self.loot else {
|
||||
return Vec::new();
|
||||
};
|
||||
loot.roll_split(table, &mut LegacyRandom::new(seed))
|
||||
.iter()
|
||||
.map(horizon_client::structure_blocks::loot_item_with_components)
|
||||
.collect()
|
||||
}
|
||||
fn seed(&self) -> i64 {
|
||||
self.seed
|
||||
}
|
||||
}
|
||||
|
||||
/// The box a mob is left with by `finalizeSpawn` under the oracle's fixed random.
|
||||
fn neutral_box(l: &World, r: &SpawnRequest) -> Aabb {
|
||||
let t = &l.s.types.types[r.entity];
|
||||
@@ -365,10 +523,28 @@ fn main() -> horizon_client::Result<()> {
|
||||
let facts = BlockFacts::load(root, registry)?;
|
||||
let data = MobData::load(root, &facts);
|
||||
let items = Items::tags_only(root)?;
|
||||
// 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)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let types = MobTypes::load(root)?;
|
||||
let biomes = Biomes::load(root)?;
|
||||
let structures = StructureSpawns::load(root);
|
||||
let collision = collisions::load(root, registry)?;
|
||||
let entity_types: HashSet<String> = fs::read(root.join("entity-types.json"))
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
|
||||
.and_then(|v| {
|
||||
v.as_object().map(|o| {
|
||||
o.keys()
|
||||
.map(|k| horizon_client::command::args::normalize(k))
|
||||
.collect()
|
||||
})
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let shared = Shared {
|
||||
facts: &facts,
|
||||
data: &data,
|
||||
@@ -378,6 +554,7 @@ fn main() -> horizon_client::Result<()> {
|
||||
structures: &structures,
|
||||
collision: &collision,
|
||||
air: facts.air,
|
||||
entity_types: &entity_types,
|
||||
};
|
||||
let mut adaptation: HashMap<String, bool> = HashMap::new();
|
||||
let mut out = String::new();
|
||||
@@ -456,6 +633,19 @@ fn main() -> horizon_client::Result<()> {
|
||||
bounds: Bx(b),
|
||||
});
|
||||
}
|
||||
// A player's third field: an omen and its amplifier.
|
||||
let omens = indexed("p")
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let (effect, amplifier) = p.split('/').nth(2)?.split_once(':')?;
|
||||
let omen = if effect == "trial_omen" {
|
||||
Omen::Trial
|
||||
} else {
|
||||
Omen::Bad
|
||||
};
|
||||
Some((omen, amplifier.parse().ok()?))
|
||||
})
|
||||
.collect();
|
||||
let players = indexed("p")
|
||||
.into_iter()
|
||||
.map(|p| {
|
||||
@@ -538,12 +728,24 @@ fn main() -> horizon_client::Result<()> {
|
||||
respawn,
|
||||
unloaded,
|
||||
spawn_mobs: get("rules", "1") != "0",
|
||||
spawners_work: get("spawners", "1") != "0",
|
||||
placed: RefCell::new(Vec::new()),
|
||||
time: Cell::new(0),
|
||||
trial_mobs: RefCell::new(Vec::new()),
|
||||
trial_count: Cell::new(0),
|
||||
omens: RefCell::new(omens),
|
||||
loot: loot.clone(),
|
||||
entities: RefCell::new(
|
||||
case_entities
|
||||
.iter()
|
||||
.filter_map(|(t, p, _)| {
|
||||
let t = types.types.get(t)?;
|
||||
Some(spawn_box(p[0], p[1], p[2], t.width as f32, t.height as f32))
|
||||
.filter_map(|(id, p, _)| {
|
||||
let t = types.types.get(id)?;
|
||||
// Entity.setPos (a shulker sits at its block's centre).
|
||||
let p = horizon_client::mobs::spawner::entity_position(t.species, *p);
|
||||
Some((
|
||||
id.clone(),
|
||||
spawn_box(p[0], p[1], p[2], t.width as f32, t.height as f32),
|
||||
))
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
@@ -560,14 +762,22 @@ fn main() -> horizon_client::Result<()> {
|
||||
let mut log: Vec<String> = Vec::new();
|
||||
let mut spawn = |l: &World, r: &SpawnRequest| -> Option<Spawned> {
|
||||
let bounds = neutral_box(l, r);
|
||||
l.entities.borrow_mut().push(bounds);
|
||||
l.entities.borrow_mut().push((r.entity.to_owned(), bounds));
|
||||
Some(Spawned {
|
||||
bounds,
|
||||
school: true,
|
||||
})
|
||||
};
|
||||
let mode = get("mode", "chunk");
|
||||
if mode == "gen" {
|
||||
if mode == "spawner" {
|
||||
spawner_case(&world, &get, &mut random, &mut log)?;
|
||||
} else if mode == "trial" {
|
||||
let loot = loot.as_ref().ok_or("no loot tables")?;
|
||||
trial_case(&world, registry, loot, &get, &mut random, &mut log)?;
|
||||
} else if mode == "vault" {
|
||||
let loot = loot.as_ref().ok_or("no loot tables")?;
|
||||
vault_case(world.players.clone(), loot, &get, &mut random, &mut log)?;
|
||||
} else if mode == "gen" {
|
||||
let mut gen_random = LegacyRandom::new(get("gseed", "0").parse()?);
|
||||
// The level random of the spawn predicates: no creature's predicate
|
||||
// draws from it (the oracle's answers fixed values).
|
||||
@@ -686,3 +896,529 @@ fn main() -> horizon_client::Result<()> {
|
||||
fs::write(args.get(3).ok_or("missing OUTPUT")?, out)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `SpawnerBlockEntity.serverTick` `ticks` times at `spos` for the spawner in
|
||||
/// `snbt` (base64 SNBT), as the oracle logs it.
|
||||
fn spawner_case(
|
||||
world: &World,
|
||||
get: &dyn Fn(&str, &'static str) -> String,
|
||||
random: &mut LegacyRandom,
|
||||
log: &mut Vec<String>,
|
||||
) -> horizon_client::Result<()> {
|
||||
let v: Vec<i32> = get("spos", "0,0,0")
|
||||
.split(',')
|
||||
.map(|n| n.parse().unwrap_or(0))
|
||||
.collect();
|
||||
let pos = [v[0], v[1], v[2]];
|
||||
let text = horizon_client::skins::base64_decode(&get("snbt", "")).ok_or("bad snbt")?;
|
||||
let tag = horizon_world::nbt::parse_snbt(&String::from_utf8(text)?)?.0;
|
||||
let mut spawner = Spawner::load(&tag);
|
||||
let ticks: i32 = get("ticks", "1").parse()?;
|
||||
for tick in 0..ticks {
|
||||
let mut events = Vec::new();
|
||||
let mut spawned = std::collections::VecDeque::new();
|
||||
let mut spawn = |l: &World, r: &SpawnerSpawn| -> Option<SpawnerAdded> {
|
||||
let t = l.s.types.types.get(r.id)?;
|
||||
let [x, y, z] = r.position;
|
||||
let bounds = spawn_box(x, y, z, t.width as f32, t.height as f32);
|
||||
l.entities.borrow_mut().push((r.id.to_owned(), bounds));
|
||||
spawned.push_back(format!(
|
||||
"{tick} spawn {} {} {} {} {}",
|
||||
r.id,
|
||||
bits(x),
|
||||
bits(y),
|
||||
bits(z),
|
||||
// Shulker.finalizeSpawn faces it south.
|
||||
yaw_text(
|
||||
r.id,
|
||||
if r.finalize && r.id == "minecraft:shulker" {
|
||||
0.0
|
||||
} else {
|
||||
r.yaw
|
||||
}
|
||||
)
|
||||
));
|
||||
Some(SpawnerAdded {
|
||||
id: spawned.len() as u32,
|
||||
bounds,
|
||||
mob: true,
|
||||
})
|
||||
};
|
||||
spawner.server_tick(world, random, pos, &mut spawn, &mut events);
|
||||
for e in events {
|
||||
match e {
|
||||
SpawnerEvent::BlockUpdated => log.push(format!("{tick} update")),
|
||||
SpawnerEvent::BlockEvent(id) => log.push(format!("{tick} bevent {id} 0")),
|
||||
// Each spawn's level event follows its tryAddFreshEntityWithPassengers.
|
||||
SpawnerEvent::LevelEvent(id) => {
|
||||
log.extend(spawned.pop_front());
|
||||
log.push(format!("{tick} levent {id} 0"));
|
||||
}
|
||||
SpawnerEvent::EntityPlace(_, p) => log.push(format!(
|
||||
"{tick} gevent minecraft:entity_place {} {} {}",
|
||||
p[0], p[1], p[2]
|
||||
)),
|
||||
SpawnerEvent::SpawnAnim(_) => log.push(format!("{tick} eevent 20")),
|
||||
}
|
||||
}
|
||||
}
|
||||
let next = spawner
|
||||
.next_data()
|
||||
.and_then(|d| d.id())
|
||||
.unwrap_or_else(|| "-".into());
|
||||
let pots: Vec<String> = spawner
|
||||
.potentials
|
||||
.iter()
|
||||
.map(|(d, w)| format!("{}*{w}", d.id().unwrap_or_else(|| "-".into())))
|
||||
.collect();
|
||||
log.push(format!(
|
||||
"end delay={} next={next} pots={}",
|
||||
spawner.spawn_delay as i16,
|
||||
pots.join(",")
|
||||
));
|
||||
log.push(format!("next {}", random.next_int_bound(1000).unwrap_or(0)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `TrialSpawner.tickServer` `ticks` times from game time `t0` at `spos` on a
|
||||
/// trial spawner block in state `state` (ominous with `ominous=1`) loaded from
|
||||
/// `snbt` (base64 SNBT), as the oracle logs it. Every `kill` ticks the mobs it
|
||||
/// spawned die.
|
||||
fn trial_case(
|
||||
world: &World,
|
||||
registry: &horizon_world::registry::BlockRegistry,
|
||||
loot: &horizon_world::loot::LootData,
|
||||
get: &dyn Fn(&str, &'static str) -> String,
|
||||
random: &mut LegacyRandom,
|
||||
log: &mut Vec<String>,
|
||||
) -> horizon_client::Result<()> {
|
||||
let v: Vec<i32> = get("spos", "0,0,0")
|
||||
.split(',')
|
||||
.map(|n| n.parse().unwrap_or(0))
|
||||
.collect();
|
||||
let pos = [v[0], v[1], v[2]];
|
||||
let block_state = |state: TrialState, ominous: bool| {
|
||||
parse_state(&format!(
|
||||
"minecraft:trial_spawner[trial_spawner_state={},ominous={ominous}]",
|
||||
state.name()
|
||||
))
|
||||
};
|
||||
let mut state =
|
||||
TrialState::from_name(&get("state", "waiting_for_players")).ok_or("bad state")?;
|
||||
let mut ominous = get("ominous", "0") == "1";
|
||||
world
|
||||
.placed
|
||||
.borrow_mut()
|
||||
.push((pos, registry.resolve(&block_state(state, ominous))?));
|
||||
let text = horizon_client::skins::base64_decode(&get("snbt", "")).ok_or("bad snbt")?;
|
||||
let tag = horizon_world::nbt::parse_snbt(&String::from_utf8(text)?)?.0;
|
||||
let mut spawner = TrialSpawner::load(&tag);
|
||||
let ticks: i64 = get("ticks", "1").parse()?;
|
||||
let t0: i64 = get("t0", "0").parse()?;
|
||||
let kill: i64 = get("kill", "0").parse()?;
|
||||
let seed = world.seed;
|
||||
let mut sequences = horizon_client::random_sequences::RandomSequences::default();
|
||||
let center = pos.map(|v| f64::from(v) + 0.5);
|
||||
for tick in 0..ticks {
|
||||
world.time.set(t0 + tick);
|
||||
let mut events = Vec::new();
|
||||
let mut spawned = std::collections::VecDeque::new();
|
||||
let mut items = Vec::new();
|
||||
let mut spawn = |l: &World, r: &SpawnerSpawn| {
|
||||
let t = l.s.types.types.get(r.id)?;
|
||||
// Slime.readAdditionalSaveData: size Size + 1, clamped to 1..=127.
|
||||
let size = match r.entity.get(&horizon_world::nbt::JavaString::from("Size")) {
|
||||
Some(Tag::Byte(v)) => i32::from(*v),
|
||||
Some(Tag::Short(v)) => i32::from(*v),
|
||||
Some(Tag::Int(v)) => *v,
|
||||
Some(Tag::Long(v)) => *v as i32,
|
||||
_ => 0,
|
||||
};
|
||||
let size = if matches!(r.id, "minecraft:slime" | "minecraft:magma_cube") {
|
||||
size.saturating_add(1).clamp(1, 127)
|
||||
} else {
|
||||
1
|
||||
};
|
||||
let [x, y, z] = r.position;
|
||||
let scale = size as f32;
|
||||
let bounds = spawn_box(x, y, z, t.width as f32 * scale, t.height as f32 * scale);
|
||||
l.entities.borrow_mut().push((r.id.to_owned(), bounds));
|
||||
let uuid = [1, 0, 0, l.trial_count.get()];
|
||||
l.trial_count.set(l.trial_count.get() + 1);
|
||||
// Zombie.readAdditionalSaveData: IsBaby halves the box
|
||||
// (getAgeScale); finalizeSpawn's fixed random never makes one.
|
||||
let baby = match r
|
||||
.entity
|
||||
.get(&horizon_world::nbt::JavaString::from("IsBaby"))
|
||||
{
|
||||
Some(Tag::Byte(v)) => *v != 0,
|
||||
Some(Tag::Int(v)) => *v != 0,
|
||||
_ => false,
|
||||
};
|
||||
let height = t.height as f32 * scale * if baby { 0.5 } else { 1.0 };
|
||||
l.trial_mobs
|
||||
.borrow_mut()
|
||||
.push((uuid, r.position, height, bounds));
|
||||
spawned.push_back(format!(
|
||||
"{tick} spawn {} {} {} {} {}",
|
||||
r.id,
|
||||
bits(x),
|
||||
bits(y),
|
||||
bits(z),
|
||||
if size == 1 {
|
||||
yaw_text(r.id, r.yaw)
|
||||
} else {
|
||||
"head".to_owned()
|
||||
}
|
||||
));
|
||||
Some(uuid)
|
||||
};
|
||||
let mut eject = |table: &str, random: &mut LegacyRandom| {
|
||||
let drops = match loot.random_sequence(table) {
|
||||
Some(s) => loot.roll_split(table, sequences.get(s, seed)),
|
||||
None => loot.roll_split(table, random),
|
||||
};
|
||||
for stack in &drops {
|
||||
random.next_double();
|
||||
let spread = 0.017_227_5 * 2.0;
|
||||
let v = [
|
||||
random.triangle(0.0, spread),
|
||||
random.triangle(0.2, spread),
|
||||
random.triangle(0.0, spread),
|
||||
];
|
||||
items.push(format!(
|
||||
"{tick} item {} {} {} {} {} {} {} {}",
|
||||
stack.item,
|
||||
stack.count,
|
||||
bits(center[0]),
|
||||
bits(center[1] + 0.7 - 0.125),
|
||||
bits(center[2]),
|
||||
bits(v[0]),
|
||||
bits(v[1]),
|
||||
bits(v[2])
|
||||
));
|
||||
}
|
||||
!drops.is_empty()
|
||||
};
|
||||
let next = spawner.server_tick(
|
||||
world,
|
||||
random,
|
||||
pos,
|
||||
state,
|
||||
ominous,
|
||||
&mut spawn,
|
||||
&mut eject,
|
||||
&mut events,
|
||||
);
|
||||
for e in events {
|
||||
match e {
|
||||
TrialEvent::BlockUpdated => log.push(format!("{tick} update")),
|
||||
TrialEvent::LevelEvent(id, p, data) => {
|
||||
// A spawn's events follow its tryAddFreshEntityWithPassengers,
|
||||
// a reward's its items.
|
||||
match id {
|
||||
3011 => log.extend(spawned.pop_front()),
|
||||
3014 => log.append(&mut items),
|
||||
_ => {}
|
||||
}
|
||||
match id {
|
||||
3020 | 3012 => log.push(format!(
|
||||
"{tick} levent {id} {data} at {},{},{}",
|
||||
p[0], p[1], p[2]
|
||||
)),
|
||||
_ => log.push(format!("{tick} levent {id} {data}")),
|
||||
}
|
||||
}
|
||||
TrialEvent::Sound(name) => log.push(format!(
|
||||
"{tick} sound minecraft:{name} {} {} {} 1.0 1.0",
|
||||
bits(center[0]),
|
||||
bits(center[1]),
|
||||
bits(center[2])
|
||||
)),
|
||||
TrialEvent::EntityPlace(_, p) => log.push(format!(
|
||||
"{tick} gevent minecraft:entity_place {} {} {}",
|
||||
p[0], p[1], p[2]
|
||||
)),
|
||||
TrialEvent::TrialOmen(uuid) => {
|
||||
let i = (uuid[3] - 1) as usize;
|
||||
let mut omens = world.omens.borrow_mut();
|
||||
let amplifier = omens.get(i).copied().flatten().map_or(0, |(_, a)| a);
|
||||
log.push(format!("{tick} effect remove minecraft:bad_omen"));
|
||||
log.push(format!(
|
||||
"{tick} effect add minecraft:trial_omen {} 0",
|
||||
TRIAL_OMEN_TICKS_PER_LEVEL * (amplifier + 1)
|
||||
));
|
||||
if let Some(o) = omens.get_mut(i) {
|
||||
*o = Some((Omen::Trial, 0));
|
||||
}
|
||||
}
|
||||
TrialEvent::Discard(uuid) => {
|
||||
let mut mobs = world.trial_mobs.borrow_mut();
|
||||
if let Some(i) = mobs.iter().position(|(u, ..)| *u == uuid) {
|
||||
let (.., b) = mobs.remove(i);
|
||||
world.entities.borrow_mut().retain(|(_, e)| *e != b);
|
||||
}
|
||||
}
|
||||
TrialEvent::SoundAt(name, b, pitch) => log.push(format!(
|
||||
"{tick} sound minecraft:{name} {} {} {} 1.0 {pitch:?}",
|
||||
bits(f64::from(b[0]) + 0.5),
|
||||
bits(f64::from(b[1]) + 0.5),
|
||||
bits(f64::from(b[2]) + 0.5)
|
||||
)),
|
||||
TrialEvent::ItemSpawner {
|
||||
item,
|
||||
position,
|
||||
delay,
|
||||
} => log.push(format!(
|
||||
"{tick} ispawner {} {} {} {} {} {delay}",
|
||||
item.item,
|
||||
item.count,
|
||||
bits(position[0]),
|
||||
bits(position[1]),
|
||||
bits(position[2])
|
||||
)),
|
||||
TrialEvent::OminousSet(set) => {
|
||||
ominous = set;
|
||||
world
|
||||
.placed
|
||||
.borrow_mut()
|
||||
.push((pos, registry.resolve(&block_state(state, ominous))?));
|
||||
log.push(format!("{tick} state {} {ominous}", state.name()));
|
||||
}
|
||||
}
|
||||
}
|
||||
if next != state || spawner.is_ominous != ominous {
|
||||
state = next;
|
||||
ominous = spawner.is_ominous;
|
||||
world
|
||||
.placed
|
||||
.borrow_mut()
|
||||
.push((pos, registry.resolve(&block_state(state, ominous))?));
|
||||
log.push(format!("{tick} state {} {ominous}", state.name()));
|
||||
}
|
||||
if kill > 0 && tick % kill == kill - 1 {
|
||||
let dead: Vec<Aabb> = world
|
||||
.trial_mobs
|
||||
.borrow_mut()
|
||||
.drain(..)
|
||||
.map(|(.., b)| b)
|
||||
.collect();
|
||||
world
|
||||
.entities
|
||||
.borrow_mut()
|
||||
.retain(|(_, b)| !dead.iter().any(|d| d == b));
|
||||
if !dead.is_empty() {
|
||||
log.push(format!("{tick} kill {}", dead.len()));
|
||||
}
|
||||
}
|
||||
}
|
||||
log.push(format!(
|
||||
"end state={} players={} mobs={} cooldown={} next_spawn={} total={} data={} eject={}",
|
||||
state.name(),
|
||||
spawner.detected_players.len(),
|
||||
spawner.current_mobs.len(),
|
||||
spawner.cooldown_ends_at,
|
||||
spawner.next_mob_spawns_at,
|
||||
spawner.total_mobs_spawned,
|
||||
spawner
|
||||
.next_spawn_data
|
||||
.as_ref()
|
||||
.and_then(|d| d.id())
|
||||
.unwrap_or_else(|| "-".into()),
|
||||
spawner.ejecting_loot_table.as_deref().unwrap_or("-")
|
||||
));
|
||||
log.push(format!("next {}", random.next_int_bound(1000).unwrap_or(0)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What a vault reads: the game time and the case's players, moved by `move`.
|
||||
struct VaultWorld {
|
||||
time: i64,
|
||||
players: Vec<([f64; 3], String)>,
|
||||
}
|
||||
impl VaultLevel for VaultWorld {
|
||||
fn game_time(&self) -> i64 {
|
||||
self.time
|
||||
}
|
||||
fn players(&self) -> Vec<VaultPlayer> {
|
||||
self.players
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (p, mode))| VaultPlayer {
|
||||
uuid: [0, 0, 0, i as i32 + 1],
|
||||
block: p.map(|v| v.floor() as i32),
|
||||
spectator: mode == "spectator",
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
/// A UUID's low 64 bits, as the oracle prints players.
|
||||
fn uuid_low(u: &[i32; 4]) -> String {
|
||||
((i64::from(u[2]) << 32) | i64::from(u[3] as u32)).to_string()
|
||||
}
|
||||
/// The display item and the connected players as the oracle prints them.
|
||||
fn vault_shared(v: &Vault) -> String {
|
||||
let mut connected: Vec<String> = v.connected_players.iter().map(uuid_low).collect();
|
||||
connected.sort();
|
||||
format!(
|
||||
"{} [{}]",
|
||||
v.display_item
|
||||
.as_ref()
|
||||
.map_or_else(|| "-".to_owned(), |s| format!("{}*{}", s.item, s.count)),
|
||||
connected.join(",")
|
||||
)
|
||||
}
|
||||
|
||||
/// `VaultBlockEntity.Server.tick` `ticks` times from game time `t0` at `spos`
|
||||
/// on a vault block in state `state` (ominous with `ominous=1`) loaded from
|
||||
/// `snbt` (base64 SNBT), with the case's key uses (`use`) and player moves
|
||||
/// (`move`) before each tick, as the oracle logs it.
|
||||
fn vault_case(
|
||||
players: Vec<([f64; 3], String)>,
|
||||
loot: &horizon_world::loot::LootData,
|
||||
get: &dyn Fn(&str, &'static str) -> String,
|
||||
random: &mut LegacyRandom,
|
||||
log: &mut Vec<String>,
|
||||
) -> horizon_client::Result<()> {
|
||||
let v: Vec<i32> = get("spos", "0,0,0")
|
||||
.split(',')
|
||||
.map(|n| n.parse().unwrap_or(0))
|
||||
.collect();
|
||||
let pos = [v[0], v[1], v[2]];
|
||||
let mut state = VaultState::from_name(&get("state", "inactive")).ok_or("bad state")?;
|
||||
let ominous = get("ominous", "0") == "1";
|
||||
let text = horizon_client::skins::base64_decode(&get("snbt", "")).ok_or("bad snbt")?;
|
||||
let tag = horizon_world::nbt::parse_snbt(&String::from_utf8(text)?)?.0;
|
||||
let mut vault = Vault::load(&tag);
|
||||
let schedule = |key: &str| -> Vec<Vec<String>> {
|
||||
get(key, "")
|
||||
.split(';')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.split('/').map(str::to_owned).collect())
|
||||
.collect()
|
||||
};
|
||||
let (uses, moves) = (schedule("use"), schedule("move"));
|
||||
let ticks: i64 = get("ticks", "1").parse()?;
|
||||
let t0: i64 = get("t0", "0").parse()?;
|
||||
let seed: i64 = get("wseed", "0").parse()?;
|
||||
let mut sequences = horizon_client::random_sequences::RandomSequences::default();
|
||||
let mut world = VaultWorld { time: t0, players };
|
||||
let center = pos.map(|v| f64::from(v) + 0.5);
|
||||
for tick in 0..ticks {
|
||||
world.time = t0 + tick;
|
||||
let at = |s: &[String]| s[0].parse() == Ok(tick);
|
||||
for m in moves.iter().filter(|m| at(m)) {
|
||||
let v: Vec<f64> = m[2].split(',').map(|n| n.parse().unwrap_or(0.0)).collect();
|
||||
world.players[m[1].parse::<usize>()?].0 = [v[0], v[1], v[2]];
|
||||
}
|
||||
let mut events = Vec::new();
|
||||
let mut roll = |table: &str, random: &mut LegacyRandom, sequence: bool| {
|
||||
let drops = match loot.random_sequence(table).filter(|_| sequence) {
|
||||
Some(s) => loot.roll_split(table, sequences.get(s, seed)),
|
||||
None => loot.roll_split(table, random),
|
||||
};
|
||||
drops
|
||||
.iter()
|
||||
.map(horizon_client::structure_blocks::loot_item_with_components)
|
||||
.collect()
|
||||
};
|
||||
for u in uses.iter().filter(|u| at(u)) {
|
||||
let player: usize = u[1].parse()?;
|
||||
let mut stack = ItemStack::new(&u[2], u[3].parse()?);
|
||||
let used = vault.use_item_on(
|
||||
&world,
|
||||
random,
|
||||
pos,
|
||||
state,
|
||||
ominous,
|
||||
[0, 0, 0, player as i32 + 1],
|
||||
Some(&stack),
|
||||
&mut roll,
|
||||
&mut events,
|
||||
);
|
||||
if world.players[player].1 != "creative" {
|
||||
stack.count -= used.unwrap_or(0);
|
||||
}
|
||||
vault_events(&mut events, &mut state, ominous, tick, center, &vault, log);
|
||||
log.push(format!(
|
||||
"{tick} use {} {}",
|
||||
if used.is_some() { "ok" } else { "pass" },
|
||||
stack.count
|
||||
));
|
||||
}
|
||||
vault.server_tick(&world, random, pos, state, ominous, &mut roll, &mut events);
|
||||
vault_events(&mut events, &mut state, ominous, tick, center, &vault, log);
|
||||
}
|
||||
let saved = vault.save();
|
||||
let config = saved
|
||||
.iter()
|
||||
.find(|(k, _)| *k == "config")
|
||||
.and_then(|(_, t)| match t {
|
||||
Tag::Compound(c) => {
|
||||
let mut keys: Vec<String> = c.keys().map(|k| k.to_string_lossy()).collect();
|
||||
keys.sort();
|
||||
Some(keys.join(","))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let eject: Vec<String> = vault
|
||||
.items_to_eject
|
||||
.iter()
|
||||
.map(|s| format!("{}*{}", s.item, s.count))
|
||||
.collect();
|
||||
log.push(format!(
|
||||
"end state={} rewarded={} resumes={} eject={} total={} shared={} config={config}",
|
||||
state.name(),
|
||||
vault
|
||||
.rewarded_players
|
||||
.iter()
|
||||
.map(uuid_low)
|
||||
.collect::<Vec<_>>()
|
||||
.join(","),
|
||||
vault.state_updating_resumes_at,
|
||||
eject.join(","),
|
||||
vault.total_ejections_needed,
|
||||
vault_shared(&vault),
|
||||
));
|
||||
log.push(format!("next {}", random.next_int_bound(1000).unwrap_or(0)));
|
||||
Ok(())
|
||||
}
|
||||
/// Logs a vault's events and applies its state changes.
|
||||
fn vault_events(
|
||||
events: &mut Vec<VaultEvent>,
|
||||
state: &mut VaultState,
|
||||
ominous: bool,
|
||||
tick: i64,
|
||||
center: [f64; 3],
|
||||
vault: &Vault,
|
||||
log: &mut Vec<String>,
|
||||
) {
|
||||
for e in events.drain(..) {
|
||||
match e {
|
||||
VaultEvent::State(s) => {
|
||||
*state = s;
|
||||
log.push(format!("{tick} state {} {ominous}", s.name()));
|
||||
}
|
||||
VaultEvent::BlockUpdated => log.push(format!("{tick} update {}", vault_shared(vault))),
|
||||
VaultEvent::LevelEvent(id, data) => log.push(format!("{tick} levent {id} {data}")),
|
||||
VaultEvent::Sound(name, volume, pitch) => log.push(format!(
|
||||
"{tick} sound minecraft:{name} {} {} {} {volume:?} {pitch:?}",
|
||||
bits(center[0]),
|
||||
bits(center[1]),
|
||||
bits(center[2])
|
||||
)),
|
||||
VaultEvent::Eject(stack, v) => log.push(format!(
|
||||
"{tick} item {} {} {} {} {} {} {} {}",
|
||||
stack.item,
|
||||
stack.count,
|
||||
bits(center[0]),
|
||||
bits(center[1] + 0.7 - 0.125),
|
||||
bits(center[2]),
|
||||
bits(v[0]),
|
||||
bits(v[1]),
|
||||
bits(v[2])
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! The resource root Horizon builds for a pack selection (`packs::build_overlay`)
|
||||
//! and what its loaders read from it, for VanillaPackResourcesOracle.
|
||||
use horizon_client::{lang::Language, packs, sound_events::SoundRegistry};
|
||||
use std::{fmt::Write, fs, path::PathBuf};
|
||||
fn fnv(bytes: &[u8]) -> u64 {
|
||||
bytes.iter().fold(0xcbf2_9ce4_8422_2325, |h, b| {
|
||||
(h ^ u64::from(*b)).wrapping_mul(0x100_0000_01b3)
|
||||
})
|
||||
}
|
||||
/// `namespace:path` to a file under the root's `assets/`.
|
||||
fn asset(root: &std::path::Path, id: &str, prefix: &str, suffix: &str) -> PathBuf {
|
||||
let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", id));
|
||||
root.join("assets")
|
||||
.join(namespace)
|
||||
.join(format!("{prefix}{path}{suffix}"))
|
||||
}
|
||||
fn types(root: &std::path::Path, file: PathBuf, key: &str) -> String {
|
||||
let _ = root;
|
||||
let value: serde_json::Value = fs::read(file)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||
.unwrap_or_default();
|
||||
value[key]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|v| {
|
||||
let t = v["type"].as_str().unwrap_or("?");
|
||||
format!(" {}", t.strip_prefix("minecraft:").unwrap_or(t))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<_> = std::env::args().collect();
|
||||
let input = fs::read_to_string(&args[1])?;
|
||||
let mut lines = input.lines();
|
||||
let root: Vec<&str> = lines.next().ok_or("empty input")?.split(' ').collect();
|
||||
let vanilla = PathBuf::from(root[1]);
|
||||
let ids: Vec<String> = root[3..].iter().map(|s| (*s).to_owned()).collect();
|
||||
let all = packs::discover(&vanilla, &PathBuf::from(root[2]));
|
||||
let enabled = packs::enabled(&all, &ids);
|
||||
let overlay = PathBuf::from(&args[2]).with_extension("overlay");
|
||||
packs::build_overlay(&vanilla, &enabled, &overlay)?;
|
||||
let language = Language::load(&[&overlay], "en_us");
|
||||
let sounds = SoundRegistry::load(&overlay);
|
||||
let mut out = String::new();
|
||||
for line in lines {
|
||||
let (kind, arg) = line.split_once(' ').unwrap_or((line, ""));
|
||||
write!(out, "{line} =>")?;
|
||||
match kind {
|
||||
"file" => match fs::read(asset(&overlay, arg, "", "")) {
|
||||
Ok(bytes) => write!(out, " {:x}", fnv(&bytes))?,
|
||||
Err(_) => out.push_str(" missing"),
|
||||
},
|
||||
"lang" => write!(out, " {}", language.get(arg).unwrap_or(arg))?,
|
||||
"sound" => {
|
||||
let event = if arg.contains(':') {
|
||||
arg.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{arg}")
|
||||
};
|
||||
match sounds.get(&event) {
|
||||
None => out.push_str(" missing"),
|
||||
Some(e) => {
|
||||
write!(out, " subtitle={}", e.subtitle.as_deref().unwrap_or("none"))?;
|
||||
for s in &e.sounds {
|
||||
if s.event {
|
||||
out.push_str(" event");
|
||||
} else {
|
||||
write!(out, " {}*{}", s.location, s.weight)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"font" => out.push_str(&types(
|
||||
&overlay,
|
||||
asset(&overlay, arg, "font/", ".json"),
|
||||
"providers",
|
||||
)),
|
||||
"atlas" => out.push_str(&types(
|
||||
&overlay,
|
||||
asset(&overlay, arg, "atlases/", ".json"),
|
||||
"sources",
|
||||
)),
|
||||
_ => return Err(format!("unknown query {line}").into()),
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
fs::write(&args[2], out)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! Runs tools/test_particle_providers_parity.py's particles through
|
||||
//! `ParticleEngine`: built with the seeds the vanilla oracle recovered, then
|
||||
//! ticked over air or a stone floor at y 63, printing their state each tick.
|
||||
use horizon_client::{
|
||||
Result,
|
||||
assets::Assets,
|
||||
blocks::BlockFacts,
|
||||
chunk::Chunk,
|
||||
collisions,
|
||||
particles::{ParticleEngine, ParticleLevel, ParticleOptions, ParticleSnapshot, ParticleType},
|
||||
world::World,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
fn d(v: f64) -> String {
|
||||
format!("{:016x}", v.to_bits())
|
||||
}
|
||||
fn f(v: f32) -> String {
|
||||
format!("{:08x}", v.to_bits())
|
||||
}
|
||||
fn color(v: &Value, default: u32) -> u32 {
|
||||
v.as_u64().map_or(default, |c| c as u32)
|
||||
}
|
||||
fn rgb(c: u32) -> [f32; 3] {
|
||||
[
|
||||
((c >> 16) & 255) as f32 / 255.0,
|
||||
((c >> 8) & 255) as f32 / 255.0,
|
||||
(c & 255) as f32 / 255.0,
|
||||
]
|
||||
}
|
||||
fn options(case: &Value) -> Option<ParticleOptions> {
|
||||
let ty = ParticleType::from_name(case["type"].as_str()?)?;
|
||||
let o = &case["options"];
|
||||
let scale = o["scale"].as_f64().unwrap_or(1.0) as f32;
|
||||
let c = color(&o["color"], 0xFFFF_FFFF);
|
||||
let argb = {
|
||||
let [r, g, b] = rgb(c);
|
||||
[r, g, b, ((c >> 24) & 255) as f32 / 255.0]
|
||||
};
|
||||
Some(match ty {
|
||||
ParticleType::Block
|
||||
| ParticleType::BlockCrumble
|
||||
| ParticleType::DustPillar
|
||||
| ParticleType::BlockMarker => ParticleOptions::block(ty, "terrain".into(), 0xFF_FFFF),
|
||||
// Stone's map colour: FallingDustParticle.Provider's tint without a tint source.
|
||||
ParticleType::FallingDust => ParticleOptions::block(ty, "terrain".into(), 0x70_7070),
|
||||
ParticleType::Dust => ParticleOptions::dust(rgb(c), scale),
|
||||
ParticleType::DustColorTransition => {
|
||||
ParticleOptions::dust_transition(rgb(c), rgb(color(&o["to"], 0)), scale)
|
||||
}
|
||||
ParticleType::Effect | ParticleType::InstantEffect => ParticleOptions {
|
||||
scale,
|
||||
..ParticleOptions::colored(ty, argb)
|
||||
},
|
||||
// The oracle's item models: one layer whose sprite is named after the item.
|
||||
ParticleType::Item => {
|
||||
let item = o["item"].as_str()?;
|
||||
ParticleOptions::item(vec![format!(
|
||||
"item_{}",
|
||||
item.strip_prefix("minecraft:").unwrap_or(item)
|
||||
)])
|
||||
}
|
||||
ParticleType::EntityEffect | ParticleType::TintedLeaves | ParticleType::Flash => {
|
||||
ParticleOptions::colored(ty, argb)
|
||||
}
|
||||
ParticleType::DragonBreath => ParticleOptions {
|
||||
scale,
|
||||
..ParticleOptions::simple(ty)
|
||||
},
|
||||
ParticleType::SculkCharge => ParticleOptions::sculk_charge(scale),
|
||||
ParticleType::Shriek => ParticleOptions::shriek(scale as i32),
|
||||
ParticleType::Trail => {
|
||||
let t = &o["target"];
|
||||
ParticleOptions::trail(
|
||||
[t[0].as_f64()?, t[1].as_f64()?, t[2].as_f64()?],
|
||||
c & 0xFF_FFFF,
|
||||
o["duration"].as_i64()? as i32,
|
||||
)
|
||||
}
|
||||
_ => ParticleOptions::simple(ty),
|
||||
})
|
||||
}
|
||||
fn state(s: &ParticleSnapshot) -> String {
|
||||
let mut out = format!(
|
||||
"{} {} {} {}",
|
||||
s.age,
|
||||
s.lifetime,
|
||||
u8::from(s.removed),
|
||||
u8::from(s.on_ground)
|
||||
);
|
||||
for v in s.position.iter().chain(&s.velocity).chain(&s.previous) {
|
||||
out += &format!(" {}", d(*v));
|
||||
}
|
||||
if let Some(q) = &s.quad {
|
||||
out += &format!(" | {} {}", f(q.size[0]), f(q.size[1]));
|
||||
for v in q.rgba.iter().chain(&q.roll) {
|
||||
out += &format!(" {}", f(*v));
|
||||
}
|
||||
out += &format!(" {}", q.sprite);
|
||||
for v in q.uv {
|
||||
out += &format!(" {}", f(v));
|
||||
}
|
||||
out += &format!(" {:x}", q.light);
|
||||
}
|
||||
out
|
||||
}
|
||||
fn world(assets: &Assets, facts: &BlockFacts, floor: bool) -> Result<World> {
|
||||
let mut world = World::new(facts.air, 0);
|
||||
let stone = assets
|
||||
.registry
|
||||
.default_state("minecraft:stone")
|
||||
.ok_or("missing stone")?;
|
||||
for cz in -3..=2 {
|
||||
for cx in -3..=2 {
|
||||
world
|
||||
.chunks
|
||||
.insert((cx, cz), Arc::new(Chunk::empty(cx, cz)));
|
||||
if floor {
|
||||
for z in 0..16 {
|
||||
for x in 0..16 {
|
||||
world.set(cx * 16 + x, 63, cz * 16 + z, stone)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(world)
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let root = Path::new(".local/horizon-client/26.3");
|
||||
let assets = Assets::load(root)?;
|
||||
let facts = BlockFacts::load(root, assets.registry)?;
|
||||
let collision = collisions::load(root, assets.registry)?;
|
||||
let worlds = [
|
||||
world(&assets, &facts, false)?,
|
||||
world(&assets, &facts, true)?,
|
||||
];
|
||||
let mut engine = ParticleEngine::new(root, &HashMap::new());
|
||||
for (ty, item) in [
|
||||
(ParticleType::ItemSlime, "item_slime_ball"),
|
||||
(ParticleType::ItemSnowball, "item_snowball"),
|
||||
(ParticleType::ItemCobweb, "item_cobweb"),
|
||||
] {
|
||||
engine.item_sprites.insert(ty, vec![item.to_owned()]);
|
||||
}
|
||||
// The oracle's output: the seed line of each case.
|
||||
let mut seeds = Vec::new();
|
||||
for line in BufReader::new(File::open(&args[2])?).lines() {
|
||||
let line = line?;
|
||||
if line.starts_with("seed ") || line.starts_with("null ") || line == "no provider" {
|
||||
seeds.push(line);
|
||||
}
|
||||
}
|
||||
let mut out = BufWriter::new(File::create(&args[3])?);
|
||||
for (n, line) in BufReader::new(File::open(&args[1])?).lines().enumerate() {
|
||||
let case: Value = serde_json::from_str(&line?)?;
|
||||
writeln!(out, "== {n}")?;
|
||||
let Some(options) = options(&case) else {
|
||||
writeln!(out, "no provider")?;
|
||||
continue;
|
||||
};
|
||||
let seed = case["seed"].as_i64().ok_or("seed")?;
|
||||
engine.seed_randoms(seed, seed ^ 0x6C_6576_656C, seed ^ 0x73_7065_6C6C);
|
||||
engine.requested = Some(Vec::new());
|
||||
engine.sounds.clear();
|
||||
let level = ParticleLevel {
|
||||
world: &worlds[usize::from(case["floor"].as_bool() == Some(true))],
|
||||
facts: &facts,
|
||||
collision: &collision,
|
||||
player: None,
|
||||
};
|
||||
let v = |key: &str| -> [f64; 3] {
|
||||
std::array::from_fn(|i| case[key][i].as_f64().unwrap_or(0.0))
|
||||
};
|
||||
let seed_line = seeds.get(n).cloned().unwrap_or_default();
|
||||
let local = seed_line
|
||||
.split(' ')
|
||||
.nth(1)
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.unwrap_or(0);
|
||||
let Some(mut particle) = engine.probe_create(&options, v("pos"), v("vel"), local, &level)
|
||||
else {
|
||||
writeln!(out, "null {}", engine.next_randoms()[0])?;
|
||||
continue;
|
||||
};
|
||||
writeln!(out, "{seed_line}")?;
|
||||
let [a, b, c] = engine.next_randoms();
|
||||
writeln!(out, "next {a} {b} {c}")?;
|
||||
let events = |engine: &mut ParticleEngine, out: &mut BufWriter<File>| -> Result<()> {
|
||||
for (o, p, v) in engine.requested.replace(Vec::new()).unwrap_or_default() {
|
||||
write!(out, "+ {}", o.ty.name())?;
|
||||
for x in p.iter().chain(&v) {
|
||||
write!(out, " {}", d(*x))?;
|
||||
}
|
||||
writeln!(out)?;
|
||||
}
|
||||
for (name, p, volume, pitch) in std::mem::take(&mut engine.sounds) {
|
||||
writeln!(
|
||||
out,
|
||||
"~ {name} {} {} {} {} {}",
|
||||
d(p[0]),
|
||||
d(p[1]),
|
||||
d(p[2]),
|
||||
f(volume),
|
||||
f(pitch)
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
writeln!(out, "0 {}", state(&engine.snapshot(&particle)))?;
|
||||
events(&mut engine, &mut out)?;
|
||||
let ticks = case["ticks"].as_i64().unwrap_or(0);
|
||||
let mut t = 1;
|
||||
while t <= ticks && particle.is_alive() {
|
||||
engine.probe_tick(&mut particle, &level);
|
||||
writeln!(out, "{t} {}", state(&engine.snapshot(&particle)))?;
|
||||
events(&mut engine, &mut out)?;
|
||||
t += 1;
|
||||
}
|
||||
}
|
||||
out.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//! Block entity data on items for tools/test_pick_block_data_parity.py: reads
|
||||
//! one JSON case per line (see VanillaPickBlockDataOracle) and prints the
|
||||
//! picked item's component patch, a tab, then the placed block entity.
|
||||
//!
|
||||
//! usage: pick_block_data_fixture INPUT OUTPUT
|
||||
use horizon_client::block_entity_item::{pick, place};
|
||||
use horizon_client::block_entity_nbt::canonical;
|
||||
use horizon_client::java_text::{double_to_string, float_to_string};
|
||||
use horizon_world::nbt::{Tag, parse_snbt};
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
|
||||
/// ParityData.canon: sorted keys, typed numbers, Java's float text.
|
||||
fn canon(tag: &Tag, out: &mut String) {
|
||||
let join = |out: &mut String, prefix: &str, items: Vec<String>| {
|
||||
out.push_str(prefix);
|
||||
out.push_str(&items.join(","));
|
||||
out.push(']');
|
||||
};
|
||||
match tag {
|
||||
Tag::Float(v) => out.push_str(&format!("{}f", float_to_string(f32::from_bits(*v)))),
|
||||
Tag::Double(v) => out.push_str(&format!("{}d", double_to_string(f64::from_bits(*v)))),
|
||||
Tag::List(items) => join(
|
||||
out,
|
||||
"[",
|
||||
items
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let mut s = String::new();
|
||||
canon(t, &mut s);
|
||||
s
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
Tag::Compound(map) => {
|
||||
let mut keys: Vec<_> = map.iter().map(|(k, v)| (k.to_string_lossy(), v)).collect();
|
||||
keys.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
out.push('{');
|
||||
for (i, (k, v)) in keys.into_iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
out.push_str(&k);
|
||||
out.push(':');
|
||||
canon(v, out);
|
||||
}
|
||||
out.push('}');
|
||||
}
|
||||
other => out.push_str(&horizon_client::item_data::canon_tag(other)),
|
||||
}
|
||||
}
|
||||
|
||||
fn snbt(value: &Value) -> Option<Tag> {
|
||||
parse_snbt(value.as_str()?).ok().map(|(tag, _)| tag)
|
||||
}
|
||||
|
||||
fn case(line: &str) -> Option<String> {
|
||||
let case: Value = serde_json::from_str(line).ok()?;
|
||||
let saved = canonical(&snbt(&case["nbt"])?)?;
|
||||
let picked = pick(&saved);
|
||||
let patch = match case.get("patch") {
|
||||
Some(p) => match snbt(p)? {
|
||||
Tag::Compound(c) => c,
|
||||
_ => return None,
|
||||
},
|
||||
None => picked.clone(),
|
||||
};
|
||||
let target = canonical(&snbt(&case["target"])?)?;
|
||||
let Tag::Compound(mut placed) = place(&target, &patch, case["op"].as_bool()?) else {
|
||||
return None;
|
||||
};
|
||||
// keepPacked is the chunk's, not saveWithFullMetadata's.
|
||||
placed.remove(&"keepPacked".into());
|
||||
let mut out = String::new();
|
||||
canon(&Tag::Compound(picked), &mut out);
|
||||
out.push('\t');
|
||||
canon(&Tag::Compound(placed), &mut out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let input = fs::read_to_string(
|
||||
args.get(1)
|
||||
.ok_or("usage: pick_block_data_fixture INPUT OUTPUT")?,
|
||||
)?;
|
||||
let lines: Vec<String> = input
|
||||
.lines()
|
||||
.map(|line| case(line).unwrap_or_else(|| "error".to_owned()))
|
||||
.collect();
|
||||
fs::write(
|
||||
args.get(2).ok_or("missing OUTPUT")?,
|
||||
lines.join("\n") + "\n",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -43,6 +43,7 @@ fn main() -> horizon_client::Result<()> {
|
||||
recipes: &recipes,
|
||||
brewing: &brewing,
|
||||
banners: &banners,
|
||||
trade: None,
|
||||
};
|
||||
let mut out = String::new();
|
||||
for (line, order) in cases.lines().zip(orders.lines()) {
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
//! Selection cost only (not block simulation). Run with prepared client resources.
|
||||
//! cargo run --profile fast -p horizon-client --example random_tick_eligibility_bench -- RESOURCES
|
||||
use horizon_client::{
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, MIN_Y, Section},
|
||||
fast_hash::FxHashMap,
|
||||
random_tick_sections::RandomTickSections,
|
||||
};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::{hint::black_box, path::Path, sync::Arc, time::Instant};
|
||||
|
||||
fn select_baseline(chunks: &FxHashMap<(i32, i32), Arc<Chunk>>, facts: &BlockFacts) -> usize {
|
||||
let mut ticking = Vec::new();
|
||||
for (&(x, z), chunk) in chunks {
|
||||
if x.abs() > 8 || z.abs() > 8 || !chunk.lit {
|
||||
continue;
|
||||
}
|
||||
let sections: Vec<i32> = chunk
|
||||
.sections
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, s)| {
|
||||
s.blocks
|
||||
.maybe_has(|id| facts.get(BlockStateId(id)).random_ticks)
|
||||
})
|
||||
.map(|(i, _)| MIN_Y + i as i32 * 16)
|
||||
.collect();
|
||||
ticking.push((x, z, sections));
|
||||
}
|
||||
black_box(ticking).iter().map(|(_, _, s)| s.len()).sum()
|
||||
}
|
||||
|
||||
fn select_cached(
|
||||
chunks: &FxHashMap<(i32, i32), Arc<Chunk>>,
|
||||
facts: &BlockFacts,
|
||||
cache: &mut RandomTickSections,
|
||||
) -> usize {
|
||||
let mut ticking = Vec::new();
|
||||
for (&(x, z), chunk) in chunks {
|
||||
if x.abs() > 8 || z.abs() > 8 || !chunk.lit {
|
||||
continue;
|
||||
}
|
||||
ticking.push((x, z, cache.get((x, z), chunk, facts)));
|
||||
}
|
||||
black_box(ticking).iter().map(|(_, _, s)| s.len()).sum()
|
||||
}
|
||||
|
||||
fn main() -> horizon_client::Result<()> {
|
||||
let root = std::env::args()
|
||||
.nth(1)
|
||||
.ok_or("expected prepared client resource directory")?;
|
||||
let start = Instant::now();
|
||||
let registry = horizon_world::level::data::load_registry(Path::new(&root))?;
|
||||
let facts = BlockFacts::load(Path::new(&root), ®istry)?;
|
||||
println!(
|
||||
"resource_load_ms={:.3}",
|
||||
start.elapsed().as_secs_f64() * 1000.0
|
||||
);
|
||||
let inactive: Vec<u16> = facts
|
||||
.states
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, f)| !f.random_ticks)
|
||||
.take(128)
|
||||
.map(|(i, _)| i as u16)
|
||||
.collect();
|
||||
let active = registry.default_state("minecraft:grass_block").unwrap().0 as u16;
|
||||
assert!(facts.get(BlockStateId(u32::from(active))).random_ticks);
|
||||
for (name, palette) in [("sparse", 1), ("dense", 128)] {
|
||||
let start = Instant::now();
|
||||
let mut chunks = FxHashMap::default();
|
||||
for z in -8..=8 {
|
||||
for x in -8..=8 {
|
||||
let mut chunk = Chunk::empty(x, z);
|
||||
chunk.lit = true;
|
||||
for (i, section) in chunk.sections.iter_mut().enumerate() {
|
||||
let mut values = std::array::from_fn(|j| inactive[j % palette]);
|
||||
if i % 3 == 0 {
|
||||
values[4095] = active;
|
||||
}
|
||||
Arc::make_mut(section).blocks = Section::from_blocks(&values);
|
||||
}
|
||||
chunks.insert((x, z), Arc::new(chunk));
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"{name} fixture_ms={:.3}",
|
||||
start.elapsed().as_secs_f64() * 1000.0
|
||||
);
|
||||
for (workload, changed) in [
|
||||
("steady", 0),
|
||||
("cold", 0),
|
||||
("reload", 0),
|
||||
("edit_1pct", 3),
|
||||
("edit_all", 289),
|
||||
] {
|
||||
let keys: Vec<_> = chunks.keys().copied().collect();
|
||||
let mut baseline = Vec::new();
|
||||
let mut candidate = Vec::new();
|
||||
let mut cache = RandomTickSections::default();
|
||||
for sample in 0..8 {
|
||||
// Alternate order; first pair warms both paths and is discarded.
|
||||
for cached in if sample % 2 == 0 {
|
||||
[false, true]
|
||||
} else {
|
||||
[true, false]
|
||||
} {
|
||||
let start = Instant::now();
|
||||
for tick in 0..300 {
|
||||
if cached && workload == "cold" {
|
||||
cache = RandomTickSections::default();
|
||||
}
|
||||
if cached && workload == "reload" {
|
||||
cache.clear();
|
||||
}
|
||||
for key in keys.iter().take(changed) {
|
||||
Arc::make_mut(chunks.get_mut(key).unwrap()).set(
|
||||
0,
|
||||
MIN_Y,
|
||||
0,
|
||||
inactive[tick % 2],
|
||||
);
|
||||
}
|
||||
black_box(if cached {
|
||||
select_cached(&chunks, &facts, &mut cache)
|
||||
} else {
|
||||
select_baseline(&chunks, &facts)
|
||||
});
|
||||
}
|
||||
// Include teardown, and remove weak handles before baseline edits.
|
||||
if cached {
|
||||
cache = RandomTickSections::default();
|
||||
}
|
||||
if sample > 0 {
|
||||
let us = start.elapsed().as_secs_f64() * 1e6 / 300.0;
|
||||
if cached {
|
||||
candidate.push(us);
|
||||
} else {
|
||||
baseline.push(us);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("{name} {workload} baseline_us={baseline:.3?} candidate_us={candidate:.3?}");
|
||||
}
|
||||
}
|
||||
println!("chunk_wrapper_bytes={}", std::mem::size_of::<Chunk>());
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
//! Synthetic village/storage save batches, using the production snapshot and
|
||||
//! full chunk NBT encoder. Run with `cargo run --profile fast -p horizon-client
|
||||
//! --example save_batch_bench`. Add `-- --nbt` to also time serialization.
|
||||
//! Exact NBT equality is always validated. No GPU, compression or disk I/O timed.
|
||||
use horizon_client::{
|
||||
biome::Biomes,
|
||||
chunk::{BLOCK_STRATEGY, Chunk},
|
||||
containers::BlockEntity,
|
||||
items::ItemStack,
|
||||
save::{self, ChunkExtras},
|
||||
};
|
||||
use horizon_core::paletted::PalettedContainer;
|
||||
use horizon_world::{
|
||||
DATA_VERSION,
|
||||
nbt::{self, Limits, Tag},
|
||||
registry::BlockRegistry,
|
||||
};
|
||||
use std::{collections::HashMap, hint::black_box, sync::Arc, time::Instant};
|
||||
|
||||
type Entities = HashMap<(i32, i32, i32), BlockEntity>;
|
||||
type Names = HashMap<(i32, i32, i32), String>;
|
||||
const BLOCKS: [&str; 5] = [
|
||||
"minecraft:air",
|
||||
"minecraft:stone",
|
||||
"minecraft:chest",
|
||||
"minecraft:furnace",
|
||||
"minecraft:oak_sign",
|
||||
];
|
||||
|
||||
// The pre-change with_entities path, with the same Chunk clone and outputs.
|
||||
fn baseline(chunks: &[Arc<Chunk>], entities: &Entities, names: &Names) -> Vec<Chunk> {
|
||||
chunks
|
||||
.iter()
|
||||
.map(|chunk| {
|
||||
let mut c = (**chunk).clone();
|
||||
c.block_entities = entities
|
||||
.iter()
|
||||
.filter(|(p, _)| p.0 >> 4 == c.x && p.2 >> 4 == c.z)
|
||||
.map(|(p, e)| (*p, e.clone()))
|
||||
.collect();
|
||||
c.block_names = names
|
||||
.iter()
|
||||
.filter(|(p, _)| p.0 >> 4 == c.x && p.2 >> 4 == c.z)
|
||||
.map(|(p, n)| (*p, n.clone()))
|
||||
.collect();
|
||||
c
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn fixture(per_chunk: usize) -> (Vec<Arc<Chunk>>, Entities, Names) {
|
||||
let mut chunks = Vec::new();
|
||||
let mut entities = Entities::new();
|
||||
let mut names = Names::new();
|
||||
let stack = ItemStack {
|
||||
item: "minecraft:stone".into(),
|
||||
count: 64,
|
||||
damage: 0,
|
||||
extra: horizon_client::item_data::decode_components(Some(&Tag::compound([(
|
||||
"minecraft:custom_data",
|
||||
Tag::compound([("fixture", Tag::String("save batch".into()))]),
|
||||
)])))
|
||||
.1,
|
||||
};
|
||||
let mut template = Chunk::empty(0, 0);
|
||||
for s in &mut template.sections[..8] {
|
||||
Arc::make_mut(s).blocks = PalettedContainer::new(BLOCK_STRATEGY, 1);
|
||||
}
|
||||
template.heights = Arc::new([64; 256]);
|
||||
for i in 0..256 {
|
||||
let x = i % 16 - 8;
|
||||
let z = i / 16 - 8;
|
||||
let mut c = template.clone();
|
||||
c.x = x;
|
||||
c.z = z;
|
||||
let mut extras = ChunkExtras::default();
|
||||
extras.root.insert("InhabitedTime".into(), Tag::Long(12000));
|
||||
for j in 0..per_chunk {
|
||||
let p = (
|
||||
x * 16 + (j % 16) as i32,
|
||||
64 + (j / 256) as i32,
|
||||
z * 16 + ((j / 16) % 16) as i32,
|
||||
);
|
||||
let (block, mut e) = match j % 4 {
|
||||
0 => (4, BlockEntity::for_block(BLOCKS[4]).unwrap()),
|
||||
1 => (3, BlockEntity::for_block(BLOCKS[3]).unwrap()),
|
||||
_ => (2, BlockEntity::Chest(vec![Some(stack.clone()); 27])),
|
||||
};
|
||||
if let BlockEntity::Furnace(f) = &mut e {
|
||||
f.items[0] = Some(stack.clone());
|
||||
f.lit_remaining = 120;
|
||||
f.cooking_timer = 45;
|
||||
}
|
||||
if let BlockEntity::Sign(s) = &mut e {
|
||||
s.front.lines[0] = "Village storage".into();
|
||||
}
|
||||
c.set(p.0 & 15, p.1, p.2 & 15, block);
|
||||
if j % 4 == 2 {
|
||||
names.insert(p, format!("Storage {x},{z}/{j}"));
|
||||
}
|
||||
// Exercise the existing NBT overlay and saved list order as well.
|
||||
if j % 8 == 2 {
|
||||
let mut raw = save::encode_block_entity(p, &e, BLOCKS[block as usize]);
|
||||
if let Tag::Compound(map) = &mut raw {
|
||||
map.insert(
|
||||
"LootTable".into(),
|
||||
Tag::String("minecraft:chests/simple_dungeon".into()),
|
||||
);
|
||||
}
|
||||
extras.block_entity_sources.push((p, raw));
|
||||
}
|
||||
extras.block_entity_order.push(p);
|
||||
entities.insert(p, e);
|
||||
}
|
||||
// An unmodelled block entity, retained from the original chunk.
|
||||
extras.block_entities.push(Tag::compound([
|
||||
("id", Tag::String("minecraft:end_gateway".into())),
|
||||
("x", Tag::Int(x * 16)),
|
||||
("y", Tag::Int(80)),
|
||||
("z", Tag::Int(z * 16)),
|
||||
("Age", Tag::Long(200)),
|
||||
]));
|
||||
c.saved = Some(Arc::new(extras));
|
||||
chunks.push(Arc::new(c));
|
||||
}
|
||||
(chunks, entities, names)
|
||||
}
|
||||
|
||||
fn encoding_fixture() -> horizon_client::Result<(BlockRegistry, Biomes)> {
|
||||
let registry = BlockRegistry::from_nbt(&Tag::compound([
|
||||
("DataVersion", Tag::Int(DATA_VERSION)),
|
||||
(
|
||||
"states",
|
||||
Tag::List(BLOCKS.iter().map(|n| Tag::String((*n).into())).collect()),
|
||||
),
|
||||
(
|
||||
"defaults",
|
||||
Tag::compound(
|
||||
BLOCKS
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, n)| (*n, Tag::Int(i as i32))),
|
||||
),
|
||||
),
|
||||
]))?;
|
||||
let temp = std::env::temp_dir().join(format!("horizon-save-batch-{}", std::process::id()));
|
||||
let result = (|| {
|
||||
let textures = temp.join("assets/minecraft/textures/colormap");
|
||||
let biomes = temp.join("data/minecraft/worldgen/biome");
|
||||
std::fs::create_dir_all(&textures)?;
|
||||
std::fs::create_dir_all(&biomes)?;
|
||||
for name in ["grass", "foliage", "dry_foliage"] {
|
||||
let mut encoder = png::Encoder::new(
|
||||
std::fs::File::create(textures.join(format!("{name}.png")))?,
|
||||
1,
|
||||
1,
|
||||
);
|
||||
encoder.set_color(png::ColorType::Rgb);
|
||||
encoder.write_header()?.write_image_data(&[0, 128, 0])?;
|
||||
}
|
||||
std::fs::write(biomes.join("plains.json"), b"{}")?;
|
||||
Biomes::load(&temp)
|
||||
})();
|
||||
std::fs::remove_dir_all(&temp)?;
|
||||
Ok((registry, result?))
|
||||
}
|
||||
|
||||
fn serialize(chunks: &[Chunk], registry: &BlockRegistry, biomes: &Biomes) -> Vec<Vec<u8>> {
|
||||
chunks
|
||||
.iter()
|
||||
.map(|c| nbt::write_named(&save::encode(c, registry, biomes), Limits::default()).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn measure(mut f: impl FnMut(), repetitions: usize) -> f64 {
|
||||
let start = Instant::now();
|
||||
for _ in 0..repetitions {
|
||||
f();
|
||||
}
|
||||
start.elapsed().as_secs_f64() * 1e6 / repetitions as f64
|
||||
}
|
||||
|
||||
fn compare(mut before: impl FnMut(), mut after: impl FnMut()) -> (f64, f64, usize) {
|
||||
for _ in 0..5 {
|
||||
before();
|
||||
after();
|
||||
}
|
||||
let estimate = measure(&mut before, 1).max(measure(&mut after, 1));
|
||||
let repetitions = (10_000.0 / estimate.max(0.01)).ceil().clamp(1.0, 10_000.0) as usize;
|
||||
let mut old = Vec::new();
|
||||
let mut new = Vec::new();
|
||||
for i in 0..21 {
|
||||
if i % 2 == 0 {
|
||||
old.push(measure(&mut before, repetitions));
|
||||
new.push(measure(&mut after, repetitions));
|
||||
} else {
|
||||
new.push(measure(&mut after, repetitions));
|
||||
old.push(measure(&mut before, repetitions));
|
||||
}
|
||||
}
|
||||
old.sort_by(f64::total_cmp);
|
||||
new.sort_by(f64::total_cmp);
|
||||
(old[10], new[10], repetitions)
|
||||
}
|
||||
|
||||
fn main() -> horizon_client::Result<()> {
|
||||
let include_nbt = std::env::args().any(|arg| arg == "--nbt");
|
||||
let (registry, biomes) = encoding_fixture()?;
|
||||
println!(
|
||||
"stage,fixture,chunks,world_entities,world_names,saved_entities,bytes,before_us,after_us,speedup,samples,reps"
|
||||
);
|
||||
for (name, density) in [("village", 12), ("storage", 48)] {
|
||||
let (chunks, entities, names) = fixture(density);
|
||||
for count in [0, 1, 16, 64, 256] {
|
||||
let batch = &chunks[..count];
|
||||
let before = baseline(batch, &entities, &names);
|
||||
let after = save::chunks_with_block_entities(batch, &entities, &names);
|
||||
for (a, b) in before.iter().zip(&after) {
|
||||
assert_eq!(a.block_names, b.block_names);
|
||||
}
|
||||
let encoded = serialize(&before, ®istry, &biomes);
|
||||
assert_eq!(
|
||||
encoded,
|
||||
serialize(&after, ®istry, &biomes),
|
||||
"exact NBT bytes: {name}/{count}"
|
||||
);
|
||||
let bytes: usize = encoded.iter().map(Vec::len).sum();
|
||||
for encode in [false, true] {
|
||||
if encode && !include_nbt {
|
||||
continue;
|
||||
}
|
||||
let run = |optimized| {
|
||||
let snapshots = if optimized {
|
||||
save::chunks_with_block_entities(
|
||||
black_box(batch),
|
||||
black_box(&entities),
|
||||
black_box(&names),
|
||||
)
|
||||
} else {
|
||||
baseline(black_box(batch), black_box(&entities), black_box(&names))
|
||||
};
|
||||
if encode {
|
||||
black_box(serialize(black_box(&snapshots), ®istry, &biomes));
|
||||
}
|
||||
black_box(snapshots);
|
||||
};
|
||||
let (old, new, reps) = compare(|| run(false), || run(true));
|
||||
println!(
|
||||
"{},{name},{count},{},{},{},{bytes},{old:.3},{new:.3},{:.3},21,{reps}",
|
||||
if encode { "snapshot+nbt" } else { "snapshot" },
|
||||
entities.len(),
|
||||
names.len(),
|
||||
count * density,
|
||||
old / new
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -10,7 +10,7 @@ use horizon_client::{
|
||||
biome::Biomes,
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, MIN_Y, SECTIONS},
|
||||
light::{LightTables, light_chunk},
|
||||
light::{LightTables, apply_lighting, light_chunk},
|
||||
save::{self, SaveTables},
|
||||
};
|
||||
use horizon_core::position::ChunkPos;
|
||||
@@ -157,7 +157,7 @@ fn main() -> horizon_client::Result<()> {
|
||||
std::array::from_fn(|dx| chunks.get(&(x + dx as i32 - 1, z + dz as i32 - 1)))
|
||||
});
|
||||
let mut chunk = chunks[&(x, z)].clone();
|
||||
chunk.sections = light_chunk(®ion, &lights, sky_light);
|
||||
apply_lighting(&mut chunk, light_chunk(®ion, &lights, sky_light));
|
||||
chunk.lit = true;
|
||||
let ours = save::encode_with(&chunk, assets.registry, &biomes, Some(&tables), sky_light);
|
||||
let theirs = &originals[&(x, z)];
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
#![forbid(unsafe_code)]
|
||||
use horizon_client::{assets, collisions, world};
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
use horizon_world::{
|
||||
collision_context::CollisionContext,
|
||||
entity_obstruction::EntityIdentity,
|
||||
world_movement::{self, CollisionCheckRequest, MovementRequest},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs,
|
||||
@@ -40,6 +45,7 @@ fn main() -> Result<()> {
|
||||
let mut scene = collisions::SceneCollisions {
|
||||
world: &world,
|
||||
binding: &binding,
|
||||
subject: None,
|
||||
};
|
||||
fs::write(
|
||||
dir.join("scene.json"),
|
||||
@@ -56,21 +62,55 @@ fn main() -> Result<()> {
|
||||
f["step"].as_str().ok_or("expected step bits")?,
|
||||
16,
|
||||
)?);
|
||||
let movement = scene.resolve(
|
||||
bounds,
|
||||
delta,
|
||||
step,
|
||||
f["grounded"].as_bool().ok_or("expected grounded flag")?,
|
||||
let entities = f["entities"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|v| {
|
||||
let a = v.as_array().ok_or("expected entity box")?;
|
||||
Ok(Aabb::new(
|
||||
vector(&Value::Array(a[..3].to_vec()))?,
|
||||
vector(&Value::Array(a[3..].to_vec()))?,
|
||||
))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let mut selected = collisions::EntityCollisions {
|
||||
blocks: scene,
|
||||
entities: &entities,
|
||||
};
|
||||
let movement = world_movement::resolve_world_movement(
|
||||
&mut selected,
|
||||
MovementRequest {
|
||||
entity: EntityIdentity(0),
|
||||
context: CollisionContext::position(bounds.min_position().y),
|
||||
bounds,
|
||||
movement: delta,
|
||||
max_step: step,
|
||||
on_ground: f["grounded"].as_bool().ok_or("expected grounded flag")?,
|
||||
candidate_budget: 1_000_000,
|
||||
},
|
||||
)?;
|
||||
let mut unobstructed = |bounds: Aabb| {
|
||||
world_movement::no_world_collision(
|
||||
&mut selected,
|
||||
CollisionCheckRequest {
|
||||
entity: EntityIdentity(0),
|
||||
context: CollisionContext::position(bounds.min_position().y),
|
||||
bounds,
|
||||
candidate_budget: 1_000_000,
|
||||
},
|
||||
)
|
||||
};
|
||||
writeln!(
|
||||
output,
|
||||
"move {:016x} {:016x} {:016x} clear {} {}",
|
||||
movement.x.to_bits(),
|
||||
movement.y.to_bits(),
|
||||
movement.z.to_bits(),
|
||||
scene.unobstructed(bounds)?,
|
||||
scene.unobstructed(bounds.move_by(movement))?
|
||||
unobstructed(bounds)?,
|
||||
unobstructed(bounds.move_by(movement))?
|
||||
)?;
|
||||
scene = selected.blocks;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -4,7 +4,7 @@ use horizon_client::{
|
||||
Result,
|
||||
assets::Assets,
|
||||
collisions,
|
||||
motion::{MotionEvent, MotionResources, MotionWorld, PlayerMotion},
|
||||
motion::{LAVA, MotionEvent, MotionResources, MotionWorld, PlayerMotion, WATER},
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
@@ -165,6 +165,64 @@ fn snapshot(out: &mut impl Write, player: &PlayerMotion) -> Result<()> {
|
||||
.filter_map(|e| match e {
|
||||
MotionEvent::Fall(e) => Some(e.trace()),
|
||||
MotionEvent::Health(e) => Some(e.trace()),
|
||||
MotionEvent::Snowflake { position, velocity } => Some(format!(
|
||||
"snowflake {:016x},{:016x},{:016x} {:016x},{:016x},{:016x}",
|
||||
position.x.to_bits(),
|
||||
position.y.to_bits(),
|
||||
position.z.to_bits(),
|
||||
velocity.x.to_bits(),
|
||||
velocity.y.to_bits(),
|
||||
velocity.z.to_bits()
|
||||
)),
|
||||
MotionEvent::Splash {
|
||||
high_speed,
|
||||
volume,
|
||||
pitch,
|
||||
} => Some(format!(
|
||||
"sound {} entity {:08x} {:08x}",
|
||||
if *high_speed { "splash-high" } else { "splash" },
|
||||
volume.to_bits(),
|
||||
pitch.to_bits()
|
||||
)),
|
||||
MotionEvent::SplashParticle {
|
||||
bubble,
|
||||
position,
|
||||
velocity,
|
||||
} => Some(format!(
|
||||
"splash-particle {} {:016x},{:016x},{:016x} {:016x},{:016x},{:016x}",
|
||||
if *bubble { "bubble" } else { "splash" },
|
||||
position.x.to_bits(),
|
||||
position.y.to_bits(),
|
||||
position.z.to_bits(),
|
||||
velocity.x.to_bits(),
|
||||
velocity.y.to_bits(),
|
||||
velocity.z.to_bits()
|
||||
)),
|
||||
MotionEvent::SplashEvent => Some("splash-event".into()),
|
||||
MotionEvent::Swim { volume, pitch } => Some(format!(
|
||||
"sound swim entity {:08x} {:08x}",
|
||||
volume.to_bits(),
|
||||
pitch.to_bits()
|
||||
)),
|
||||
MotionEvent::Emission {
|
||||
state,
|
||||
in_water: true,
|
||||
..
|
||||
} => Some(format!("muffled-step {}", state.0)),
|
||||
MotionEvent::BubbleColumnParticle { bubble, position } => Some(format!(
|
||||
"column-particle {} {:016x},{:016x},{:016x} 1 {:016x},{:016x},{:016x} {:016x}",
|
||||
if *bubble { "bubble" } else { "splash" },
|
||||
position.x.to_bits(),
|
||||
position.y.to_bits(),
|
||||
position.z.to_bits(),
|
||||
0f64.to_bits(),
|
||||
if *bubble { 0.01f64 } else { 0.0 }.to_bits(),
|
||||
0f64.to_bits(),
|
||||
if *bubble { 0.2f64 } else { 1.0 }.to_bits()
|
||||
)),
|
||||
MotionEvent::Drown => Some("broadcast 67".into()),
|
||||
// The oracle marks every getAdvancements call (ENTER_BLOCK.trigger) so.
|
||||
MotionEvent::EnterBlock { .. } => Some("hurt-criterion".into()),
|
||||
MotionEvent::Waypoint { receiver, enabled } => Some(format!(
|
||||
"waypoint {} {enabled}",
|
||||
if *receiver { "receive" } else { "transmit" }
|
||||
@@ -232,8 +290,15 @@ fn main() -> Result<()> {
|
||||
instances.next().expect("unexpected instance allocation")
|
||||
}),
|
||||
)?;
|
||||
// The oracle runs block-inside effects only in "snow_tick".
|
||||
player.block_effects = false;
|
||||
// Entity.portalCooldown and portalProcess, and the entry of the last
|
||||
// portal_tick that went through.
|
||||
let mut portal = horizon_client::nether_portal::PortalState::default();
|
||||
let mut travel: Option<horizon_core::position::BlockPos> = None;
|
||||
for op in f["ops"].as_array().unwrap() {
|
||||
let mut world = MotionWorld {
|
||||
entities: &[],
|
||||
player: &mut player,
|
||||
scene: &mut scene,
|
||||
assets: &assets,
|
||||
@@ -261,6 +326,7 @@ fn main() -> Result<()> {
|
||||
jump: mask & 16 != 0,
|
||||
shift: mask & 32 != 0,
|
||||
sprint: mask & 64 != 0,
|
||||
analog: None,
|
||||
})?;
|
||||
}
|
||||
"movement_statistics" => world.check_movement_statistics(vector(&op["value"]))?,
|
||||
@@ -356,7 +422,8 @@ fn main() -> Result<()> {
|
||||
Some(vector(&op["value"]))
|
||||
},
|
||||
),
|
||||
"block" => {
|
||||
// A block, or every block of the box from position to "to".
|
||||
"block" | "fill" => {
|
||||
let p = &op["position"];
|
||||
let mut state = assets
|
||||
.registry
|
||||
@@ -374,12 +441,15 @@ fn main() -> Result<()> {
|
||||
.id(&definition)
|
||||
.ok_or("missing fixture state")?;
|
||||
}
|
||||
world.scene.set(
|
||||
p[0].as_i64().unwrap() as i32,
|
||||
p[1].as_i64().unwrap() as i32,
|
||||
p[2].as_i64().unwrap() as i32,
|
||||
state,
|
||||
)?;
|
||||
let at = |v: &Value, i: usize| v[i].as_i64().unwrap() as i32;
|
||||
let to = if op["to"].is_array() { &op["to"] } else { p };
|
||||
for x in at(p, 0)..=at(to, 0) {
|
||||
for y in at(p, 1)..=at(to, 1) {
|
||||
for z in at(p, 2)..=at(to, 2) {
|
||||
world.scene.set(x, y, z, state)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"landing" => {
|
||||
let p = &op["position"];
|
||||
@@ -397,6 +467,26 @@ fn main() -> Result<()> {
|
||||
p,
|
||||
)?;
|
||||
}
|
||||
// Entity.baseTick's fluid phase alone (wasEyeInWater,
|
||||
// updateFluidInteraction, lava fall halving, firstTick).
|
||||
"fluid_tick" => world.fluid_base_tick()?,
|
||||
// LivingEntity.baseTick's air supply alone.
|
||||
"air_tick" => world.tick_environment_damage()?,
|
||||
"air" => world.player.air = op["value"].as_i64().unwrap() as i32,
|
||||
"pitch" => world.player.pitch = single(&op["value"]),
|
||||
"swimming" => world.player.swimming = op["value"].as_bool().unwrap(),
|
||||
"effect" => {
|
||||
let on = op["value"].as_bool().unwrap();
|
||||
let status = &mut world.player.status;
|
||||
match op["name"].as_str().unwrap() {
|
||||
"minecraft:dolphins_grace" => status.dolphins_grace = on,
|
||||
"minecraft:water_breathing" | "minecraft:conduit_power" => {
|
||||
status.water_breathing = on
|
||||
}
|
||||
"minecraft:breath_of_the_nautilus" => status.breath_of_the_nautilus = on,
|
||||
other => return Err(format!("unknown fixture effect {other}").into()),
|
||||
}
|
||||
}
|
||||
"travel" => world.travel_in_air(vector(&op["value"]))?,
|
||||
"tick" => {
|
||||
let v = vector(&op["value"]);
|
||||
@@ -409,6 +499,40 @@ fn main() -> Result<()> {
|
||||
op["jumping"].as_bool().unwrap(),
|
||||
)?;
|
||||
}
|
||||
"snow_tick" => {
|
||||
// The clock, then the player tick: baseTick's powder snow
|
||||
// flags lead PlayerMotion::player_movement_tick.
|
||||
world.player.health.advance_clock();
|
||||
let v = vector(&op["value"]);
|
||||
world.player.block_effects = true;
|
||||
let ticked = world.player_movement_tick(
|
||||
[v.x as f32, v.z as f32],
|
||||
op["jumping"].as_bool().unwrap(),
|
||||
);
|
||||
world.player.block_effects = false;
|
||||
ticked?;
|
||||
}
|
||||
"boots" => {
|
||||
// Leather boots are also freeze-immune wearables.
|
||||
let boots = op["value"].as_bool().unwrap();
|
||||
world.player.leather_boots = boots;
|
||||
world.player.freeze_immune = boots;
|
||||
}
|
||||
"frozen" => world.player.ticks_frozen = op["value"].as_i64().unwrap() as i32,
|
||||
"portal_tick" => {
|
||||
// Entity.handlePortal without the destination: players wait
|
||||
// the creative delay (0) when invulnerable, else 80 ticks.
|
||||
let creative = op["creative"].as_bool().unwrap();
|
||||
let transition = if creative { 0 } else { 80 };
|
||||
let alive = !world.player.health.dead_or_dying();
|
||||
travel = portal.handle(alive, transition, 10);
|
||||
}
|
||||
"weaving" => world.player.status.weaving = op["value"].as_bool().unwrap(),
|
||||
"flying" => {
|
||||
let flying = op["value"].as_bool().unwrap();
|
||||
world.player.may_fly = op["may_fly"].as_bool().unwrap_or(flying);
|
||||
world.player.flying = flying;
|
||||
}
|
||||
"trigger" => world.player.jump_trigger_time = op["value"].as_i64().unwrap() as i32,
|
||||
"delay" => world.player.no_jump_delay = op["value"].as_i64().unwrap() as i32,
|
||||
"move" => world.move_self(vector(&op["value"]))?,
|
||||
@@ -476,9 +600,49 @@ fn main() -> Result<()> {
|
||||
}
|
||||
_ => return Err("unknown motion operation".into()),
|
||||
}
|
||||
// NetherPortalBlock.entityInside: canUsePortal, setAsInsidePortal.
|
||||
for e in &player.events {
|
||||
if let MotionEvent::InsidePortal { pos } = e
|
||||
&& !player.health.dead_or_dying()
|
||||
{
|
||||
portal.set_inside(*pos, 10);
|
||||
}
|
||||
}
|
||||
let check_block = scene.block(25, 3, 25);
|
||||
snapshot(&mut out, &player)?;
|
||||
writeln!(out, "block {}", check_block.0)?;
|
||||
let stuck = player.stuck_multiplier;
|
||||
writeln!(
|
||||
out,
|
||||
"block {} frozen {} {} {} {:016x},{:016x},{:016x} fluid {} {} {} {} {:016x} {:016x} swim {} {} air {}",
|
||||
check_block.0,
|
||||
player.ticks_frozen,
|
||||
player.in_powder_snow,
|
||||
player.was_in_powder_snow,
|
||||
stuck.x.to_bits(),
|
||||
stuck.y.to_bits(),
|
||||
stuck.z.to_bits(),
|
||||
player.fluids.was_touching_water,
|
||||
player.fluids.was_eye_in_water,
|
||||
player.is_eye_in_fluid(WATER),
|
||||
player.fluids.first_tick,
|
||||
player.fluid_height(WATER).to_bits(),
|
||||
player.fluid_height(LAVA).to_bits(),
|
||||
player.swimming,
|
||||
player.fluids.was_eye_in_water,
|
||||
player.air
|
||||
)?;
|
||||
writeln!(
|
||||
out,
|
||||
"portal {} {} {}",
|
||||
portal.cooldown,
|
||||
portal.process.map_or("none".into(), |p| format!(
|
||||
"{},{},{} {} {}",
|
||||
p.entry.x, p.entry.y, p.entry.z, p.time, p.inside
|
||||
)),
|
||||
travel
|
||||
.take()
|
||||
.map_or("-".into(), |t| format!("{},{},{}", t.x, t.y, t.z))
|
||||
)?;
|
||||
let h = &player.health;
|
||||
let canonical = |v: f32| {
|
||||
if v.is_nan() {
|
||||
|
||||
@@ -7,7 +7,7 @@ use horizon_client::{
|
||||
biome::{BiomeInfo, Biomes, GrassModifier},
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, Light, MIN_Y, index},
|
||||
mesher::{MeshTables, Mesher, OPAQUE_LAYER, Vertex},
|
||||
mesher::{MeshTables, Mesher, OPAQUE_LAYER, TerrainVertex},
|
||||
models,
|
||||
};
|
||||
use horizon_core::biome::BiomeZoom;
|
||||
@@ -33,7 +33,8 @@ fn argb(color: u32) -> i32 {
|
||||
}
|
||||
/// Horizon keeps sky*16 in the low byte and block*16 above it; vanilla's light
|
||||
/// coords keep block in the low half-word and sky in the high one.
|
||||
fn light_coords(light: u32) -> i32 {
|
||||
fn light_coords(light: u16) -> i32 {
|
||||
let light = u32::from(light);
|
||||
((light & 0xFF) << 16 | (light >> 8 & 0xFF)) as i32
|
||||
}
|
||||
/// Scenes cover the 3x3 chunk region around chunk (0, 0).
|
||||
@@ -133,6 +134,7 @@ fn scene(
|
||||
blend_radius,
|
||||
smooth_lighting,
|
||||
cutout_leaves,
|
||||
cull_leaves: false,
|
||||
};
|
||||
let section_y = int(&row["section_y"])?;
|
||||
let started = Instant::now();
|
||||
@@ -146,9 +148,12 @@ fn scene(
|
||||
}
|
||||
for quad in vertices {
|
||||
let sprite = names
|
||||
.get(&(quad[0].layer & !OPAQUE_LAYER))
|
||||
.get(&u32::from(quad[0].layer & !OPAQUE_LAYER))
|
||||
.ok_or("unknown sprite layer")?;
|
||||
if quad.iter().any(|v: &Vertex| v.layer != quad[0].layer) {
|
||||
if quad
|
||||
.iter()
|
||||
.any(|v: &TerrainVertex| v.layer != quad[0].layer)
|
||||
{
|
||||
return Err("quad mixes sprite layers".into());
|
||||
}
|
||||
let vertices: Vec<Value> = quad
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
mod common;
|
||||
use common::{Fake, d, describe, f, flush, result_name};
|
||||
use horizon_client::sound_engine::{
|
||||
Attenuation, Backend, InstanceId, SoundEngine, SoundInstance, TickContext,
|
||||
Attenuation, Backend, CartSound, InstanceId, SoundEngine, SoundInstance, TickContext,
|
||||
};
|
||||
use horizon_client::sound_events::SoundRegistry;
|
||||
use horizon_core::random::LegacyRandom;
|
||||
@@ -19,6 +19,9 @@ fn main() {
|
||||
let mut backend = Fake::default();
|
||||
let mut ctx = TickContext::default();
|
||||
let mut instances: BTreeMap<String, InstanceId> = BTreeMap::new();
|
||||
// Script cart names to entity ids (a removed cart leaves the level).
|
||||
let mut cart_ids: BTreeMap<String, u32> = BTreeMap::new();
|
||||
let mut removed: BTreeMap<u32, CartSound> = BTreeMap::new();
|
||||
let input = std::fs::read_to_string(&args[2]).expect("input");
|
||||
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3]).expect("output"));
|
||||
for line in input.lines() {
|
||||
@@ -49,6 +52,21 @@ fn main() {
|
||||
"loop" => SoundInstance::biome_loop(&event, random),
|
||||
"uloop" => SoundInstance::under_liquid_loop(&event, random),
|
||||
"usub" => SoundInstance::under_liquid_sub(&event, random),
|
||||
"cart" | "rcart" | "rcartu" => {
|
||||
let id = cart_ids[a[3]];
|
||||
let cart = ctx
|
||||
.carts
|
||||
.iter()
|
||||
.find(|c| c.id == id)
|
||||
.or_else(|| removed.get(&id))
|
||||
.copied()
|
||||
.expect("cart");
|
||||
if a[2] == "cart" {
|
||||
SoundInstance::minecart(&cart, random)
|
||||
} else {
|
||||
SoundInstance::riding_minecart(&cart, a[2] == "rcartu", random)
|
||||
}
|
||||
}
|
||||
_ => panic!("{line}"),
|
||||
};
|
||||
if let Some(old) = instances.remove(a[1]) {
|
||||
@@ -92,6 +110,26 @@ fn main() {
|
||||
ctx.player_underwater = a[1] == "1";
|
||||
ctx.player_removed = a[2] == "1";
|
||||
}
|
||||
"cart" => {
|
||||
let next = cart_ids.len() as u32 + 1;
|
||||
let id = *cart_ids.entry(a[1].to_owned()).or_insert(next);
|
||||
ctx.carts.retain(|c| c.id != id);
|
||||
let cart = CartSound {
|
||||
id,
|
||||
position: [d(a[2]), d(a[3]), d(a[4])],
|
||||
// Vec3.horizontalDistance.
|
||||
speed: (d(a[5]) * d(a[5]) + d(a[7]) * d(a[7])).sqrt(),
|
||||
};
|
||||
if a[8] == "1" {
|
||||
// Out of the level, still there for a later play.
|
||||
removed.insert(id, cart);
|
||||
} else {
|
||||
removed.remove(&id);
|
||||
ctx.carts.push(cart);
|
||||
}
|
||||
}
|
||||
"ride" => ctx.vehicle = cart_ids.get(a[1]).copied(),
|
||||
"freeze" => ctx.frozen = a[1] == "1",
|
||||
"tick" => engine.tick(a[1] == "1", &ctx, &mut backend),
|
||||
"forget" => {
|
||||
if let Some(id) = instances.remove(a[1]) {
|
||||
|
||||
@@ -17,6 +17,7 @@ fn keys(mask: u64) -> Keys {
|
||||
jump: mask & 16 != 0,
|
||||
shift: mask & 32 != 0,
|
||||
sprint: mask & 64 != 0,
|
||||
analog: None,
|
||||
}
|
||||
}
|
||||
fn main() -> Result<()> {
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
//! The chunk pipeline (generation, decoration, lighting, meshing) on its worker
|
||||
//! pool, without a window: how fast a render distance fills with N workers, and
|
||||
//! what the render thread pays for it in `Streaming::update`.
|
||||
//!
|
||||
//! streaming_bench RESOURCES [--threads N] [--radius R] [--seed S] [--fly CHUNKS_PER_S] [--seconds T] [--hz F]
|
||||
//!
|
||||
//! First the area around (0, 0) fills; then, with `--fly`, the centre moves along +x
|
||||
//! at that speed for T seconds while frames run at F per second. Each phase reports
|
||||
//! its wall time, chunks generated and sections meshed, and the time `update`
|
||||
//! took on the calling thread (the render thread in the game): total, median,
|
||||
//! p99 and slowest. Compile with HORIZON_BENCH_COUNT_ALLOCATIONS=1 to count the
|
||||
//! allocations made on the calling thread during updates and by everything.
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
biome::Biomes,
|
||||
blocks::BlockFacts,
|
||||
light::LightTables,
|
||||
mesher::MeshTables,
|
||||
models,
|
||||
streaming::{Streaming, WorkerContext},
|
||||
vanillagen::VanillaTerrain,
|
||||
world::World,
|
||||
};
|
||||
use std::{
|
||||
alloc::{GlobalAlloc, Layout, System},
|
||||
cell::Cell,
|
||||
path::PathBuf,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU64, Ordering::Relaxed},
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
const TRACK: bool = option_env!("HORIZON_BENCH_COUNT_ALLOCATIONS").is_some();
|
||||
static ALLOCATIONS: AtomicU64 = AtomicU64::new(0);
|
||||
static BYTES: AtomicU64 = AtomicU64::new(0);
|
||||
thread_local! {
|
||||
static LOCAL: Cell<(u64, u64)> = const { Cell::new((0, 0)) };
|
||||
}
|
||||
fn count(size: usize) {
|
||||
if TRACK {
|
||||
ALLOCATIONS.fetch_add(1, Relaxed);
|
||||
BYTES.fetch_add(size as u64, Relaxed);
|
||||
let _ = LOCAL.try_with(|c| {
|
||||
let (n, b) = c.get();
|
||||
c.set((n + 1, b + size as u64));
|
||||
});
|
||||
}
|
||||
}
|
||||
struct CountingAllocator;
|
||||
// Delegates every operation and layout unchanged to System.
|
||||
unsafe impl GlobalAlloc for CountingAllocator {
|
||||
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
|
||||
count(layout.size());
|
||||
unsafe { System.alloc(layout) }
|
||||
}
|
||||
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
|
||||
count(layout.size());
|
||||
unsafe { System.alloc_zeroed(layout) }
|
||||
}
|
||||
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
|
||||
unsafe { System.dealloc(ptr, layout) };
|
||||
}
|
||||
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, size: usize) -> *mut u8 {
|
||||
count(size);
|
||||
unsafe { System.realloc(ptr, layout, size) }
|
||||
}
|
||||
}
|
||||
#[global_allocator]
|
||||
static ALLOCATOR: CountingAllocator = CountingAllocator;
|
||||
|
||||
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Phase {
|
||||
updates: Vec<f64>,
|
||||
local: (u64, u64),
|
||||
uploads: usize,
|
||||
}
|
||||
impl Phase {
|
||||
fn update(&mut self, streaming: &mut Streaming, world: &mut World) {
|
||||
let before = LOCAL.with(Cell::get);
|
||||
let started = Instant::now();
|
||||
let (uploads, _) = streaming.update(world);
|
||||
self.updates.push(started.elapsed().as_secs_f64() * 1e3);
|
||||
let after = LOCAL.with(Cell::get);
|
||||
self.local.0 += after.0 - before.0;
|
||||
self.local.1 += after.1 - before.1;
|
||||
self.uploads += uploads.len();
|
||||
drop(uploads);
|
||||
}
|
||||
fn report(&self, name: &str, wall: Duration, streaming: &Streaming, generated: u64) {
|
||||
let mut sorted = self.updates.clone();
|
||||
sorted.sort_by(f64::total_cmp);
|
||||
let at = |p: f64| sorted[((sorted.len() as f64 * p) as usize).min(sorted.len() - 1)];
|
||||
let total: f64 = sorted.iter().sum();
|
||||
println!(
|
||||
"{name}: {:.2} s, {} chunks generated, {} sections meshed; update {:.1} ms total over {} calls, p50 {:.3} p99 {:.3} max {:.2} ms",
|
||||
wall.as_secs_f64(),
|
||||
streaming.generated_total - generated,
|
||||
self.uploads,
|
||||
total,
|
||||
sorted.len(),
|
||||
at(0.5),
|
||||
at(0.99),
|
||||
sorted.last().copied().unwrap_or(0.0),
|
||||
);
|
||||
if TRACK {
|
||||
println!(
|
||||
" update allocations: {} ({:.1} MiB)",
|
||||
self.local.0,
|
||||
self.local.1 as f64 / 1048576.0
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Nothing queued or in flight. Sections at the edge of the area stay dirty
|
||||
/// (their outer neighbours are never lit), so they don't count.
|
||||
fn settled(streaming: &Streaming) -> bool {
|
||||
streaming
|
||||
.stages()
|
||||
.iter()
|
||||
.all(|&(name, n)| name == "protos" || n == 0)
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let root = PathBuf::from(args.first().ok_or("usage: streaming_bench RESOURCES")?);
|
||||
let flag = |name: &str, default: f64| -> f64 {
|
||||
args.iter()
|
||||
.position(|a| a == name)
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(default)
|
||||
};
|
||||
let auto = std::thread::available_parallelism().map_or(2, |n| n.get() - 1);
|
||||
let threads = flag("--threads", auto as f64) as usize;
|
||||
let radius = flag("--radius", 8.0) as i32;
|
||||
let seed = flag("--seed", 1234.0) as i64;
|
||||
let fly = flag("--fly", 0.0);
|
||||
let seconds = flag("--seconds", 10.0);
|
||||
let hz = flag("--hz", 240.0);
|
||||
|
||||
let assets = Arc::new(Assets::load(&root)?);
|
||||
let facts = BlockFacts::load(&root, assets.registry)?;
|
||||
let biomes = Arc::new(Biomes::load(&root)?);
|
||||
let tables = Arc::new(MeshTables::new(
|
||||
&assets,
|
||||
&facts,
|
||||
models::load_visibility(&root, assets.registry)?,
|
||||
)?);
|
||||
let light = Arc::new(LightTables::load(&root, assets.registry, &facts)?);
|
||||
let terrain = Arc::new(VanillaTerrain::new(
|
||||
&root.join("worldgen"),
|
||||
assets.registry,
|
||||
&biomes,
|
||||
seed,
|
||||
)?);
|
||||
let context = Arc::new(WorkerContext {
|
||||
assets,
|
||||
tables,
|
||||
biomes,
|
||||
light,
|
||||
});
|
||||
let mut world = World::new(facts.air, seed);
|
||||
let mut streaming = Streaming::new(terrain, context, threads);
|
||||
streaming.radius = radius;
|
||||
streaming.center = (0, 0);
|
||||
println!("{threads} workers, radius {radius}, seed {seed}");
|
||||
let (allocations, bytes) = (ALLOCATIONS.load(Relaxed), BYTES.load(Relaxed));
|
||||
|
||||
// Fill: update as often as the game would uncapped, until settled.
|
||||
let mut phase = Phase::default();
|
||||
let started = Instant::now();
|
||||
let mut quiet = 0;
|
||||
while quiet < 20 {
|
||||
let uploads = phase.uploads;
|
||||
phase.update(&mut streaming, &mut world);
|
||||
quiet = if settled(&streaming) && phase.uploads == uploads {
|
||||
quiet + 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
std::thread::sleep(Duration::from_micros(500));
|
||||
}
|
||||
phase.report("fill", started.elapsed(), &streaming, 0);
|
||||
|
||||
if fly > 0.0 {
|
||||
let mut phase = Phase::default();
|
||||
let generated = streaming.generated_total;
|
||||
let frame = Duration::from_secs_f64(1.0 / hz);
|
||||
let started = Instant::now();
|
||||
let mut behind = Vec::new();
|
||||
while started.elapsed().as_secs_f64() < seconds {
|
||||
let next = Instant::now() + frame;
|
||||
let x = (started.elapsed().as_secs_f64() * fly) as i32;
|
||||
streaming.center = (x, 0);
|
||||
phase.update(&mut streaming, &mut world);
|
||||
// Columns within 2 chunks of the player still missing their meshes.
|
||||
let near = (-2..=2)
|
||||
.flat_map(|dz| (-2..=2).map(move |dx| (x + dx, dz)))
|
||||
.filter(|&(cx, cz)| !streaming.is_lit(cx, cz))
|
||||
.count();
|
||||
behind.push(near);
|
||||
if let Some(wait) = next.checked_duration_since(Instant::now()) {
|
||||
std::thread::sleep(wait);
|
||||
}
|
||||
}
|
||||
phase.report("fly", started.elapsed(), &streaming, generated);
|
||||
let unlit = behind.iter().filter(|&&n| n > 0).count();
|
||||
println!(
|
||||
" frames with an unlit column within 2 chunks of the player: {unlit} of {} ({:.1}%)",
|
||||
behind.len(),
|
||||
unlit as f64 * 100.0 / behind.len().max(1) as f64
|
||||
);
|
||||
}
|
||||
if TRACK {
|
||||
println!(
|
||||
"all threads: {} allocations ({:.1} MiB)",
|
||||
ALLOCATIONS.load(Relaxed) - allocations,
|
||||
(BYTES.load(Relaxed) - bytes) as f64 / 1048576.0
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
//! Resident-key synchronization used by Game (registration callback work excluded).
|
||||
//! Run: CARGO_INCREMENTAL=0 cargo run -p horizon-client --profile fast --example tick_chunk_membership_bench
|
||||
use horizon_client::{
|
||||
chunk::Chunk, chunk_map::ChunkMap, tick_chunk_membership::TickChunkMembership,
|
||||
};
|
||||
use std::{collections::HashSet, hint::black_box, sync::Arc, time::Instant};
|
||||
|
||||
type Key = (i32, i32);
|
||||
#[derive(Default)]
|
||||
struct Old {
|
||||
keys: HashSet<Key>,
|
||||
}
|
||||
impl Old {
|
||||
fn sync(&mut self, map: &ChunkMap) -> (usize, usize) {
|
||||
let loaded: HashSet<_> = map.keys().copied().collect();
|
||||
let mut counts = (0, 0);
|
||||
for key in loaded.difference(&self.keys.clone()) {
|
||||
black_box(key);
|
||||
counts.0 += 1;
|
||||
}
|
||||
for key in self.keys.difference(&loaded) {
|
||||
black_box(key);
|
||||
counts.1 += 1;
|
||||
}
|
||||
self.keys = loaded;
|
||||
counts
|
||||
}
|
||||
}
|
||||
fn new_sync(cache: &mut TickChunkMembership, map: &ChunkMap) -> (usize, usize) {
|
||||
if !cache.synchronize(map) {
|
||||
return (0, 0);
|
||||
}
|
||||
let added = cache.take_added();
|
||||
for key in &added {
|
||||
black_box(key);
|
||||
}
|
||||
for key in cache.removed() {
|
||||
black_box(key);
|
||||
}
|
||||
let counts = (added.len(), cache.removed().len());
|
||||
cache.recycle_added(added);
|
||||
counts
|
||||
}
|
||||
fn map(offset: i32) -> ChunkMap {
|
||||
let mut map = ChunkMap::default();
|
||||
// Share a payload: the synchronization path reads only keys, not block data.
|
||||
let chunk = Arc::new(Chunk::empty(0, 0));
|
||||
for x in -16..=16 {
|
||||
for z in -16..=16 {
|
||||
map.insert((x + offset, z), chunk.clone());
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
fn run(new: bool, scenario: usize, iterations: usize) -> (f64, (usize, usize)) {
|
||||
let mut a = map(0);
|
||||
let b = map(33);
|
||||
let chunk = Arc::new(Chunk::empty(0, 0));
|
||||
let mut old = Old::default();
|
||||
let mut cache = TickChunkMembership::default();
|
||||
for _ in 0..64 {
|
||||
if new {
|
||||
black_box(new_sync(&mut cache, &a));
|
||||
} else {
|
||||
black_box(old.sync(&a));
|
||||
}
|
||||
}
|
||||
let mut total = (0, 0);
|
||||
let start = Instant::now();
|
||||
for i in 0..iterations {
|
||||
let resident = match scenario {
|
||||
0 => &a,
|
||||
1 => {
|
||||
let (previous, next) = if i % 2 == 0 {
|
||||
((-16, -16), (-17, -16))
|
||||
} else {
|
||||
((-17, -16), (-16, -16))
|
||||
};
|
||||
a.remove(&previous);
|
||||
a.insert(next, chunk.clone());
|
||||
&a
|
||||
}
|
||||
2 => {
|
||||
if i % 2 == 0 {
|
||||
&b
|
||||
} else {
|
||||
&a
|
||||
}
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let counts = if new {
|
||||
new_sync(black_box(&mut cache), black_box(resident))
|
||||
} else {
|
||||
old.sync(black_box(resident))
|
||||
};
|
||||
total.0 += counts.0;
|
||||
total.1 += counts.1;
|
||||
}
|
||||
(
|
||||
start.elapsed().as_secs_f64() * 1e9 / iterations as f64,
|
||||
black_box(total),
|
||||
)
|
||||
}
|
||||
fn main() {
|
||||
println!("1089 keys, 64 warmup calls, 9 samples; ns/sync, median");
|
||||
for (scenario, name, iterations) in [
|
||||
(0, "stable", 10_000),
|
||||
(1, "one-key same-count replacement", 2_000),
|
||||
(2, "whole-map replacement", 1_000),
|
||||
] {
|
||||
let mut before = Vec::new();
|
||||
let mut after = Vec::new();
|
||||
for sample in 0..9 {
|
||||
let (old, new) = if sample % 2 == 0 {
|
||||
(
|
||||
run(false, scenario, iterations),
|
||||
run(true, scenario, iterations),
|
||||
)
|
||||
} else {
|
||||
let new = run(true, scenario, iterations);
|
||||
(run(false, scenario, iterations), new)
|
||||
};
|
||||
assert_eq!(old.1, new.1);
|
||||
let expected = match scenario {
|
||||
0 => (0, 0),
|
||||
1 => (iterations, iterations),
|
||||
_ => (1089 * iterations, 1089 * iterations),
|
||||
};
|
||||
assert_eq!(new.1, expected);
|
||||
before.push(old.0);
|
||||
after.push(new.0);
|
||||
}
|
||||
before.sort_by(f64::total_cmp);
|
||||
after.sort_by(f64::total_cmp);
|
||||
println!(
|
||||
"{name}: {:.1} -> {:.1} ns, {:.2}x; {iterations} iterations/sample",
|
||||
before[4],
|
||||
after[4],
|
||||
before[4] / after[4]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
//! Trading for tools/test_trading_parity.py: runs each case of INPUT as
|
||||
//! VanillaTradingOracle does (offer generation, special prices, restocks,
|
||||
//! the merchant menu, NBT) and prints the same lines.
|
||||
//!
|
||||
//! usage: trading_fixture RESOURCES INPUT VANILLA_OUTPUT OUTPUT
|
||||
use horizon_client::{
|
||||
enchant::Enchantments,
|
||||
entity_nbt,
|
||||
gossip::{GossipType, Gossips},
|
||||
items::{ItemStack, Items},
|
||||
menu::{Env, Input, Kind, Menu},
|
||||
merchant::{self, Event, MerchantOffer, Session, TradeContext, TradeEnv, Trader, Trades},
|
||||
random_sequences::RandomSequences,
|
||||
};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::{collections::HashMap, fmt::Write as _, fs, path::Path};
|
||||
|
||||
const PLAYER: [i32; 4] = [0, 0x1234, 0, 0x5678];
|
||||
|
||||
/// Canonical NBT text, as VanillaTradingOracle prints it.
|
||||
fn canon(t: &Tag, out: &mut String) {
|
||||
match t {
|
||||
Tag::Compound(c) => {
|
||||
out.push('{');
|
||||
let mut keys: Vec<String> = c.keys().map(|k| k.to_string_lossy()).collect();
|
||||
keys.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16()));
|
||||
for (i, k) in keys.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
quote(k, out);
|
||||
out.push(':');
|
||||
canon(t.get(k).expect("key"), out);
|
||||
}
|
||||
out.push('}');
|
||||
}
|
||||
Tag::List(l) => {
|
||||
out.push('[');
|
||||
for (i, v) in l.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
canon(v, out);
|
||||
}
|
||||
out.push(']');
|
||||
}
|
||||
Tag::String(s) => quote(&s.to_string_lossy(), out),
|
||||
Tag::Byte(v) => write!(out, "{v}b").unwrap(),
|
||||
Tag::Short(v) => write!(out, "{v}s").unwrap(),
|
||||
Tag::Int(v) => write!(out, "{v}").unwrap(),
|
||||
Tag::Long(v) => write!(out, "{v}L").unwrap(),
|
||||
Tag::Float(v) => write!(out, "f:{v:x}").unwrap(),
|
||||
Tag::Double(v) => write!(out, "d:{v:x}").unwrap(),
|
||||
Tag::ByteArray(v) => {
|
||||
out.push_str("[B;");
|
||||
let s: Vec<String> = v.iter().map(|b| (*b as i8).to_string()).collect();
|
||||
out.push_str(&s.join(","));
|
||||
out.push(']');
|
||||
}
|
||||
Tag::IntArray(v) => {
|
||||
out.push_str("[I;");
|
||||
let s: Vec<String> = v.iter().map(i32::to_string).collect();
|
||||
out.push_str(&s.join(","));
|
||||
out.push(']');
|
||||
}
|
||||
Tag::LongArray(v) => {
|
||||
out.push_str("[L;");
|
||||
let s: Vec<String> = v.iter().map(i64::to_string).collect();
|
||||
out.push_str(&s.join(","));
|
||||
out.push(']');
|
||||
}
|
||||
Tag::End => out.push_str("?End"),
|
||||
}
|
||||
}
|
||||
fn quote(s: &str, out: &mut String) {
|
||||
out.push('"');
|
||||
for c in s.chars() {
|
||||
if c == '"' || c == '\\' {
|
||||
out.push('\\');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out.push('"');
|
||||
}
|
||||
fn canon_str(t: &Tag) -> String {
|
||||
let mut s = String::new();
|
||||
canon(t, &mut s);
|
||||
s
|
||||
}
|
||||
fn stack(s: Option<&ItemStack>) -> String {
|
||||
match s {
|
||||
None => "-".into(),
|
||||
Some(s) => canon_str(&horizon_client::save::item_to_nbt(s)),
|
||||
}
|
||||
}
|
||||
|
||||
struct World<'a> {
|
||||
items: &'a Items,
|
||||
enchantments: &'a Enchantments,
|
||||
trades: &'a Trades,
|
||||
sequences: RandomSequences,
|
||||
seed: i64,
|
||||
}
|
||||
/// A merchant from PROF:TYPE:LEVEL or "wt", generated level by level.
|
||||
struct Merchant {
|
||||
villager: bool,
|
||||
profession: String,
|
||||
variant: String,
|
||||
level: i32,
|
||||
trader: Trader,
|
||||
}
|
||||
impl World<'_> {
|
||||
fn reset(&mut self, seed: i64) {
|
||||
self.sequences = RandomSequences::default();
|
||||
self.seed = seed;
|
||||
}
|
||||
fn add_set(
|
||||
&mut self,
|
||||
offers: &mut Vec<MerchantOffer>,
|
||||
set: &str,
|
||||
variant: &str,
|
||||
origin: [f64; 3],
|
||||
) {
|
||||
let Some(sequence) = self.trades.sequence(set) else {
|
||||
return;
|
||||
};
|
||||
let ctx = TradeContext {
|
||||
items: self.items,
|
||||
enchantments: self.enchantments,
|
||||
variant,
|
||||
origin,
|
||||
maps: None,
|
||||
};
|
||||
let random = self.sequences.get(&sequence, self.seed);
|
||||
offers.extend(self.trades.offers_from_set(&ctx, set, random));
|
||||
}
|
||||
fn merchant(&mut self, spec: &str, origin: [f64; 3]) -> Merchant {
|
||||
let mut offers = Vec::new();
|
||||
if spec == "wt" {
|
||||
for set in ["buying", "uncommon", "common"] {
|
||||
self.add_set(
|
||||
&mut offers,
|
||||
&format!("minecraft:wandering_trader/{set}"),
|
||||
"minecraft:plains",
|
||||
origin,
|
||||
);
|
||||
}
|
||||
return Merchant {
|
||||
villager: false,
|
||||
profession: "minecraft:wandering_trader".into(),
|
||||
variant: "minecraft:plains".into(),
|
||||
level: 1,
|
||||
trader: Trader {
|
||||
offers,
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
}
|
||||
let p: Vec<&str> = spec.split(':').collect();
|
||||
let (profession, variant) = (format!("minecraft:{}", p[0]), format!("minecraft:{}", p[1]));
|
||||
let max: i32 = p[2].parse().unwrap_or(1);
|
||||
for level in 1..=max {
|
||||
let set = Trades::set_for(&profession, level);
|
||||
if self.trades.sets.contains_key(&set) {
|
||||
self.add_set(&mut offers, &set, &variant, origin);
|
||||
}
|
||||
}
|
||||
Merchant {
|
||||
villager: true,
|
||||
profession,
|
||||
variant,
|
||||
level: max,
|
||||
trader: Trader {
|
||||
offers,
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn kv(fields: &[&str]) -> HashMap<String, String> {
|
||||
let mut m: HashMap<String, String> = HashMap::new();
|
||||
for f in &fields[2..] {
|
||||
if let Some((k, v)) = f.split_once('=') {
|
||||
m.entry(k.to_owned())
|
||||
.and_modify(|e| {
|
||||
e.push(' ');
|
||||
e.push_str(v);
|
||||
})
|
||||
.or_insert_with(|| v.to_owned());
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// Uses, demand, gossip, xp and restock state, as the oracle's `prepare`.
|
||||
fn prepare(m: &mut Merchant, gossips: &mut Gossips, kv: &HashMap<String, String>) {
|
||||
for key in ["uses", "demand"] {
|
||||
let Some(list) = kv.get(key) else { continue };
|
||||
for s in list.split(' ') {
|
||||
let mut f = s.split(':');
|
||||
let i: usize = f.next().and_then(|v| v.parse().ok()).unwrap_or(0);
|
||||
let n: i32 = f.next().and_then(|v| v.parse().ok()).unwrap_or(0);
|
||||
let Some(o) = m.trader.offers.get_mut(i) else {
|
||||
continue;
|
||||
};
|
||||
if key == "uses" {
|
||||
o.uses += n;
|
||||
} else {
|
||||
o.demand = n;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(list) = kv.get("gossip") {
|
||||
for s in list.split(' ') {
|
||||
let (kind, n) = s.split_once(':').unwrap_or((s, "0"));
|
||||
if let Some(kind) = GossipType::from_id(kind) {
|
||||
gossips.add(PLAYER, kind, n.parse().unwrap_or(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(xp) = kv.get("xp") {
|
||||
m.trader.xp = xp.parse().unwrap_or(0);
|
||||
}
|
||||
if let Some(v) = kv.get("lastrestock") {
|
||||
m.trader.last_restock = v.parse().unwrap_or(0);
|
||||
}
|
||||
if let Some(v) = kv.get("restocks") {
|
||||
m.trader.restocks_today = v.parse().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let usage = "usage: trading_fixture RESOURCES INPUT VANILLA_OUTPUT OUTPUT";
|
||||
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 enchantments = Enchantments::load(root);
|
||||
let trades = Trades::load(root);
|
||||
let recipes = horizon_client::crafting::Recipes::load(root, &items)?;
|
||||
let brewing = horizon_client::brewing::Brewing::load(root, &items);
|
||||
let banners = horizon_client::banner::Banners::load(root);
|
||||
let types: HashMap<_, _> = horizon_client::mobs::MobTypes::load(root)?
|
||||
.types
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, std::sync::Arc::new(v)))
|
||||
.collect();
|
||||
// The vanilla villagers the nbt cases read.
|
||||
let snbt: HashMap<&str, &str> = vanilla
|
||||
.lines()
|
||||
.filter_map(|l| {
|
||||
let (name, rest) = l.split_once(' ')?;
|
||||
Some((name, rest.strip_prefix("snbt ")?))
|
||||
})
|
||||
.collect();
|
||||
let mut world = World {
|
||||
items: &items,
|
||||
enchantments: &enchantments,
|
||||
trades: &trades,
|
||||
sequences: RandomSequences::default(),
|
||||
seed: 0,
|
||||
};
|
||||
let mut out = String::new();
|
||||
for line in input.lines().filter(|l| !l.trim().is_empty()) {
|
||||
let fields: Vec<&str> = line.split(' ').collect();
|
||||
let (kind, name) = (fields[0], fields[1]);
|
||||
let kv = kv(&fields);
|
||||
let seed: i64 = kv.get("seed").and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
world.reset(seed);
|
||||
let origin = [0.5, -60.0, 0.5];
|
||||
match kind {
|
||||
"gen" => {
|
||||
let specs: Vec<String> = kv["v"].split(' ').map(str::to_owned).collect();
|
||||
for (k, spec) in specs.iter().enumerate() {
|
||||
let m = world.merchant(spec, origin);
|
||||
writeln!(
|
||||
out,
|
||||
"{name} v{k} {}",
|
||||
canon_str(&entity_nbt::offers_to_nbt(&m.trader.offers))
|
||||
)?;
|
||||
}
|
||||
let mut line = format!("{name} draws");
|
||||
for id in trades.sequences() {
|
||||
write!(line, " {}", world.sequences.get(&id, seed).next_int())?;
|
||||
}
|
||||
writeln!(out, "{line}")?;
|
||||
}
|
||||
"price" => {
|
||||
let mut m = world.merchant(&kv["v"], origin);
|
||||
let mut gossips = Gossips::default();
|
||||
prepare(&mut m, &mut gossips, &kv);
|
||||
let hero = kv.get("hero").and_then(|h| h.parse().ok());
|
||||
let rep = gossips.reputation(PLAYER);
|
||||
merchant::update_special_prices(&mut m.trader.offers, rep, hero);
|
||||
let mut line = format!("{name} rep {rep}");
|
||||
for o in &m.trader.offers {
|
||||
write!(line, " {}/{}", o.cost_a_count(&items), o.special_price_diff)?;
|
||||
}
|
||||
writeln!(out, "{line}")?;
|
||||
}
|
||||
"restock" => {
|
||||
let mut m = world.merchant(&kv["v"], origin);
|
||||
let mut gossips = Gossips::default();
|
||||
prepare(&mut m, &mut gossips, &kv);
|
||||
let steps = kv.get("steps").cloned().unwrap_or_default();
|
||||
for (k, s) in steps.split(',').filter(|s| !s.is_empty()).enumerate() {
|
||||
let f: Vec<&str> = s.split(':').collect();
|
||||
let mut line = format!("{name} {k} {s}");
|
||||
match f[0] {
|
||||
"use" => {
|
||||
let i: usize = f[1].parse().unwrap_or(0);
|
||||
if let Some(o) = m.trader.offers.get_mut(i) {
|
||||
o.uses += f[2].parse::<i32>().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
"check" => {
|
||||
let time: i64 = f[1].parse().unwrap_or(0);
|
||||
let day: i64 = f[2].parse().unwrap_or(0);
|
||||
let should = m.trader.should_restock(time, day);
|
||||
if should {
|
||||
m.trader.restock(time);
|
||||
}
|
||||
write!(line, " {should}")?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
write!(
|
||||
line,
|
||||
" lr {} rt {} cd {}",
|
||||
m.trader.last_restock, m.trader.restocks_today, m.trader.check_day
|
||||
)?;
|
||||
for o in &m.trader.offers {
|
||||
write!(line, " {}/{}", o.uses, o.demand)?;
|
||||
}
|
||||
writeln!(out, "{line}")?;
|
||||
}
|
||||
}
|
||||
"nbt" => {
|
||||
let text = snbt.get(name).ok_or("missing vanilla snbt")?;
|
||||
let (tag, _) = horizon_world::nbt::parse_snbt(text)?;
|
||||
let id = if kv["v"] == "wt" {
|
||||
"minecraft:wandering_trader"
|
||||
} else {
|
||||
"minecraft:villager"
|
||||
};
|
||||
let mut tag = tag;
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
c.insert("id".into(), Tag::String(id.into()));
|
||||
}
|
||||
let mob = entity_nbt::mob_from_nbt(&tag, &types, 1).ok_or("unreadable villager")?;
|
||||
let back = entity_nbt::mob_to_nbt(&mob, [0; 4], None);
|
||||
writeln!(out, "{name} snbt {text}")?;
|
||||
let mut kept = horizon_world::nbt::Compound::new();
|
||||
for key in [
|
||||
"Offers",
|
||||
"Xp",
|
||||
"LastRestock",
|
||||
"RestocksToday",
|
||||
"VillagerData",
|
||||
] {
|
||||
if let Some(v) = back.get(key) {
|
||||
kept.insert(key.into(), v.clone());
|
||||
}
|
||||
}
|
||||
writeln!(out, "{name} canon {}", canon_str(&Tag::Compound(kept)))?;
|
||||
}
|
||||
"trade" => {
|
||||
let env_trade = TradeEnv {
|
||||
trades: &trades,
|
||||
enchantments: &enchantments,
|
||||
maps: None,
|
||||
};
|
||||
let env = Env {
|
||||
items: &items,
|
||||
recipes: &recipes,
|
||||
brewing: &brewing,
|
||||
banners: &banners,
|
||||
trade: Some(&env_trade),
|
||||
};
|
||||
trade(&mut out, name, &kv, &mut world, &env, origin)?;
|
||||
}
|
||||
_ => return Err(format!("unknown case {kind}").into()),
|
||||
}
|
||||
}
|
||||
fs::write(args.get(4).ok_or(usage)?, out)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn trade(
|
||||
out: &mut String,
|
||||
name: &str,
|
||||
kv: &HashMap<String, String>,
|
||||
world: &mut World,
|
||||
env: &Env,
|
||||
origin: [f64; 3],
|
||||
) -> horizon_client::Result<()> {
|
||||
let vseed: i64 = kv.get("vseed").and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
let mut m = world.merchant(&kv["v"], origin);
|
||||
let mut gossips = Gossips::default();
|
||||
if m.villager {
|
||||
prepare(&mut m, &mut gossips, kv);
|
||||
}
|
||||
let hero: Option<i32> = kv.get("hero").and_then(|h| h.parse().ok());
|
||||
let mut menu = Menu::new(Kind::Merchant);
|
||||
if let Some(inv) = kv.get("inv") {
|
||||
for s in inv.split(',') {
|
||||
let (slot, rest) = s.split_once(':').ok_or("inv")?;
|
||||
let (item, count) = rest.rsplit_once('*').ok_or("inv")?;
|
||||
let item = if item.contains(':') {
|
||||
item.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{item}")
|
||||
};
|
||||
menu.set_player_item(slot.parse()?, Some(ItemStack::new(&item, count.parse()?)));
|
||||
}
|
||||
}
|
||||
menu.selected = kv.get("hand").and_then(|h| h.parse().ok()).unwrap_or(8);
|
||||
// Villager.mobInteract / WanderingTrader.mobInteract.
|
||||
let open = !m.trader.offers.is_empty();
|
||||
let unhappy = if m.villager && !open { 40 } else { 0 };
|
||||
let mut voice = LegacyRandom::new(vseed);
|
||||
if unhappy > 0 {
|
||||
// setUnhappy: makeSound(VILLAGER_NO) draws the voice pitch.
|
||||
voice.next_float();
|
||||
voice.next_float();
|
||||
}
|
||||
writeln!(out, "{name} open {open} unhappy {unhappy}")?;
|
||||
let mut session = Session::new(m.trader.offers.clone(), m.villager);
|
||||
session.xp = m.trader.xp;
|
||||
session.level = m.level;
|
||||
session.profession = m.profession.clone();
|
||||
session.variant = m.variant.clone();
|
||||
session.random = voice.clone();
|
||||
session.origin = origin;
|
||||
session.reputation = gossips.reputation(PLAYER);
|
||||
session.hero = hero;
|
||||
for id in Session::sequences_needed(world.trades, &m.profession, m.level) {
|
||||
let r = world.sequences.lend(&id, world.seed);
|
||||
session.sequences.insert(id, r);
|
||||
}
|
||||
session.update_special_prices();
|
||||
let mut regen = false;
|
||||
let mut open_menu = open;
|
||||
if open_menu {
|
||||
menu.merchant = Some(Box::new(session));
|
||||
}
|
||||
let steps = kv.get("steps").cloned().unwrap_or_default();
|
||||
let mut session_after: Option<Box<Session>> = None;
|
||||
for (k, s) in steps.split(',').filter(|s| !s.is_empty()).enumerate() {
|
||||
let f: Vec<&str> = s.split(':').collect();
|
||||
match f[0] {
|
||||
"select" if open_menu => menu.merchant_select(env, f[1].parse().unwrap_or(0)),
|
||||
"click" if open_menu => {
|
||||
let input = Input::parse(&f[3].to_uppercase()).ok_or("input")?;
|
||||
let _ = menu.click(env, f[1].parse()?, f[2].parse()?, input);
|
||||
}
|
||||
"tick" => {
|
||||
let sess = menu.merchant.as_mut().or(session_after.as_mut());
|
||||
if let Some(sess) = sess
|
||||
&& sess.villager
|
||||
&& sess.last_traded
|
||||
{
|
||||
sess.last_traded = false;
|
||||
gossips.add(PLAYER, GossipType::Trading, 2);
|
||||
sess.reputation = gossips.reputation(PLAYER);
|
||||
if open_menu {
|
||||
sess.update_special_prices();
|
||||
menu.merchant_update(env);
|
||||
}
|
||||
}
|
||||
}
|
||||
"close" if open_menu => {
|
||||
menu.merchant_close(env);
|
||||
open_menu = false;
|
||||
session_after = Some(menu.merchant.take().ok_or("session")?);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let mut line = format!("{name} {k} {s} menu {open_menu}");
|
||||
if open_menu {
|
||||
for i in 0..3 {
|
||||
write!(line, " {}", stack(menu.slot(i)))?;
|
||||
}
|
||||
write!(
|
||||
line,
|
||||
" fxp {}",
|
||||
menu.merchant.as_ref().map_or(0, |s| s.future_xp)
|
||||
)?;
|
||||
}
|
||||
write!(line, " carried {}", stack(menu.carried.as_ref()))?;
|
||||
line.push_str(" inv");
|
||||
for i in 0..36 {
|
||||
if let Some(st) = menu.player_item(i) {
|
||||
write!(line, " {i}={}", stack(Some(st)))?;
|
||||
}
|
||||
}
|
||||
let sess = menu
|
||||
.merchant
|
||||
.as_deref_mut()
|
||||
.or(session_after.as_deref_mut());
|
||||
let (xp, level, offers, orbs) = match sess {
|
||||
Some(sess) => {
|
||||
let mut orbs = Vec::new();
|
||||
for e in sess.events.drain(..) {
|
||||
match e {
|
||||
Event::Orb(v) => orbs.push(v),
|
||||
Event::LevelUp(_) => regen = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(sess.xp, sess.level, sess.offers.clone(), orbs)
|
||||
}
|
||||
None => (m.trader.xp, m.level, m.trader.offers.clone(), Vec::new()),
|
||||
};
|
||||
write!(line, " xp {xp}")?;
|
||||
if m.villager {
|
||||
write!(
|
||||
line,
|
||||
" lvl {level} rep {} regen {regen}",
|
||||
gossips.reputation(PLAYER)
|
||||
)?;
|
||||
}
|
||||
line.push_str(" offers");
|
||||
for o in &offers {
|
||||
write!(
|
||||
line,
|
||||
" {}/{}/{}",
|
||||
o.uses,
|
||||
o.special_price_diff,
|
||||
o.cost_a_count(env.items)
|
||||
)?;
|
||||
}
|
||||
line.push_str(" orbs");
|
||||
for v in orbs {
|
||||
write!(line, " {v}")?;
|
||||
}
|
||||
writeln!(out, "{line}")?;
|
||||
}
|
||||
let random = match menu.merchant.as_mut().or(session_after.as_mut()) {
|
||||
Some(s) => s.random.next_int(),
|
||||
None => voice.next_int(),
|
||||
};
|
||||
writeln!(out, "{name} next {random}")?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//! WeatherEffectRenderer column instances and quads, without a GPU.
|
||||
use horizon_client::weather;
|
||||
use std::{fmt::Write, fs};
|
||||
fn bits(value: f32) -> String {
|
||||
if value.is_nan() {
|
||||
"nan".into()
|
||||
} else {
|
||||
format!("{:x}", value.to_bits())
|
||||
}
|
||||
}
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<_> = std::env::args().collect();
|
||||
let mut output = String::new();
|
||||
for (case, line) in fs::read_to_string(&args[1])?.lines().enumerate() {
|
||||
let p: Vec<&str> = line.split_whitespace().collect();
|
||||
let int = |i: usize| p[i].parse::<i32>().unwrap();
|
||||
let kind = int(0);
|
||||
let ticks: i64 = p[1].parse()?;
|
||||
let (x, bottom, top, z, light) = (int(2), int(3), int(4), int(5), int(6));
|
||||
let partial: f32 = p[7].parse()?;
|
||||
let camera = [p[8].parse()?, p[9].parse()?, p[10].parse()?];
|
||||
let radius = int(11);
|
||||
let intensity: f32 = p[12].parse()?;
|
||||
let mut random = weather::column_random(x, z);
|
||||
let column = if kind == 0 {
|
||||
weather::rain_column(&mut random, ticks, x, bottom, top, z, light, partial)
|
||||
} else {
|
||||
weather::snow_column(&mut random, ticks, x, bottom, top, z, light, partial)
|
||||
};
|
||||
write!(
|
||||
output,
|
||||
"{case} {} {} {} {} {} {} {}",
|
||||
column.x,
|
||||
column.z,
|
||||
column.bottom_y,
|
||||
column.top_y,
|
||||
bits(column.u_offset),
|
||||
bits(column.v_offset),
|
||||
column.light_coords
|
||||
)?;
|
||||
let max_alpha = if kind == 0 { 1.0 } else { 0.8 };
|
||||
for v in weather::column_quad(&column, camera, max_alpha, radius, intensity) {
|
||||
write!(
|
||||
output,
|
||||
" |{},{},{},{},{},{:x},{}",
|
||||
bits(v.pos[0]),
|
||||
bits(v.pos[1]),
|
||||
bits(v.pos[2]),
|
||||
bits(v.uv[0]),
|
||||
bits(v.uv[1]),
|
||||
v.color,
|
||||
v.light_coords
|
||||
)?;
|
||||
}
|
||||
output.push('\n');
|
||||
}
|
||||
fs::write(&args[2], output)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -294,6 +294,8 @@ pub struct EntityView {
|
||||
pub struck: Vec<EntityView>,
|
||||
pub fishing_open_water: Option<bool>,
|
||||
pub variant: Option<String>,
|
||||
/// The data components the entity reports (`Entity.get`): id and value.
|
||||
pub components: Vec<(String, String)>,
|
||||
}
|
||||
fn range_f(v: &Value, x: f64) -> bool {
|
||||
match v {
|
||||
@@ -310,7 +312,7 @@ fn range_i(v: &Value, x: i64) -> bool {
|
||||
range_f(v, x as f64)
|
||||
}
|
||||
/// A registry id, `#tag`, or list matched against one id.
|
||||
fn id_matches(v: &Value, registry: &str, id: &str, w: &dyn World) -> bool {
|
||||
pub fn id_matches(v: &Value, registry: &str, id: &str, w: &dyn World) -> bool {
|
||||
match v {
|
||||
Value::Null => true,
|
||||
Value::String(s) => match s.strip_prefix('#') {
|
||||
@@ -442,6 +444,26 @@ pub fn location(v: &Value, p: [f64; 3], w: &dyn World) -> bool {
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `DataComponentExactPredicate.test` on an entity: every expected
|
||||
/// component equals what the entity reports (absent ones never match).
|
||||
fn components(expected: &Value, have: &[(String, String)]) -> bool {
|
||||
let full = |id: &str| {
|
||||
if id.contains(':') {
|
||||
id.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
}
|
||||
};
|
||||
expected.as_object().is_some_and(|o| {
|
||||
o.iter().all(|(id, want)| {
|
||||
let id = full(id);
|
||||
want.as_str().is_some_and(|want| {
|
||||
have.iter()
|
||||
.any(|(k, v)| full(k) == id && full(v) == full(want))
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
/// `EntityPredicate` (26.x keyed sub-predicates).
|
||||
pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> bool {
|
||||
if v.is_null() {
|
||||
@@ -521,7 +543,8 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
|
||||
"minecraft:type_specific/fishing_hook" => p["in_open_water"]
|
||||
.as_bool()
|
||||
.is_none_or(|want| e.fishing_open_water == Some(want)),
|
||||
"minecraft:components" | "minecraft:predicates" => true,
|
||||
"minecraft:components" => components(p, &e.components),
|
||||
"minecraft:predicates" => true,
|
||||
_ => false,
|
||||
};
|
||||
if !ok {
|
||||
@@ -571,6 +594,7 @@ pub fn conditions(v: &Value, ctx: &Entities, w: &dyn World) -> bool {
|
||||
w,
|
||||
)
|
||||
}
|
||||
"minecraft:match_block" => ctx.block_pos.is_some_and(|b| block(v, b, w)),
|
||||
"minecraft:block_state_property" => ctx.block_pos.is_some_and(|b| {
|
||||
let mut pred = serde_json::Map::new();
|
||||
pred.insert("blocks".into(), v["block"].clone());
|
||||
@@ -929,6 +953,24 @@ mod tests {
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn bucket_axolotl_block_advancements_require_the_placed_block() {
|
||||
let ctx = Entities {
|
||||
this: None,
|
||||
origin: [0.0; 3],
|
||||
tool: None,
|
||||
block_pos: Some([0; 3]),
|
||||
};
|
||||
let stone = serde_json::json!({"type":"minecraft:match_block","blocks":"minecraft:stone"});
|
||||
let wheat = serde_json::json!({"type":"minecraft:match_block","blocks":"minecraft:wheat"});
|
||||
assert!(conditions(&stone, &ctx, &Flat));
|
||||
assert!(!conditions(&wheat, &ctx, &Flat));
|
||||
let absent = Entities {
|
||||
block_pos: None,
|
||||
..ctx
|
||||
};
|
||||
assert!(!conditions(&stone, &absent, &Flat));
|
||||
}
|
||||
#[test]
|
||||
fn predicates_match_tags_ranges_and_locations() {
|
||||
let w = Flat;
|
||||
let stone = ItemView {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -9,7 +9,7 @@ use horizon_world::{
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
fs::File,
|
||||
fs::{self, File},
|
||||
io::{self, BufReader},
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
@@ -55,14 +55,19 @@ pub struct Assets {
|
||||
pub properties: Properties,
|
||||
pub material_states: [BlockStateId; 6],
|
||||
pub sprite_size: u32,
|
||||
pub sprite_minimum_size: u32,
|
||||
pub pixels: Vec<u8>,
|
||||
pub texture_layers: u32,
|
||||
pub model_catalog: crate::models::StateModels,
|
||||
/// Shared parsed resources; item-only validation errors surface at item preparation.
|
||||
pub model_library: std::result::Result<Arc<horizon_assets::resolve::ModelLibrary>, String>,
|
||||
pub model_placements: Vec<(horizon_core::position::BlockPos, BlockStateId)>,
|
||||
pub model_gallery: Vec<Value>,
|
||||
pub animations: Vec<crate::sprites::SpriteAnimation>,
|
||||
pub sprite_layers: std::collections::BTreeMap<String, u32>,
|
||||
pub translucent_layers: Vec<bool>,
|
||||
/// Each layer's `MipmapGenerator` settings.
|
||||
pub sprite_mips: Vec<crate::mipmap::Settings>,
|
||||
/// Block names in `#minecraft:blocks_fluid_flow` (fluid flow vectors).
|
||||
pub blocks_fluid_flow: BTreeSet<String>,
|
||||
}
|
||||
@@ -95,8 +100,7 @@ impl Assets {
|
||||
if manifest["data_version"].as_i64() != Some(i64::from(DATA_VERSION)) {
|
||||
return Err(invalid("resources do not match Horizon's pinned data version").into());
|
||||
}
|
||||
let facts: Value =
|
||||
serde_json::from_reader(BufReader::new(File::open(root.join("facts.json"))?))?;
|
||||
let facts: Value = serde_json::from_slice(&fs::read(root.join("facts.json"))?)?;
|
||||
let states = facts["states"]
|
||||
.as_array()
|
||||
.ok_or_else(|| invalid("missing registry state facts"))?;
|
||||
@@ -180,16 +184,46 @@ impl Assets {
|
||||
.collect::<Result<_>>()?,
|
||||
));
|
||||
let mut material_states = [BlockStateId(0); 6];
|
||||
let mut sprites = crate::sprites::Sprites::default();
|
||||
for (layer, name) in MATERIALS.iter().enumerate() {
|
||||
let state = registry
|
||||
material_states[layer] = registry
|
||||
.default_state(&format!("minecraft:{name}"))
|
||||
.ok_or_else(|| invalid("missing test-scene material"))?;
|
||||
material_states[layer] = state;
|
||||
sprites.insert(root, &format!("minecraft:block/{name}"))?;
|
||||
}
|
||||
let registry: &'static BlockRegistry = Box::leak(Box::new(registry));
|
||||
let models = crate::models::prepare(root, registry, &mut sprites)?;
|
||||
Self::with_models(
|
||||
root,
|
||||
registry,
|
||||
properties,
|
||||
material_states,
|
||||
blocks_fluid_flow,
|
||||
)
|
||||
}
|
||||
/// The same blocks with the models and sprites of another resource root
|
||||
/// (resource packs changed): the registry and block facts come from the
|
||||
/// game data, which resource packs do not touch.
|
||||
pub fn reload(&self, root: &Path) -> Result<Self> {
|
||||
Self::with_models(
|
||||
root,
|
||||
self.registry,
|
||||
self.properties.clone(),
|
||||
self.material_states,
|
||||
self.blocks_fluid_flow.clone(),
|
||||
)
|
||||
}
|
||||
fn with_models(
|
||||
root: &Path,
|
||||
registry: &'static BlockRegistry,
|
||||
properties: Properties,
|
||||
material_states: [BlockStateId; 6],
|
||||
blocks_fluid_flow: BTreeSet<String>,
|
||||
) -> Result<Self> {
|
||||
let mut sprites = crate::sprites::Sprites::default();
|
||||
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)?;
|
||||
let model_library = parsed.into_item_library();
|
||||
// Fluids are drawn by the fluid renderer, not block models; breaking
|
||||
// progress uses the destroy-stage overlays.
|
||||
for name in [
|
||||
@@ -204,19 +238,23 @@ impl Assets {
|
||||
for stage in 0..10 {
|
||||
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
|
||||
}
|
||||
sprites.finish();
|
||||
Ok(Self {
|
||||
registry,
|
||||
properties,
|
||||
material_states,
|
||||
sprite_size: sprites.size,
|
||||
sprite_minimum_size: sprites.minimum_size,
|
||||
texture_layers: u32::try_from(sprites.layers.len())?,
|
||||
pixels: sprites.pixels,
|
||||
model_catalog: models.catalog,
|
||||
model_library,
|
||||
model_placements: models.placements,
|
||||
model_gallery: models.states,
|
||||
animations: sprites.animations,
|
||||
sprite_layers: sprites.layers,
|
||||
translucent_layers: sprites.translucent,
|
||||
sprite_mips: sprites.mips,
|
||||
blocks_fluid_flow,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -61,10 +61,78 @@ pub struct Heard {
|
||||
pub struct MixerBackend {
|
||||
mixer: Arc<Mutex<Mixer>>,
|
||||
root: PathBuf,
|
||||
cache: HashMap<String, Option<(Arc<Vec<f32>>, u32)>>,
|
||||
cache: Arc<Mutex<SoundCache>>,
|
||||
/// The decoder thread for static sounds, started with the first one.
|
||||
decoder: Option<std::sync::mpsc::Sender<Decode>>,
|
||||
next: ChannelId,
|
||||
live: bool,
|
||||
}
|
||||
/// Decoded samples and their rate (`None`: the file did not load).
|
||||
type Samples = Option<(Arc<Vec<f32>>, u32)>;
|
||||
/// Decoded static sounds (`SoundBufferLibrary`'s buffers), dropped least
|
||||
/// recently used first past `CACHE_BYTES`: kept forever, every sound heard in
|
||||
/// a session stayed (up to 1.4 GiB of samples, villages playing hundreds).
|
||||
#[derive(Default)]
|
||||
struct SoundCache {
|
||||
entries: HashMap<String, (Samples, u64)>,
|
||||
bytes: usize,
|
||||
clock: u64,
|
||||
}
|
||||
const CACHE_BYTES: usize = 64 << 20;
|
||||
impl SoundCache {
|
||||
fn get(&mut self, key: &str) -> Option<Samples> {
|
||||
self.clock += 1;
|
||||
let entry = self.entries.get_mut(key)?;
|
||||
entry.1 = self.clock;
|
||||
Some(entry.0.clone())
|
||||
}
|
||||
fn insert(&mut self, key: String, samples: Samples) {
|
||||
self.clock += 1;
|
||||
self.bytes += Self::size(&samples);
|
||||
if let Some((old, _)) = self.entries.insert(key, (samples, self.clock)) {
|
||||
self.bytes -= Self::size(&old);
|
||||
}
|
||||
while self.bytes > CACHE_BYTES && self.entries.len() > 1 {
|
||||
let Some(oldest) = self
|
||||
.entries
|
||||
.iter()
|
||||
.min_by_key(|(_, (_, used))| *used)
|
||||
.map(|(k, _)| k.clone())
|
||||
else {
|
||||
break;
|
||||
};
|
||||
if let Some((gone, _)) = self.entries.remove(&oldest) {
|
||||
self.bytes -= Self::size(&gone);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn size(samples: &Samples) -> usize {
|
||||
samples
|
||||
.as_ref()
|
||||
.map_or(0, |(s, _)| s.len() * std::mem::size_of::<f32>())
|
||||
}
|
||||
}
|
||||
/// A static sound to decode for a channel waiting on it.
|
||||
struct Decode {
|
||||
key: String,
|
||||
file: PathBuf,
|
||||
channel: ChannelId,
|
||||
}
|
||||
/// Give a channel waiting on its sound the decoded samples and start it, as
|
||||
/// the buffer future attaching and playing the source. A source stopped or
|
||||
/// released meanwhile stays so.
|
||||
fn attach(mixer: &Mutex<Mixer>, channel: ChannelId, samples: Samples, looping: bool) {
|
||||
if let Some(samples) = samples
|
||||
&& let Ok(mut m) = mixer.lock()
|
||||
&& let Some(c) = m.channels.get_mut(&channel)
|
||||
&& c.state == State::Initial
|
||||
{
|
||||
c.samples = Some(samples);
|
||||
c.position = 0.0;
|
||||
c.state = State::Playing;
|
||||
c.looping |= looping;
|
||||
}
|
||||
}
|
||||
pub struct Audio {
|
||||
_stream: Option<cpal::Stream>,
|
||||
pub engine: SoundEngine,
|
||||
@@ -176,53 +244,59 @@ impl Backend for MixerBackend {
|
||||
fn set_relative(&mut self, channel: ChannelId, relative: bool) {
|
||||
self.with(channel, |c| c.relative = relative);
|
||||
}
|
||||
/// `getCompleteBuffer` (decoded now, cached) or `getStream` (decoded on a
|
||||
/// worker), then attach and `alSourcePlay`. A sound that cannot load
|
||||
/// leaves its source initial, as a failed buffer future does in vanilla.
|
||||
/// `getCompleteBuffer` (cached, else decoded on the decoder thread) or
|
||||
/// `getStream` (decoded on a thread of its own), then attach and
|
||||
/// `alSourcePlay`. A sound that cannot load leaves its source initial,
|
||||
/// as a failed buffer future does in vanilla.
|
||||
fn start(&mut self, channel: ChannelId, sound: &Sound, looping: bool) {
|
||||
if !self.live {
|
||||
return;
|
||||
}
|
||||
let (namespace, path) = sound.path();
|
||||
let file = self.root.join("assets").join(namespace).join(&path);
|
||||
let attach = move |mixer: &Mutex<Mixer>, samples: Option<(Arc<Vec<f32>>, u32)>| {
|
||||
if let Some(samples) = samples
|
||||
&& let Ok(mut m) = mixer.lock()
|
||||
&& let Some(c) = m.channels.get_mut(&channel)
|
||||
{
|
||||
c.samples = Some(samples);
|
||||
c.position = 0.0;
|
||||
c.state = State::Playing;
|
||||
}
|
||||
};
|
||||
if sound.stream {
|
||||
// A streamed sound loops by restarting its stream.
|
||||
let mixer = self.mixer.clone();
|
||||
std::thread::spawn(move || {
|
||||
let samples = decode(&file).map(|(s, r)| (Arc::new(s), r));
|
||||
if looping
|
||||
&& let Ok(mut m) = mixer.lock()
|
||||
&& let Some(c) = m.channels.get_mut(&channel)
|
||||
{
|
||||
c.looping = true;
|
||||
}
|
||||
attach(&mixer, samples);
|
||||
attach(&mixer, channel, samples, looping);
|
||||
});
|
||||
} else {
|
||||
let samples = self
|
||||
.cache
|
||||
.entry(format!("{namespace}:{path}"))
|
||||
.or_insert_with(|| decode(&file).map(|(s, r)| (Arc::new(s), r)))
|
||||
.clone();
|
||||
attach(&self.mixer, samples);
|
||||
return;
|
||||
}
|
||||
}
|
||||
fn stop(&mut self, channel: ChannelId) {
|
||||
self.with(channel, |c| {
|
||||
if c.state != State::Initial {
|
||||
c.state = State::Stopped;
|
||||
}
|
||||
let key = format!("{namespace}:{path}");
|
||||
let cached = self.cache.lock().ok().and_then(|mut c| c.get(&key));
|
||||
if let Some(samples) = cached {
|
||||
attach(&self.mixer, channel, samples, false);
|
||||
return;
|
||||
}
|
||||
let decoder = self.decoder.get_or_insert_with(|| {
|
||||
let (send, receive) = std::sync::mpsc::channel::<Decode>();
|
||||
let (mixer, cache) = (self.mixer.clone(), self.cache.clone());
|
||||
std::thread::Builder::new()
|
||||
.name("sound decoder".into())
|
||||
.spawn(move || {
|
||||
for job in receive {
|
||||
// Queued twice before the first decode finished.
|
||||
let cached = cache.lock().ok().and_then(|mut c| c.get(&job.key));
|
||||
let samples = cached.unwrap_or_else(|| {
|
||||
let samples = decode(&job.file).map(|(s, r)| (Arc::new(s), r));
|
||||
if let Ok(mut c) = cache.lock() {
|
||||
c.insert(job.key, samples.clone());
|
||||
}
|
||||
samples
|
||||
});
|
||||
attach(&mixer, job.channel, samples, false);
|
||||
}
|
||||
})
|
||||
.expect("sound decoder thread");
|
||||
send
|
||||
});
|
||||
let _ = decoder.send(Decode { key, file, channel });
|
||||
}
|
||||
/// `alSourceStop`: any state becomes stopped, an initial source too (a
|
||||
/// sound still decoding then never starts).
|
||||
fn stop(&mut self, channel: ChannelId) {
|
||||
self.with(channel, |c| c.state = State::Stopped);
|
||||
}
|
||||
fn pause(&mut self, channel: ChannelId) {
|
||||
self.with(channel, |c| {
|
||||
@@ -327,6 +401,71 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
*s = s.clamp(-1.0, 1.0);
|
||||
}
|
||||
}
|
||||
/// Why the game should not open an output, if it should not:
|
||||
/// `HORIZON_NO_AUDIO`, or a virtual screen (`vscreen`), where agents and
|
||||
/// harnesses run the game and must stay off the user's speakers.
|
||||
/// `HORIZON_AUDIO=1` opens the output anyway (testing into a null sink).
|
||||
fn output_disabled(var: impl Fn(&str) -> Option<std::ffi::OsString>) -> Option<String> {
|
||||
if var("HORIZON_NO_AUDIO").is_some() {
|
||||
return Some("audio off: HORIZON_NO_AUDIO is set".into());
|
||||
}
|
||||
if var("HORIZON_AUDIO").is_some_and(|v| v == "1") {
|
||||
return None;
|
||||
}
|
||||
let display = var("WAYLAND_DISPLAY")?;
|
||||
Path::new(&display)
|
||||
.components()
|
||||
.any(|c| c.as_os_str() == "vscreen")
|
||||
.then(|| "audio off on a virtual screen (vscreen); HORIZON_AUDIO=1 opens it".into())
|
||||
}
|
||||
/// An output stream on `device` in its default configuration, whatever its
|
||||
/// sample format: the mixer works in f32 and converts.
|
||||
fn open_device(
|
||||
device: &cpal::Device,
|
||||
mixer: Arc<Mutex<Mixer>>,
|
||||
) -> Result<(cpal::Stream, cpal::SupportedStreamConfig)> {
|
||||
use cpal::SampleFormat as F;
|
||||
let config = device.default_output_config()?;
|
||||
let stream = match config.sample_format() {
|
||||
F::F32 => build_stream::<f32>(device, &config, mixer),
|
||||
F::F64 => build_stream::<f64>(device, &config, mixer),
|
||||
F::I16 => build_stream::<i16>(device, &config, mixer),
|
||||
F::I32 => build_stream::<i32>(device, &config, mixer),
|
||||
F::I8 => build_stream::<i8>(device, &config, mixer),
|
||||
F::U16 => build_stream::<u16>(device, &config, mixer),
|
||||
F::U32 => build_stream::<u32>(device, &config, mixer),
|
||||
F::U8 => build_stream::<u8>(device, &config, mixer),
|
||||
other => Err(format!("unsupported sample format {other}").into()),
|
||||
}?;
|
||||
stream.play()?;
|
||||
Ok((stream, config))
|
||||
}
|
||||
fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
|
||||
device: &cpal::Device,
|
||||
config: &cpal::SupportedStreamConfig,
|
||||
mixer: Arc<Mutex<Mixer>>,
|
||||
) -> Result<cpal::Stream> {
|
||||
let rate = config.sample_rate().0;
|
||||
let channels = config.channels() as usize;
|
||||
let mut scratch = Vec::new();
|
||||
let stream = device.build_output_stream(
|
||||
&config.config(),
|
||||
move |data: &mut [T], _| {
|
||||
scratch.resize(data.len(), 0.0f32);
|
||||
if let Ok(mut m) = mixer.lock() {
|
||||
mix(&mut m, &mut scratch, channels, rate);
|
||||
} else {
|
||||
scratch.fill(0.0);
|
||||
}
|
||||
for (out, s) in data.iter_mut().zip(&scratch) {
|
||||
*out = T::from_sample(*s);
|
||||
}
|
||||
},
|
||||
|e| log::warn!("Horizon: audio stream error: {e}"),
|
||||
None,
|
||||
)?;
|
||||
Ok(stream)
|
||||
}
|
||||
impl Audio {
|
||||
pub fn new(root: &Path) -> Self {
|
||||
Self::with_output(root, true)
|
||||
@@ -347,7 +486,7 @@ impl Audio {
|
||||
Ok(v) => (Some(v), true),
|
||||
Err(e) => {
|
||||
if output {
|
||||
eprintln!("Horizon: audio unavailable: {e}");
|
||||
log::warn!("Horizon: no sound: {e}");
|
||||
}
|
||||
(None, false)
|
||||
}
|
||||
@@ -360,7 +499,8 @@ impl Audio {
|
||||
backend: MixerBackend {
|
||||
mixer,
|
||||
root: root.to_owned(),
|
||||
cache: HashMap::new(),
|
||||
cache: Arc::default(),
|
||||
decoder: None,
|
||||
next: 0,
|
||||
live,
|
||||
},
|
||||
@@ -374,32 +514,40 @@ impl Audio {
|
||||
}
|
||||
}
|
||||
fn open(mixer: Arc<Mutex<Mixer>>) -> Result<cpal::Stream> {
|
||||
// Test runs (harnesses, agents) launch the game on a virtual screen;
|
||||
// keep them off the user's speakers.
|
||||
if std::env::var_os("HORIZON_NO_AUDIO").is_some() {
|
||||
return Err("audio output disabled by HORIZON_NO_AUDIO".into());
|
||||
if let Some(reason) = output_disabled(|name| std::env::var_os(name)) {
|
||||
return Err(reason.into());
|
||||
}
|
||||
let host = cpal::default_host();
|
||||
let device = host
|
||||
.default_output_device()
|
||||
.ok_or("no audio output device")?;
|
||||
let config = device.default_output_config()?;
|
||||
let rate = config.sample_rate().0;
|
||||
let channels = config.channels() as usize;
|
||||
let stream = device.build_output_stream(
|
||||
&config.into(),
|
||||
move |data: &mut [f32], _| {
|
||||
if let Ok(mut m) = mixer.lock() {
|
||||
mix(&mut m, data, channels, rate);
|
||||
} else {
|
||||
data.fill(0.0);
|
||||
let mut errors = Vec::new();
|
||||
// The default device first, then any other output a host lists:
|
||||
// a default that cannot open (busy, or a stale ALSA default) should
|
||||
// not leave the game silent when another device works.
|
||||
let default = host.default_output_device();
|
||||
let others = host.output_devices().into_iter().flatten();
|
||||
let mut tried = Vec::new();
|
||||
for device in default.into_iter().chain(others) {
|
||||
let name = device.name().unwrap_or_else(|_| "unnamed device".into());
|
||||
if tried.contains(&name) {
|
||||
continue;
|
||||
}
|
||||
tried.push(name.clone());
|
||||
match open_device(&device, mixer.clone()) {
|
||||
Ok((stream, config)) => {
|
||||
log::info!(
|
||||
"Horizon: audio output {name} ({} Hz, {} channels, {})",
|
||||
config.sample_rate().0,
|
||||
config.channels(),
|
||||
config.sample_format()
|
||||
);
|
||||
return Ok(stream);
|
||||
}
|
||||
},
|
||||
|e| eprintln!("Horizon: audio stream error: {e}"),
|
||||
None,
|
||||
)?;
|
||||
stream.play()?;
|
||||
Ok(stream)
|
||||
Err(e) => errors.push(format!("{name}: {e}")),
|
||||
}
|
||||
}
|
||||
if errors.is_empty() {
|
||||
return Err(format!("no audio output device on the {} host", host.id().name()).into());
|
||||
}
|
||||
Err(format!("no audio output device opened ({})", errors.join("; ")).into())
|
||||
}
|
||||
fn bound(&mut self) -> Bound<'_, MixerBackend> {
|
||||
Bound {
|
||||
@@ -452,6 +600,16 @@ impl Audio {
|
||||
pub fn stop_all(&mut self) {
|
||||
self.engine.stop_all(&mut self.backend);
|
||||
}
|
||||
/// `SoundManager.reload` (resource packs changed): stop every sound, then
|
||||
/// read sound events and sound files from the new resource root.
|
||||
pub fn reload(&mut self, root: &Path) {
|
||||
self.stop_all();
|
||||
self.engine.registry = SoundRegistry::load(root);
|
||||
self.backend.root = root.to_owned();
|
||||
let mut cache = self.backend.cache.lock().unwrap();
|
||||
cache.entries.clear();
|
||||
cache.bytes = 0;
|
||||
}
|
||||
/// Play a sound event at a world position, as `ClientLevel.playLocalSound`
|
||||
/// (a `SimpleSoundInstance` seeded from the level random), or relative to
|
||||
/// the listener without attenuation (`None`).
|
||||
@@ -459,6 +617,23 @@ impl Audio {
|
||||
let seed = self.random.next_long();
|
||||
self.play_seeded(event, at, volume, pitch, seed);
|
||||
}
|
||||
/// `LivingEntity.handleDamageEvent`'s pitch, `(r - r) * 0.2 + 1`, drawn on the
|
||||
/// client (the entity's own random there, never the server's world random).
|
||||
pub fn hurt_pitch(&mut self) -> f32 {
|
||||
(self.random.next_float() - self.random.next_float()) * 0.2 + 1.0
|
||||
}
|
||||
/// `AbstractWidget.playButtonClickSound`: an unseeded, relative UI sound.
|
||||
pub fn play_button_click(&mut self) {
|
||||
self.play_instance(
|
||||
SoundInstance::for_ui(
|
||||
"minecraft:ui.button.click",
|
||||
1.0,
|
||||
0.25,
|
||||
SoundEngine::unseeded(),
|
||||
),
|
||||
false,
|
||||
);
|
||||
}
|
||||
/// `play` with the seed a sound packet carries.
|
||||
pub fn play_seeded(
|
||||
&mut self,
|
||||
@@ -525,6 +700,21 @@ impl Audio {
|
||||
self.release(id);
|
||||
}
|
||||
/// Every sound event id (for command completion).
|
||||
/// The engine's table sizes and the mixer's channels (`status`).
|
||||
pub fn census(&self) -> serde_json::Value {
|
||||
let mut census = self.engine.census();
|
||||
census["channels"] = self
|
||||
.backend
|
||||
.mixer
|
||||
.lock()
|
||||
.map_or(0, |m| m.channels.len())
|
||||
.into();
|
||||
if let Ok(cache) = self.backend.cache.lock() {
|
||||
census["decoded"] = cache.entries.len().into();
|
||||
census["decoded_mib"] = (cache.bytes >> 20).into();
|
||||
}
|
||||
census
|
||||
}
|
||||
pub fn event_names(&self) -> Vec<String> {
|
||||
let mut v: Vec<String> = self.engine.registry.names().cloned().collect();
|
||||
v.sort();
|
||||
@@ -576,6 +766,38 @@ impl Sounds for Audio {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn button_click_uses_vanilla_ui_parameters_without_advancing_world_random() {
|
||||
let mut audio = Audio::silent(Path::new("/nonexistent/horizon-test-assets"));
|
||||
audio.engine.registry.add_file(
|
||||
"minecraft",
|
||||
br#"{"ui.button.click":{"sounds":["gui/button"]}}"#,
|
||||
&|_, _| true,
|
||||
);
|
||||
// Exercise the mixer without opening an output device. The missing
|
||||
// sample is harmless: this test checks the source the engine creates.
|
||||
audio.engine.loaded = true;
|
||||
audio.backend.live = true;
|
||||
let mut random = audio.random.clone();
|
||||
audio.play_button_click();
|
||||
let instance = audio.engine.instance(1).expect("click reached the engine");
|
||||
assert_eq!(instance.identifier, "minecraft:ui.button.click");
|
||||
assert_eq!(instance.source, crate::sound_engine::UI);
|
||||
assert_eq!(instance.volume, 0.25);
|
||||
assert_eq!(instance.pitch, 1.0);
|
||||
assert_eq!(instance.attenuation, Attenuation::None);
|
||||
assert_eq!(instance.position, [0.0; 3]);
|
||||
assert!(instance.relative);
|
||||
assert!(!instance.looping);
|
||||
assert_eq!(audio.random.next_long(), random.next_long());
|
||||
audio.pause();
|
||||
assert_eq!(
|
||||
audio.backend.mixer.lock().unwrap().channels[&1].state,
|
||||
State::Initial
|
||||
);
|
||||
audio.stop_all();
|
||||
}
|
||||
|
||||
fn channel(samples: Vec<f32>, linear: Option<f32>, at: [f32; 3], looping: bool) -> Channel {
|
||||
Channel {
|
||||
samples: Some((Arc::new(samples), 48_000)),
|
||||
@@ -590,6 +812,47 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoded_sounds_stay_within_the_budget_least_recently_used_out() {
|
||||
let mut cache = SoundCache::default();
|
||||
let quarter = CACHE_BYTES / 4 / std::mem::size_of::<f32>();
|
||||
let sound = || Some((Arc::new(vec![0.0f32; quarter]), 44100));
|
||||
for key in ["a", "b", "c", "d"] {
|
||||
cache.insert(key.into(), sound());
|
||||
}
|
||||
assert!(cache.get("a").is_some());
|
||||
cache.insert("e".into(), sound());
|
||||
assert!(cache.bytes <= CACHE_BYTES);
|
||||
assert!(
|
||||
cache.get("b").is_none(),
|
||||
"the least recently used goes first"
|
||||
);
|
||||
assert!(cache.get("a").is_some() && cache.get("e").is_some());
|
||||
// A failed load is remembered without counting.
|
||||
cache.insert("missing".into(), None);
|
||||
assert_eq!(cache.get("missing"), Some(None));
|
||||
}
|
||||
#[test]
|
||||
fn output_stays_off_on_a_virtual_screen_unless_asked() {
|
||||
let env = |pairs: &'static [(&'static str, &'static str)]| {
|
||||
move |name: &str| {
|
||||
pairs
|
||||
.iter()
|
||||
.find(|(k, _)| *k == name)
|
||||
.map(|(_, v)| std::ffi::OsString::from(*v))
|
||||
}
|
||||
};
|
||||
assert!(output_disabled(env(&[("WAYLAND_DISPLAY", "wayland-1")])).is_none());
|
||||
assert!(output_disabled(env(&[])).is_none());
|
||||
const VSCREEN: &str = "/run/user/1000/vscreen/main/wayland-1";
|
||||
assert!(output_disabled(env(&[("WAYLAND_DISPLAY", VSCREEN)])).is_some());
|
||||
assert!(
|
||||
output_disabled(env(&[("WAYLAND_DISPLAY", VSCREEN), ("HORIZON_AUDIO", "1")])).is_none()
|
||||
);
|
||||
assert!(
|
||||
output_disabled(env(&[("HORIZON_NO_AUDIO", "1"), ("HORIZON_AUDIO", "1")])).is_some()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn linear_attenuation_reaches_zero_at_the_max_distance() {
|
||||
assert_eq!(distance_gain(None, 100.0), 1.0);
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
//! The Microsoft account the launcher signed in, handed over in a session
|
||||
//! file (docs/microsoft-account.md), and the Minecraft services call
|
||||
//! online-mode servers need: authlib's MinecraftServicesSessionService.joinServer
|
||||
//! through the services discovery document. The access token stays in this
|
||||
//! process's memory: it is never logged, printed or written anywhere.
|
||||
use std::{
|
||||
path::Path,
|
||||
sync::{Arc, Mutex, OnceLock},
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
|
||||
/// MinecraftServicesEnvironment.PROD.
|
||||
pub const DEFAULT_DISCOVERY: &str = "https://discovery.minecraftservices.com/minecraft/client";
|
||||
/// A session file older than this is refused: it can't be replayed later.
|
||||
const MAX_SESSION_FILE_AGE: Duration = Duration::from_secs(120);
|
||||
|
||||
/// A signed-in account (vanilla's User with type msa).
|
||||
pub struct AuthSession {
|
||||
pub name: String,
|
||||
pub uuid: u128,
|
||||
pub xuid: Option<String>,
|
||||
access_token: String,
|
||||
}
|
||||
impl std::fmt::Debug for AuthSession {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AuthSession")
|
||||
.field("name", &self.name)
|
||||
.field("uuid", &horizon_protocol::crypto::uuid_string(self.uuid))
|
||||
.field("access_token", &"<hidden>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// A UUID with or without dashes.
|
||||
pub fn parse_uuid(text: &str) -> Option<u128> {
|
||||
let hex: String = text.chars().filter(|c| *c != '-').collect();
|
||||
(hex.len() == 32)
|
||||
.then(|| u128::from_str_radix(&hex, 16).ok())
|
||||
.flatten()
|
||||
}
|
||||
|
||||
impl AuthSession {
|
||||
/// For tests and harnesses.
|
||||
pub fn new(name: &str, uuid: u128, access_token: &str) -> Self {
|
||||
Self {
|
||||
name: name.to_owned(),
|
||||
uuid,
|
||||
xuid: None,
|
||||
access_token: access_token.to_owned(),
|
||||
}
|
||||
}
|
||||
/// 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")?;
|
||||
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")?;
|
||||
if name.is_empty() || name.chars().count() > 16 {
|
||||
return Err("the session file'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")?;
|
||||
let access_token = field("access_token")
|
||||
.filter(|t| !t.is_empty())
|
||||
.ok_or("the session file 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());
|
||||
}
|
||||
Ok(Self {
|
||||
name: name.to_owned(),
|
||||
uuid,
|
||||
xuid: field("xuid").map(str::to_owned),
|
||||
access_token: access_token.to_owned(),
|
||||
})
|
||||
}
|
||||
/// Read the session file the launcher wrote and delete it at once. It
|
||||
/// must be fresh and, on Unix, readable by its owner only.
|
||||
pub fn load(path: &Path) -> Result<Self, String> {
|
||||
let meta = std::fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let refuse = |why: &str| {
|
||||
let _ = std::fs::remove_file(path);
|
||||
Err(format!("{}: {why}", path.display()))
|
||||
};
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if meta.permissions().mode() & 0o077 != 0 {
|
||||
return refuse("other users can read it");
|
||||
}
|
||||
}
|
||||
let age = meta
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|m| SystemTime::now().duration_since(m).ok())
|
||||
.unwrap_or_default();
|
||||
if age > MAX_SESSION_FILE_AGE {
|
||||
return refuse("it is too old (start the game from the launcher)");
|
||||
}
|
||||
let text = std::fs::read_to_string(path);
|
||||
let _ = std::fs::remove_file(path);
|
||||
Self::parse(&text.map_err(|e| format!("{}: {e}", path.display()))?)
|
||||
}
|
||||
}
|
||||
|
||||
static CURRENT: OnceLock<Arc<AuthSession>> = OnceLock::new();
|
||||
|
||||
/// The account this game was started with (set once at start).
|
||||
pub fn set_current(session: AuthSession) {
|
||||
let _ = CURRENT.set(Arc::new(session));
|
||||
}
|
||||
pub fn current() -> Option<Arc<AuthSession>> {
|
||||
CURRENT.get().cloned()
|
||||
}
|
||||
|
||||
/// Why joining a server through the session service failed, like the
|
||||
/// AuthenticationException subclasses authlib throws.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum JoinError {
|
||||
/// No answer, or a 5xx (AuthenticationUnavailableException).
|
||||
Unavailable,
|
||||
/// 401 or ForbiddenOperationException (InvalidCredentialsException).
|
||||
InvalidSession,
|
||||
/// InsufficientPrivilegesException: multiplayer is off for the account.
|
||||
InsufficientPrivileges,
|
||||
/// multiplayer.access.banned or FORCED_USERNAME_CHANGE.
|
||||
Banned,
|
||||
/// Anything else, with the service's message.
|
||||
Other(String),
|
||||
}
|
||||
impl JoinError {
|
||||
/// MinecraftClientHttpException.toAuthenticationException.
|
||||
pub fn from_response(status: u16, body: &str) -> Self {
|
||||
let v: serde_json::Value = serde_json::from_str(body).unwrap_or_default();
|
||||
let error = v.get("error").and_then(|e| e.as_str()).unwrap_or("");
|
||||
match error {
|
||||
"ForbiddenOperationException" => return Self::InvalidSession,
|
||||
"multiplayer.access.banned" | "FORCED_USERNAME_CHANGE" => return Self::Banned,
|
||||
"InsufficientPrivilegesException" => return Self::InsufficientPrivileges,
|
||||
_ => {}
|
||||
}
|
||||
match status {
|
||||
401 => Self::InvalidSession,
|
||||
500.. => Self::Unavailable,
|
||||
_ => Self::Other(
|
||||
v.get("errorMessage")
|
||||
.and_then(|m| m.as_str())
|
||||
.map_or_else(|| format!("Status: {status}"), str::to_owned),
|
||||
),
|
||||
}
|
||||
}
|
||||
/// ClientHandshakePacketListenerImpl.authenticateServer's message: the
|
||||
/// key of the reason inside "disconnect.loginFailedInfo", or its text.
|
||||
pub fn reason(&self) -> Result<&'static str, &str> {
|
||||
match self {
|
||||
Self::Unavailable => Ok("disconnect.loginFailedInfo.serversUnavailable"),
|
||||
Self::InvalidSession => Ok("disconnect.loginFailedInfo.invalidSession"),
|
||||
Self::InsufficientPrivileges => Ok("disconnect.loginFailedInfo.insufficientPrivileges"),
|
||||
Self::Banned => Ok("disconnect.loginFailedInfo.userBanned"),
|
||||
Self::Other(message) => Err(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The services discovery document URL: `HORIZON_API_DISCOVERY` overrides
|
||||
/// it like authlib's `-Dminecraft.api.discovery.host`.
|
||||
pub fn discovery_url() -> String {
|
||||
std::env::var("HORIZON_API_DISCOVERY")
|
||||
.ok()
|
||||
.filter(|u| !u.is_empty())
|
||||
.unwrap_or_else(|| DEFAULT_DISCOVERY.to_owned())
|
||||
}
|
||||
|
||||
fn agent() -> ureq::Agent {
|
||||
ureq::AgentBuilder::new()
|
||||
.timeout(Duration::from_secs(15))
|
||||
.user_agent(concat!("Horizon/", env!("CARGO_PKG_VERSION")))
|
||||
.build()
|
||||
}
|
||||
|
||||
/// The discovery document, fetched once per game (a failure is retried next time).
|
||||
fn discovery() -> Result<Arc<serde_json::Value>, JoinError> {
|
||||
static CACHE: Mutex<Option<Arc<serde_json::Value>>> = Mutex::new(None);
|
||||
if let Some(d) = CACHE.lock().unwrap().clone() {
|
||||
return Ok(d);
|
||||
}
|
||||
let doc: serde_json::Value = agent()
|
||||
.get(&discovery_url())
|
||||
.call()
|
||||
.ok()
|
||||
.and_then(|r| r.into_string().ok())
|
||||
.and_then(|t| serde_json::from_str(&t).ok())
|
||||
.ok_or(JoinError::Unavailable)?;
|
||||
let doc = Arc::new(doc);
|
||||
*CACHE.lock().unwrap() = Some(doc.clone());
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
/// MinecraftServicesDiscoveryService.getUrl(service, endpoint).
|
||||
pub fn endpoint(doc: &serde_json::Value, service: &str, name: &str) -> Option<String> {
|
||||
doc.pointer(&format!("/discovery/{service}/endpoints/{name}/uri"))
|
||||
.and_then(|u| u.as_str())
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
/// The JoinMinecraftServerRequest body; `selectedProfile` is the undashed UUID.
|
||||
pub fn join_request(session: &AuthSession, server_hash: &str) -> String {
|
||||
serde_json::json!({
|
||||
"accessToken": session.access_token,
|
||||
"selectedProfile": format!("{:032x}", session.uuid),
|
||||
"serverId": server_hash,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// MinecraftServicesSessionService.joinServer: tell the session service this
|
||||
/// account is joining the server whose hash is `server_hash`.
|
||||
pub fn join_server(session: &AuthSession, server_hash: &str) -> Result<(), JoinError> {
|
||||
let doc = discovery()?;
|
||||
let url = endpoint(&doc, "session", "join").ok_or(JoinError::Unavailable)?;
|
||||
match agent()
|
||||
.post(&url)
|
||||
.set("Content-Type", "application/json; charset=utf-8")
|
||||
.send_string(&join_request(session, server_hash))
|
||||
{
|
||||
Ok(_) => Ok(()),
|
||||
Err(ureq::Error::Status(status, response)) => Err(JoinError::from_response(
|
||||
status,
|
||||
&response.into_string().unwrap_or_default(),
|
||||
)),
|
||||
Err(ureq::Error::Transport(_)) => Err(JoinError::Unavailable),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const UUID: u128 = 0x069a79f444e94726a5befca90e38aaf5;
|
||||
|
||||
fn write(dir: &Path, text: &str, mode: u32) -> std::path::PathBuf {
|
||||
let path = dir.join("session.json");
|
||||
std::fs::write(&path, text).unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap();
|
||||
}
|
||||
let _ = mode;
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_files_are_read_once_and_must_be_private() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-auth-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let text = r#"{"name":"Notch","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","access_token":"mock-token","user_type":"msa"}"#;
|
||||
let path = write(&dir, text, 0o600);
|
||||
let s = AuthSession::load(&path).unwrap();
|
||||
assert_eq!((s.name.as_str(), s.uuid), ("Notch", UUID));
|
||||
assert!(!path.exists(), "deleted after reading");
|
||||
assert!(!format!("{s:?}").contains("mock-token"));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let path = write(&dir, text, 0o644);
|
||||
assert!(
|
||||
AuthSession::load(&path)
|
||||
.unwrap_err()
|
||||
.contains("other users")
|
||||
);
|
||||
assert!(!path.exists());
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_file_fields() {
|
||||
assert!(
|
||||
AuthSession::parse(r#"{"name":"Notch","uuid":"069a79f444e94726a5befca90e38aaf5"}"#)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
AuthSession::parse(
|
||||
r#"{"name":"","uuid":"069a79f444e94726a5befca90e38aaf5","access_token":"t"}"#
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(AuthSession::parse(r#"{"name":"A","uuid":"nope","access_token":"t"}"#).is_err());
|
||||
assert!(AuthSession::parse(r#"{"name":"A","uuid":"069a79f444e94726a5befca90e38aaf5","access_token":"t","user_type":"legacy"}"#).is_err());
|
||||
let s = AuthSession::parse(r#"{"name":"A","uuid":"069a79f444e94726a5befca90e38aaf5","access_token":"t","xuid":"25"}"#).unwrap();
|
||||
assert_eq!(s.xuid.as_deref(), Some("25"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_request_like_authlib() {
|
||||
let s = AuthSession::new("Notch", UUID, "tok");
|
||||
let v: serde_json::Value = serde_json::from_str(&join_request(&s, "-7c9d")).unwrap();
|
||||
assert_eq!(
|
||||
v,
|
||||
serde_json::json!({"accessToken": "tok", "selectedProfile": "069a79f444e94726a5befca90e38aaf5", "serverId": "-7c9d"})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_map_like_authlib() {
|
||||
let e = |status, body| JoinError::from_response(status, body);
|
||||
assert_eq!(
|
||||
e(
|
||||
403,
|
||||
r#"{"error":"ForbiddenOperationException","errorMessage":"Invalid token"}"#
|
||||
),
|
||||
JoinError::InvalidSession
|
||||
);
|
||||
assert_eq!(e(401, ""), JoinError::InvalidSession);
|
||||
assert_eq!(
|
||||
e(403, r#"{"error":"InsufficientPrivilegesException"}"#),
|
||||
JoinError::InsufficientPrivileges
|
||||
);
|
||||
assert_eq!(
|
||||
e(403, r#"{"error":"multiplayer.access.banned"}"#),
|
||||
JoinError::Banned
|
||||
);
|
||||
assert_eq!(e(503, "down"), JoinError::Unavailable);
|
||||
assert_eq!(
|
||||
e(429, r#"{"errorMessage":"Too many"}"#),
|
||||
JoinError::Other("Too many".into())
|
||||
);
|
||||
assert_eq!(
|
||||
JoinError::InvalidSession.reason(),
|
||||
Ok("disconnect.loginFailedInfo.invalidSession")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_endpoints() {
|
||||
let doc = serde_json::json!({"discovery": {"session": {"endpoints": {"join": {"uri": "https://s/join"}}}}});
|
||||
assert_eq!(
|
||||
endpoint(&doc, "session", "join").as_deref(),
|
||||
Some("https://s/join")
|
||||
);
|
||||
assert_eq!(endpoint(&doc, "session", "verify"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
//! Beehives and bee nests (`BeehiveBlockEntity`): up to three bees stored as
|
||||
//! entity tags, let out when their time inside is up (never at night or in
|
||||
//! the rain, nor through a blocked front, unless the hive is emptied in an
|
||||
//! emergency), honey for every bee coming home with nectar. The host (the game
|
||||
//! or the mob AI probe) loads and adds the bees through [`HiveHost`].
|
||||
use crate::mobs::{Mob, Species, bee};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use horizon_world::nbt::{Compound, Tag};
|
||||
|
||||
pub const MAX_OCCUPANTS: usize = 3;
|
||||
/// `BeehiveBlockEntity.IGNORED_BEE_TAGS`: dropped from a bee going in, and
|
||||
/// again from the tag it comes out with.
|
||||
const IGNORED_BEE_TAGS: [&str; 25] = [
|
||||
"Air",
|
||||
"drop_chances",
|
||||
"equipment",
|
||||
"Brain",
|
||||
"CanPickUpLoot",
|
||||
"DeathTime",
|
||||
"fall_distance",
|
||||
"FallFlying",
|
||||
"Fire",
|
||||
"HurtTime",
|
||||
"LeftHanded",
|
||||
"Motion",
|
||||
"NoGravity",
|
||||
"OnGround",
|
||||
"PortalCooldown",
|
||||
"Pos",
|
||||
"Rotation",
|
||||
"sleeping_pos",
|
||||
"CannotEnterHiveTicks",
|
||||
"TicksSincePollination",
|
||||
"CropsGrownSincePollination",
|
||||
"hive_pos",
|
||||
"Passengers",
|
||||
"leash",
|
||||
"UUID",
|
||||
];
|
||||
|
||||
/// `BeehiveBlockEntity`: the bees inside and the flower one of them knew.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Beehive {
|
||||
pub bees: Vec<Occupant>,
|
||||
pub flower: Option<[i32; 3]>,
|
||||
}
|
||||
/// `BeehiveBlockEntity.Occupant`: a bee's tag (with its `id`), its time in
|
||||
/// the hive and the time it stays at least.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Occupant {
|
||||
pub entity_data: Tag,
|
||||
pub ticks_in_hive: i32,
|
||||
pub min_ticks_in_hive: i32,
|
||||
}
|
||||
/// `BeehiveBlockEntity.BeeReleaseStatus`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Release {
|
||||
HoneyDelivered,
|
||||
BeeReleased,
|
||||
Emergency,
|
||||
}
|
||||
|
||||
/// What a hive needs from the level it is in.
|
||||
pub trait HiveHost {
|
||||
/// `level.getRandom()`.
|
||||
fn random(&mut self) -> &mut LegacyRandom;
|
||||
/// `EnvironmentAttributes.BEES_STAY_IN_HIVE` at a position.
|
||||
fn bees_stay_in_hive(&self, at: [i32; 3]) -> bool;
|
||||
/// A non-empty collision shape at a block.
|
||||
fn blocked(&self, at: [i32; 3]) -> bool;
|
||||
/// `BlockState.setValue(HONEY_LEVEL, level)` on the hive.
|
||||
fn set_honey_level(&mut self, at: [i32; 3], level: i32);
|
||||
/// `EntityType.loadEntityRecursive` (the tag has its `id` and a `Pos`):
|
||||
/// a new entity, or `None` for anything but a `#beehive_inhabitors`.
|
||||
fn load(&mut self, tag: &Tag) -> Option<Mob>;
|
||||
/// `Level.addFreshEntity`.
|
||||
fn add(&mut self, m: Mob) -> bool;
|
||||
fn sound(&mut self, name: &'static str, at: [f64; 3]);
|
||||
}
|
||||
|
||||
fn strip_ignored(c: &mut Compound) {
|
||||
for key in IGNORED_BEE_TAGS {
|
||||
c.remove(&key.into());
|
||||
}
|
||||
}
|
||||
fn int(tag: Option<&Tag>) -> Option<i32> {
|
||||
match tag {
|
||||
Some(Tag::Int(v)) => Some(*v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn pos(tag: Option<&Tag>) -> Option<[i32; 3]> {
|
||||
match tag {
|
||||
Some(Tag::IntArray(p)) if p.len() == 3 => Some([p[0], p[1], p[2]]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl Occupant {
|
||||
/// `Occupant.of`: the bee's saved tag without the ignored tags, staying
|
||||
/// 2400 ticks with nectar, else 600.
|
||||
pub fn of(mut tag: Tag) -> Self {
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
strip_ignored(c);
|
||||
}
|
||||
let nectar = matches!(tag.get("HasNectar"), Some(Tag::Byte(v)) if *v != 0);
|
||||
Self {
|
||||
entity_data: tag,
|
||||
ticks_in_hive: 0,
|
||||
min_ticks_in_hive: if nectar { 2400 } else { 600 },
|
||||
}
|
||||
}
|
||||
/// `Occupant.create`: a worldgen nest's bee, a bare bee tag.
|
||||
pub fn create(ticks_in_hive: i32) -> Self {
|
||||
Self {
|
||||
entity_data: Tag::compound([("id", Tag::String("minecraft:bee".into()))]),
|
||||
ticks_in_hive,
|
||||
min_ticks_in_hive: 600,
|
||||
}
|
||||
}
|
||||
fn has_nectar(&self) -> bool {
|
||||
matches!(self.entity_data.get("HasNectar"), Some(Tag::Byte(v)) if *v != 0)
|
||||
}
|
||||
fn from_nbt(tag: &Tag) -> Option<Self> {
|
||||
let data = tag.get("entity_data")?;
|
||||
if !matches!(data, Tag::Compound(c) if c.contains_key(&"id".into())) {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
entity_data: data.clone(),
|
||||
ticks_in_hive: int(tag.get("ticks_in_hive"))?,
|
||||
min_ticks_in_hive: int(tag.get("min_ticks_in_hive"))?,
|
||||
})
|
||||
}
|
||||
fn to_nbt(&self) -> Tag {
|
||||
Tag::compound([
|
||||
("entity_data", self.entity_data.clone()),
|
||||
("ticks_in_hive", Tag::Int(self.ticks_in_hive)),
|
||||
("min_ticks_in_hive", Tag::Int(self.min_ticks_in_hive)),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
impl Beehive {
|
||||
pub fn full(&self) -> bool {
|
||||
self.bees.len() == MAX_OCCUPANTS
|
||||
}
|
||||
/// `loadAdditional`: `bees` (all or none, as the list codec reads them)
|
||||
/// and `flower_pos`.
|
||||
pub fn from_nbt(tag: &Tag) -> Self {
|
||||
let bees = match tag.get("bees") {
|
||||
Some(Tag::List(list)) => list
|
||||
.iter()
|
||||
.map(Occupant::from_nbt)
|
||||
.collect::<Option<Vec<_>>>()
|
||||
.unwrap_or_default(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
Self {
|
||||
bees,
|
||||
flower: pos(tag.get("flower_pos")),
|
||||
}
|
||||
}
|
||||
/// `saveAdditional`.
|
||||
pub fn to_nbt(&self) -> Vec<(&'static str, Tag)> {
|
||||
let mut out = vec![(
|
||||
"bees",
|
||||
Tag::List(self.bees.iter().map(Occupant::to_nbt).collect()),
|
||||
)];
|
||||
if let Some(f) = self.flower {
|
||||
out.push(("flower_pos", Tag::IntArray(f.to_vec())));
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `addOccupant` (the bee's own tag, saved by the host): the hive keeps
|
||||
/// the bee's flower, or now and then swaps its own for it. The host then
|
||||
/// discards the bee.
|
||||
pub fn add_occupant(
|
||||
&mut self,
|
||||
at: [i32; 3],
|
||||
tag: Tag,
|
||||
bee_flower: Option<[i32; 3]>,
|
||||
host: &mut impl HiveHost,
|
||||
) -> bool {
|
||||
if self.bees.len() >= MAX_OCCUPANTS {
|
||||
return false;
|
||||
}
|
||||
self.bees.push(Occupant::of(tag));
|
||||
if let Some(f) = bee_flower
|
||||
&& (self.flower.is_none() || host.random().next_boolean())
|
||||
{
|
||||
self.flower = Some(f);
|
||||
}
|
||||
let corner = at.map(f64::from);
|
||||
host.sound("block.beehive.enter", corner);
|
||||
true
|
||||
}
|
||||
/// `serverTick`: the bees whose time is up come out, then the hive
|
||||
/// hums now and then while it holds any. `honey` is the hive state's
|
||||
/// honey level as the tick starts (`None` without the property);
|
||||
/// `facing` its front.
|
||||
pub fn server_tick(
|
||||
&mut self,
|
||||
at: [i32; 3],
|
||||
facing: [i32; 2],
|
||||
honey: Option<i32>,
|
||||
host: &mut impl HiveHost,
|
||||
) {
|
||||
let flower = self.flower;
|
||||
let mut i = 0;
|
||||
while i < self.bees.len() {
|
||||
// BeeData.tick: ticksInHive++ > minTicksInHive.
|
||||
let b = &mut self.bees[i];
|
||||
let up = b.ticks_in_hive > b.min_ticks_in_hive;
|
||||
b.ticks_in_hive += 1;
|
||||
if up {
|
||||
let status = if b.has_nectar() {
|
||||
Release::HoneyDelivered
|
||||
} else {
|
||||
Release::BeeReleased
|
||||
};
|
||||
let occupant = b.clone();
|
||||
if release(host, at, facing, honey, &occupant, status, flower).is_some() {
|
||||
self.bees.remove(i);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if !self.bees.is_empty() && host.random().next_double() < 0.005 {
|
||||
let hum = [
|
||||
f64::from(at[0]) + 0.5,
|
||||
f64::from(at[1]),
|
||||
f64::from(at[2]) + 0.5,
|
||||
];
|
||||
host.sound("block.beehive.work", hum);
|
||||
}
|
||||
}
|
||||
/// `releaseAllOccupants`: the ids of the bees let out.
|
||||
pub fn release_all(
|
||||
&mut self,
|
||||
at: [i32; 3],
|
||||
facing: [i32; 2],
|
||||
honey: Option<i32>,
|
||||
status: Release,
|
||||
host: &mut impl HiveHost,
|
||||
) -> Vec<u32> {
|
||||
let flower = self.flower;
|
||||
let mut out = Vec::new();
|
||||
self.bees.retain(
|
||||
|b| match release(host, at, facing, honey, b, status, flower) {
|
||||
Some(id) => {
|
||||
out.extend(id);
|
||||
false
|
||||
}
|
||||
None => true,
|
||||
},
|
||||
);
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// `releaseOccupant`: `None` while the bee stays in, else the id of the bee
|
||||
/// let out (`Some(None)` when the level refused it).
|
||||
fn release(
|
||||
host: &mut impl HiveHost,
|
||||
at: [i32; 3],
|
||||
facing: [i32; 2],
|
||||
honey: Option<i32>,
|
||||
occupant: &Occupant,
|
||||
status: Release,
|
||||
flower: Option<[i32; 3]>,
|
||||
) -> Option<Option<u32>> {
|
||||
let emergency = status == Release::Emergency;
|
||||
if host.bees_stay_in_hive(at) && !emergency {
|
||||
return None;
|
||||
}
|
||||
let blocked = host.blocked([at[0] + facing[0], at[1], at[2] + facing[1]]);
|
||||
if blocked && !emergency {
|
||||
return None;
|
||||
}
|
||||
// Occupant.createEntity: the tag without the ignored tags.
|
||||
let mut tag = occupant.entity_data.clone();
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
strip_ignored(c);
|
||||
let center = [at[0], at[1], at[2]].map(|v| Tag::Double((f64::from(v) + 0.5).to_bits()));
|
||||
c.insert("Pos".into(), Tag::List(center.to_vec()));
|
||||
}
|
||||
let mut m = host.load(&tag)?;
|
||||
m.no_gravity = true;
|
||||
if m.species() == Species::Bee {
|
||||
bee::set_hive(&mut m, Some(at));
|
||||
// setBeeReleaseData: the time inside counts towards growing up and
|
||||
// away from love.
|
||||
let ticks = occupant.ticks_in_hive;
|
||||
if !m.extra.age_locked {
|
||||
let age = m.extra.age;
|
||||
m.extra.age = if age < 0 {
|
||||
(age + ticks).min(0)
|
||||
} else if age > 0 {
|
||||
(age - ticks).max(0)
|
||||
} else {
|
||||
age
|
||||
};
|
||||
}
|
||||
m.extra.in_love = (m.extra.in_love - ticks).max(0);
|
||||
if let Some(f) = flower
|
||||
&& bee::get(&m).is_none_or(|b| b.flower.is_none())
|
||||
&& host.random().next_float() < 0.9
|
||||
{
|
||||
bee::set_flower(&mut m, Some(f));
|
||||
}
|
||||
if status == Release::HoneyDelivered {
|
||||
bee::drop_off_nectar(&mut m);
|
||||
if let Some(level) = honey.filter(|&l| l < 5) {
|
||||
let mut increase = if host.random().next_int_bound(100).unwrap_or(1) == 0 {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
};
|
||||
if level + increase > 5 {
|
||||
increase -= 1;
|
||||
}
|
||||
host.set_honey_level(at, level + increase);
|
||||
}
|
||||
}
|
||||
let delta = if blocked {
|
||||
0.0
|
||||
} else {
|
||||
0.55 + f64::from(m.width() / 2.0)
|
||||
};
|
||||
let p = horizon_core::geometry::Vec3::new(
|
||||
f64::from(at[0]) + 0.5 + delta * f64::from(facing[0]),
|
||||
f64::from(at[1]) + 0.5 - f64::from(m.height() / 2.0),
|
||||
f64::from(at[2]) + 0.5 + delta * f64::from(facing[1]),
|
||||
);
|
||||
m.position = p;
|
||||
m.previous = p;
|
||||
}
|
||||
host.sound(
|
||||
"block.beehive.exit",
|
||||
[
|
||||
f64::from(at[0]) + 0.5,
|
||||
f64::from(at[1]) + 0.5,
|
||||
f64::from(at[2]) + 0.5,
|
||||
],
|
||||
);
|
||||
let id = m.id;
|
||||
Some(host.add(m).then_some(id))
|
||||
}
|
||||
|
||||
/// `CampfireBlock.isSmokeyPos`: a lit campfire up to five blocks below,
|
||||
/// unless a block whose collision crosses the smoke's centre post comes
|
||||
/// first (a lit campfire right under that block still counts).
|
||||
pub fn smokey(w: &crate::mobs::MobWorld, at: [i32; 3]) -> bool {
|
||||
let lit = |y: i32| {
|
||||
w.has_tag("minecraft:campfires", w.block_name(at[0], y, at[2]))
|
||||
&& w.prop(at[0], y, at[2], "lit") == Some("true")
|
||||
};
|
||||
for i in 1..=5 {
|
||||
let y = at[1] - i;
|
||||
if lit(y) {
|
||||
return true;
|
||||
}
|
||||
// SHAPE_VIRTUAL_POST: Block.column(4, 0, 16), the middle quarter.
|
||||
let (x0, z0) = (f64::from(at[0]), f64::from(at[2]));
|
||||
let blocked = w.block_collision_boxes(at[0], y, at[2]).iter().any(|b| {
|
||||
let (min, max) = (b.min_position(), b.max_position());
|
||||
min.x < x0 + 0.625 && max.x > x0 + 0.375 && min.z < z0 + 0.625 && max.z > z0 + 0.375
|
||||
});
|
||||
if blocked {
|
||||
return lit(y - 1);
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// `HorizontalDirectionalBlock.FACING` as an x/z step.
|
||||
pub fn facing_step(facing: Option<&str>) -> [i32; 2] {
|
||||
match facing {
|
||||
Some("south") => [0, 1],
|
||||
Some("west") => [-1, 0],
|
||||
Some("east") => [1, 0],
|
||||
_ => [0, -1],
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn nbt_round_trips_in_vanilla_order() {
|
||||
let tag = Tag::compound([
|
||||
(
|
||||
"bees",
|
||||
Tag::List(vec![
|
||||
Occupant::create(37).to_nbt(),
|
||||
Occupant::create(0).to_nbt(),
|
||||
]),
|
||||
),
|
||||
("flower_pos", Tag::IntArray(vec![1, -60, 3])),
|
||||
]);
|
||||
let hive = Beehive::from_nbt(&tag);
|
||||
assert_eq!(hive.bees.len(), 2);
|
||||
assert_eq!(hive.bees[0].ticks_in_hive, 37);
|
||||
assert_eq!(hive.flower, Some([1, -60, 3]));
|
||||
assert_eq!(Tag::compound(hive.to_nbt()), tag);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_bee_drops_the_whole_list() {
|
||||
let tag = Tag::compound([(
|
||||
"bees",
|
||||
Tag::List(vec![
|
||||
Occupant::create(1).to_nbt(),
|
||||
Tag::compound([("ticks_in_hive", Tag::Int(3))]),
|
||||
]),
|
||||
)]);
|
||||
assert!(Beehive::from_nbt(&tag).bees.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn going_in_strips_the_ignored_tags() {
|
||||
let tag = Tag::compound([
|
||||
("id", Tag::String("minecraft:bee".into())),
|
||||
("UUID", Tag::IntArray(vec![1, 2, 3, 4])),
|
||||
("Health", Tag::Float(10.0_f32.to_bits())),
|
||||
("HasNectar", Tag::Byte(1)),
|
||||
("hive_pos", Tag::IntArray(vec![0, 0, 0])),
|
||||
]);
|
||||
let o = Occupant::of(tag);
|
||||
assert_eq!(o.min_ticks_in_hive, 2400);
|
||||
assert!(o.entity_data.get("UUID").is_none());
|
||||
assert!(o.entity_data.get("hive_pos").is_none());
|
||||
assert!(o.entity_data.get("Health").is_some());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
//! `horizon-client --benchmark SCENARIO`: the run `tools/perf_bench.py` drives from outside, driven
|
||||
//! from inside, so anyone can measure their machine with nothing but the game. A thread sends the
|
||||
//! same control commands `horizon-ctl` would, through the game's own command queue, and writes a
|
||||
//! report (docs/benchmark-report.md) to `benchmarks/` in the game folder.
|
||||
//!
|
||||
//! The scenes, the world (seed 1234, creative) and every step and figure match `tools/perf_bench.py`,
|
||||
//! so reports from the two can be compared.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::mpsc::{self, Sender};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use horizon_bench::{App, Frames, Kind, Report};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::control::Request;
|
||||
|
||||
pub const SEED: i64 = 1234;
|
||||
/// The world the benchmark creates under `saves/`, replaced on every run.
|
||||
pub const WORLD: &str = "horizon-benchmark";
|
||||
/// Written into the world so a later run only ever replaces its own world.
|
||||
const MARKER: &str = "horizon-benchmark.txt";
|
||||
|
||||
/// A straight flight over terrain: name, start, end, pitch (`tools/graphics_bench.py`'s paths).
|
||||
type Scene = (&'static str, [f64; 3], [f64; 3], f64);
|
||||
pub const SCENES: [Scene; 4] = [
|
||||
(
|
||||
"village",
|
||||
[-960.0, 125.0, 80.0],
|
||||
[-960.0, 125.0, 272.0],
|
||||
35.0,
|
||||
),
|
||||
(
|
||||
"forest",
|
||||
[-1100.0, 120.0, -560.0],
|
||||
[-1100.0, 120.0, -368.0],
|
||||
25.0,
|
||||
),
|
||||
(
|
||||
"ocean",
|
||||
[-300.0, 85.0, -420.0],
|
||||
[-300.0, 85.0, -228.0],
|
||||
10.0,
|
||||
),
|
||||
(
|
||||
"mountain",
|
||||
[560.0, 260.0, 860.0],
|
||||
[752.0, 260.0, 860.0],
|
||||
30.0,
|
||||
),
|
||||
];
|
||||
const FLIGHT_SECONDS: f64 = 12.0;
|
||||
const SETTLE: Duration = Duration::from_secs(2);
|
||||
const LOAD_LIMIT: Duration = Duration::from_secs(600);
|
||||
|
||||
/// `standard` flies every scene; a scene's name flies only that one.
|
||||
pub fn scenes(scenario: &str) -> Option<Vec<Scene>> {
|
||||
match scenario {
|
||||
"standard" => Some(SCENES.to_vec()),
|
||||
name => SCENES.iter().find(|s| s.0 == name).map(|s| vec![*s]),
|
||||
}
|
||||
}
|
||||
|
||||
/// What the game knows about the run before it starts.
|
||||
pub struct Setup {
|
||||
pub scenario: String,
|
||||
pub started: Instant,
|
||||
pub app: App,
|
||||
pub simulation_distance: u32,
|
||||
pub vsync: bool,
|
||||
/// `None`: unlimited.
|
||||
pub fps_cap: Option<u32>,
|
||||
pub offscreen: bool,
|
||||
pub game_dir: PathBuf,
|
||||
}
|
||||
|
||||
/// Starts the driver; the game quits when it is done, and the report's path is printed.
|
||||
pub fn start(requests: Sender<Request>, setup: Setup) {
|
||||
thread::Builder::new()
|
||||
.name("horizon-benchmark".into())
|
||||
.spawn(move || {
|
||||
let control = Control(requests);
|
||||
match run(&control, setup) {
|
||||
Ok(path) => println!("Horizon: benchmark report {}", path.display()),
|
||||
Err(e) => eprintln!("Horizon: benchmark failed: {e}"),
|
||||
}
|
||||
let _ = control.call("quit");
|
||||
})
|
||||
.expect("spawn the benchmark thread");
|
||||
}
|
||||
|
||||
struct Control(Sender<Request>);
|
||||
|
||||
impl Control {
|
||||
fn call(&self, line: &str) -> Result<Value, String> {
|
||||
let (reply, response) = mpsc::channel();
|
||||
self.0
|
||||
.send(Request {
|
||||
line: line.to_owned(),
|
||||
reply,
|
||||
})
|
||||
.map_err(|_| "the game has exited".to_owned())?;
|
||||
// Flights and loads reply when they finish.
|
||||
let text = response
|
||||
.recv_timeout(LOAD_LIMIT)
|
||||
.map_err(|_| format!("{line}: no reply"))?;
|
||||
let value: Value = serde_json::from_str(&text).map_err(|e| e.to_string())?;
|
||||
match value.get("error").and_then(Value::as_str) {
|
||||
Some(e) => Err(format!("{line}: {e}")),
|
||||
None => Ok(value),
|
||||
}
|
||||
}
|
||||
fn status(&self) -> Value {
|
||||
self.call("status").unwrap_or(Value::Null)
|
||||
}
|
||||
}
|
||||
|
||||
fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
|
||||
let scenes =
|
||||
scenes(&setup.scenario).ok_or_else(|| format!("unknown scenario {}", setup.scenario))?;
|
||||
let mut report = Report::new(Kind::Game, setup.app.clone());
|
||||
let pid = std::process::id();
|
||||
report.conditions.load_avg_1m = horizon_bench::load_average();
|
||||
report.conditions.power = horizon_bench::power_source().map(str::to_owned);
|
||||
let rss = RssSampler::start(pid);
|
||||
|
||||
let title = poll(control, LOAD_LIMIT, |s| {
|
||||
s["screen"] == "title" && !s["renderer"].is_null()
|
||||
})?;
|
||||
report.metric("startup.title_ms", &[ms(setup.started.elapsed())]);
|
||||
let renderer = &title["renderer"];
|
||||
report.hardware.gpu = Some(horizon_bench::Gpu {
|
||||
name: text(&renderer["gpu"]),
|
||||
vendor: match text(&renderer["vendor"]) {
|
||||
// Metal reports no vendor ID.
|
||||
v if v.is_empty() && text(&renderer["gpu"]).starts_with("Apple") => "apple".into(),
|
||||
v => v,
|
||||
},
|
||||
driver: text(&renderer["driver"]),
|
||||
backend: text(&renderer["backend"]).to_lowercase(),
|
||||
kind: text(&renderer["device_type"]),
|
||||
});
|
||||
report.hardware.display = serde_json::from_value(title["display"].clone()).ok();
|
||||
|
||||
replace_world(&setup.game_dir)?;
|
||||
control.call(&format!("open {WORLD} {SEED} creative"))?;
|
||||
std::fs::write(
|
||||
setup.game_dir.join("saves").join(WORLD).join(MARKER),
|
||||
"Created by horizon-client --benchmark; replaced by the next run.\n",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let loaded = wait_loaded(control, |_| true)?;
|
||||
report.metric("startup.world_ms", &[ms(loaded - setup.started)]);
|
||||
|
||||
let mut steady = Vec::new();
|
||||
for (name, start, end, pitch) in scenes {
|
||||
control.call("time 6000")?;
|
||||
let meshed = control.status()["meshed_sections"].as_f64().unwrap_or(0.0);
|
||||
let generated = control.status()["generated_chunks"].as_f64().unwrap_or(0.0);
|
||||
let [x, y, z] = start;
|
||||
control.call(&format!("tp {x} {y} {z}"))?;
|
||||
let teleported = Instant::now();
|
||||
// A zero-length flight holds the player at the start, flying.
|
||||
control.call(&format!("fly {x} {y} {z} {x} {y} {z} 0.1 {pitch}"))?;
|
||||
control.call(&format!("look {:.3} {pitch}", yaw(start, end)))?;
|
||||
let near = |p: &Value| {
|
||||
let p: Vec<f64> = p
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_f64)
|
||||
.collect();
|
||||
p.len() == 3 && (0..3).map(|i| (p[i] - start[i]).powi(2)).sum::<f64>() < 4.0
|
||||
};
|
||||
let loaded = wait_loaded(control, near)?;
|
||||
let load = loaded - teleported;
|
||||
report.metric(&format!("chunks.{name}.load_ms"), &[ms(load)]);
|
||||
let status = control.status();
|
||||
let meshes = status["meshed_sections"].as_f64().unwrap_or(0.0) - meshed;
|
||||
let chunks = status["generated_chunks"].as_f64().unwrap_or(0.0) - generated;
|
||||
report.metric(&format!("chunks.{name}.meshed_count"), &[meshes]);
|
||||
report.metric(&format!("chunks.{name}.generated_count"), &[chunks]);
|
||||
if load.as_secs_f64() > 0.0 {
|
||||
report.metric(
|
||||
&format!("chunks.{name}.generated_per_s"),
|
||||
&[chunks / load.as_secs_f64()],
|
||||
);
|
||||
}
|
||||
thread::sleep(SETTLE);
|
||||
let [ex, ey, ez] = end;
|
||||
let begin = Instant::now();
|
||||
let flight = control.call(&format!(
|
||||
"fly {x} {y} {z} {ex} {ey} {ez} {FLIGHT_SECONDS} {pitch} raw"
|
||||
))?;
|
||||
steady.push((begin, Instant::now()));
|
||||
let series = |key: &str| -> Vec<f32> {
|
||||
flight[key]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_f64)
|
||||
.map(|v| v as f32)
|
||||
.collect()
|
||||
};
|
||||
let frames = Frames::of(
|
||||
name,
|
||||
0,
|
||||
&series("frame_ms"),
|
||||
&series("cpu_ms"),
|
||||
&series("gpu_ms"),
|
||||
)
|
||||
.ok_or("the flight drew no frames")?;
|
||||
report.metric(&format!("fps.{name}.avg_fps"), &[frames.avg_fps]);
|
||||
report.metric(&format!("fps.{name}.low_1pct_fps"), &[frames.low_1pct_fps]);
|
||||
report.metric(
|
||||
&format!("fps.{name}.low_01pct_fps"),
|
||||
&[frames.low_01pct_fps],
|
||||
);
|
||||
report.frames.push(frames);
|
||||
}
|
||||
|
||||
let status = control.status();
|
||||
let renderer = &status["renderer"];
|
||||
let c = &mut report.conditions;
|
||||
c.scenario = Some(setup.scenario.clone());
|
||||
c.seed = Some(SEED.to_string());
|
||||
c.render_distance = status["render_distance"].as_u64().map(|d| d as u32);
|
||||
c.simulation_distance = Some(setup.simulation_distance);
|
||||
c.window = serde_json::from_value(renderer["size"].clone()).ok();
|
||||
c.vsync = Some(setup.vsync);
|
||||
c.fps_cap = setup.fps_cap;
|
||||
c.offscreen = Some(setup.offscreen);
|
||||
c.repeats = Some(1);
|
||||
c.warmup_s = Some(SETTLE.as_secs_f64());
|
||||
if let Some(bytes) = renderer["gpu_allocated_bytes"].as_f64() {
|
||||
report.metric("memory.gpu_mb", &[bytes / f64::from(1 << 20)]);
|
||||
}
|
||||
let samples = rss.stop();
|
||||
if let Some(peak) = samples.iter().map(|s| s.1).max() {
|
||||
report.metric("memory.rss_peak_mb", &[peak as f64 / f64::from(1 << 20)]);
|
||||
}
|
||||
let mut during: Vec<u64> = samples
|
||||
.iter()
|
||||
.filter(|(t, _)| steady.iter().any(|(a, b)| a <= t && t <= b))
|
||||
.map(|s| s.1)
|
||||
.collect();
|
||||
during.sort_unstable();
|
||||
if let Some(&median) = during.get(during.len() / 2) {
|
||||
report.metric(
|
||||
"memory.rss_steady_mb",
|
||||
&[median as f64 / f64::from(1 << 20)],
|
||||
);
|
||||
}
|
||||
if let Ok(meta) = std::env::current_exe().and_then(std::fs::metadata) {
|
||||
report.metric("game.binary_bytes", &[meta.len() as f64]);
|
||||
}
|
||||
crate::telemetry::keep_benchmark(&crate::logging::game_home(), &report);
|
||||
report
|
||||
.write_to(&setup.game_dir.join("benchmarks"))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Removes the previous run's world, never one the benchmark didn't create.
|
||||
fn replace_world(game_dir: &Path) -> Result<(), String> {
|
||||
let world = game_dir.join("saves").join(WORLD);
|
||||
if !world.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
if !world.join(MARKER).exists() {
|
||||
return Err(format!(
|
||||
"{} exists and was not created by the benchmark: rename it",
|
||||
world.display()
|
||||
));
|
||||
}
|
||||
std::fs::remove_dir_all(&world).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn poll(
|
||||
control: &Control,
|
||||
limit: Duration,
|
||||
ready: impl Fn(&Value) -> bool,
|
||||
) -> Result<Value, String> {
|
||||
let deadline = Instant::now() + limit;
|
||||
while Instant::now() < deadline {
|
||||
let status = control.status();
|
||||
if ready(&status) {
|
||||
return Ok(status);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
Err(format!("not ready after {} s", limit.as_secs()))
|
||||
}
|
||||
|
||||
/// Fewer section meshes than this in a second is the game remeshing blocks that change (random
|
||||
/// ticks), not loading.
|
||||
const QUIET_MESHES_PER_S: f64 = 100.0;
|
||||
|
||||
/// Waits until the player is spawned where `near` says, nothing is queued, the resident and
|
||||
/// generated chunk counts have not changed for a second and the last sections are drawn (they wait
|
||||
/// for their neighbours' light, so after the counts settle); returns when the last of these
|
||||
/// happened (`tools/perf_bench.py`'s `wait_loaded`). With `status.streaming`, the sections are
|
||||
/// drawn once no stage has held work for half a second: blocks changing after the load (fluids
|
||||
/// settling, random ticks) only remesh in bursts. Without it, meshing has to go quiet.
|
||||
fn wait_loaded(control: &Control, near: impl Fn(&Value) -> bool) -> Result<Instant, String> {
|
||||
let deadline = Instant::now() + LOAD_LIMIT;
|
||||
let (mut last, mut changed, mut meshing) = (None, Instant::now(), Instant::now());
|
||||
let mut previous: Option<(Instant, f64)> = None;
|
||||
while Instant::now() < deadline {
|
||||
let s = control.status();
|
||||
let now = Instant::now();
|
||||
let position = &s["player"]["position"];
|
||||
if position.is_null()
|
||||
|| !near(position)
|
||||
|| s["pending_chunks"] != 0
|
||||
|| s["screen"] == "title"
|
||||
{
|
||||
(last, changed, previous, meshing) = (None, now, None, now);
|
||||
} else {
|
||||
let counts = (s["chunks"].as_u64(), s["generated_chunks"].as_u64());
|
||||
if last != Some(counts) {
|
||||
(last, changed) = (Some(counts), now);
|
||||
}
|
||||
let meshed = s["meshed_sections"].as_f64().unwrap_or(0.0);
|
||||
let quiet = if let Some(stages) = s["streaming"].as_object() {
|
||||
// Protos outside the view distance wait for neighbours that are never generated.
|
||||
if stages
|
||||
.iter()
|
||||
.any(|(k, v)| k != "protos" && v.as_u64().unwrap_or(0) > 0)
|
||||
{
|
||||
meshing = now;
|
||||
}
|
||||
Duration::from_millis(500)
|
||||
} else {
|
||||
if let Some((at, before)) = previous
|
||||
&& meshed - before > QUIET_MESHES_PER_S * (now - at).as_secs_f64()
|
||||
{
|
||||
meshing = now;
|
||||
}
|
||||
Duration::from_secs(1)
|
||||
};
|
||||
previous = Some((now, meshed));
|
||||
if now - changed >= Duration::from_secs(1) && now - meshing >= quiet {
|
||||
return Ok(changed.max(meshing));
|
||||
}
|
||||
}
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
Err(format!("not loaded after {} s", LOAD_LIMIT.as_secs()))
|
||||
}
|
||||
|
||||
/// Vanilla's yaw facing from `start` to `end`.
|
||||
fn yaw(start: [f64; 3], end: [f64; 3]) -> f64 {
|
||||
(-(end[0] - start[0])).atan2(end[2] - start[2]).to_degrees()
|
||||
}
|
||||
|
||||
fn ms(d: Duration) -> f64 {
|
||||
d.as_secs_f64() * 1000.0
|
||||
}
|
||||
|
||||
fn text(v: &Value) -> String {
|
||||
v.as_str().unwrap_or_default().to_owned()
|
||||
}
|
||||
|
||||
/// Samples the process's resident memory four times a second, as `tools/perf_bench.py` does.
|
||||
struct RssSampler {
|
||||
stop: Sender<()>,
|
||||
thread: thread::JoinHandle<Vec<(Instant, u64)>>,
|
||||
}
|
||||
|
||||
impl RssSampler {
|
||||
fn start(pid: u32) -> Self {
|
||||
let (stop, stopped) = mpsc::channel();
|
||||
let thread = thread::spawn(move || {
|
||||
let mut samples = Vec::new();
|
||||
while let Err(mpsc::RecvTimeoutError::Timeout) =
|
||||
stopped.recv_timeout(Duration::from_millis(250))
|
||||
{
|
||||
samples.extend(horizon_bench::rss_bytes(pid).map(|b| (Instant::now(), b)));
|
||||
}
|
||||
samples
|
||||
});
|
||||
Self { stop, thread }
|
||||
}
|
||||
fn stop(self) -> Vec<(Instant, u64)> {
|
||||
let _ = self.stop.send(());
|
||||
self.thread.join().unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn scenarios() {
|
||||
assert_eq!(scenes("standard").unwrap().len(), 4);
|
||||
assert_eq!(scenes("ocean").unwrap()[0].0, "ocean");
|
||||
assert!(scenes("nether").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yaw_matches_vanilla() {
|
||||
// South (+Z) is 0, west (-X) 90, east (+X) -90.
|
||||
assert!((yaw([0.0; 3], [0.0, 0.0, 10.0])).abs() < 1e-9);
|
||||
assert!((yaw([0.0; 3], [-10.0, 0.0, 0.0]) - 90.0).abs() < 1e-9);
|
||||
assert!((yaw([0.0; 3], [10.0, 0.0, 0.0]) + 90.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_replaces_its_own_world() {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("horizon-benchmark-test-{}", std::process::id()));
|
||||
let world = dir.join("saves").join(WORLD);
|
||||
std::fs::create_dir_all(&world).unwrap();
|
||||
assert!(
|
||||
replace_world(&dir).is_err(),
|
||||
"a player's world of that name stays"
|
||||
);
|
||||
assert!(world.exists());
|
||||
std::fs::write(world.join(MARKER), "").unwrap();
|
||||
replace_world(&dir).unwrap();
|
||||
assert!(!world.exists());
|
||||
replace_world(&dir).unwrap();
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
//! horizon-ctl: drive a running Horizon client through its control socket.
|
||||
#![forbid(unsafe_code)]
|
||||
use std::{
|
||||
io::{BufRead, BufReader, Write},
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, ExitCode, Stdio},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use horizon_client::control::UnixStream;
|
||||
|
||||
const USAGE: &str = "usage: horizon-ctl [--socket PATH] COMMAND [ARGS...]
|
||||
|
||||
Local commands:
|
||||
launch [GAME ARGS...] start horizon-client with a control socket and wait for it
|
||||
hold KEY SECONDS press KEY, wait, release it
|
||||
wait-ready [SECONDS] wait until the player has spawned and no chunks are pending
|
||||
- run commands from stdin, one per line (\"sleep S\" waits locally)
|
||||
|
||||
Game commands (run \"horizon-ctl help\" against a running game for the full list):
|
||||
status, key, mouse, look, turn, tp, hotbar, give, render-distance, time,
|
||||
screenshot PATH, stats, bench SECONDS, open, save, quit
|
||||
|
||||
Replies are JSON lines; the exit status is 1 when the reply has an \"error\".
|
||||
The socket defaults to $HORIZON_CONTROL or $XDG_RUNTIME_DIR/horizon-control.sock.";
|
||||
|
||||
struct Client {
|
||||
writer: UnixStream,
|
||||
reader: BufReader<UnixStream>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
fn connect(path: &Path) -> Result<Self, String> {
|
||||
let stream = UnixStream::connect(path).map_err(|e| {
|
||||
format!(
|
||||
"cannot connect to {} ({e}); is the game running with --control?",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
let reader = BufReader::new(stream.try_clone().map_err(|e| e.to_string())?);
|
||||
Ok(Self {
|
||||
writer: stream,
|
||||
reader,
|
||||
})
|
||||
}
|
||||
fn send(&mut self, line: &str) -> Result<String, String> {
|
||||
writeln!(self.writer, "{line}").map_err(|e| e.to_string())?;
|
||||
let mut reply = String::new();
|
||||
if self
|
||||
.reader
|
||||
.read_line(&mut reply)
|
||||
.map_err(|e| e.to_string())?
|
||||
== 0
|
||||
{
|
||||
return Err("the game closed the connection".into());
|
||||
}
|
||||
Ok(reply.trim_end().to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
fn is_error(reply: &str) -> bool {
|
||||
serde_json::from_str::<serde_json::Value>(reply).map_or(true, |v| v.get("error").is_some())
|
||||
}
|
||||
|
||||
/// Screenshot paths are written by the game process: make them absolute here.
|
||||
fn absolute_paths(words: &mut [String]) {
|
||||
if words.first().map(String::as_str) == Some("screenshot")
|
||||
&& let Some(path) = words.get_mut(1)
|
||||
&& let Ok(cwd) = std::env::current_dir()
|
||||
{
|
||||
*path = cwd.join(&*path).display().to_string();
|
||||
}
|
||||
}
|
||||
|
||||
fn run_line(client: &mut Client, words: &mut [String]) -> Result<bool, String> {
|
||||
match words.first().map(String::as_str) {
|
||||
Some("sleep") => {
|
||||
let seconds: f64 = words
|
||||
.get(1)
|
||||
.ok_or("sleep SECONDS")?
|
||||
.parse()
|
||||
.map_err(|_| "sleep SECONDS")?;
|
||||
thread::sleep(Duration::from_secs_f64(seconds));
|
||||
Ok(true)
|
||||
}
|
||||
Some("hold") => {
|
||||
let (key, seconds) = match words {
|
||||
[_, key, seconds] => (
|
||||
key.clone(),
|
||||
seconds.parse::<f64>().map_err(|_| "hold KEY SECONDS")?,
|
||||
),
|
||||
_ => return Err("hold KEY SECONDS".into()),
|
||||
};
|
||||
let down = client.send(&format!("key {key} down"))?;
|
||||
if is_error(&down) {
|
||||
println!("{down}");
|
||||
return Ok(false);
|
||||
}
|
||||
thread::sleep(Duration::from_secs_f64(seconds));
|
||||
let up = client.send(&format!("key {key} up"))?;
|
||||
println!("{up}");
|
||||
Ok(!is_error(&up))
|
||||
}
|
||||
Some("wait-ready") => {
|
||||
let limit = words
|
||||
.get(1)
|
||||
.map_or(Ok(120.0), |s| s.parse::<f64>())
|
||||
.map_err(|_| "wait-ready [SECONDS]")?;
|
||||
let deadline = Instant::now() + Duration::from_secs_f64(limit);
|
||||
loop {
|
||||
let reply = client.send("status")?;
|
||||
let status: serde_json::Value =
|
||||
serde_json::from_str(&reply).map_err(|e| e.to_string())?;
|
||||
if !status["player"].is_null() && status["pending_chunks"] == 0 {
|
||||
println!("{reply}");
|
||||
return Ok(true);
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
println!(
|
||||
"{{\"error\":\"not ready after {limit} seconds\",\"status\":{reply}}}"
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
absolute_paths(words);
|
||||
let reply = client.send(&words.join(" "))?;
|
||||
println!("{reply}");
|
||||
Ok(!is_error(&reply))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn launch(socket: &Path, args: &[String]) -> Result<bool, String> {
|
||||
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
|
||||
let game = exe.with_file_name("horizon-client");
|
||||
let log = socket.with_extension("log");
|
||||
let file = std::fs::File::create(&log).map_err(|e| format!("{}: {e}", log.display()))?;
|
||||
let child = Command::new(&game)
|
||||
.args(args)
|
||||
.arg("--control-socket")
|
||||
.arg(socket)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(file.try_clone().map_err(|e| e.to_string())?)
|
||||
.stderr(file)
|
||||
.spawn()
|
||||
.map_err(|e| format!("{}: {e}", game.display()))?;
|
||||
let deadline = Instant::now() + Duration::from_secs(300);
|
||||
while Instant::now() < deadline {
|
||||
if UnixStream::connect(socket).is_ok() {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({ "ok": true, "pid": child.id(), "socket": socket, "log": log })
|
||||
);
|
||||
return Ok(true);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({ "error": "control socket did not open within 300 seconds", "log": log })
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let mut args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let mut socket = horizon_client::control::default_path();
|
||||
if args.first().map(String::as_str) == Some("--socket") && args.len() >= 2 {
|
||||
socket = PathBuf::from(args.remove(1));
|
||||
args.remove(0);
|
||||
}
|
||||
let result = match args.first().map(String::as_str) {
|
||||
None | Some("-h" | "--help") => {
|
||||
println!("{USAGE}");
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
Some("launch") => launch(&socket, &args[1..]),
|
||||
Some("-") => Client::connect(&socket).and_then(|mut client| {
|
||||
let mut all_ok = true;
|
||||
for line in std::io::stdin().lock().lines() {
|
||||
let line = line.map_err(|e| e.to_string())?;
|
||||
let mut words: Vec<String> = line.split_whitespace().map(str::to_owned).collect();
|
||||
if words.is_empty() || words[0].starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
all_ok &= run_line(&mut client, &mut words)?;
|
||||
}
|
||||
Ok(all_ok)
|
||||
}),
|
||||
Some(_) => Client::connect(&socket).and_then(|mut client| run_line(&mut client, &mut args)),
|
||||
};
|
||||
match result {
|
||||
Ok(true) => ExitCode::SUCCESS,
|
||||
Ok(false) => ExitCode::from(1),
|
||||
Err(e) => {
|
||||
eprintln!("horizon-ctl: {e}");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,647 @@
|
||||
//! Every command horizon-ctl knows: its usage, help, examples and arguments.
|
||||
//! The same table prints `help`, checks `COMMAND --help` and describes the
|
||||
//! MCP tools, so the CLI and the MCP server always offer the same commands.
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Ty {
|
||||
Int,
|
||||
Num,
|
||||
Str,
|
||||
Bool,
|
||||
/// One of these words.
|
||||
Enum(&'static [&'static str]),
|
||||
/// The rest of the line, spaces included.
|
||||
Rest,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Param {
|
||||
pub name: &'static str,
|
||||
pub ty: Ty,
|
||||
pub required: bool,
|
||||
/// Sent as `--name value` (`--name` alone for booleans), not by position.
|
||||
pub flag: bool,
|
||||
pub help: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Group {
|
||||
Session,
|
||||
Observe,
|
||||
Move,
|
||||
Act,
|
||||
Screens,
|
||||
World,
|
||||
Scripting,
|
||||
}
|
||||
impl Group {
|
||||
pub const ALL: [Group; 7] = [
|
||||
Group::Session,
|
||||
Group::Observe,
|
||||
Group::Move,
|
||||
Group::Act,
|
||||
Group::Screens,
|
||||
Group::World,
|
||||
Group::Scripting,
|
||||
];
|
||||
pub fn title(self) -> &'static str {
|
||||
match self {
|
||||
Group::Session => "Session",
|
||||
Group::Observe => "Observe (JSON state)",
|
||||
Group::Move => "Move and look",
|
||||
Group::Act => "Act on the world",
|
||||
Group::Screens => "Screens, menus and chat",
|
||||
Group::World => "World and cheats",
|
||||
Group::Scripting => "Scripting and benchmarks",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Cmd {
|
||||
pub name: &'static str,
|
||||
/// What the game receives instead of `name` (aliases such as look-at-block).
|
||||
pub wire: Option<&'static str>,
|
||||
pub group: Group,
|
||||
pub usage: &'static str,
|
||||
pub summary: &'static str,
|
||||
pub details: &'static str,
|
||||
pub examples: &'static [&'static str],
|
||||
pub params: &'static [Param],
|
||||
/// Offered as an MCP tool.
|
||||
pub mcp: bool,
|
||||
/// Runs in horizon-ctl itself, not in the game.
|
||||
pub local: bool,
|
||||
}
|
||||
|
||||
const fn p(name: &'static str, ty: Ty, help: &'static str) -> Param {
|
||||
Param {
|
||||
name,
|
||||
ty,
|
||||
required: true,
|
||||
flag: false,
|
||||
help,
|
||||
}
|
||||
}
|
||||
const fn opt(name: &'static str, ty: Ty, help: &'static str) -> Param {
|
||||
Param {
|
||||
name,
|
||||
ty,
|
||||
required: false,
|
||||
flag: false,
|
||||
help,
|
||||
}
|
||||
}
|
||||
const fn flag(name: &'static str, ty: Ty, help: &'static str) -> Param {
|
||||
Param {
|
||||
name,
|
||||
ty,
|
||||
required: false,
|
||||
flag: true,
|
||||
help,
|
||||
}
|
||||
}
|
||||
const XYZ: [Param; 3] = [
|
||||
p("x", Ty::Num, "X coordinate"),
|
||||
p("y", Ty::Num, "Y coordinate"),
|
||||
p("z", Ty::Num, "Z coordinate"),
|
||||
];
|
||||
const BLOCK_XYZ: [Param; 3] = [
|
||||
p("x", Ty::Int, "block X"),
|
||||
p("y", Ty::Int, "block Y"),
|
||||
p("z", Ty::Int, "block Z"),
|
||||
];
|
||||
const OVER: Param = flag(
|
||||
"over",
|
||||
Ty::Num,
|
||||
"turn with mouse motion spread over this many seconds (eased), as a player would",
|
||||
);
|
||||
const FACE: Ty = Ty::Enum(&["down", "up", "north", "south", "west", "east"]);
|
||||
|
||||
macro_rules! cmd {
|
||||
($name:expr, $group:ident, $usage:expr, $summary:expr, $details:expr, [$($ex:expr),* $(,)?], $params:expr, mcp: $mcp:expr, local: $local:expr $(, wire: $wire:expr)?) => {
|
||||
Cmd {
|
||||
name: $name,
|
||||
wire: cmd!(@wire $($wire)?),
|
||||
group: Group::$group,
|
||||
usage: $usage,
|
||||
summary: $summary,
|
||||
details: $details,
|
||||
examples: &[$($ex),*],
|
||||
params: $params,
|
||||
mcp: $mcp,
|
||||
local: $local,
|
||||
}
|
||||
};
|
||||
(@wire) => { None };
|
||||
(@wire $w:expr) => { Some($w) };
|
||||
}
|
||||
|
||||
pub const COMMANDS: &[Cmd] = &[
|
||||
// Session
|
||||
cmd!("launch", Session, "launch [--world NAME] [--seed N] [--creative] [--version TAG] [--game PATH] [--wait] [GAME ARGS...]",
|
||||
"start Horizon with the control socket and wait until it answers",
|
||||
"Finds the game in this order: --game PATH; a horizon-client next to horizon-ctl (a build folder, a release folder or a launcher install); the newest version installed by the Horizon launcher (--version TAG picks one). Launcher installs run with their prepared resources and keep saves in the game home ($HORIZON_HOME, else ~/.local/share/horizon). --wait also waits until the player has spawned and chunks are loaded. Other arguments go to the game (horizon-client --help lists them). The game's output goes to a .log file beside the socket.",
|
||||
["horizon-ctl launch --world Survival --seed 42 --wait", "horizon-ctl launch --world Flat --creative --render-distance 8"],
|
||||
&[flag("world", Ty::Str, "world folder to open or create"), flag("seed", Ty::Str, "seed for a new world"), flag("creative", Ty::Bool, "creative mode for a new world"),
|
||||
flag("version", Ty::Str, "an installed launcher version tag such as v2026.10.13"), flag("game", Ty::Str, "path to a horizon-client binary"),
|
||||
flag("wait", Ty::Bool, "also wait until the world is ready (default in MCP)"),
|
||||
flag("offscreen", Ty::Bool, "render without showing a window (screenshots still work): locked or headless sessions")],
|
||||
mcp: true, local: true),
|
||||
cmd!("wait-for", Session, "wait-for screen NAME|widget ID|loaded|chat TEXT|event KIND [--timeout S] [--since SEQ]", "wait until the game reaches a state, then reply",
|
||||
"screen takes the names state shows (playing, inventory, paused, dead, title...); widget an id from ui; loaded means a world is open, the player is in it and no chunk is pending; chat a new line containing TEXT; event a kind from events (death, respawn, item, screen...). chat and event count from --since SEQ (take last from events before acting), else from now. A timeout (default 30 s) is an error with kind timeout.",
|
||||
["horizon-ctl wait-for screen inventory", "horizon-ctl wait-for loaded --timeout 120", "horizon-ctl wait-for chat joined the game", "horizon-ctl wait-for event death --timeout 600"],
|
||||
&[flag("timeout", Ty::Num, "seconds (default 30)"), flag("since", Ty::Int, "chat and event: count from this event seq"), p("condition", Ty::Enum(&["screen", "widget", "loaded", "chat", "event"]), "what to wait for"), opt("value", Ty::Rest, "the screen name, widget id, chat text or event kind")], mcp: true, local: false),
|
||||
cmd!("events", Observe, "events [--since SEQ]", "what happened since SEQ, oldest first: screens, chat, deaths, respawns, items gained or lost, dimensions, worlds",
|
||||
"Every event has seq and tick; pass the reply's last as --since next time. The game keeps the newest 256.",
|
||||
["horizon-ctl events", "horizon-ctl events --since 42"], &[flag("since", Ty::Int, "only events after this seq")], mcp: true, local: false),
|
||||
cmd!("wait-ready", Session, "wait-ready [SECONDS]", "wait until the player has spawned and no chunks are pending",
|
||||
"Polls status every 250 ms; replies with the status, or an error after SECONDS (default 120).",
|
||||
["horizon-ctl wait-ready 300"], &[opt("seconds", Ty::Num, "how long to wait (default 120)")], mcp: true, local: true),
|
||||
cmd!("logs", Session, "logs [--lines N] [--crash]", "the end of the game's log, or its newest crash report",
|
||||
"Reads the game home ($HORIZON_HOME, else ~/.local/share/horizon): logs/latest.log (the last N lines, default 100) or, with --crash, the newest file in crash-reports. Read-only; works whether the game runs or not.",
|
||||
["horizon-ctl logs", "horizon-ctl logs --lines 300", "horizon-ctl logs --crash"],
|
||||
&[flag("lines", Ty::Int, "how many lines of latest.log (default 100)"), flag("crash", Ty::Bool, "the newest crash report instead")],
|
||||
mcp: true, local: true),
|
||||
cmd!("status", Session, "status", "game, renderer and streaming state (the scripting socket's original summary)",
|
||||
"", ["horizon-ctl status"], &[], mcp: false, local: false),
|
||||
cmd!("open", Session, "open WORLD [SEED] [creative]", "open or create a world from the title screen",
|
||||
"Opens saves/WORLD, creating it with SEED (and creative mode) when it doesn't exist.",
|
||||
["horizon-ctl open Test 12345", "horizon-ctl open Build 0 creative"],
|
||||
&[p("world", Ty::Str, "world folder"), opt("seed", Ty::Int, "seed for a new world"), opt("mode", Ty::Enum(&["creative"]), "creative for a creative world")], mcp: true, local: false),
|
||||
cmd!("save", Session, "save", "save the world", "", ["horizon-ctl save"], &[], mcp: true, local: false),
|
||||
cmd!("quit", Session, "quit", "save and exit the game", "", ["horizon-ctl quit"], &[], mcp: true, local: false),
|
||||
cmd!("screenshot", Session, "screenshot PATH", "write the next rendered frame as a PNG",
|
||||
"Relative paths are taken from horizon-ctl's directory. Over MCP the image is returned directly.",
|
||||
["horizon-ctl screenshot view.png"], &[], mcp: true, local: false),
|
||||
// Observe
|
||||
cmd!("state", Observe, "state", "player, world, screen and crosshair target in one reply",
|
||||
"position, block_pos, eye, yaw, pitch, velocity, on_ground, health, food, saturation, xp, effects, gamemode, dimension, time, weather, the screen or title menu, the held item, what the crosshair points at and whether an action is running.",
|
||||
["horizon-ctl state"], &[], mcp: true, local: false),
|
||||
cmd!("looking-at", Observe, "looking-at", "the block or entity under the crosshair",
|
||||
"kind is block, entity or none; a block has pos, state, face, point and distance; an entity has id, type, health and distance.",
|
||||
["horizon-ctl looking-at"], &[], mcp: true, local: false),
|
||||
cmd!("entities", Observe, "entities [RADIUS] [TYPE]", "mobs, dropped items and experience orbs near the player, nearest first",
|
||||
"RADIUS defaults to 32. TYPE filters by entity type (pig, minecraft:item, ...). Mobs have an id for look-at entity and attack.",
|
||||
["horizon-ctl entities", "horizon-ctl entities 16 cow", "horizon-ctl entities 8 item"],
|
||||
&[opt("radius", Ty::Num, "search radius in blocks (default 32)"), opt("type", Ty::Str, "entity type filter")], mcp: true, local: false),
|
||||
cmd!("block", Observe, "block X Y Z", "the block state at a position", "",
|
||||
["horizon-ctl block 10 64 -3"], &BLOCK_XYZ, mcp: true, local: false),
|
||||
cmd!("blocks", Observe, "blocks X1 Y1 Z1 X2 Y2 Z2", "the non-air blocks of a box, with counts per block",
|
||||
"At most 32768 blocks per request.", ["horizon-ctl blocks 0 60 0 15 70 15"],
|
||||
&[p("x1", Ty::Int, "corner X"), p("y1", Ty::Int, "corner Y"), p("z1", Ty::Int, "corner Z"), p("x2", Ty::Int, "opposite X"), p("y2", Ty::Int, "opposite Y"), p("z2", Ty::Int, "opposite Z")], mcp: true, local: false),
|
||||
cmd!("find-block", Observe, "find-block BLOCK [RADIUS]", "the nearest loaded blocks of a kind",
|
||||
"Searches a cube of RADIUS (default 32, at most 64) around the player; returns the count and the 16 nearest.",
|
||||
["horizon-ctl find-block oak_log", "horizon-ctl find-block diamond_ore 48"],
|
||||
&[p("block", Ty::Str, "block id such as oak_log"), opt("radius", Ty::Int, "search radius (default 32)")], mcp: true, local: false),
|
||||
cmd!("inventory", Observe, "inventory", "hotbar, main inventory, armor, offhand, carried stack and item counts",
|
||||
"Slots carry the ids click-slot takes: 0-8 hotbar, 9-35 inventory, 500-503 armor (head to feet), 504 offhand. Stacks with components list them as SNBT.",
|
||||
["horizon-ctl inventory"], &[], mcp: true, local: false),
|
||||
cmd!("screen", Observe, "screen", "the open screen or title menu, its buttons and its slots",
|
||||
"buttons are what click-button takes; slots are what click-slot takes (crafting grid 100-103 in the inventory, 100-108 at a crafting table, result 200, containers from 300, stations from 600). On the title screen, menu has the mode, worlds and the create-world fields.",
|
||||
["horizon-ctl screen"], &[], mcp: true, local: false),
|
||||
cmd!("messages", Observe, "messages [N]", "recent chat and system messages, the title and the action bar",
|
||||
"N defaults to 20; chat is newest first.", ["horizon-ctl messages 5"], &[opt("n", Ty::Int, "how many chat lines (default 20)")], mcp: true, local: false),
|
||||
cmd!("menu", Observe, "menu", "the open menu's slots and carried stack, with station details", "",
|
||||
["horizon-ctl menu"], &[], mcp: false, local: false),
|
||||
// Move
|
||||
cmd!("move", Move, "move forward|back|left|right SECONDS [--sprint] [--sneak] [--jump]", "walk in a direction relative to the view for a time",
|
||||
"Replies when done with the start and end positions.",
|
||||
["horizon-ctl move forward 2", "horizon-ctl move forward 1.5 --sprint --jump"],
|
||||
&[p("direction", Ty::Enum(&["forward", "back", "left", "right"]), "direction relative to the view"), p("seconds", Ty::Num, "how long to walk"),
|
||||
flag("sprint", Ty::Bool, "sprint"), flag("sneak", Ty::Bool, "sneak"), flag("jump", Ty::Bool, "keep jumping")], mcp: true, local: false),
|
||||
cmd!("goto", Move, "goto X Y Z [--near R] [--sprint] [--direct] [--timeout S]", "walk to a position, finding a path through loaded terrain",
|
||||
"A* over standable blocks (steps up one block, drops up to three, avoids lava, swims when it must), then steering with jumps when stuck. Without a path it fails at once (--direct walks straight at the target anyway, falls included). --near R stops within R blocks (use 1.5-3 to reach a block you then break). Replies with arrived, position and distance, or an error when stuck or out of time (default 60 s).",
|
||||
["horizon-ctl goto 120 70 -40", "horizon-ctl goto 8 64 3 --near 2 --sprint"],
|
||||
&[XYZ[0], XYZ[1], XYZ[2], flag("near", Ty::Num, "arrive within this many blocks (default 0.6)"), flag("sprint", Ty::Bool, "sprint"), flag("direct", Ty::Bool, "walk straight when no path is found"), flag("timeout", Ty::Num, "give up after this many seconds (default 60)")], mcp: true, local: false),
|
||||
cmd!("jump", Move, "jump", "jump once and reply when landed", "", ["horizon-ctl jump"], &[], mcp: true, local: false),
|
||||
cmd!("look", Move, "look YAW PITCH", "set the view direction in degrees",
|
||||
"Yaw 0 faces south (+Z), 90 west (-X), 180 north, -90 east; pitch 90 looks straight down.",
|
||||
["horizon-ctl look 90 10"], &[p("yaw", Ty::Num, "yaw in degrees"), p("pitch", Ty::Num, "pitch in degrees (-90 up .. 90 down)")], mcp: true, local: false),
|
||||
cmd!("turn", Move, "turn DYAW DPITCH [--over S]", "rotate the view by an amount",
|
||||
"Without --over the view jumps. With --over S the turn is mouse motion fed over S seconds with ease-in-out, through the same path as a real mouse (sensitivity, inverted axes, cinematic camera), and the reply comes once it is done.",
|
||||
["horizon-ctl turn 45 0", "horizon-ctl turn -90 10 --over 0.5"],
|
||||
&[p("dyaw", Ty::Num, "yaw change in degrees"), p("dpitch", Ty::Num, "pitch change in degrees"), OVER], mcp: true, local: false),
|
||||
cmd!("mouse-move", Move, "mouse-move DX DY", "move the mouse: relative motion that turns the view like a player's mouse",
|
||||
"DX and DY are mouse units (pixels of relative motion) added to the frame's accumulated movement and turned by MouseHandler.turnPlayer: degrees = units x 0.15 x (sensitivity x 0.6 + 0.2)^3 x 8 (without the 8 while scoping), inverted axes and the cinematic camera included. Positive DX turns right, positive DY looks down. Works in the world with no screen open; replies with yaw and pitch after the frame that turned.",
|
||||
["horizon-ctl mouse-move 100 0", "horizon-ctl mouse-move -25 40"],
|
||||
&[p("dx", Ty::Num, "horizontal motion (positive turns right)"), p("dy", Ty::Num, "vertical motion (positive looks down)")], mcp: true, local: false),
|
||||
cmd!("look-at", Move, "look-at X Y Z | look-at block X Y Z [FACE] | look-at entity ID [--over S]", "turn to face a point, a block (a visible face of it) or an entity",
|
||||
"look-at block aims at a face the crosshair can reach and fails when none is visible. --over S turns there with mouse motion over S seconds (the shorter way round) instead of jumping.",
|
||||
["horizon-ctl look-at 10.5 65 3.5", "horizon-ctl look-at block 10 64 3 up", "horizon-ctl look-at entity 42 --over 0.4"],
|
||||
&[XYZ[0], XYZ[1], XYZ[2], OVER], mcp: true, local: false),
|
||||
cmd!("look-at-block", Move, "look-at-block X Y Z [FACE] [--over S]", "aim the crosshair at a block (same as look-at block)", "",
|
||||
["horizon-ctl look-at-block 10 64 3", "horizon-ctl look-at-block 10 64 3 up --over 0.3"], &[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], opt("face", FACE, "which face to aim at"), OVER], mcp: true, local: false, wire: "look-at block"),
|
||||
cmd!("look-at-entity", Move, "look-at-entity ID [--over S]", "face an entity (same as look-at entity)", "",
|
||||
["horizon-ctl look-at-entity 42"], &[p("id", Ty::Int, "entity id from entities"), OVER], mcp: true, local: false, wire: "look-at entity"),
|
||||
cmd!("tp", Move, "tp X Y Z", "teleport the player", "", ["horizon-ctl tp 0 80 0"], &XYZ, mcp: true, local: false),
|
||||
// Act
|
||||
cmd!("break", Act, "break X Y Z [--timeout S]", "mine a block, holding the attack button until it breaks",
|
||||
"Aims at a visible face (the block must be within reach: use goto --near 2 first), mines with the held item, then waits up to a second for the drops. Replies with block_before, block, ticks, drops and picked_up (inventory change).",
|
||||
["horizon-ctl break 10 64 3"], &[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], flag("timeout", Ty::Num, "give up after this many seconds (default 30)")], mcp: true, local: false),
|
||||
cmd!("place", Act, "place X Y Z [--item ITEM]", "place the held item (or ITEM from the hotbar) at an empty position",
|
||||
"Clicks the face of a solid neighbour touching X Y Z. Replies with the block placed and the inventory change.",
|
||||
["horizon-ctl place 10 64 4 --item oak_planks"], &[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], flag("item", Ty::Str, "hotbar item to select first")], mcp: true, local: false),
|
||||
cmd!("interact", Act, "interact X Y Z [FACE]", "right-click a block: open a chest or crafting table, press a button, open a door",
|
||||
"Replies with the screen afterwards and whether the block changed.", ["horizon-ctl interact 5 64 2"],
|
||||
&[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], opt("face", FACE, "which face to click")], mcp: true, local: false),
|
||||
cmd!("use-item", Act, "use-item [--hold SECONDS]", "right-click with the held item: eat, drink, throw, draw a bow",
|
||||
"Hold for 1.7 s to eat.", ["horizon-ctl use-item --hold 1.7"], &[flag("hold", Ty::Num, "hold the button this long")], mcp: true, local: false),
|
||||
cmd!("attack", Act, "attack [ID]", "swing at what the crosshair targets (after facing entity ID)",
|
||||
"Replies with the target's health before and after.", ["horizon-ctl attack", "horizon-ctl attack 42"],
|
||||
&[opt("id", Ty::Int, "entity id to face first")], mcp: true, local: false),
|
||||
cmd!("select", Act, "select SLOT|ITEM", "select a hotbar slot 1-9, or the hotbar slot holding ITEM", "",
|
||||
["horizon-ctl select 3", "horizon-ctl select oak_planks"], &[p("slot", Ty::Str, "1-9 or an item id")], mcp: true, local: false),
|
||||
cmd!("drop", Act, "drop [all]", "drop one of the held item, or the whole stack", "", ["horizon-ctl drop", "horizon-ctl drop all"],
|
||||
&[opt("all", Ty::Bool, "drop the whole stack")], mcp: true, local: false),
|
||||
cmd!("hold", Act, "hold KEY SECONDS", "press a key, wait, release it", "Keys as for key.", ["horizon-ctl hold w 2"],
|
||||
&[p("key", Ty::Str, "key name"), p("seconds", Ty::Num, "how long")], mcp: false, local: true),
|
||||
cmd!("key", Act, "key KEY down|up|tap|repeat", "press, release or auto-repeat a key (w a s d space shift ctrl e q esc f3 1-9 arrows ...)",
|
||||
"down holds the key until key ... up, release or stop; wait does not release it. move, goto, jump, break and the other actions release the movement keys when they end.",
|
||||
["horizon-ctl key space tap"], &[p("key", Ty::Str, "key name"), p("action", Ty::Enum(&["down", "up", "tap", "repeat"]), "what to do")], mcp: true, local: false),
|
||||
cmd!("mouse", Act, "mouse left|right|middle down|up|click", "press or release a mouse button in the world", "",
|
||||
["horizon-ctl mouse right click"], &[p("button", Ty::Enum(&["left", "right", "middle"]), "button"), p("action", Ty::Enum(&["down", "up", "click"]), "what to do")], mcp: false, local: false),
|
||||
cmd!("pad", Act, "pad BUTTON down|up|tap | pad stick left|right X Y | pad trigger left|right V | pad release | pad status", "a virtual controller: buttons, sticks and triggers as a real pad would send them",
|
||||
"Buttons by position: south east west north lb rb lt rt select start ls rs up down left right (south is Xbox A / PlayStation cross). Sticks run -1..1 with up positive and stay tilted until centred; triggers 0..1. Merged with any real controller; status lists the connected ones and what is held.",
|
||||
["horizon-ctl pad south tap", "horizon-ctl pad stick left 0 1", "horizon-ctl pad trigger right 1", "horizon-ctl pad status"],
|
||||
&[p("what", Ty::Str, "a button name, stick, trigger, release or status"), opt("a", Ty::Str, "down|up|tap, or left|right"), opt("b", Ty::Num, "stick X or trigger value"), opt("c", Ty::Num, "stick Y")], mcp: false, local: false),
|
||||
cmd!("release", Act, "release", "release every key and mouse button", "", ["horizon-ctl release"], &[], mcp: false, local: false),
|
||||
cmd!("stop", Act, "stop", "cancel the running action (goto, break...) and release every key", "", ["horizon-ctl stop"], &[], mcp: true, local: false),
|
||||
// Screens
|
||||
cmd!("open-inventory", Screens, "open-inventory", "open the inventory screen (crafting grid 100-103, result 200)", "",
|
||||
["horizon-ctl open-inventory"], &[], mcp: true, local: false),
|
||||
cmd!("click-slot", Screens, "click-slot SLOT [right] [shift]", "click a slot of the open inventory or menu",
|
||||
"SLOT ids as listed by screen: 0-8 hotbar, 9-35 inventory, 100+ crafting grid, 200 result, 300+ container, 500-503 armor, 504 offhand, 600+ station. A left click picks up or puts down the carried stack; right puts down one item; shift moves the stack across.",
|
||||
["horizon-ctl click-slot 36", "horizon-ctl click-slot 100 right", "horizon-ctl click-slot 200 shift"],
|
||||
&[p("slot", Ty::Int, "slot id"), opt("right", Ty::Bool, "right-click"), opt("shift", Ty::Bool, "shift-click")], mcp: true, local: false),
|
||||
cmd!("close", Screens, "close", "close the open menu and return to the game", "", ["horizon-ctl close"], &[], mcp: true, local: false),
|
||||
cmd!("ui", Screens, "ui", "every widget of the open screen: id, kind, label, bounds, value",
|
||||
"Kinds: button, slider (value 0..1), field, row (worlds, servers, packs, languages), tab (selected), slot (slot-ID, with its stack). Ids come from labels (\"FOV: 70\" is fov, a repeat gets -2) or kind-N without one, and stay put when a value changes. Reflects the last drawn frame (frame counts them).",
|
||||
["horizon-ctl ui"], &[], mcp: true, local: false),
|
||||
cmd!("click", Screens, "click ID [right] [shift]", "click a widget of ui by its id, as the mouse would",
|
||||
"A field takes focus (then type); slot-ID clicks a slot like click-slot.",
|
||||
["horizon-ctl click done", "horizon-ctl click video-settings", "horizon-ctl click row-0", "horizon-ctl click slot-0 shift"],
|
||||
&[p("id", Ty::Str, "widget id from ui"), opt("right", Ty::Bool, "right-click"), opt("shift", Ty::Bool, "shift-click (slots)")], mcp: true, local: false),
|
||||
cmd!("set", Screens, "set ID VALUE", "move a slider of ui to VALUE (0..1), as a click on its track",
|
||||
"The option behind it rounds to its own steps; read the new label with ui.",
|
||||
["horizon-ctl set fov 0.5", "horizon-ctl set render-distance 0"],
|
||||
&[p("id", Ty::Str, "slider id from ui"), p("value", Ty::Num, "0..1 along the track")], mcp: true, local: false),
|
||||
cmd!("options-get", Screens, "options-get [KEY...]", "the game's options (options.txt) by key, with their type and range or choices",
|
||||
"Keys are vanilla's: fov, renderDistance, guiScale, gamma, maxFps, lang, resourcePacks, soundCategory_music, key_key.jump... Enums are given and set by name, key bindings by key name (key.keyboard.w).",
|
||||
["horizon-ctl options-get", "horizon-ctl options-get fov renderDistance"], &[opt("keys", Ty::Rest, "option keys (default: all)")], mcp: true, local: false),
|
||||
cmd!("options-set", Screens, "options-set KEY VALUE", "set one option, apply it at once and save options.txt, as the options screen does",
|
||||
"A value the option refuses is an error saying what it accepts. Lists are JSON: options-set resourcePacks [\"vanilla\"].",
|
||||
["horizon-ctl options-set fov 90", "horizon-ctl options-set renderClouds fast", "horizon-ctl options-set key_key.jump key.keyboard.j"],
|
||||
&[p("key", Ty::Str, "option key from options-get"), p("value", Ty::Rest, "the new value")], mcp: true, local: false),
|
||||
cmd!("click-button", Screens, "click-button LABEL", "click a button of the open screen or title menu by its label",
|
||||
"Case-insensitive; a unique part of the label is enough. screen lists the buttons.",
|
||||
["horizon-ctl click-button Singleplayer", "horizon-ctl click-button \"Create New World\"", "horizon-ctl click-button respawn"],
|
||||
&[p("label", Ty::Rest, "button label")], mcp: true, local: false),
|
||||
cmd!("click-at", Screens, "click-at X Y [right]", "click at GUI pixel X Y of the window (screen gives button rectangles)", "",
|
||||
["horizon-ctl click-at 200 120"], &[p("x", Ty::Num, "GUI x"), p("y", Ty::Num, "GUI y"), opt("right", Ty::Bool, "right-click")], mcp: true, local: false),
|
||||
cmd!("drag-at", Screens, "drag-at [right] X Y X Y...", "press at the first GUI point, move through the others, release at the last", "",
|
||||
["horizon-ctl drag-at 130 100 148 100 166 100", "horizon-ctl drag-at right 130 100 148 100"], &[p("points", Ty::Rest, "right, then GUI x y pairs")], mcp: true, local: false),
|
||||
cmd!("scroll-at", Screens, "scroll-at X Y STEPS", "turn the mouse wheel at GUI pixel X Y (positive scrolls up)", "",
|
||||
["horizon-ctl scroll-at 130 100 -1"], &[p("x", Ty::Num, "GUI x"), p("y", Ty::Num, "GUI y"), p("steps", Ty::Num, "wheel steps")], mcp: true, local: false),
|
||||
cmd!("type", Screens, "type [--field name|seed] [--replace] TEXT", "type into the focused text field (chat box, world name, anvil, sign...)",
|
||||
"On the title's create-world screen, --field name|seed picks the field and --replace clears it first; type \\b deletes one character.",
|
||||
["horizon-ctl type hello everyone", "horizon-ctl type --field name --replace Agent World", "horizon-ctl type --field seed 12345"], &[flag("field", Ty::Enum(&["name", "seed"]), "create-world field to type into"), flag("replace", Ty::Bool, "clear the field first"), p("text", Ty::Rest, "text to type")], mcp: true, local: false),
|
||||
cmd!("chat", Screens, "chat TEXT", "send a chat line (or a /command) as if typed", "", ["horizon-ctl chat hello"],
|
||||
&[p("text", Ty::Rest, "message")], mcp: true, local: false),
|
||||
cmd!("command", Screens, "command TEXT", "run a command (the leading / is optional)", "",
|
||||
["horizon-ctl command time set day", "horizon-ctl command /give @s oak_log 4"], &[p("command", Ty::Rest, "command line")], mcp: true, local: false),
|
||||
cmd!("respawn", Screens, "respawn", "respawn from the death screen", "", ["horizon-ctl respawn"], &[], mcp: true, local: false),
|
||||
cmd!("pause", Screens, "pause", "open the pause menu", "", ["horizon-ctl pause"], &[], mcp: true, local: false),
|
||||
cmd!("resume", Screens, "resume", "leave the pause menu", "", ["horizon-ctl resume"], &[], mcp: true, local: false),
|
||||
cmd!("chat-log", Screens, "chat-log [N]", "the newest N chat messages", "", ["horizon-ctl chat-log 5"], &[], mcp: false, local: false),
|
||||
// World
|
||||
cmd!("give", World, "give ITEM [COUNT]", "add items to the inventory", "", ["horizon-ctl give oak_log 16"],
|
||||
&[p("item", Ty::Str, "item id"), opt("count", Ty::Int, "how many (default 1)")], mcp: true, local: false),
|
||||
cmd!("time", World, "time TICKS", "set the time of day (0 sunrise, 6000 noon, 13000 night)", "", ["horizon-ctl time 6000"],
|
||||
&[p("ticks", Ty::Int, "day time")], mcp: true, local: false),
|
||||
cmd!("setblock", World, "setblock X Y Z BLOCK[props]", "set a block (with neighbour updates)", "", ["horizon-ctl setblock 0 64 0 stone"],
|
||||
&[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], p("block", Ty::Str, "block state such as oak_stairs[facing=east]")], mcp: true, local: false),
|
||||
cmd!("summon", World, "summon KIND [X Y Z] [baby]", "spawn a mob", "", ["horizon-ctl summon cow"], &[p("kind", Ty::Str, "mob type")], mcp: true, local: false),
|
||||
cmd!("effect", World, "effect ID SECONDS [AMPLIFIER] | effect clear", "give a status effect", "", ["horizon-ctl effect speed 30 1"], &[], mcp: false, local: false),
|
||||
cmd!("render-distance", World, "render-distance N", "chunks, 2..64", "", ["horizon-ctl render-distance 8"], &[p("chunks", Ty::Int, "render distance")], mcp: false, local: false),
|
||||
cmd!("wait", World, "wait TICKS", "wait until TICKS game ticks have passed (20 per second)",
|
||||
"Keys and mouse buttons held with key ... down or mouse ... down stay held while it waits. Release them with key ... up, release or stop.",
|
||||
["horizon-ctl wait 40", "horizon-ctl key w down && horizon-ctl wait 40 && horizon-ctl key w up"],
|
||||
&[p("ticks", Ty::Int, "game ticks")], mcp: true, local: false),
|
||||
// Scripting
|
||||
cmd!("script", Scripting, "script [FILE] [--keep-going]", "run commands from FILE (or stdin), one per line",
|
||||
"Lines are split like a shell (quotes keep spaces); # starts a comment; sleep SECONDS waits. Stops at the first error unless --keep-going. horizon-ctl - is the older form: read stdin and keep going.",
|
||||
["horizon-ctl script play.txt", "printf 'state\\ngoto 0 64 0\\n' | horizon-ctl script"], &[], mcp: false, local: true),
|
||||
cmd!("sleep", Scripting, "sleep SECONDS", "wait (in scripts)", "", ["sleep 0.5"], &[], mcp: false, local: true),
|
||||
cmd!("mcp", Scripting, "mcp", "serve the Model Context Protocol on stdin/stdout, one tool per command",
|
||||
"Add it to an MCP client as a stdio server: command horizon-ctl, args [\"mcp\"] (with --socket PATH first if needed). The screenshot tool returns the image.",
|
||||
["horizon-ctl mcp"], &[], mcp: false, local: true),
|
||||
cmd!("bench", Scripting, "bench SECONDS", "reset frame stats, wait, reply with the summary", "", ["horizon-ctl bench 10"], &[], mcp: false, local: false),
|
||||
cmd!("stats", Scripting, "stats [reset]", "frame-time summary since the last reset", "", ["horizon-ctl stats"], &[], mcp: false, local: false),
|
||||
cmd!("fly", Scripting, "fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH [raw]]", "fly the camera along a line, then reply with the frame summary",
|
||||
"With raw, the reply also lists every frame time in milliseconds (frame_ms).",
|
||||
["horizon-ctl fly 0 100 0 500 100 0 20"], &[], mcp: false, local: false),
|
||||
];
|
||||
|
||||
pub fn find(name: &str) -> Option<&'static Cmd> {
|
||||
COMMANDS.iter().find(|c| c.name == name)
|
||||
}
|
||||
|
||||
/// `horizon-ctl help`.
|
||||
pub fn overview() -> String {
|
||||
let mut out = String::from(
|
||||
"horizon-ctl: play and script a running Horizon from the command line.\n\n\
|
||||
usage: horizon-ctl [--socket PATH] [--timeout SECONDS] [--pretty] COMMAND [ARGS...]\n\
|
||||
\x20 horizon-ctl COMMAND --help (or: horizon-ctl help COMMAND)\n",
|
||||
);
|
||||
for group in Group::ALL {
|
||||
out.push_str(&format!("\n{}:\n", group.title()));
|
||||
for c in COMMANDS.iter().filter(|c| c.group == group) {
|
||||
out.push_str(&format!(" {:<16} {}\n", c.name, c.summary));
|
||||
}
|
||||
}
|
||||
out.push_str(
|
||||
"\nEvery reply is one JSON line. Errors are {\"error\": \"...\"}; the exit status is 0 on\n\
|
||||
success, 1 when the game replied with an error, 2 for a usage error and 3 when the\n\
|
||||
game cannot be reached or started. Actions (goto, break, place...) block until done.\n\
|
||||
horizon-ctl help --game lists the running game's own commands, test hooks included.\n\
|
||||
The socket is $HORIZON_CONTROL, else $XDG_RUNTIME_DIR/horizon-control.sock (on Windows\n\
|
||||
%TEMP%\\horizon-control.json, a localhost TCP port with a token; any path ending in .json\n\
|
||||
uses that transport).\n\n\
|
||||
First steps:\n\
|
||||
\x20 horizon-ctl launch --world Test --seed 1 --wait\n\
|
||||
\x20 horizon-ctl state\n\
|
||||
\x20 horizon-ctl find-block oak_log\n\
|
||||
\x20 horizon-ctl goto X Y Z --near 2 && horizon-ctl break X Y Z\n\
|
||||
\x20 horizon-ctl inventory\n",
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
/// `horizon-ctl COMMAND --help`.
|
||||
pub fn detail(c: &Cmd) -> String {
|
||||
let mut out = format!(
|
||||
"usage: horizon-ctl {}\n\n{}.\n",
|
||||
c.usage,
|
||||
capitalize(c.summary)
|
||||
);
|
||||
if !c.details.is_empty() {
|
||||
out.push('\n');
|
||||
out.push_str(&wrap(c.details, 78));
|
||||
}
|
||||
if !c.examples.is_empty() {
|
||||
out.push_str("\nExamples:\n");
|
||||
for e in c.examples {
|
||||
out.push_str(&format!(" {e}\n"));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn capitalize(s: &str) -> String {
|
||||
let mut chars = s.chars();
|
||||
chars
|
||||
.next()
|
||||
.map(|c| c.to_uppercase().chain(chars).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn wrap(text: &str, width: usize) -> String {
|
||||
let mut out = String::new();
|
||||
let mut line = String::new();
|
||||
for word in text.split_whitespace() {
|
||||
if !line.is_empty() && line.len() + 1 + word.len() > width {
|
||||
out.push_str(&line);
|
||||
out.push('\n');
|
||||
line.clear();
|
||||
}
|
||||
if !line.is_empty() {
|
||||
line.push(' ');
|
||||
}
|
||||
line.push_str(word);
|
||||
}
|
||||
if !line.is_empty() {
|
||||
out.push_str(&line);
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The MCP tool name of a command (`look-at-block` gives `look_at_block`).
|
||||
pub fn tool_name(c: &Cmd) -> String {
|
||||
c.name.replace('-', "_")
|
||||
}
|
||||
|
||||
/// The JSON schema of a command's arguments.
|
||||
pub fn schema(c: &Cmd) -> Value {
|
||||
let mut props = Map::new();
|
||||
let mut required = Vec::new();
|
||||
for p in c.params {
|
||||
let mut s = match p.ty {
|
||||
Ty::Int => json!({ "type": "integer" }),
|
||||
Ty::Num => json!({ "type": "number" }),
|
||||
Ty::Str | Ty::Rest => json!({ "type": "string" }),
|
||||
Ty::Bool => json!({ "type": "boolean" }),
|
||||
Ty::Enum(values) => json!({ "type": "string", "enum": values }),
|
||||
};
|
||||
s["description"] = json!(p.help);
|
||||
props.insert(p.name.to_owned(), s);
|
||||
if p.required {
|
||||
required.push(p.name);
|
||||
}
|
||||
}
|
||||
json!({ "type": "object", "properties": props, "required": required })
|
||||
}
|
||||
|
||||
/// The command line for an MCP call's arguments: positional values in order,
|
||||
/// then `--flag value` options.
|
||||
pub fn line_for(c: &Cmd, args: &Value) -> Result<Vec<String>, String> {
|
||||
let mut words: Vec<String> = c
|
||||
.wire
|
||||
.unwrap_or(c.name)
|
||||
.split(' ')
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
let mut flags = Vec::new();
|
||||
let mut gap: Option<&str> = None;
|
||||
let mut rest: Option<String> = None;
|
||||
for p in c.params {
|
||||
let value = args.get(p.name).filter(|v| !v.is_null());
|
||||
let text = match value {
|
||||
None => {
|
||||
if p.required {
|
||||
return Err(format!("{} is required", p.name));
|
||||
}
|
||||
if !p.flag {
|
||||
gap.get_or_insert(p.name);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Some(Value::Bool(b)) => {
|
||||
if p.ty != Ty::Bool {
|
||||
return Err(format!("{} must be a {}", p.name, ty_name(p.ty)));
|
||||
}
|
||||
if *b {
|
||||
if p.flag {
|
||||
flags.push(format!("--{}", p.name));
|
||||
} else {
|
||||
words.push(p.name.to_owned());
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Some(Value::String(s)) => s.clone(),
|
||||
Some(Value::Number(n)) => n.to_string(),
|
||||
Some(other) => {
|
||||
return Err(format!(
|
||||
"{} must be a {}, not {other}",
|
||||
p.name,
|
||||
ty_name(p.ty)
|
||||
));
|
||||
}
|
||||
};
|
||||
match p.ty {
|
||||
Ty::Int if text.parse::<i64>().is_err() => {
|
||||
return Err(format!("{} must be an integer, not {text}", p.name));
|
||||
}
|
||||
Ty::Num if text.parse::<f64>().is_err() => {
|
||||
return Err(format!("{} must be a number, not {text}", p.name));
|
||||
}
|
||||
Ty::Enum(values) if !values.contains(&text.as_str()) => {
|
||||
return Err(format!("{} must be one of {values:?}, not {text}", p.name));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if p.flag {
|
||||
flags.push(format!("--{}", p.name));
|
||||
flags.push(text);
|
||||
} else {
|
||||
if let Some(missing) = gap {
|
||||
return Err(format!("{} needs {missing} to be given too", p.name));
|
||||
}
|
||||
if p.ty == Ty::Rest {
|
||||
rest = Some(text);
|
||||
} else if text.contains(char::is_whitespace) || text.is_empty() {
|
||||
return Err(format!("{} must be a single word", p.name));
|
||||
} else {
|
||||
words.push(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Options go before free text, which runs to the end of the line.
|
||||
words.extend(flags);
|
||||
words.extend(rest);
|
||||
Ok(words)
|
||||
}
|
||||
|
||||
fn ty_name(ty: Ty) -> &'static str {
|
||||
match ty {
|
||||
Ty::Int => "integer",
|
||||
Ty::Num => "number",
|
||||
Ty::Bool => "boolean",
|
||||
_ => "string",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn names_are_unique_and_documented() {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for c in COMMANDS {
|
||||
assert!(seen.insert(c.name), "duplicate command {}", c.name);
|
||||
assert!(
|
||||
c.usage.starts_with(c.name.split('-').next().unwrap()),
|
||||
"{}",
|
||||
c.name
|
||||
);
|
||||
assert!(
|
||||
!c.summary.is_empty() && !c.examples.is_empty(),
|
||||
"{}",
|
||||
c.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_arguments_become_command_lines() {
|
||||
let goto = find("goto").unwrap();
|
||||
let words = line_for(
|
||||
goto,
|
||||
&json!({ "x": 1.5, "y": 64, "z": -3, "near": 2, "sprint": true }),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
words,
|
||||
["goto", "1.5", "64", "-3", "--near", "2", "--sprint"]
|
||||
);
|
||||
assert!(
|
||||
line_for(goto, &json!({ "x": 1, "y": 2 }))
|
||||
.unwrap_err()
|
||||
.contains("z is required")
|
||||
);
|
||||
assert!(line_for(goto, &json!({ "x": "a", "y": 2, "z": 3 })).is_err());
|
||||
let block = find("look-at-block").unwrap();
|
||||
assert_eq!(
|
||||
line_for(block, &json!({ "x": 1, "y": 2, "z": 3, "face": "up" })).unwrap(),
|
||||
["look-at", "block", "1", "2", "3", "up"]
|
||||
);
|
||||
assert!(
|
||||
line_for(
|
||||
block,
|
||||
&json!({ "x": 1, "y": 2, "z": 3, "face": "sideways" })
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let turn = find("turn").unwrap();
|
||||
assert_eq!(
|
||||
line_for(turn, &json!({ "dyaw": -90, "dpitch": 10.5, "over": 0.5 })).unwrap(),
|
||||
["turn", "-90", "10.5", "--over", "0.5"]
|
||||
);
|
||||
let mouse = find("mouse-move").unwrap();
|
||||
assert_eq!(tool_name(mouse), "mouse_move");
|
||||
assert_eq!(
|
||||
line_for(mouse, &json!({ "dx": -25, "dy": 40 })).unwrap(),
|
||||
["mouse-move", "-25", "40"]
|
||||
);
|
||||
assert_eq!(
|
||||
line_for(block, &json!({ "x": 1, "y": 2, "z": 3, "over": 0.3 })).unwrap(),
|
||||
["look-at", "block", "1", "2", "3", "--over", "0.3"]
|
||||
);
|
||||
let button = find("click-button").unwrap();
|
||||
assert_eq!(
|
||||
line_for(button, &json!({ "label": "Create New World" })).unwrap(),
|
||||
["click-button", "Create New World"]
|
||||
);
|
||||
let slot = find("click-slot").unwrap();
|
||||
assert_eq!(
|
||||
line_for(slot, &json!({ "slot": 200, "shift": true })).unwrap(),
|
||||
["click-slot", "200", "shift"]
|
||||
);
|
||||
let open = find("open").unwrap();
|
||||
assert!(
|
||||
line_for(open, &json!({ "world": "A", "mode": "creative" }))
|
||||
.unwrap_err()
|
||||
.contains("seed")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schemas_list_required_parameters() {
|
||||
let s = schema(find("break").unwrap());
|
||||
assert_eq!(s["required"], json!(["x", "y", "z"]));
|
||||
assert_eq!(s["properties"]["timeout"]["type"], "number");
|
||||
assert_eq!(tool_name(find("look-at-entity").unwrap()), "look_at_entity");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_mentions_every_command() {
|
||||
let text = overview();
|
||||
for c in COMMANDS {
|
||||
assert!(text.contains(c.name));
|
||||
assert!(detail(c).contains("usage: horizon-ctl"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
//! `horizon-ctl launch`: find a Horizon to start and start it with a control
|
||||
//! socket. Mirrors where the launcher puts things (launcher/src/backend.rs):
|
||||
//! versions under `<launcher home>/versions/<tag>`, each with its prepared
|
||||
//! resources in `.local/horizon-client/<minecraft version>` and run from the
|
||||
//! game home, where saves and options.txt live.
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, Stdio},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use horizon_client::logging::{data_dir, game_home};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[cfg(windows)]
|
||||
const CLIENT: &str = "horizon-client.exe";
|
||||
#[cfg(not(windows))]
|
||||
const CLIENT: &str = "horizon-client";
|
||||
|
||||
pub fn launcher_home() -> PathBuf {
|
||||
match std::env::var_os("HORIZON_LAUNCHER_HOME").filter(|h| !h.is_empty()) {
|
||||
Some(h) => PathBuf::from(h),
|
||||
None => data_dir().join("horizon-launcher"),
|
||||
}
|
||||
}
|
||||
/// `v2026.10.13.1` gives [2026, 10, 13, 1]; a pre-release suffix is ignored.
|
||||
pub fn version_parts(tag: &str) -> Vec<u64> {
|
||||
tag.trim_start_matches('v')
|
||||
.split('-')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.split('.')
|
||||
.map(|p| p.parse().unwrap_or(0))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Installed launcher versions, newest first.
|
||||
pub fn installed(versions: &Path) -> Vec<String> {
|
||||
let mut tags: Vec<String> = std::fs::read_dir(versions)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter(|e| e.path().join(".installed").exists() && e.path().join(CLIENT).exists())
|
||||
.filter_map(|e| e.file_name().into_string().ok())
|
||||
.collect();
|
||||
tags.sort_by_key(|t| std::cmp::Reverse(version_parts(t)));
|
||||
tags
|
||||
}
|
||||
|
||||
/// A folder's prepared resources, as the launcher prepares them.
|
||||
pub fn prepared_resources(dir: &Path) -> Option<PathBuf> {
|
||||
let version = std::fs::read_to_string(dir.join("research-lock.json"))
|
||||
.ok()
|
||||
.and_then(|t| serde_json::from_str::<Value>(&t).ok())
|
||||
.and_then(|v| v["minecraft"]["version"].as_str().map(str::to_owned))
|
||||
.unwrap_or_else(|| "26.3".into());
|
||||
let resources = dir.join(".local/horizon-client").join(version);
|
||||
resources
|
||||
.join("horizon-resources.json")
|
||||
.exists()
|
||||
.then_some(resources)
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Install {
|
||||
pub game: PathBuf,
|
||||
/// Passed as --resources unless the caller gave their own.
|
||||
pub resources: Option<PathBuf>,
|
||||
/// The working directory (the game home for prepared installs).
|
||||
pub cwd: Option<PathBuf>,
|
||||
pub source: &'static str,
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
/// Which game to start: an explicit binary, the one beside horizon-ctl, or a
|
||||
/// launcher install.
|
||||
pub fn find(
|
||||
game: Option<&Path>,
|
||||
version: Option<&str>,
|
||||
beside: Option<&Path>,
|
||||
launcher: &Path,
|
||||
) -> Result<Install, String> {
|
||||
let prepared = |dir: &Path, source, version| {
|
||||
let resources = prepared_resources(dir);
|
||||
Install {
|
||||
game: dir.join(CLIENT),
|
||||
cwd: resources.as_ref().map(|_| game_home()),
|
||||
resources,
|
||||
source,
|
||||
version,
|
||||
}
|
||||
};
|
||||
if let Some(game) = game {
|
||||
if !game.is_file() {
|
||||
return Err(format!("{} is not a file", game.display()));
|
||||
}
|
||||
let dir = game.parent().unwrap_or(Path::new("."));
|
||||
let mut install = prepared(dir, "--game", None);
|
||||
install.game = game.to_owned();
|
||||
return Ok(install);
|
||||
}
|
||||
let versions = launcher.join("versions");
|
||||
if version.is_none()
|
||||
&& let Some(dir) = beside
|
||||
&& dir.join(CLIENT).is_file()
|
||||
{
|
||||
return Ok(prepared(dir, "beside horizon-ctl", None));
|
||||
}
|
||||
let tags = installed(&versions);
|
||||
let tag = match version {
|
||||
Some(v) => {
|
||||
let want = if v.starts_with('v') { v.to_owned() } else { format!("v{v}") };
|
||||
tags.iter()
|
||||
.find(|t| **t == want || **t == v)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("version {v} is not installed in {} (installed: {tags:?})", versions.display()))?
|
||||
}
|
||||
None => tags.first().cloned().ok_or_else(|| {
|
||||
format!(
|
||||
"no Horizon found: no horizon-client beside horizon-ctl and no version installed by the launcher in {}. Install one with the launcher (horizon-launcher install latest) or pass --game PATH.",
|
||||
versions.display()
|
||||
)
|
||||
})?,
|
||||
};
|
||||
let dir = versions.join(&tag);
|
||||
let install = prepared(&dir, "launcher", Some(tag.clone()));
|
||||
if install.resources.is_none() {
|
||||
return Err(format!(
|
||||
"{tag} is installed but not prepared; prepare it in the launcher first (horizon-launcher prepare {tag})"
|
||||
));
|
||||
}
|
||||
Ok(install)
|
||||
}
|
||||
|
||||
pub struct Options {
|
||||
pub game: Option<PathBuf>,
|
||||
pub version: Option<String>,
|
||||
pub wait: bool,
|
||||
pub game_args: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn parse(args: &[String]) -> Result<Options, String> {
|
||||
let mut o = Options {
|
||||
game: None,
|
||||
version: None,
|
||||
wait: false,
|
||||
game_args: Vec::new(),
|
||||
};
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
let value = |i: usize| {
|
||||
args.get(i + 1)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("{} needs a value", args[i]))
|
||||
};
|
||||
match args[i].as_str() {
|
||||
"--game" => {
|
||||
o.game = Some(value(i)?.into());
|
||||
i += 1;
|
||||
}
|
||||
"--version" => {
|
||||
o.version = Some(value(i)?);
|
||||
i += 1;
|
||||
}
|
||||
"--wait" | "--wait-ready" => o.wait = true,
|
||||
other => o.game_args.push(other.to_owned()),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
/// Start the game and wait for its control endpoint; Ok(reply) or Err(reply).
|
||||
pub fn launch(socket: &Path, args: &[String]) -> Result<Value, Value> {
|
||||
let o = parse(args).map_err(|e| json!({ "error": e, "kind": "usage" }))?;
|
||||
if horizon_client::control::connect(socket).is_ok() {
|
||||
return Err(json!({
|
||||
"error": format!("a game is already listening on {}; quit it first or pass another --socket", socket.display()),
|
||||
"kind": "connection",
|
||||
}));
|
||||
}
|
||||
let exe = std::env::current_exe().ok();
|
||||
let beside = exe.as_deref().and_then(Path::parent);
|
||||
let install = find(
|
||||
o.game.as_deref(),
|
||||
o.version.as_deref(),
|
||||
beside,
|
||||
&launcher_home(),
|
||||
)
|
||||
.map_err(|e| json!({ "error": e, "kind": "launch" }))?;
|
||||
let log = socket.with_extension("log");
|
||||
let file = std::fs::File::create(&log)
|
||||
.map_err(|e| json!({ "error": format!("{}: {e}", log.display()), "kind": "launch" }))?;
|
||||
let mut cmd = Command::new(&install.game);
|
||||
let gave_resources = o.game_args.iter().any(|a| a == "--resources");
|
||||
if let Some(resources) = install.resources.as_ref().filter(|_| !gave_resources) {
|
||||
cmd.arg("--resources").arg(resources);
|
||||
}
|
||||
if let Some(cwd) = &install.cwd {
|
||||
let _ = std::fs::create_dir_all(cwd);
|
||||
cmd.current_dir(cwd);
|
||||
}
|
||||
let stderr = file
|
||||
.try_clone()
|
||||
.map_err(|e| json!({ "error": e.to_string(), "kind": "launch" }))?;
|
||||
let mut child = cmd
|
||||
.args(&o.game_args)
|
||||
.arg("--control-socket")
|
||||
.arg(socket)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(file)
|
||||
.stderr(stderr)
|
||||
.spawn()
|
||||
.map_err(
|
||||
|e| json!({ "error": format!("{}: {e}", install.game.display()), "kind": "launch" }),
|
||||
)?;
|
||||
let deadline = Instant::now() + Duration::from_secs(300);
|
||||
let info = json!({
|
||||
"pid": child.id(),
|
||||
"socket": socket,
|
||||
"log": log,
|
||||
"game": install.game,
|
||||
"source": install.source,
|
||||
"version": install.version,
|
||||
"resources": install.resources,
|
||||
"home": install.cwd,
|
||||
});
|
||||
while Instant::now() < deadline {
|
||||
if horizon_client::control::connect(socket).is_ok() {
|
||||
let mut reply = json!({ "ok": true });
|
||||
reply
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.extend(info.as_object().unwrap().clone());
|
||||
return Ok(reply);
|
||||
}
|
||||
if let Ok(Some(status)) = child.try_wait() {
|
||||
let tail = std::fs::read_to_string(&log).unwrap_or_default();
|
||||
let tail: Vec<&str> = tail.lines().rev().take(15).collect();
|
||||
return Err(json!({
|
||||
"error": format!("the game exited ({status}) before opening the control socket"),
|
||||
"kind": "launch",
|
||||
"log_tail": tail.into_iter().rev().collect::<Vec<_>>(),
|
||||
"log": log,
|
||||
}));
|
||||
}
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
Err(
|
||||
json!({ "error": "control socket did not open within 300 seconds", "kind": "launch", "log": log }),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-ctl-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
fn fake_version(versions: &Path, tag: &str, prepared: bool) -> PathBuf {
|
||||
let dir = versions.join(tag);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join(CLIENT), "").unwrap();
|
||||
std::fs::write(dir.join(".installed"), "").unwrap();
|
||||
std::fs::write(
|
||||
dir.join("research-lock.json"),
|
||||
r#"{"minecraft":{"version":"26.3"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
if prepared {
|
||||
let res = dir.join(".local/horizon-client/26.3");
|
||||
std::fs::create_dir_all(&res).unwrap();
|
||||
std::fs::write(res.join("horizon-resources.json"), "{}").unwrap();
|
||||
}
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versions_sort_newest_first() {
|
||||
let home = temp("sort");
|
||||
let versions = home.join("versions");
|
||||
for tag in ["v2026.9.30", "v2026.10.13", "v2026.10.13.1", "v2026.10.2"] {
|
||||
fake_version(&versions, tag, true);
|
||||
}
|
||||
assert_eq!(
|
||||
installed(&versions),
|
||||
["v2026.10.13.1", "v2026.10.13", "v2026.10.2", "v2026.9.30"]
|
||||
);
|
||||
let install = find(None, None, None, &home).unwrap();
|
||||
assert_eq!(install.version.as_deref(), Some("v2026.10.13.1"));
|
||||
assert!(
|
||||
install
|
||||
.resources
|
||||
.unwrap()
|
||||
.ends_with(".local/horizon-client/26.3")
|
||||
);
|
||||
assert_eq!(install.cwd, Some(game_home()));
|
||||
let picked = find(None, Some("2026.10.2"), None, &home).unwrap();
|
||||
assert_eq!(picked.version.as_deref(), Some("v2026.10.2"));
|
||||
assert!(find(None, Some("v1.0"), None, &home).is_err());
|
||||
let _ = std::fs::remove_dir_all(home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_game_beside_ctl_wins_and_dev_builds_keep_the_directory() {
|
||||
let home = temp("beside");
|
||||
fake_version(&home.join("versions"), "v2026.10.13", true);
|
||||
let build = temp("build");
|
||||
std::fs::write(build.join(CLIENT), "").unwrap();
|
||||
let install = find(None, None, Some(&build), &home).unwrap();
|
||||
assert_eq!(install.source, "beside horizon-ctl");
|
||||
assert_eq!((install.resources, install.cwd), (None, None));
|
||||
// --version always means a launcher install.
|
||||
assert_eq!(
|
||||
find(None, Some("v2026.10.13"), Some(&build), &home)
|
||||
.unwrap()
|
||||
.source,
|
||||
"launcher"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(home);
|
||||
let _ = std::fs::remove_dir_all(build);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unprepared_or_missing_installs_explain_themselves() {
|
||||
let home = temp("unprepared");
|
||||
assert!(
|
||||
find(None, None, None, &home)
|
||||
.unwrap_err()
|
||||
.contains("no Horizon found")
|
||||
);
|
||||
fake_version(&home.join("versions"), "v2026.10.13", false);
|
||||
assert!(
|
||||
find(None, None, None, &home)
|
||||
.unwrap_err()
|
||||
.contains("not prepared")
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_options_pass_the_rest_to_the_game() {
|
||||
let args: Vec<String> = ["--world", "A", "--version", "v1", "--wait", "--seed", "3"]
|
||||
.map(String::from)
|
||||
.to_vec();
|
||||
let o = parse(&args).unwrap();
|
||||
assert_eq!(o.version.as_deref(), Some("v1"));
|
||||
assert!(o.wait);
|
||||
assert_eq!(o.game_args, ["--world", "A", "--seed", "3"]);
|
||||
assert!(parse(&["--game".to_owned()]).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
//! horizon-ctl: play and script a running Horizon client through its control
|
||||
//! socket, from a shell, a script or an AI agent (`horizon-ctl mcp`).
|
||||
#![forbid(unsafe_code)]
|
||||
mod commands;
|
||||
mod launch;
|
||||
mod mcp;
|
||||
|
||||
use std::{
|
||||
io::{BufRead, BufReader, Write},
|
||||
path::{Path, PathBuf},
|
||||
process::ExitCode,
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use horizon_client::control::Stream;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Exit statuses.
|
||||
const GAME_ERROR: u8 = 1;
|
||||
const USAGE: u8 = 2;
|
||||
const UNREACHABLE: u8 = 3;
|
||||
|
||||
/// A failure, with the exit status it maps to.
|
||||
#[derive(Debug)]
|
||||
pub struct Failure {
|
||||
pub reply: Value,
|
||||
pub status: u8,
|
||||
}
|
||||
impl Failure {
|
||||
fn usage(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
reply: json!({ "error": message.into(), "kind": "usage" }),
|
||||
status: USAGE,
|
||||
}
|
||||
}
|
||||
fn unreachable(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
reply: json!({ "error": message.into(), "kind": "connection" }),
|
||||
status: UNREACHABLE,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Client {
|
||||
writer: Stream,
|
||||
reader: BufReader<Stream>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
fn connect(path: &Path, timeout: Option<Duration>) -> Result<Self, Failure> {
|
||||
let stream = horizon_client::control::connect(path).map_err(|e| {
|
||||
Failure::unreachable(format!(
|
||||
"cannot connect to {} ({e}); is the game running with --control? Start one with: horizon-ctl launch --world NAME",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
stream
|
||||
.set_read_timeout(timeout)
|
||||
.map_err(|e| Failure::unreachable(e.to_string()))?;
|
||||
let reader = BufReader::new(
|
||||
stream
|
||||
.try_clone()
|
||||
.map_err(|e| Failure::unreachable(e.to_string()))?,
|
||||
);
|
||||
Ok(Self {
|
||||
writer: stream,
|
||||
reader,
|
||||
})
|
||||
}
|
||||
/// Send one line and read its JSON reply.
|
||||
fn send(&mut self, line: &str) -> Result<Value, Failure> {
|
||||
writeln!(self.writer, "{line}").map_err(|e| Failure::unreachable(e.to_string()))?;
|
||||
let mut reply = String::new();
|
||||
let n = self.reader.read_line(&mut reply).map_err(|e| {
|
||||
Failure::unreachable(
|
||||
if matches!(
|
||||
e.kind(),
|
||||
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
|
||||
) {
|
||||
"timed out waiting for the game's reply (--timeout)".to_owned()
|
||||
} else {
|
||||
e.to_string()
|
||||
},
|
||||
)
|
||||
})?;
|
||||
if n == 0 {
|
||||
return Err(Failure::unreachable("the game closed the connection"));
|
||||
}
|
||||
serde_json::from_str(reply.trim_end()).map_err(|_| Failure {
|
||||
reply: json!({ "error": "the game sent a reply that is not JSON", "reply": reply.trim_end() }),
|
||||
status: GAME_ERROR,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Global options and the connection, opened on first use.
|
||||
pub struct Session {
|
||||
pub socket: PathBuf,
|
||||
pub timeout: Option<Duration>,
|
||||
client: Option<Client>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn new(socket: PathBuf, timeout: Option<Duration>) -> Self {
|
||||
Self {
|
||||
socket,
|
||||
timeout,
|
||||
client: None,
|
||||
}
|
||||
}
|
||||
fn client(&mut self) -> Result<&mut Client, Failure> {
|
||||
if self.client.is_none() {
|
||||
self.client = Some(Client::connect(&self.socket, self.timeout)?);
|
||||
}
|
||||
Ok(self.client.as_mut().unwrap())
|
||||
}
|
||||
/// Send a line to the game; a reply with "error" is a failure.
|
||||
pub fn game(&mut self, line: &str) -> Result<Value, Failure> {
|
||||
let result = self.client()?.send(line);
|
||||
if result.as_ref().is_err_and(|f| f.status == UNREACHABLE) {
|
||||
// Reconnect next time (the game may have restarted).
|
||||
self.client = None;
|
||||
}
|
||||
let reply = result?;
|
||||
if reply.get("error").is_some() {
|
||||
return Err(Failure {
|
||||
reply,
|
||||
status: GAME_ERROR,
|
||||
});
|
||||
}
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
/// Run one command (CLI words); Ok(reply) or a failure.
|
||||
pub fn run(&mut self, words: &[String]) -> Result<Value, Failure> {
|
||||
let Some(name) = words.first().map(String::as_str) else {
|
||||
return Err(Failure::usage("no command; try horizon-ctl help"));
|
||||
};
|
||||
if words.iter().skip(1).any(|w| w == "--help" || w == "-h") {
|
||||
return Err(Failure::usage(
|
||||
"use horizon-ctl COMMAND --help outside scripts",
|
||||
));
|
||||
}
|
||||
let number = |i: usize, what: &str| -> Result<f64, Failure> {
|
||||
words
|
||||
.get(i)
|
||||
.ok_or_else(|| Failure::usage(format!("missing {what}; usage: {}", usage(name))))?
|
||||
.parse::<f64>()
|
||||
.map_err(|_| {
|
||||
Failure::usage(format!("{what} must be a number; usage: {}", usage(name)))
|
||||
})
|
||||
};
|
||||
match name {
|
||||
"sleep" => {
|
||||
thread::sleep(Duration::from_secs_f64(number(1, "SECONDS")?.max(0.0)));
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
"launch" => {
|
||||
let socket = self.socket.clone();
|
||||
let wait = words.iter().any(|w| w == "--wait" || w == "--wait-ready");
|
||||
let reply = launch::launch(&socket, &words[1..]).map_err(|reply| Failure {
|
||||
status: if reply["kind"] == "usage" {
|
||||
USAGE
|
||||
} else {
|
||||
UNREACHABLE
|
||||
},
|
||||
reply,
|
||||
})?;
|
||||
if !wait {
|
||||
return Ok(reply);
|
||||
}
|
||||
let mut ready = self.wait_ready(300.0)?;
|
||||
ready["launch"] = reply;
|
||||
Ok(ready)
|
||||
}
|
||||
"hold" => {
|
||||
let (key, seconds) = match words {
|
||||
[_, key, _] => (key.clone(), number(2, "SECONDS")?),
|
||||
_ => return Err(Failure::usage(format!("usage: {}", usage(name)))),
|
||||
};
|
||||
self.game(&format!("key {key} down"))?;
|
||||
thread::sleep(Duration::from_secs_f64(seconds.max(0.0)));
|
||||
self.game(&format!("key {key} up"))
|
||||
}
|
||||
"logs" => logs(&horizon_client::logging::game_home(), &words[1..]),
|
||||
"wait-ready" => {
|
||||
let limit = if words.len() > 1 {
|
||||
number(1, "SECONDS")?
|
||||
} else {
|
||||
120.0
|
||||
};
|
||||
self.wait_ready(limit)
|
||||
}
|
||||
"help" if words.get(1).is_some_and(|w| w == "--game") => self.game("help"),
|
||||
_ => {
|
||||
let mut words = words.to_vec();
|
||||
if let Some(cmd) = commands::find(name) {
|
||||
if cmd.local {
|
||||
return Err(Failure::usage(format!(
|
||||
"{name} runs only as horizon-ctl {name}, not inside a script"
|
||||
)));
|
||||
}
|
||||
if let Some(wire) = cmd.wire {
|
||||
words.splice(0..1, wire.split(' ').map(str::to_owned));
|
||||
}
|
||||
}
|
||||
absolute_paths(&mut words);
|
||||
self.game(&words.join(" "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_ready(&mut self, limit: f64) -> Result<Value, Failure> {
|
||||
let deadline = Instant::now() + Duration::from_secs_f64(limit.max(0.0));
|
||||
loop {
|
||||
let status = self.game("status")?;
|
||||
if !status["player"].is_null() && status["pending_chunks"] == 0 {
|
||||
return Ok(status);
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
return Err(Failure {
|
||||
reply: json!({ "error": format!("not ready after {limit} seconds"), "status": status }),
|
||||
status: GAME_ERROR,
|
||||
});
|
||||
}
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `logs [--lines N] [--crash]` for the game in `home`: the reply's `text` is the log's last
|
||||
/// lines or the report.
|
||||
fn logs(home: &Path, words: &[String]) -> Result<Value, Failure> {
|
||||
use horizon_client::logging;
|
||||
let mut lines = 100;
|
||||
let mut crash = false;
|
||||
let mut it = words.iter();
|
||||
while let Some(word) = it.next() {
|
||||
match word.as_str() {
|
||||
"--crash" => crash = true,
|
||||
"--lines" => {
|
||||
lines = it
|
||||
.next()
|
||||
.and_then(|n| n.parse::<usize>().ok())
|
||||
.filter(|&n| n > 0)
|
||||
.ok_or_else(|| Failure::usage("--lines takes a number above 0"))?;
|
||||
}
|
||||
other => {
|
||||
return Err(Failure::usage(format!(
|
||||
"unknown argument {other}; usage: {}",
|
||||
usage("logs")
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let missing = |what: String| Failure {
|
||||
reply: json!({ "error": what }),
|
||||
status: GAME_ERROR,
|
||||
};
|
||||
if crash {
|
||||
let path = logging::newest_crash_report(home).ok_or_else(|| {
|
||||
missing(format!(
|
||||
"no crash report in {}",
|
||||
logging::crash_reports_dir(home).display()
|
||||
))
|
||||
})?;
|
||||
let text = std::fs::read_to_string(&path)
|
||||
.map_err(|e| missing(format!("{}: {e}", path.display())))?;
|
||||
return Ok(json!({ "path": path, "text": text }));
|
||||
}
|
||||
let path = logging::logs_dir(home).join("latest.log");
|
||||
let tail =
|
||||
logging::tail(&path, lines).map_err(|e| missing(format!("{}: {e}", path.display())))?;
|
||||
Ok(json!({ "path": path, "text": tail.join("\n") + "\n" }))
|
||||
}
|
||||
|
||||
fn usage(name: &str) -> String {
|
||||
commands::find(name).map_or_else(|| name.to_owned(), |c| format!("horizon-ctl {}", c.usage))
|
||||
}
|
||||
|
||||
/// Screenshot paths are written by the game process: make them absolute here.
|
||||
fn absolute_paths(words: &mut [String]) {
|
||||
if words.first().map(String::as_str) == Some("screenshot")
|
||||
&& let Some(path) = words.get_mut(1)
|
||||
&& let Ok(cwd) = std::env::current_dir()
|
||||
{
|
||||
*path = cwd.join(&*path).display().to_string();
|
||||
}
|
||||
}
|
||||
|
||||
/// Split a script line like a shell: quotes keep spaces, backslash escapes.
|
||||
pub fn split_line(line: &str) -> Result<Vec<String>, String> {
|
||||
let mut words = Vec::new();
|
||||
let mut word = String::new();
|
||||
let mut in_word = false;
|
||||
let mut quote: Option<char> = None;
|
||||
let mut chars = line.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
match (quote, c) {
|
||||
(Some(q), c) if c == q => quote = None,
|
||||
(Some('"'), '\\') => word.push(chars.next().unwrap_or('\\')),
|
||||
(Some(_), c) => word.push(c),
|
||||
(None, '"' | '\'') => {
|
||||
quote = Some(c);
|
||||
in_word = true;
|
||||
}
|
||||
(None, '\\') => {
|
||||
word.push(chars.next().unwrap_or('\\'));
|
||||
in_word = true;
|
||||
}
|
||||
(None, '#') if !in_word => break,
|
||||
(None, c) if c.is_whitespace() => {
|
||||
if in_word {
|
||||
words.push(std::mem::take(&mut word));
|
||||
in_word = false;
|
||||
}
|
||||
}
|
||||
(None, c) => {
|
||||
word.push(c);
|
||||
in_word = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if quote.is_some() {
|
||||
return Err("unterminated quote".into());
|
||||
}
|
||||
if in_word {
|
||||
words.push(word);
|
||||
}
|
||||
Ok(words)
|
||||
}
|
||||
|
||||
fn print(value: &Value, pretty: bool) {
|
||||
let text = if pretty {
|
||||
serde_json::to_string_pretty(value).unwrap_or_default()
|
||||
} else {
|
||||
value.to_string()
|
||||
};
|
||||
println!("{text}");
|
||||
}
|
||||
|
||||
/// Run a script; the exit status of the first failure (or the last one with --keep-going).
|
||||
fn script(session: &mut Session, reader: impl BufRead, keep_going: bool, pretty: bool) -> u8 {
|
||||
let mut status = 0;
|
||||
for (n, line) in reader.lines().enumerate() {
|
||||
let Ok(line) = line else { break };
|
||||
let words = match split_line(&line) {
|
||||
Ok(w) if w.is_empty() => continue,
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
print(
|
||||
&json!({ "error": e, "kind": "usage", "line": n + 1 }),
|
||||
pretty,
|
||||
);
|
||||
status = USAGE;
|
||||
if keep_going {
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
match session.run(&words) {
|
||||
Ok(reply) => print(&reply, pretty),
|
||||
Err(mut f) => {
|
||||
f.reply["line"] = json!(n + 1);
|
||||
print(&f.reply, pretty);
|
||||
status = f.status;
|
||||
if !keep_going || f.status == UNREACHABLE {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
status
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let mut args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let mut socket = horizon_client::control::default_path();
|
||||
let mut timeout = None;
|
||||
let mut pretty = false;
|
||||
// Global options before the command.
|
||||
loop {
|
||||
match args.first().map(String::as_str) {
|
||||
Some("--socket") if args.len() >= 2 => {
|
||||
socket = PathBuf::from(args.remove(1));
|
||||
args.remove(0);
|
||||
}
|
||||
Some("--timeout") if args.len() >= 2 => {
|
||||
let Ok(s) = args[1].parse::<f64>() else {
|
||||
print(
|
||||
&json!({ "error": "--timeout takes seconds", "kind": "usage" }),
|
||||
false,
|
||||
);
|
||||
return ExitCode::from(USAGE);
|
||||
};
|
||||
timeout = Some(Duration::from_secs_f64(s.max(0.001)));
|
||||
args.drain(0..2);
|
||||
}
|
||||
Some("--pretty") => {
|
||||
pretty = true;
|
||||
args.remove(0);
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
let mut session = Session::new(socket, timeout);
|
||||
let name = args.first().cloned();
|
||||
let status = match name.as_deref() {
|
||||
None | Some("-h" | "--help") => {
|
||||
print!("{}", commands::overview());
|
||||
0
|
||||
}
|
||||
Some("help") if args.get(1).is_none_or(|a| a != "--game") => match args.get(1) {
|
||||
None => {
|
||||
print!("{}", commands::overview());
|
||||
0
|
||||
}
|
||||
Some(c) => match commands::find(c) {
|
||||
Some(cmd) => {
|
||||
print!("{}", commands::detail(cmd));
|
||||
0
|
||||
}
|
||||
None => {
|
||||
print(
|
||||
&json!({ "error": format!("unknown command {c}; horizon-ctl help lists them, horizon-ctl help --game lists the game's own"), "kind": "usage" }),
|
||||
pretty,
|
||||
);
|
||||
USAGE
|
||||
}
|
||||
},
|
||||
},
|
||||
Some(c) if args.len() == 2 && matches!(args[1].as_str(), "--help" | "-h") => {
|
||||
match commands::find(c) {
|
||||
Some(cmd) => {
|
||||
print!("{}", commands::detail(cmd));
|
||||
0
|
||||
}
|
||||
None => {
|
||||
print(
|
||||
&json!({ "error": format!("no help for {c}; horizon-ctl help --game lists the game's own commands"), "kind": "usage" }),
|
||||
pretty,
|
||||
);
|
||||
USAGE
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("mcp") => match mcp::serve(&mut session) {
|
||||
Ok(()) => 0,
|
||||
Err(e) => {
|
||||
eprintln!("horizon-ctl mcp: {e}");
|
||||
UNREACHABLE
|
||||
}
|
||||
},
|
||||
// The original form: stdin, keep going after errors.
|
||||
Some("-") => script(&mut session, std::io::stdin().lock(), true, pretty),
|
||||
Some("script") => {
|
||||
let keep_going = args.iter().any(|a| a == "--keep-going");
|
||||
match args.iter().skip(1).find(|a| !a.starts_with("--")) {
|
||||
Some(file) if file != "-" => match std::fs::File::open(file) {
|
||||
Ok(f) => script(&mut session, BufReader::new(f), keep_going, pretty),
|
||||
Err(e) => {
|
||||
print(
|
||||
&json!({ "error": format!("{file}: {e}"), "kind": "usage" }),
|
||||
pretty,
|
||||
);
|
||||
USAGE
|
||||
}
|
||||
},
|
||||
_ => script(&mut session, std::io::stdin().lock(), keep_going, pretty),
|
||||
}
|
||||
}
|
||||
// Text for people; scripts and MCP get the JSON reply.
|
||||
Some("logs") => match session.run(&args) {
|
||||
Ok(reply) => {
|
||||
print!("{}", reply["text"].as_str().unwrap_or_default());
|
||||
0
|
||||
}
|
||||
Err(f) => {
|
||||
print(&f.reply, pretty);
|
||||
f.status
|
||||
}
|
||||
},
|
||||
Some(_) => match session.run(&args) {
|
||||
Ok(reply) => {
|
||||
print(&reply, pretty);
|
||||
0
|
||||
}
|
||||
Err(f) => {
|
||||
print(&f.reply, pretty);
|
||||
f.status
|
||||
}
|
||||
},
|
||||
};
|
||||
ExitCode::from(status)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn logs_reads_the_end_of_latest_log_and_the_newest_crash_report() {
|
||||
let home = std::env::temp_dir().join(format!("horizon-ctl-logs-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&home);
|
||||
let words = |s: &str| -> Vec<String> { s.split_whitespace().map(String::from).collect() };
|
||||
let failure = logs(&home, &words("")).unwrap_err();
|
||||
assert!(
|
||||
failure.reply["error"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("latest.log")
|
||||
);
|
||||
std::fs::create_dir_all(home.join("logs")).unwrap();
|
||||
let text: String = (1..=150)
|
||||
.map(|i| format!("[10:00:00] [Render thread/INFO]: line {i}\n"))
|
||||
.collect();
|
||||
std::fs::write(home.join("logs/latest.log"), text).unwrap();
|
||||
let reply = logs(&home, &words("")).unwrap();
|
||||
let tail = reply["text"].as_str().unwrap();
|
||||
assert_eq!(tail.lines().count(), 100);
|
||||
assert!(tail.starts_with("[10:00:00] [Render thread/INFO]: line 51\n"));
|
||||
assert!(tail.ends_with("line 150\n"));
|
||||
let reply = logs(&home, &words("--lines 2")).unwrap();
|
||||
assert_eq!(
|
||||
reply["text"],
|
||||
"[10:00:00] [Render thread/INFO]: line 149\n[10:00:00] [Render thread/INFO]: line 150\n"
|
||||
);
|
||||
assert_eq!(logs(&home, &words("--lines 0")).unwrap_err().status, USAGE);
|
||||
assert_eq!(logs(&home, &words("--tail")).unwrap_err().status, USAGE);
|
||||
assert!(logs(&home, &words("--crash")).is_err());
|
||||
std::fs::create_dir_all(home.join("crash-reports")).unwrap();
|
||||
std::fs::write(
|
||||
home.join("crash-reports/crash-2026-10-13_10.00.00-client.txt"),
|
||||
"old",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
home.join("crash-reports/crash-2026-10-14_10.00.00-client.txt"),
|
||||
"new",
|
||||
)
|
||||
.unwrap();
|
||||
let reply = logs(&home, &words("--crash")).unwrap();
|
||||
assert_eq!(reply["text"], "new");
|
||||
let _ = std::fs::remove_dir_all(&home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_lines_split_like_a_shell() {
|
||||
assert_eq!(
|
||||
split_line("goto 1 2 3 --near 2").unwrap(),
|
||||
["goto", "1", "2", "3", "--near", "2"]
|
||||
);
|
||||
assert_eq!(
|
||||
split_line("click-button \"Create New World\"").unwrap(),
|
||||
["click-button", "Create New World"]
|
||||
);
|
||||
assert_eq!(
|
||||
split_line("type 'it''s' # comment").unwrap(),
|
||||
["type", "its"]
|
||||
);
|
||||
assert_eq!(split_line("chat a\\ b").unwrap(), ["chat", "a b"]);
|
||||
assert_eq!(split_line("chat issue#4").unwrap(), ["chat", "issue#4"]);
|
||||
assert!(split_line(" # only a comment").unwrap().is_empty());
|
||||
assert!(split_line("type \"open").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreachable_games_fail_with_status_3() {
|
||||
let mut s = Session::new(std::env::temp_dir().join("horizon-ctl-nobody.sock"), None);
|
||||
let f = s.run(&["state".to_owned()]).unwrap_err();
|
||||
assert_eq!(f.status, UNREACHABLE);
|
||||
assert_eq!(f.reply["kind"], "connection");
|
||||
let f = s.run(&["sleep".to_owned(), "x".to_owned()]).unwrap_err();
|
||||
assert_eq!(f.status, USAGE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshot_paths_become_absolute() {
|
||||
let mut words = vec!["screenshot".to_owned(), "a.png".to_owned()];
|
||||
absolute_paths(&mut words);
|
||||
assert!(Path::new(&words[1]).is_absolute());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
//! `horizon-ctl mcp`: the Model Context Protocol over stdin and stdout
|
||||
//! (JSON-RPC 2.0, one message per line, like the launcher's `mcp`), so AI
|
||||
//! agents can play Horizon. Each tool is a horizon-ctl command from the
|
||||
//! shared table, run through the same `Session` as the CLI.
|
||||
use crate::{Session, commands};
|
||||
use serde_json::{Value, json};
|
||||
use std::io::{BufRead, Write};
|
||||
|
||||
const PROTOCOL: &str = "2025-06-18";
|
||||
|
||||
fn tools() -> Vec<Value> {
|
||||
commands::COMMANDS
|
||||
.iter()
|
||||
.filter(|c| c.mcp)
|
||||
.map(|c| {
|
||||
let mut description = format!("{}. Usage: horizon-ctl {}", capital(c.summary), c.usage);
|
||||
if !c.details.is_empty() {
|
||||
description.push_str(". ");
|
||||
description.push_str(c.details);
|
||||
}
|
||||
let mut schema = commands::schema(c);
|
||||
if c.name == "screenshot" {
|
||||
schema = json!({ "type": "object", "properties": {} });
|
||||
description = "Capture the game window and return it as a PNG image.".into();
|
||||
}
|
||||
json!({ "name": commands::tool_name(c), "description": description, "inputSchema": schema })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn capital(s: &str) -> String {
|
||||
let mut c = s.chars();
|
||||
c.next()
|
||||
.map(|f| f.to_uppercase().chain(c).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Standard base64 (RFC 4648) for image content.
|
||||
pub fn base64(bytes: &[u8]) -> String {
|
||||
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
|
||||
for chunk in bytes.chunks(3) {
|
||||
let n = (u32::from(chunk[0]) << 16)
|
||||
| (u32::from(*chunk.get(1).unwrap_or(&0)) << 8)
|
||||
| u32::from(*chunk.get(2).unwrap_or(&0));
|
||||
for i in 0..4 {
|
||||
if i <= chunk.len() {
|
||||
out.push(ALPHABET[(n >> (18 - 6 * i) & 63) as usize] as char);
|
||||
} else {
|
||||
out.push('=');
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// One tool call: (content blocks, is_error).
|
||||
fn call(&mut self, name: &str, args: &Value) -> (Vec<Value>, bool) {
|
||||
let text = |v: &Value| json!({ "type": "text", "text": serde_json::to_string_pretty(v).unwrap_or_default() });
|
||||
let Some(cmd) = commands::COMMANDS
|
||||
.iter()
|
||||
.find(|c| c.mcp && commands::tool_name(c) == name)
|
||||
else {
|
||||
return (
|
||||
vec![text(&json!({ "error": format!("unknown tool {name}") }))],
|
||||
true,
|
||||
);
|
||||
};
|
||||
if cmd.name == "screenshot" {
|
||||
let path = std::env::temp_dir().join(format!("horizon-ctl-{}.png", std::process::id()));
|
||||
let result = self
|
||||
.run(&["screenshot".to_owned(), path.display().to_string()])
|
||||
.and_then(|reply| {
|
||||
std::fs::read(&path)
|
||||
.map(|png| (reply, png))
|
||||
.map_err(|e| crate::Failure {
|
||||
reply: json!({ "error": format!("{}: {e}", path.display()) }),
|
||||
status: 1,
|
||||
})
|
||||
});
|
||||
let _ = std::fs::remove_file(&path);
|
||||
return match result {
|
||||
Ok((reply, png)) => (
|
||||
vec![
|
||||
json!({ "type": "image", "data": base64(&png), "mimeType": "image/png" }),
|
||||
text(&reply),
|
||||
],
|
||||
false,
|
||||
),
|
||||
Err(f) => (vec![text(&f.reply)], true),
|
||||
};
|
||||
}
|
||||
if cmd.name == "logs" {
|
||||
let words = match commands::line_for(cmd, args) {
|
||||
Ok(w) => w,
|
||||
Err(e) => return (vec![text(&json!({ "error": e, "kind": "usage" }))], true),
|
||||
};
|
||||
return match self.run(&words) {
|
||||
Ok(reply) => (
|
||||
vec![
|
||||
json!({ "type": "text", "text": format!("{}\n\n{}", reply["path"].as_str().unwrap_or_default(), reply["text"].as_str().unwrap_or_default()) }),
|
||||
],
|
||||
false,
|
||||
),
|
||||
Err(f) => (vec![text(&f.reply)], true),
|
||||
};
|
||||
}
|
||||
let mut words = match commands::line_for(cmd, args) {
|
||||
Ok(w) => w,
|
||||
Err(e) => return (vec![text(&json!({ "error": e, "kind": "usage" }))], true),
|
||||
};
|
||||
if cmd.name == "launch" {
|
||||
// An agent wants a world it can play once the call returns.
|
||||
if args.get("wait").and_then(Value::as_bool) != Some(false)
|
||||
&& !words.contains(&"--wait".to_owned())
|
||||
{
|
||||
words.push("--wait".into());
|
||||
}
|
||||
words.push("--no-pause".into());
|
||||
}
|
||||
match self.run(&words) {
|
||||
Ok(reply) => (vec![text(&reply)], false),
|
||||
Err(f) => (vec![text(&f.reply)], true),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle(&mut self, method: &str, params: &Value) -> Result<Value, (i64, String)> {
|
||||
match method {
|
||||
"initialize" => Ok(json!({
|
||||
"protocolVersion": params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL),
|
||||
"capabilities": { "tools": {} },
|
||||
"serverInfo": { "name": "horizon-ctl", "version": env!("CARGO_PKG_VERSION") },
|
||||
"instructions": "Play Horizon, a Rust reimplementation of Minecraft Java Edition. Start with launch (it finds the game installed by the Horizon launcher and opens or creates a world), then read the world with state, inventory, screen, entities and find_block, and act with goto, look_at_block, break, place, interact, click_slot and click_button. Actions block until they finish and report what happened. Coordinates are block coordinates; yaw 0 faces south (+Z).",
|
||||
})),
|
||||
"ping" => Ok(json!({})),
|
||||
"tools/list" => Ok(json!({ "tools": tools() })),
|
||||
"tools/call" => {
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or((-32602, "name is required".to_string()))?;
|
||||
if !tools().iter().any(|t| t["name"] == name) {
|
||||
return Err((-32602, format!("Unknown tool {name}")));
|
||||
}
|
||||
let args = params.get("arguments").cloned().unwrap_or(json!({}));
|
||||
let (content, error) = self.call(name, &args);
|
||||
Ok(json!({ "content": content, "isError": error }))
|
||||
}
|
||||
_ => Err((-32601, format!("Method not found: {method}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn serve(session: &mut Session) -> Result<(), String> {
|
||||
let stdin = std::io::stdin();
|
||||
let mut stdout = std::io::stdout();
|
||||
for line in stdin.lock().lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(&line) {
|
||||
Err(e) => Some(
|
||||
json!({ "jsonrpc": "2.0", "id": null, "error": { "code": -32700, "message": e.to_string() } }),
|
||||
),
|
||||
Ok(msg) => {
|
||||
let method = msg
|
||||
.get("method")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
// Notifications (no id) get no reply.
|
||||
msg.get("id").cloned().map(|id| match session.handle(method, ¶ms) {
|
||||
Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
|
||||
Err((code, message)) => json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }),
|
||||
})
|
||||
}
|
||||
};
|
||||
if let Some(reply) = reply {
|
||||
writeln!(stdout, "{reply}")
|
||||
.and_then(|_| stdout.flush())
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn base64_matches_rfc_4648() {
|
||||
assert_eq!(base64(b""), "");
|
||||
assert_eq!(base64(b"f"), "Zg==");
|
||||
assert_eq!(base64(b"fo"), "Zm8=");
|
||||
assert_eq!(base64(b"foo"), "Zm9v");
|
||||
assert_eq!(base64(b"foobar"), "Zm9vYmFy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_have_schemas_and_unique_names() {
|
||||
let list = tools();
|
||||
let mut names = std::collections::HashSet::new();
|
||||
for t in &list {
|
||||
assert!(names.insert(t["name"].as_str().unwrap().to_owned()));
|
||||
assert_eq!(t["inputSchema"]["type"], "object");
|
||||
}
|
||||
for want in [
|
||||
"launch",
|
||||
"state",
|
||||
"goto",
|
||||
"break_block",
|
||||
"place",
|
||||
"click_slot",
|
||||
"click_button",
|
||||
"screenshot",
|
||||
"inventory",
|
||||
"logs",
|
||||
] {
|
||||
let want = if want == "break_block" { "break" } else { want };
|
||||
assert!(names.contains(want), "{want}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_basics() {
|
||||
let mut s = Session::new(std::env::temp_dir().join("horizon-ctl-mcp-test.sock"), None);
|
||||
let init = s
|
||||
.handle("initialize", &json!({ "protocolVersion": "2025-03-26" }))
|
||||
.unwrap();
|
||||
assert_eq!(init["protocolVersion"], "2025-03-26");
|
||||
assert!(s.handle("nope", &json!({})).is_err());
|
||||
let r = s
|
||||
.handle(
|
||||
"tools/call",
|
||||
&json!({ "name": "goto", "arguments": { "x": 1 } }),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["isError"], true);
|
||||
assert!(
|
||||
r["content"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("y is required")
|
||||
);
|
||||
// No game: a connection error, reported as a tool error.
|
||||
let r = s
|
||||
.handle("tools/call", &json!({ "name": "state", "arguments": {} }))
|
||||
.unwrap();
|
||||
assert_eq!(r["isError"], true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
//! Block entities carried by items, vanilla 26.3: Ctrl + Pick Block in creative
|
||||
//! (`ServerGamePacketListenerImpl.addBlockDataToItem`) and placing such an item
|
||||
//! (`BlockItem.updateCustomBlockEntityTag`, then
|
||||
//! `BlockEntity.applyComponentsFromItemStack`). Both work on the block entity's
|
||||
//! saved NBT (`block_entity_nbt::canonical`) and the item's component patch.
|
||||
//!
|
||||
//! What a type turns into item components (`removeComponentsFromTag`,
|
||||
//! `collectImplicitComponents`) leaves its NBT; the rest goes to
|
||||
//! `minecraft:block_entity_data`, tagged with the type's id.
|
||||
use crate::block_entity_nbt::{self, canonical};
|
||||
use horizon_world::nbt::{Compound, JavaString, Tag};
|
||||
|
||||
pub const BLOCK_ENTITY_DATA: &str = "minecraft:block_entity_data";
|
||||
|
||||
/// `BlockEntityTypes.OP_ONLY_CUSTOM_DATA`: the types whose data only a player
|
||||
/// who can use game master blocks places (`BlockEntityType.onlyOpCanSetNbt`).
|
||||
pub const OP_ONLY: [&str; 6] = [
|
||||
"minecraft:command_block",
|
||||
"minecraft:lectern",
|
||||
"minecraft:sign",
|
||||
"minecraft:hanging_sign",
|
||||
"minecraft:mob_spawner",
|
||||
"minecraft:trial_spawner",
|
||||
];
|
||||
|
||||
/// The `minecraft:block_entity_data` of `stack`: its type id and its data.
|
||||
pub fn data(stack: &crate::items::ItemStack) -> Option<(String, &Tag)> {
|
||||
let data = stack.data()?.other(BLOCK_ENTITY_DATA)?;
|
||||
Some((block_entity_nbt::id(data)?, data))
|
||||
}
|
||||
|
||||
/// `BlockItem.shouldPrintOpWarning` for a player with game master permission:
|
||||
/// the item carries data of an op-only type.
|
||||
pub fn op_warning(stack: &crate::items::ItemStack) -> bool {
|
||||
data(stack).is_some_and(|(id, _)| OP_ONLY.contains(&id.as_str()))
|
||||
}
|
||||
|
||||
/// `Spawner.getSpawnEntityDisplayName` (`SpawnData.entity.id` of the item's
|
||||
/// data, which `ItemStack.addDetailsToTooltip` reads for spawners and trial
|
||||
/// spawners alike): the entity type a spawner item spawns.
|
||||
pub fn spawner_entity(stack: &crate::items::ItemStack) -> Option<String> {
|
||||
let (_, data) = data(stack)?;
|
||||
match data.get("SpawnData")?.get("entity")?.get("id")? {
|
||||
Tag::String(id) => Some(id.to_string_lossy()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A part of a block entity's NBT that its item holds as a component.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Part {
|
||||
/// The NBT key and the component hold the same value.
|
||||
Same(&'static str, &'static str),
|
||||
/// `Items` as `minecraft:container`, in a container of this many slots.
|
||||
Items(usize),
|
||||
/// `LootTable` and `LootTableSeed` as `minecraft:container_loot`.
|
||||
Loot,
|
||||
/// `is_waxed` as `minecraft:waxed`.
|
||||
Waxed,
|
||||
/// A decorated pot's `item` as `minecraft:container`.
|
||||
PotItem,
|
||||
/// Left out of the item's data, with no component.
|
||||
Drop(&'static str),
|
||||
}
|
||||
const NAME: Part = Part::Same("CustomName", "minecraft:custom_name");
|
||||
const LOCK: Part = Part::Same("lock", "minecraft:lock");
|
||||
const CONTAINER: &str = "minecraft:container";
|
||||
|
||||
/// Each type's `removeComponentsFromTag`, `collectImplicitComponents` and
|
||||
/// `applyImplicitComponents`.
|
||||
fn parts(id: &str) -> &'static [Part] {
|
||||
match id {
|
||||
"minecraft:chest"
|
||||
| "minecraft:trapped_chest"
|
||||
| "minecraft:barrel"
|
||||
| "minecraft:shulker_box" => &[NAME, LOCK, Part::Items(27), Part::Loot],
|
||||
"minecraft:dispenser" | "minecraft:dropper" | "minecraft:crafter" => {
|
||||
&[NAME, LOCK, Part::Items(9), Part::Loot]
|
||||
}
|
||||
"minecraft:hopper" => &[NAME, LOCK, Part::Items(5), Part::Loot],
|
||||
"minecraft:furnace" | "minecraft:smoker" | "minecraft:blast_furnace" => {
|
||||
&[NAME, LOCK, Part::Items(3)]
|
||||
}
|
||||
"minecraft:brewing_stand" => &[NAME, LOCK, Part::Items(5)],
|
||||
"minecraft:beacon" => &[NAME, LOCK],
|
||||
"minecraft:enchanting_table" => &[NAME],
|
||||
"minecraft:command_block" => &[NAME, Part::Drop("conditionMet"), Part::Drop("powered")],
|
||||
"minecraft:campfire" => &[Part::Items(4)],
|
||||
"minecraft:chiseled_bookshelf" => &[Part::Items(6)],
|
||||
"minecraft:shelf" => &[Part::Items(3)],
|
||||
"minecraft:beehive" => &[Part::Same("bees", "minecraft:bees")],
|
||||
"minecraft:banner" => &[Part::Same("patterns", "minecraft:banner_patterns"), NAME],
|
||||
"minecraft:decorated_pot" => &[
|
||||
Part::Same("sherds", "minecraft:pot_decorations"),
|
||||
Part::PotItem,
|
||||
],
|
||||
"minecraft:sign" | "minecraft:hanging_sign" => &[
|
||||
Part::Same("front_text", "minecraft:sign_text_front"),
|
||||
Part::Same("back_text", "minecraft:sign_text_back"),
|
||||
Part::Waxed,
|
||||
],
|
||||
"minecraft:skull" => &[
|
||||
Part::Same("profile", "minecraft:profile"),
|
||||
Part::Same("note_block_sound", "minecraft:note_block_sound"),
|
||||
Part::Same("custom_name", "minecraft:custom_name"),
|
||||
],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The component a part reads, and what it holds for `tag` (None: unset).
|
||||
fn collect(part: Part, tag: &Tag) -> Option<(&'static str, Option<Tag>)> {
|
||||
Some(match part {
|
||||
Part::Same(key, component) => (
|
||||
component,
|
||||
tag.get(key).cloned().or_else(|| unset(component)),
|
||||
),
|
||||
Part::Items(_) => {
|
||||
let slots = match tag.get("Items") {
|
||||
Some(Tag::List(items)) => items.iter().filter_map(slot_entry).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
(CONTAINER, Some(Tag::List(slots)))
|
||||
}
|
||||
Part::Loot => {
|
||||
let table = tag.get("LootTable").cloned();
|
||||
let loot = table.map(|t| {
|
||||
let mut entries = vec![("loot_table", t)];
|
||||
if let Some(seed) = tag.get("LootTableSeed") {
|
||||
entries.push(("seed", seed.clone()));
|
||||
}
|
||||
Tag::compound(entries)
|
||||
});
|
||||
("minecraft:container_loot", loot)
|
||||
}
|
||||
Part::Waxed => (
|
||||
"minecraft:waxed",
|
||||
matches!(tag.get("is_waxed"), Some(Tag::Byte(1)))
|
||||
.then(|| Tag::Compound(Compound::new())),
|
||||
),
|
||||
Part::PotItem => {
|
||||
let slots = match tag.get("item") {
|
||||
Some(Tag::Compound(item)) => Some(container_slot(0, item.clone())),
|
||||
_ => None,
|
||||
};
|
||||
(CONTAINER, Some(Tag::List(slots.into_iter().collect())))
|
||||
}
|
||||
Part::Drop(_) => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// What a type that always collects `component` gives when its NBT leaves the
|
||||
/// value out (its empty value, which the NBT omits).
|
||||
fn unset(component: &str) -> Option<Tag> {
|
||||
Some(match component {
|
||||
"minecraft:bees" | "minecraft:banner_patterns" => Tag::List(Vec::new()),
|
||||
"minecraft:pot_decorations" => Tag::Compound(Compound::new()),
|
||||
"minecraft:sign_text_front" | "minecraft:sign_text_back" => {
|
||||
block_entity_nbt::sign_side(None)
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// An `Items` entry as a `minecraft:container` slot.
|
||||
fn slot_entry(entry: &Tag) -> Option<Tag> {
|
||||
let Tag::Compound(entry) = entry else {
|
||||
return None;
|
||||
};
|
||||
let mut item = entry.clone();
|
||||
let slot = match item.remove(&JavaString::from("Slot"))? {
|
||||
Tag::Byte(s) => i32::from(s),
|
||||
_ => return None,
|
||||
};
|
||||
Some(container_slot(slot, item))
|
||||
}
|
||||
|
||||
/// An `ItemContainerContents` slot: `ItemStackTemplate` leaves out a count of 1.
|
||||
fn container_slot(slot: i32, mut item: Compound) -> Tag {
|
||||
if item.get(&JavaString::from("count")) == Some(&Tag::Int(1)) {
|
||||
item.remove(&JavaString::from("count"));
|
||||
}
|
||||
Tag::compound([("slot", Tag::Int(slot)), ("item", Tag::Compound(item))])
|
||||
}
|
||||
|
||||
/// The value of `component` on the clone item of a type that collects it: a
|
||||
/// picked item leaves it out of its patch, and a placed item without it in its
|
||||
/// patch gives it to the block entity.
|
||||
fn item_default(component: &str) -> Option<Tag> {
|
||||
Some(match component {
|
||||
CONTAINER | "minecraft:bees" | "minecraft:banner_patterns" => Tag::List(Vec::new()),
|
||||
"minecraft:pot_decorations" => {
|
||||
let brick = || Tag::compound([("id", Tag::String("minecraft:brick".into()))]);
|
||||
Tag::compound([
|
||||
("back", brick()),
|
||||
("front", brick()),
|
||||
("left", brick()),
|
||||
("right", brick()),
|
||||
])
|
||||
}
|
||||
"minecraft:sign_text_front" | "minecraft:sign_text_back" => {
|
||||
block_entity_nbt::sign_side(None)
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
const METADATA: [&str; 6] = ["id", "x", "y", "z", "keepPacked", "components"];
|
||||
|
||||
/// `saveCustomOnly`: the saved NBT without its id, position and components.
|
||||
fn custom_only(saved: &Tag) -> Compound {
|
||||
match saved {
|
||||
Tag::Compound(c) => c
|
||||
.iter()
|
||||
.filter(|(k, _)| !METADATA.contains(&k.to_string_lossy().as_str()))
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect(),
|
||||
_ => Compound::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `addBlockDataToItem`: the component patch Ctrl + Pick Block gives the clone
|
||||
/// item of the block entity saved as `saved` (a full, canonical compound).
|
||||
pub fn pick(saved: &Tag) -> Compound {
|
||||
let id = block_entity_nbt::id(saved).unwrap_or_default();
|
||||
let mut patch = Compound::new();
|
||||
// BlockItem.setBlockEntityData after removeComponentsFromTag.
|
||||
let mut data = custom_only(saved);
|
||||
for part in parts(&id) {
|
||||
let key = match *part {
|
||||
Part::Same(key, _) | Part::Drop(key) => key,
|
||||
Part::Items(_) => "Items",
|
||||
Part::Loot => {
|
||||
data.remove(&JavaString::from("LootTableSeed"));
|
||||
"LootTable"
|
||||
}
|
||||
Part::Waxed => "is_waxed",
|
||||
Part::PotItem => "item",
|
||||
};
|
||||
data.remove(&JavaString::from(key));
|
||||
}
|
||||
if !data.is_empty() {
|
||||
data.insert("id".into(), Tag::String(id.as_str().into()));
|
||||
patch.insert(BLOCK_ENTITY_DATA.into(), Tag::Compound(data));
|
||||
}
|
||||
// ItemStack.applyComponents(collectComponents()): the block entity's own
|
||||
// components, then the implicit ones; a value equal to the item's own is
|
||||
// no change.
|
||||
let mut collected = match saved.get("components") {
|
||||
Some(Tag::Compound(c)) => c.clone(),
|
||||
_ => Compound::new(),
|
||||
};
|
||||
for part in parts(&id) {
|
||||
if let Some((component, value)) = collect(*part, saved) {
|
||||
match value {
|
||||
Some(v) => collected.insert(component.into(), v),
|
||||
None => collected.remove(&JavaString::from(component)),
|
||||
};
|
||||
}
|
||||
}
|
||||
for (component, value) in collected {
|
||||
if item_default(&component.to_string_lossy()).as_ref() == Some(&value) {
|
||||
patch.remove(&component);
|
||||
} else {
|
||||
patch.insert(component, value);
|
||||
}
|
||||
}
|
||||
patch
|
||||
}
|
||||
|
||||
/// The block entity saved as `saved` after an item with component patch
|
||||
/// `components` is placed as it: `BlockItem.updateCustomBlockEntityTag` (the
|
||||
/// item's data of the same type, op-only types only when `game_master`, the
|
||||
/// player can use game master blocks), then `applyComponentsFromItemStack`.
|
||||
pub fn place(saved: &Tag, components: &Compound, game_master: bool) -> Tag {
|
||||
let Some(id) = block_entity_nbt::id(saved) else {
|
||||
return saved.clone();
|
||||
};
|
||||
let Tag::Compound(mut tag) = saved.clone() else {
|
||||
return saved.clone();
|
||||
};
|
||||
// TypedEntityData.loadInto: saveCustomOnly, merge, loadCustomOnly when changed.
|
||||
if let Some(Tag::Compound(data)) = components.get(&JavaString::from(BLOCK_ENTITY_DATA))
|
||||
&& data
|
||||
.get(&JavaString::from("id"))
|
||||
.is_some_and(|t| matches!(t, Tag::String(s) if s.to_string_lossy() == id))
|
||||
&& (game_master || !OP_ONLY.contains(&id.as_str()))
|
||||
{
|
||||
let custom = custom_only(saved);
|
||||
let mut merged = custom.clone();
|
||||
let mut data = data.clone();
|
||||
data.remove(&JavaString::from("id"));
|
||||
crate::data_cmd::merge_compound(&mut merged, &data);
|
||||
if merged != custom {
|
||||
for key in METADATA {
|
||||
if let Some(v) = tag.get(&JavaString::from(key)) {
|
||||
merged.insert(key.into(), v.clone());
|
||||
}
|
||||
}
|
||||
if let Some(Tag::Compound(loaded)) = canonical(&Tag::Compound(merged)) {
|
||||
tag = loaded;
|
||||
}
|
||||
}
|
||||
}
|
||||
// applyImplicitComponents, then the rest of the patch as the block
|
||||
// entity's own components.
|
||||
let mut read = vec![BLOCK_ENTITY_DATA, "minecraft:block_state"];
|
||||
// The item's components: its patch over its own defaults.
|
||||
let get = |c: &str| match components.get(&JavaString::from(c)) {
|
||||
Some(v) => Some(v.clone()),
|
||||
None if components.contains_key(&JavaString::from(format!("!{c}").as_str())) => None,
|
||||
None => item_default(c),
|
||||
};
|
||||
let mut set = |key: &str, value: Option<Tag>| match value {
|
||||
Some(v) => tag.insert(key.into(), v),
|
||||
None => tag.remove(&JavaString::from(key)),
|
||||
};
|
||||
for part in parts(&id) {
|
||||
match *part {
|
||||
Part::Same(key, component) => {
|
||||
read.push(component);
|
||||
set(key, get(component));
|
||||
}
|
||||
Part::Items(size) => {
|
||||
read.push(CONTAINER);
|
||||
let items = container_slots(get(CONTAINER).as_ref())
|
||||
.filter(|(slot, _)| (*slot as usize) < size)
|
||||
.map(|(slot, mut item)| {
|
||||
item.insert("Slot".into(), Tag::Byte(slot as i8));
|
||||
Tag::Compound(item)
|
||||
})
|
||||
.collect();
|
||||
set("Items", Some(Tag::List(items)));
|
||||
}
|
||||
Part::Loot => {
|
||||
read.push("minecraft:container_loot");
|
||||
if let Some(loot) = get("minecraft:container_loot") {
|
||||
set("LootTable", loot.get("loot_table").cloned());
|
||||
set("LootTableSeed", loot.get("seed").cloned());
|
||||
}
|
||||
}
|
||||
Part::Waxed => {
|
||||
read.push("minecraft:waxed");
|
||||
set(
|
||||
"is_waxed",
|
||||
Some(Tag::Byte(i8::from(get("minecraft:waxed").is_some()))),
|
||||
);
|
||||
}
|
||||
Part::PotItem => {
|
||||
read.push(CONTAINER);
|
||||
let first = container_slots(get(CONTAINER).as_ref()).find(|(slot, _)| *slot == 0);
|
||||
set("item", first.map(|(_, item)| Tag::Compound(item)));
|
||||
}
|
||||
Part::Drop(_) => {}
|
||||
}
|
||||
}
|
||||
let own: Compound = components
|
||||
.iter()
|
||||
.filter(|(k, _)| {
|
||||
let k = k.to_string_lossy();
|
||||
!k.starts_with('!') && !read.contains(&k.as_str())
|
||||
})
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect();
|
||||
tag.insert("components".into(), Tag::Compound(own));
|
||||
let tag = Tag::Compound(tag);
|
||||
canonical(&tag).unwrap_or(tag)
|
||||
}
|
||||
|
||||
/// The slots of a `minecraft:container` component.
|
||||
fn container_slots(container: Option<&Tag>) -> impl Iterator<Item = (i32, Compound)> + '_ {
|
||||
let list = match container {
|
||||
Some(Tag::List(list)) => list.as_slice(),
|
||||
_ => &[],
|
||||
};
|
||||
list.iter().filter_map(|entry| {
|
||||
let slot = match entry.get("slot")? {
|
||||
Tag::Int(s) => *s,
|
||||
_ => return None,
|
||||
};
|
||||
match entry.get("item")? {
|
||||
Tag::Compound(item) => Some((slot, item.clone())),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn snbt(text: &str) -> Tag {
|
||||
horizon_world::nbt::parse_snbt(text).expect("snbt").0
|
||||
}
|
||||
fn saved(text: &str) -> Tag {
|
||||
canonical(&snbt(text)).expect("known type")
|
||||
}
|
||||
fn canon(patch: &Compound) -> String {
|
||||
crate::item_data::canon_tag(&Tag::Compound(patch.clone()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_filled_named_chest_gives_its_items_and_name_as_components() {
|
||||
let chest = saved(
|
||||
"{id:\"minecraft:chest\",x:1,y:2,z:3,CustomName:\"Loot\",\
|
||||
Items:[{Slot:4b,id:\"minecraft:stone\",count:5}]}",
|
||||
);
|
||||
let patch = pick(&chest);
|
||||
assert_eq!(
|
||||
canon(&patch),
|
||||
crate::item_data::canon_tag(&snbt(
|
||||
"{\"minecraft:container\":[{slot:4,item:{id:\"minecraft:stone\",count:5}}],\
|
||||
\"minecraft:custom_name\":\"Loot\"}"
|
||||
))
|
||||
);
|
||||
// Placing it back gives the same chest.
|
||||
let empty = saved("{id:\"minecraft:chest\",x:1,y:2,z:3}");
|
||||
assert_eq!(place(&empty, &patch, false), chest);
|
||||
// An empty chest has nothing to carry.
|
||||
assert!(pick(&empty).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_block_keeps_its_command_in_block_entity_data() {
|
||||
let block = saved(
|
||||
"{id:\"minecraft:command_block\",x:0,y:0,z:0,Command:\"say hi\",auto:1b,powered:1b}",
|
||||
);
|
||||
let patch = pick(&block);
|
||||
let data = patch
|
||||
.get(&JavaString::from(BLOCK_ENTITY_DATA))
|
||||
.expect("data");
|
||||
assert_eq!(data.get("Command"), Some(&Tag::String("say hi".into())));
|
||||
assert_eq!(data.get("powered"), None);
|
||||
assert_eq!(
|
||||
data.get("id"),
|
||||
Some(&Tag::String("minecraft:command_block".into()))
|
||||
);
|
||||
// Only a player who can use game master blocks places it.
|
||||
let fresh = saved("{id:\"minecraft:command_block\",x:0,y:0,z:0}");
|
||||
assert_eq!(place(&fresh, &patch, false), fresh);
|
||||
assert_eq!(
|
||||
place(&fresh, &patch, true).get("Command"),
|
||||
Some(&Tag::String("say hi".into()))
|
||||
);
|
||||
// Data of another type is ignored.
|
||||
let chest = saved("{id:\"minecraft:chest\",x:0,y:0,z:0}");
|
||||
assert_eq!(place(&chest, &patch, true), chest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_data_names_its_spawn_and_op_only_types() {
|
||||
let item = |text: &str| {
|
||||
let components = snbt(&format!("{{\"minecraft:block_entity_data\":{text}}}"));
|
||||
let (damage, extra) = crate::item_data::decode_components(Some(&components));
|
||||
crate::items::ItemStack {
|
||||
damage,
|
||||
extra,
|
||||
..crate::items::ItemStack::new("minecraft:spawner", 1)
|
||||
}
|
||||
};
|
||||
let spawner =
|
||||
item("{id:\"minecraft:mob_spawner\",SpawnData:{entity:{id:\"minecraft:zombie\"}}}");
|
||||
assert_eq!(
|
||||
spawner_entity(&spawner).as_deref(),
|
||||
Some("minecraft:zombie")
|
||||
);
|
||||
assert!(op_warning(&spawner));
|
||||
let chest = item("{id:\"minecraft:chest\",LootTable:\"x\"}");
|
||||
assert_eq!(spawner_entity(&chest), None);
|
||||
assert!(!op_warning(&chest));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_written_sign_gives_its_text_and_wax() {
|
||||
let sign = saved(
|
||||
"{id:\"minecraft:sign\",x:0,y:0,z:0,is_waxed:1b,\
|
||||
front_text:{messages:[\"a\",\"b\",\"\",\"\"]}}",
|
||||
);
|
||||
let patch = pick(&sign);
|
||||
let keys: Vec<String> = patch.keys().map(|k| k.to_string_lossy()).collect();
|
||||
assert_eq!(keys, ["minecraft:sign_text_front", "minecraft:waxed"]);
|
||||
let fresh = saved("{id:\"minecraft:sign\",x:0,y:0,z:0}");
|
||||
assert_eq!(place(&fresh, &patch, false), sign);
|
||||
}
|
||||
}
|
||||
@@ -259,7 +259,7 @@ fn is_pos(tag: &Tag) -> bool {
|
||||
matches!(tag, Tag::IntArray(v) if v.len() == 3)
|
||||
}
|
||||
/// The loaded `SignText` of one side, or the empty text.
|
||||
fn sign_side(tag: Option<&Tag>) -> Tag {
|
||||
pub(crate) fn sign_side(tag: Option<&Tag>) -> Tag {
|
||||
let empty = || string("");
|
||||
let mut messages: Vec<Tag> = match tag.and_then(|t| t.get("messages")) {
|
||||
Some(Tag::List(list)) => list.iter().filter_map(component).collect(),
|
||||
|
||||
@@ -143,6 +143,7 @@ pub fn draw_part(
|
||||
let color = pack_color(tint, 255, diffuse(normal, lights));
|
||||
for (v, position) in poly.iter().zip(pos) {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: position.to_array(),
|
||||
uv: [r.u0 + (r.u1 - r.u0) * v[3], r.v0 + (r.v1 - r.v0) * v[4]],
|
||||
layer: 0,
|
||||
@@ -637,6 +638,7 @@ fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
|
||||
0xFF00_0000 | (argb & 255) << 16 | (argb >> 8 & 255) << 8 | (argb >> 16 & 255);
|
||||
for ((cx, cy), uv) in corners.into_iter().zip(uvs) {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: call
|
||||
.matrix
|
||||
.transform_point3(Vec3::new(cx, cy, 0.0))
|
||||
@@ -1587,6 +1589,7 @@ impl BeamPart {
|
||||
(at(b, self.end), [s.u0, v1]),
|
||||
] {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: p.to_array(),
|
||||
uv,
|
||||
layer: 6,
|
||||
@@ -1656,6 +1659,7 @@ fn portal_cube(m: Mat4, faces: &[Direction], layer: u32, out: &mut Vec<Vertex>)
|
||||
for &d in faces {
|
||||
for c in PORTAL_FACES[d as usize] {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: m.transform_point3(Vec3::from(c)).to_array(),
|
||||
uv: [0.0; 2],
|
||||
layer,
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
//! Per-state gameplay facts exported from vanilla's own block-state accessors
|
||||
//! (`gameplay-facts.json`), indexed by registry state ID.
|
||||
mod decode;
|
||||
use crate::Result;
|
||||
use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fs::File, io::BufReader, path::Path, sync::Arc};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs::{self, File},
|
||||
io::BufReader,
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum RenderShape {
|
||||
@@ -54,6 +61,8 @@ pub struct StateFacts {
|
||||
pub waterlogged: bool,
|
||||
/// `hasOffsetFunction`: the model is jittered per column.
|
||||
pub random_offset: bool,
|
||||
/// Whether the outline, not just the model, follows BlockState.getOffset.
|
||||
pub outline_offset: bool,
|
||||
/// `BlockState.isSolid()` (legacy solidity; fluid surfaces read it).
|
||||
pub legacy_solid: bool,
|
||||
/// `isFaceSturdy(.., SupportType.FULL)` by Direction ordinal.
|
||||
@@ -62,8 +71,8 @@ pub struct StateFacts {
|
||||
pub classes: Arc<[Arc<str>]>,
|
||||
/// `getShape` (the outline used for picking) as boxes in block space,
|
||||
/// without the random offset; and its edges as LevelRenderer draws them.
|
||||
pub outline: Arc<[[f32; 6]]>,
|
||||
pub outline_edges: Arc<[[f32; 6]]>,
|
||||
pub outline: Arc<[[f64; 6]]>,
|
||||
pub outline_edges: Arc<[[f64; 6]]>,
|
||||
/// `getMapColor(..).id` (0 = NONE).
|
||||
pub map_color: u8,
|
||||
}
|
||||
@@ -78,11 +87,11 @@ pub const MAP_COLORS: [u32; 62] = [
|
||||
14200723, 8365974,
|
||||
];
|
||||
/// The 12 edges of the unit cube (outline data predating the export).
|
||||
fn cube_edges() -> Vec<[f32; 6]> {
|
||||
fn cube_edges() -> Vec<[f64; 6]> {
|
||||
let mut edges = Vec::new();
|
||||
for a in 0..3 {
|
||||
for (u, v) in [(0., 0.), (1., 0.), (0., 1.), (1., 1.)] {
|
||||
let mut from = [0.0f32; 3];
|
||||
let mut from = [0.0f64; 3];
|
||||
let (b, c) = ((a + 1) % 3, (a + 2) % 3);
|
||||
from[b] = u;
|
||||
from[c] = v;
|
||||
@@ -104,11 +113,13 @@ pub struct BlockFacts {
|
||||
}
|
||||
impl BlockFacts {
|
||||
pub fn load(root: &Path, registry: &BlockRegistry) -> Result<Self> {
|
||||
let raw: Value = serde_json::from_reader(BufReader::new(File::open(
|
||||
root.join("gameplay-facts.json"),
|
||||
)?))?;
|
||||
let entries = raw["states"]
|
||||
.as_array()
|
||||
let bytes = fs::read(root.join("gameplay-facts.json"))?;
|
||||
serde_json::from_slice::<decode::Validate>(&bytes)?;
|
||||
let raw: decode::Gameplay<'_> = serde_json::from_slice(&bytes)?;
|
||||
let entries = raw
|
||||
.states
|
||||
.0
|
||||
.as_ref()
|
||||
.ok_or("missing gameplay state facts")?;
|
||||
if entries.len() != registry.len() {
|
||||
return Err("gameplay facts do not match the block registry".into());
|
||||
@@ -142,41 +153,22 @@ impl BlockFacts {
|
||||
.collect();
|
||||
let mut names: HashMap<String, Arc<str>> = HashMap::new();
|
||||
let mut intern = |s: &str| -> Arc<str> {
|
||||
names
|
||||
.entry(s.to_owned())
|
||||
.or_insert_with(|| Arc::from(s))
|
||||
.clone()
|
||||
};
|
||||
let mut shapes: HashMap<String, Arc<[[f32; 6]]>> = HashMap::new();
|
||||
let mut shape = |v: &Value, fallback: &dyn Fn() -> Vec<[f32; 6]>| -> Arc<[[f32; 6]]> {
|
||||
let Some(list) = v.as_array() else {
|
||||
return fallback().into();
|
||||
};
|
||||
shapes
|
||||
.entry(v.to_string())
|
||||
.or_insert_with(|| {
|
||||
list.iter()
|
||||
.filter_map(|b| {
|
||||
let b = b.as_array()?;
|
||||
let n =
|
||||
|i: usize| b.get(i).and_then(Value::as_f64).unwrap_or(0.0) as f32;
|
||||
Some([n(0), n(1), n(2), n(3), n(4), n(5)])
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.clone()
|
||||
if let Some(name) = names.get(s) {
|
||||
return name.clone();
|
||||
}
|
||||
let name: Arc<str> = Arc::from(s);
|
||||
names.insert(s.to_owned(), name.clone());
|
||||
name
|
||||
};
|
||||
let mut shapes = decode::Shapes::default();
|
||||
let mut states = Vec::with_capacity(entries.len());
|
||||
for (index, entry) in entries.iter().enumerate() {
|
||||
let id = BlockStateId(index as u32);
|
||||
if entry["id"].as_u64() != Some(index as u64) {
|
||||
if entry.id != Some(index as u64) {
|
||||
return Err("gameplay facts are not in registry order".into());
|
||||
}
|
||||
let state = registry.state(id).ok_or("missing registry state")?;
|
||||
let num = |k: &str| entry[k].as_f64().unwrap_or(0.0);
|
||||
let flag = |k: &str| entry[k].as_bool().unwrap_or(false);
|
||||
let sound = &entry["sound"];
|
||||
let sound_id = |k: &str| sound[k]["id"].as_str().unwrap_or("").to_owned();
|
||||
let sound = &entry.sound;
|
||||
let prop = |k: &str| state.properties.get(k).map(String::as_str);
|
||||
let (fluid, fluid_level, fluid_falling) = match state.name.as_str() {
|
||||
"minecraft:water" | "minecraft:lava" => {
|
||||
@@ -215,10 +207,10 @@ impl BlockFacts {
|
||||
}
|
||||
};
|
||||
let edges = move || if air { Vec::new() } else { cube_edges() };
|
||||
let outline = shape(&entry["outline"], &cube);
|
||||
let outline_edges = shape(&entry["outline_edges"], &edges);
|
||||
let outline = shapes.intern(entry.outline, cube)?;
|
||||
let outline_edges = shapes.intern(entry.outline_edges, edges)?;
|
||||
// The export holds MapColor.col; ids are positions in MAP_COLORS.
|
||||
let col = entry["map_color"].as_u64().unwrap_or(0) as u32;
|
||||
let col = entry.map_color.unwrap_or(0) as u32;
|
||||
let map_color = if col == 0 {
|
||||
0
|
||||
} else {
|
||||
@@ -229,40 +221,41 @@ impl BlockFacts {
|
||||
outline,
|
||||
outline_edges,
|
||||
name: intern(&state.name),
|
||||
emission: num("emission") as u8,
|
||||
dampening: num("dampening") as u8,
|
||||
skylight_down: flag("skylight_down"),
|
||||
shape_occlusion: flag("shape_occlusion"),
|
||||
can_occlude: flag("can_occlude"),
|
||||
solid_render: flag("solid_render"),
|
||||
hardness: num("hardness") as f32,
|
||||
requires_tool: flag("requires_tool"),
|
||||
replaceable: flag("replaceable"),
|
||||
random_ticks: flag("random_ticks"),
|
||||
block_entity: flag("block_entity"),
|
||||
render_shape: match entry["render_shape"].as_str() {
|
||||
emission: entry.emission as u8,
|
||||
dampening: entry.dampening as u8,
|
||||
skylight_down: entry.skylight_down,
|
||||
shape_occlusion: entry.shape_occlusion,
|
||||
can_occlude: entry.can_occlude,
|
||||
solid_render: entry.solid_render,
|
||||
hardness: entry.hardness as f32,
|
||||
requires_tool: entry.requires_tool,
|
||||
replaceable: entry.replaceable,
|
||||
random_ticks: entry.random_ticks,
|
||||
block_entity: entry.block_entity,
|
||||
render_shape: match entry.render_shape.as_deref() {
|
||||
Some("INVISIBLE") => RenderShape::Invisible,
|
||||
Some("ENTITYBLOCK_ANIMATED") => RenderShape::Animated,
|
||||
_ => RenderShape::Model,
|
||||
},
|
||||
full_collision: flag("full_collision"),
|
||||
friction: num("friction") as f32,
|
||||
explosion_resistance: num("explosion_resistance") as f32,
|
||||
item: intern(entry["item"].as_str().unwrap_or("minecraft:air")),
|
||||
full_collision: entry.full_collision,
|
||||
friction: entry.friction as f32,
|
||||
explosion_resistance: entry.explosion_resistance as f32,
|
||||
item: intern(entry.item.as_deref().unwrap_or("minecraft:air")),
|
||||
sounds: Sounds {
|
||||
volume: sound["volume"].as_f64().unwrap_or(1.0) as f32,
|
||||
pitch: sound["pitch"].as_f64().unwrap_or(1.0) as f32,
|
||||
break_sound: intern(&sound_id("break")),
|
||||
step: intern(&sound_id("step")),
|
||||
place: intern(&sound_id("place")),
|
||||
hit: intern(&sound_id("hit")),
|
||||
fall: intern(&sound_id("fall")),
|
||||
volume: sound.volume as f32,
|
||||
pitch: sound.pitch as f32,
|
||||
break_sound: intern(sound.r#break.id.as_deref().unwrap_or("")),
|
||||
step: intern(sound.step.id.as_deref().unwrap_or("")),
|
||||
place: intern(sound.place.id.as_deref().unwrap_or("")),
|
||||
hit: intern(sound.hit.id.as_deref().unwrap_or("")),
|
||||
fall: intern(sound.fall.id.as_deref().unwrap_or("")),
|
||||
},
|
||||
fluid,
|
||||
fluid_level,
|
||||
fluid_falling,
|
||||
waterlogged: prop("waterlogged") == Some("true"),
|
||||
random_offset: flag("offset"),
|
||||
random_offset: entry.offset,
|
||||
outline_offset: entry.outline_offset.unwrap_or(entry.offset),
|
||||
legacy_solid: behaviour_states[index][1].as_u64().unwrap_or(0) & 1 != 0,
|
||||
sturdy_full: (behaviour_states[index][0].as_u64().unwrap_or(0) & 63) as u8,
|
||||
classes: classes
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
//! Typed projection of gameplay facts. Scalar conversions deliberately retain Value's
|
||||
//! permissive defaults; maps retain its last-key-wins behavior. Repeated geometry stays
|
||||
//! borrowed until it is interned, rather than allocating a JSON tree for every state.
|
||||
use serde::{
|
||||
Deserialize, Deserializer,
|
||||
de::{MapAccess, SeqAccess, Visitor},
|
||||
};
|
||||
use serde_json::{Value, value::RawValue};
|
||||
use std::{
|
||||
collections::{HashMap, hash_map::Entry as MapEntry},
|
||||
fmt,
|
||||
hash::{Hash, Hasher},
|
||||
marker::PhantomData,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
// RawValue skips numeric range and depth checks. A non-allocating validation pass
|
||||
// keeps the original Value loader's JSON errors, even in overwritten/unused fields.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Validate;
|
||||
impl<'de> Container<'de> for Validate {}
|
||||
impl<'de> Deserialize<'de> for Validate {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
container(d)
|
||||
}
|
||||
}
|
||||
|
||||
trait Container<'de>: Default {
|
||||
fn map<A: MapAccess<'de>>(mut map: A) -> Result<Self, A::Error> {
|
||||
while map.next_key::<serde::de::IgnoredAny>()?.is_some() {
|
||||
map.next_value::<Validate>()?;
|
||||
}
|
||||
Ok(Self::default())
|
||||
}
|
||||
fn seq<A: SeqAccess<'de>>(mut seq: A) -> Result<Self, A::Error> {
|
||||
while seq.next_element::<Validate>()?.is_some() {}
|
||||
Ok(Self::default())
|
||||
}
|
||||
}
|
||||
struct ContainerVisitor<T>(PhantomData<T>);
|
||||
impl<'de, T: Container<'de>> Visitor<'de> for ContainerVisitor<T> {
|
||||
type Value = T;
|
||||
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
f.write_str("JSON facts")
|
||||
}
|
||||
fn visit_map<A: MapAccess<'de>>(self, map: A) -> Result<T, A::Error> {
|
||||
T::map(map)
|
||||
}
|
||||
fn visit_seq<A: SeqAccess<'de>>(self, seq: A) -> Result<T, A::Error> {
|
||||
T::seq(seq)
|
||||
}
|
||||
fn visit_unit<E: serde::de::Error>(self) -> Result<T, E> {
|
||||
Ok(T::default())
|
||||
}
|
||||
fn visit_bool<E: serde::de::Error>(self, _: bool) -> Result<T, E> {
|
||||
Ok(T::default())
|
||||
}
|
||||
fn visit_i64<E: serde::de::Error>(self, _: i64) -> Result<T, E> {
|
||||
Ok(T::default())
|
||||
}
|
||||
fn visit_u64<E: serde::de::Error>(self, _: u64) -> Result<T, E> {
|
||||
Ok(T::default())
|
||||
}
|
||||
fn visit_f64<E: serde::de::Error>(self, _: f64) -> Result<T, E> {
|
||||
Ok(T::default())
|
||||
}
|
||||
fn visit_str<E: serde::de::Error>(self, _: &str) -> Result<T, E> {
|
||||
Ok(T::default())
|
||||
}
|
||||
}
|
||||
fn container<'de, D: Deserializer<'de>, T: Container<'de>>(d: D) -> Result<T, D::Error> {
|
||||
d.deserialize_any(ContainerVisitor(PhantomData))
|
||||
}
|
||||
macro_rules! read_field {
|
||||
($map:ident, typed) => {
|
||||
$map.next_value()?
|
||||
};
|
||||
($map:ident, value($convert:expr)) => {
|
||||
($convert)($map.next_value::<Value>()?)
|
||||
};
|
||||
}
|
||||
macro_rules! object {
|
||||
($name:ident<$life:lifetime> { $($field:ident: $ty:ty = $default:expr => $kind:ident $(($convert:expr))?),* $(,)? }) => {
|
||||
pub(super) struct $name<$life> {
|
||||
$(pub $field: $ty,)*
|
||||
marker: PhantomData<&$life ()>,
|
||||
}
|
||||
impl Default for $name<'_> {
|
||||
fn default() -> Self { Self { $($field: $default,)* marker: PhantomData } }
|
||||
}
|
||||
impl<$life> Container<$life> for $name<$life> {
|
||||
fn map<A: MapAccess<$life>>(mut map: A) -> Result<Self, A::Error> {
|
||||
#[derive(Deserialize)]
|
||||
#[allow(non_camel_case_types)]
|
||||
#[serde(field_identifier)]
|
||||
enum Field { $($field,)* #[serde(other)] Unknown }
|
||||
let mut result = Self::default();
|
||||
while let Some(field) = map.next_key::<Field>()? {
|
||||
match field {
|
||||
$(Field::$field => result.$field = read_field!(map, $kind $(($convert))?),)*
|
||||
Field::Unknown => { map.next_value::<Validate>()?; }
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
impl<$life> Deserialize<$life> for $name<$life> {
|
||||
fn deserialize<D: Deserializer<$life>>(d: D) -> Result<Self, D::Error> { container(d) }
|
||||
}
|
||||
};
|
||||
}
|
||||
fn number(v: Value) -> f64 {
|
||||
v.as_f64().unwrap_or(0.0)
|
||||
}
|
||||
fn flag(v: Value) -> bool {
|
||||
v.as_bool().unwrap_or(false)
|
||||
}
|
||||
fn unsigned(v: Value) -> Option<u64> {
|
||||
v.as_u64()
|
||||
}
|
||||
fn text(v: Value) -> Option<String> {
|
||||
if let Value::String(s) = v {
|
||||
Some(s)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Entries<'a>(pub Option<Vec<Entry<'a>>>);
|
||||
impl<'de> Container<'de> for Entries<'de> {
|
||||
fn seq<A: SeqAccess<'de>>(mut seq: A) -> Result<Self, A::Error> {
|
||||
let mut entries = Vec::new();
|
||||
while let Some(entry) = seq.next_element()? {
|
||||
entries.push(entry);
|
||||
}
|
||||
Ok(Self(Some(entries)))
|
||||
}
|
||||
}
|
||||
impl<'de> Deserialize<'de> for Entries<'de> {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
container(d)
|
||||
}
|
||||
}
|
||||
object!(Gameplay<'a> { states: Entries<'a> = Entries::default() => typed });
|
||||
object!(SoundId<'a> { id: Option<String> = None => value(text) });
|
||||
object!(Sound<'a> {
|
||||
volume: f64 = 1.0 => value(|v: Value| v.as_f64().unwrap_or(1.0)),
|
||||
pitch: f64 = 1.0 => value(|v: Value| v.as_f64().unwrap_or(1.0)),
|
||||
r#break: SoundId<'a> = SoundId::default() => typed,
|
||||
step: SoundId<'a> = SoundId::default() => typed,
|
||||
place: SoundId<'a> = SoundId::default() => typed,
|
||||
hit: SoundId<'a> = SoundId::default() => typed,
|
||||
fall: SoundId<'a> = SoundId::default() => typed,
|
||||
});
|
||||
object!(Entry<'a> {
|
||||
id: Option<u64> = None => value(unsigned),
|
||||
emission: f64 = 0.0 => value(number),
|
||||
dampening: f64 = 0.0 => value(number),
|
||||
skylight_down: bool = false => value(flag),
|
||||
shape_occlusion: bool = false => value(flag),
|
||||
can_occlude: bool = false => value(flag),
|
||||
solid_render: bool = false => value(flag),
|
||||
hardness: f64 = 0.0 => value(number),
|
||||
requires_tool: bool = false => value(flag),
|
||||
replaceable: bool = false => value(flag),
|
||||
random_ticks: bool = false => value(flag),
|
||||
block_entity: bool = false => value(flag),
|
||||
render_shape: Option<String> = None => value(text),
|
||||
full_collision: bool = false => value(flag),
|
||||
friction: f64 = 0.0 => value(number),
|
||||
explosion_resistance: f64 = 0.0 => value(number),
|
||||
item: Option<String> = None => value(text),
|
||||
sound: Sound<'a> = Sound::default() => typed,
|
||||
offset: bool = false => value(flag),
|
||||
outline_offset: Option<bool> = None => value(|v: Value| v.as_bool()),
|
||||
map_color: Option<u64> = None => value(unsigned),
|
||||
outline: Option<&'a RawValue> = None => typed,
|
||||
outline_edges: Option<&'a RawValue> = None => typed,
|
||||
});
|
||||
|
||||
// Match Value::to_string identity without serializing: integer/float spelling is
|
||||
// distinct, as is the sign of floating zero. Object keys are already sorted by Value.
|
||||
struct ShapeKey(Value);
|
||||
fn same(a: &Value, b: &Value) -> bool {
|
||||
match (a, b) {
|
||||
(Value::Number(a), Value::Number(b)) => {
|
||||
a == b && a.as_f64().map(f64::to_bits) == b.as_f64().map(f64::to_bits)
|
||||
}
|
||||
(Value::Array(a), Value::Array(b)) => {
|
||||
a.len() == b.len() && a.iter().zip(b).all(|(a, b)| same(a, b))
|
||||
}
|
||||
(Value::Object(a), Value::Object(b)) => {
|
||||
a.len() == b.len()
|
||||
&& a.iter()
|
||||
.zip(b)
|
||||
.all(|((ak, av), (bk, bv))| ak == bk && same(av, bv))
|
||||
}
|
||||
_ => a == b,
|
||||
}
|
||||
}
|
||||
impl PartialEq for ShapeKey {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
same(&self.0, &other.0)
|
||||
}
|
||||
}
|
||||
impl Eq for ShapeKey {}
|
||||
fn shape_hash<H: Hasher>(v: &Value, h: &mut H) {
|
||||
std::mem::discriminant(v).hash(h);
|
||||
match v {
|
||||
Value::Null => {}
|
||||
Value::Bool(b) => b.hash(h),
|
||||
Value::String(s) => s.hash(h),
|
||||
Value::Number(n) => {
|
||||
n.is_f64().hash(h);
|
||||
n.hash(h);
|
||||
n.as_f64().map(f64::to_bits).hash(h);
|
||||
}
|
||||
Value::Array(a) => {
|
||||
a.len().hash(h);
|
||||
for v in a {
|
||||
shape_hash(v, h);
|
||||
}
|
||||
}
|
||||
Value::Object(o) => {
|
||||
o.len().hash(h);
|
||||
for (k, v) in o {
|
||||
k.hash(h);
|
||||
shape_hash(v, h);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Hash for ShapeKey {
|
||||
fn hash<H: Hasher>(&self, h: &mut H) {
|
||||
shape_hash(&self.0, h);
|
||||
}
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub(super) struct Shapes<'a> {
|
||||
raw: HashMap<&'a str, usize>,
|
||||
canonical: HashMap<ShapeKey, usize>,
|
||||
values: Vec<Arc<[[f64; 6]]>>,
|
||||
}
|
||||
impl<'a> Shapes<'a> {
|
||||
pub fn intern(
|
||||
&mut self,
|
||||
raw: Option<&'a RawValue>,
|
||||
fallback: impl FnOnce() -> Vec<[f64; 6]>,
|
||||
) -> crate::Result<Arc<[[f64; 6]]>> {
|
||||
let Some(raw) = raw else {
|
||||
return Ok(fallback().into());
|
||||
};
|
||||
if let Some(&id) = self.raw.get(raw.get()) {
|
||||
return Ok(self.values[id].clone());
|
||||
}
|
||||
let value: Value = serde_json::from_str(raw.get())?;
|
||||
if !value.is_array() {
|
||||
return Ok(fallback().into());
|
||||
}
|
||||
let id = match self.canonical.entry(ShapeKey(value)) {
|
||||
MapEntry::Occupied(entry) => *entry.get(),
|
||||
MapEntry::Vacant(entry) => {
|
||||
let shape = entry
|
||||
.key()
|
||||
.0
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|b| {
|
||||
let b = b.as_array()?;
|
||||
Some(std::array::from_fn(|i| {
|
||||
b.get(i).and_then(Value::as_f64).unwrap_or(0.0)
|
||||
}))
|
||||
})
|
||||
.collect::<Arc<[[f64; 6]]>>();
|
||||
let id = self.values.len();
|
||||
self.values.push(shape);
|
||||
entry.insert(id);
|
||||
id
|
||||
}
|
||||
};
|
||||
self.raw.insert(raw.get(), id);
|
||||
Ok(self.values[id].clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "decode_tests.rs"]
|
||||
mod tests;
|
||||
Loaded 100 of 974 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user