mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
718
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aad95ab6fb | ||
|
|
5d210dca4a | ||
|
|
26980884cb | ||
|
|
7c0f02b141 | ||
|
|
8c02d4c08d | ||
|
|
8eded02226 | ||
|
|
73569f01f9 | ||
|
|
ae9fec6c37 | ||
|
|
42945a35d9 | ||
|
|
e32e5031c1 | ||
|
|
e2d8d9f04f | ||
|
|
67f0621115 | ||
|
|
79b60587eb | ||
|
|
785f200c39 | ||
|
|
a6c9a9269e | ||
|
|
03e621ed3a | ||
|
|
86df5cf6bd | ||
|
|
2eef4e4bf5 | ||
|
|
c730ed9ed8 | ||
|
|
618db927d9 | ||
|
|
f69c4b8731 | ||
|
|
3d67cb265e | ||
|
|
8a030c7976 | ||
|
|
5c725a6391 | ||
|
|
11445532e3 | ||
|
|
e45020d545 | ||
|
|
e7f7a89608 | ||
|
|
ce57e88dba | ||
|
|
f5dca46dde | ||
|
|
651c763cbb | ||
|
|
731e386757 | ||
|
|
48216ae380 | ||
|
|
cc7047d2cd | ||
|
|
0ce2a01a8e | ||
|
|
07d63b6433 | ||
|
|
3907175c6a | ||
|
|
13f8074118 | ||
|
|
4ec6870749 | ||
|
|
0cf37641f7 | ||
|
|
9722ef5001 | ||
|
|
88da88eeec | ||
|
|
b42d2386d5 | ||
|
|
057ba5ff01 | ||
|
|
e397a4841f | ||
|
|
ce66c97f70 | ||
|
|
5373bcef52 | ||
|
|
4a0926855c | ||
|
|
8991087ebe | ||
|
|
4e9df566a9 | ||
|
|
bd73df40be | ||
|
|
3296bacea1 | ||
|
|
9e98bebb9d | ||
|
|
48e8439bd1 | ||
|
|
a0757ea032 | ||
|
|
9b41ff8f14 | ||
|
|
10979fb9af | ||
|
|
53fcba1093 | ||
|
|
805dc51d6e | ||
|
|
fac77bf305 | ||
|
|
da06dc61db | ||
|
|
fa93944959 | ||
|
|
69545bd5cc | ||
|
|
ce7932e9ee | ||
|
|
54081d5169 | ||
|
|
5a7055e512 | ||
|
|
f7c8bb0af2 | ||
|
|
defaa7cd2a | ||
|
|
9e50fd8df2 | ||
|
|
8b53bd8a47 | ||
|
|
f441acf2f8 | ||
|
|
9d555dcdd0 | ||
|
|
2ff8f5277d | ||
|
|
9414c55d22 | ||
|
|
19898a0d77 | ||
|
|
d93c739edc | ||
|
|
2b756b7957 | ||
|
|
df1155b8b5 | ||
|
|
ea6ad5165f | ||
|
|
a13e6b40ce | ||
|
|
d500f28ee4 | ||
|
|
c95ab6c26e | ||
|
|
c94b6a0972 | ||
|
|
399178174d | ||
|
|
8011d274eb | ||
|
|
5e90e26de0 | ||
|
|
59a8aa2cd5 | ||
|
|
84e9dd59fd | ||
|
|
76b151d2b9 | ||
|
|
373742573f | ||
|
|
47bfa67f49 | ||
|
|
1ea04a2b10 | ||
|
|
c05a00092e | ||
|
|
622b9a3a95 | ||
|
|
19220000be | ||
|
|
1c341d58a6 | ||
|
|
7d5261345a | ||
|
|
07a7a26d7d | ||
|
|
a307bd2e3d | ||
|
|
9b1e413edf | ||
|
|
2a7303f76f | ||
|
|
64c6c292ef | ||
|
|
e2ea3014d5 | ||
|
|
c42ae71384 | ||
|
|
0a1d03b4b7 | ||
|
|
e17e8b9d36 | ||
|
|
a4b7ac4a9b | ||
|
|
a8dbaaabd4 | ||
|
|
d468e1e78d | ||
|
|
9a0077794c | ||
|
|
418e22a4b5 | ||
|
|
1b17bef90d | ||
|
|
621cea18ca | ||
|
|
d21b122379 | ||
|
|
fd5360533b | ||
|
|
32439053c9 | ||
|
|
8f51e68d2d | ||
|
|
76e3827545 | ||
|
|
f94dfe1f08 | ||
|
|
a43620023d | ||
|
|
4d164deb16 | ||
|
|
182a8e1a15 | ||
|
|
1c5e5b96aa | ||
|
|
fa0b364f9d | ||
|
|
43287c18c2 | ||
|
|
d38c07c2fa | ||
|
|
d2898fcba1 | ||
|
|
a8a00aac94 | ||
|
|
10d76ad89d | ||
|
|
4768d3f65b | ||
|
|
f32a91d793 | ||
|
|
cef652d026 | ||
|
|
266aff190e | ||
|
|
cde54369a0 | ||
|
|
627837e8c3 | ||
|
|
42bb310df9 | ||
|
|
ddc5aaed0e | ||
|
|
79f0ddee2c | ||
|
|
adb1476582 | ||
|
|
f910be28c8 | ||
|
|
36c4ceb723 | ||
|
|
0231d2890c | ||
|
|
494afc8dc3 | ||
|
|
0431f12a6f | ||
|
|
572addb2ff | ||
|
|
7c06feec74 | ||
|
|
451318860e | ||
|
|
7426aed33c | ||
|
|
24abdb664d | ||
|
|
85e5ba9d85 | ||
|
|
24428c4f83 | ||
|
|
5c88c46c51 | ||
|
|
f7d2a0b311 | ||
|
|
97993634c0 | ||
|
|
75573c292a | ||
|
|
a480d10157 | ||
|
|
092bf87012 | ||
|
|
ca1e3a17c8 | ||
|
|
b0c8f964b2 | ||
|
|
4953305c7b | ||
|
|
ee3e874eb8 | ||
|
|
ee59df13a9 | ||
|
|
5229857dab | ||
|
|
5148b2ee2c | ||
|
|
c468938a66 | ||
|
|
895dc26848 | ||
|
|
bf8d84930f | ||
|
|
af9d888410 | ||
|
|
272414cc05 | ||
|
|
dbfaf2e003 | ||
|
|
1612c86c15 | ||
|
|
8beeea55ff | ||
|
|
a6b183f7f5 | ||
|
|
6af77dfbe4 | ||
|
|
56fcc91e4b | ||
|
|
aac0dc7f60 | ||
|
|
fcb37f05e9 | ||
|
|
adb07c96ff | ||
|
|
d983b47db6 | ||
|
|
0a733cec55 | ||
|
|
303aa35a6f | ||
|
|
df8bc603ff | ||
|
|
6008be0a68 | ||
|
|
ffa46a50af | ||
|
|
aec5c20698 | ||
|
|
a64877f1a1 | ||
|
|
731b4256db | ||
|
|
0001363aa0 | ||
|
|
86cf1542d1 | ||
|
|
0af3dd3900 | ||
|
|
0def8931cb | ||
|
|
39b74fb468 | ||
|
|
b464f977f1 | ||
|
|
c5656493cd | ||
|
|
99b07dc810 | ||
|
|
d2e8e5fe28 | ||
|
|
c2665ee7bd | ||
|
|
8aacf93d8f | ||
|
|
70de685d2b | ||
|
|
4992d877c7 | ||
|
|
79f78f904b | ||
|
|
21ac62a085 | ||
|
|
cf5a979b35 | ||
|
|
d157880712 | ||
|
|
f95a279a92 | ||
|
|
14c6a197e4 | ||
|
|
9e98c1902b | ||
|
|
78e8556e5a | ||
|
|
1f16677a44 | ||
|
|
0ceede079e | ||
|
|
9ed2f482af | ||
|
|
995187d82d | ||
|
|
f3a3dd22bd | ||
|
|
cd4f8ba849 | ||
|
|
874d62b49b | ||
|
|
ee4b4c7834 | ||
|
|
0bc6c689da | ||
|
|
3ce89ebb8a | ||
|
|
bf2c5e7946 | ||
|
|
2e730b9213 | ||
|
|
0e025df8e1 | ||
|
|
857a88351f | ||
|
|
a8682eb4c5 | ||
|
|
cd99c1c683 | ||
|
|
3667bb963c | ||
|
|
c569bbe291 | ||
|
|
4b28390f72 | ||
|
|
f62e07c36a | ||
|
|
bb24420df2 | ||
|
|
2647ed7ed0 | ||
|
|
8993bd4594 | ||
|
|
764b4e0803 | ||
|
|
ea8964506c | ||
|
|
8e095b88fa | ||
|
|
ca790e8dba | ||
|
|
a19a6b5bcc | ||
|
|
9a836c50a8 | ||
|
|
a906bb50ba | ||
|
|
be85b81d13 | ||
|
|
bb3ec12ed0 | ||
|
|
cd30aae4a8 | ||
|
|
8246171ae9 | ||
|
|
44b8aeab0a | ||
|
|
ab8d803d5c | ||
|
|
07f2913d53 | ||
|
|
737841550f | ||
|
|
4f992028bf | ||
|
|
c0f0c17ed4 | ||
|
|
2fe7cb3a4b | ||
|
|
0ab07f7209 | ||
|
|
6a4bed4744 | ||
|
|
c74948d24c | ||
|
|
ba3a0fcf01 | ||
|
|
e805e9886c | ||
|
|
c755ca04f5 | ||
|
|
08c77ad785 | ||
|
|
471a8fd2f9 | ||
|
|
a9bcaa6ed5 | ||
|
|
eaf6b03f86 | ||
|
|
cdd55e54a5 | ||
|
|
fdf4b16e72 | ||
|
|
d79b394086 | ||
|
|
745c756df5 | ||
|
|
a3f38b76aa | ||
|
|
1935cfc7e5 | ||
|
|
8fcae5043b | ||
|
|
c1d2eddb5d | ||
|
|
3215ee942e | ||
|
|
cbd051a1d7 | ||
|
|
bc549713bb | ||
|
|
63044f339c | ||
|
|
c3595f1a9c | ||
|
|
03c423daac | ||
|
|
3d0b76400b | ||
|
|
6da2963080 | ||
|
|
5b9c75c017 | ||
|
|
d04c266edb | ||
|
|
5f0fea0bd6 | ||
|
|
0c933cc9c5 | ||
|
|
8625ec35e0 | ||
|
|
13cd89578a | ||
|
|
25f2e2396d | ||
|
|
204a887ca2 | ||
|
|
76d2985865 | ||
|
|
32a37e8937 | ||
|
|
e293132dda | ||
|
|
875773a08f | ||
|
|
18f9ae5413 | ||
|
|
a9ad185287 | ||
|
|
50215b484a | ||
|
|
c7985974e7 | ||
|
|
a0bcc13f1c | ||
|
|
96822cd5f8 | ||
|
|
3cd155792d | ||
|
|
24bcef42be | ||
|
|
4b18b79d15 | ||
|
|
1e800099e9 | ||
|
|
e3909f4370 | ||
|
|
82c8de262f | ||
|
|
b77891dfe0 | ||
|
|
41bd91f295 | ||
|
|
00343ad2e2 | ||
|
|
f1d5bf5209 | ||
|
|
1f2f2a9f6d | ||
|
|
5fcea03fb8 | ||
|
|
d634870f15 | ||
|
|
b18df1fd6b | ||
|
|
573f3a64f7 | ||
|
|
20cc48b48f | ||
|
|
519ec1217c | ||
|
|
def901b06f | ||
|
|
51c49e11f4 | ||
|
|
7a78d15c9e | ||
|
|
36775db223 | ||
|
|
567a68dd4f | ||
|
|
00a2ea23f0 | ||
|
|
4de112fe16 | ||
|
|
87cf8cb9e9 | ||
|
|
2a9d67bd68 | ||
|
|
a714a9b6f8 | ||
|
|
e046036869 | ||
|
|
2d1dd54b33 | ||
|
|
02a6ab7429 | ||
|
|
bdc758c5e5 | ||
|
|
40972de77a | ||
|
|
d4d46b1bb7 | ||
|
|
faba097ff2 | ||
|
|
c234641ead | ||
|
|
bff5121f6e | ||
|
|
40fbae13b6 | ||
|
|
feb85f6d15 | ||
|
|
f422cbb79a | ||
|
|
ac2547c24a | ||
|
|
b0b4414397 | ||
|
|
80dacdba68 | ||
|
|
453ef077f4 | ||
|
|
4ea2243e4d | ||
|
|
73787507d1 | ||
|
|
1b141ef330 | ||
|
|
f2a323a2d9 | ||
|
|
44fba3bf9d | ||
|
|
163bcdd604 | ||
|
|
e7fded804e | ||
|
|
dd1fd6d3c5 | ||
|
|
56c352679d | ||
|
|
e30527c800 | ||
|
|
52298d9a37 | ||
|
|
aea18f245f | ||
|
|
eed2d8ddff | ||
|
|
f5d81e883c | ||
|
|
be161f100b | ||
|
|
159a5dfa5f | ||
|
|
de6f15e664 | ||
|
|
6c078b2852 | ||
|
|
2c9bfbb70a | ||
|
|
a9bf5dc8e5 | ||
|
|
c5b6eed173 | ||
|
|
e0b75a86b7 | ||
|
|
378739703b | ||
|
|
7a6a4d9249 | ||
|
|
5b47d53dbf | ||
|
|
76e659c74b | ||
|
|
89cbe03ebe | ||
|
|
0bc19687aa | ||
|
|
a49f93f9ef | ||
|
|
567cc33576 | ||
|
|
164f1dfd52 | ||
|
|
c296578a17 | ||
|
|
7b5a57be7c | ||
|
|
f3099e8340 | ||
|
|
db13bd7df5 | ||
|
|
2ebc1c44b3 | ||
|
|
ae3622eb92 | ||
|
|
60bc15a63e | ||
|
|
70c4b205ba | ||
|
|
2b6ae71fb7 | ||
|
|
720240feac | ||
|
|
3a474c2060 | ||
|
|
176da24f2e | ||
|
|
f257131bde | ||
|
|
409a014ff0 | ||
|
|
55cd4e90f4 | ||
|
|
fcf3a3e267 | ||
|
|
be03fec7fc | ||
|
|
e8247df5f0 | ||
|
|
20d9c4c984 | ||
|
|
62cacfa607 | ||
|
|
43223c34e2 | ||
|
|
6e09dbe10a | ||
|
|
2fd82a0c2c | ||
|
|
7b8d8a604d | ||
|
|
ac287c78c5 | ||
|
|
6ad66df8d0 | ||
|
|
5620e4f1d1 | ||
|
|
8bf73f89d4 | ||
|
|
b171508c0f | ||
|
|
6d238a89c5 | ||
|
|
dd9f8a9088 | ||
|
|
6bbcf8e3d6 | ||
|
|
4f9df73831 | ||
|
|
47aaf3a015 | ||
|
|
3e825eaa40 | ||
|
|
831cd657de | ||
|
|
7dacd43706 | ||
|
|
643dccfa42 | ||
|
|
6a5e3a836d | ||
|
|
d5ab26f1d9 | ||
|
|
32be8bac0a | ||
|
|
eae7688378 | ||
|
|
6d15bad156 | ||
|
|
2d8d55942c | ||
|
|
2efa0d65b5 | ||
|
|
bfa622bbde | ||
|
|
0bc5f4263c | ||
|
|
8f8db8096b | ||
|
|
2ec71e5387 | ||
|
|
6bf479ec0b | ||
|
|
323002cf98 | ||
|
|
df58a474fb | ||
|
|
e508a5ce1d | ||
|
|
d1db8c4a54 | ||
|
|
fdee6576c6 | ||
|
|
1b6a3cf535 | ||
|
|
256f7c200a | ||
|
|
96a1e1cb24 | ||
|
|
798249a212 | ||
|
|
dde52bc1e9 | ||
|
|
fde95ff20e | ||
|
|
f0c7df2000 | ||
|
|
512962e3ca | ||
|
|
aab43c46ae | ||
|
|
f57c59a971 | ||
|
|
845a122faa | ||
|
|
54bae6131b | ||
|
|
4db511079d | ||
|
|
c63fa74239 | ||
|
|
c7c9725942 | ||
|
|
4b43242a61 | ||
|
|
9a71700d51 | ||
|
|
6fff54b95d | ||
|
|
9dd896bfc3 | ||
|
|
7ddb4ddae5 | ||
|
|
4676ea2819 | ||
|
|
3c08f74d79 | ||
|
|
571066158e | ||
|
|
c1b4ccd571 | ||
|
|
f845e26050 | ||
|
|
5177fb3601 | ||
|
|
c717628169 | ||
|
|
e93c99a4c7 | ||
|
|
8901a49d0b | ||
|
|
817a71ffa5 | ||
|
|
d85a3603bd | ||
|
|
d8f81f7a98 | ||
|
|
1ccc41c8b2 | ||
|
|
1d937524a9 | ||
|
|
6da2d8ebcc | ||
|
|
3415246998 | ||
|
|
c741bde15f | ||
|
|
166b7f8d05 | ||
|
|
73c726759e | ||
|
|
eb3f2b94ca | ||
|
|
dc206d6132 | ||
|
|
84d3d2a9a0 | ||
|
|
a2363a3bf6 | ||
|
|
8f3d35ab6d | ||
|
|
454b3842bb | ||
|
|
05517a167f | ||
|
|
d0d5e79f1a | ||
|
|
65732406fc | ||
|
|
f72c8674ad | ||
|
|
7d27950e31 | ||
|
|
9190d14b7f | ||
|
|
5b62d771bb | ||
|
|
2932704156 | ||
|
|
b540ccb642 | ||
|
|
c2f3d01ee4 | ||
|
|
6e7b34d5b8 | ||
|
|
1541086b21 | ||
|
|
6829da90f2 | ||
|
|
4029475397 | ||
|
|
daa8b2f528 | ||
|
|
e6f0384dda | ||
|
|
8a1515b64f | ||
|
|
85fa29ee7a | ||
|
|
319343992e | ||
|
|
f683d359a2 | ||
|
|
0fbb7bdd0b | ||
|
|
8d0ec947a3 | ||
|
|
092dc7c641 | ||
|
|
14f58d85e0 | ||
|
|
2ac362f1d3 | ||
|
|
b63debb376 | ||
|
|
2e2f4a6581 | ||
|
|
0436c9470d | ||
|
|
4fb261fd30 | ||
|
|
e19803aa62 | ||
|
|
5f49a85a4a | ||
|
|
999c532a57 | ||
|
|
cf97a5e1dc | ||
|
|
65fc3b9f94 | ||
|
|
6f7096b396 | ||
|
|
ff6e01de03 | ||
|
|
0b5f0a3edf | ||
|
|
0f482a45c8 | ||
|
|
c47b30fcf0 | ||
|
|
9f7bd91109 | ||
|
|
e8eaaf59d7 | ||
|
|
20fada25bd | ||
|
|
e627842ede | ||
|
|
4cfa6a507c | ||
|
|
d370364f12 | ||
|
|
594b502780 | ||
|
|
c1211b2245 | ||
|
|
c12f5cf41a | ||
|
|
65d5a35cd2 | ||
|
|
c6531e4bde | ||
|
|
d223b383ee | ||
|
|
b651ca5f93 | ||
|
|
5ca2021223 | ||
|
|
50daf6ecfc | ||
|
|
f32e696eb9 | ||
|
|
eb3789038e | ||
|
|
ce77a4189d | ||
|
|
118bfa2be2 | ||
|
|
209b898c02 | ||
|
|
4ac29bf1c1 | ||
|
|
9a2d87ec97 | ||
|
|
a1af7fdad5 | ||
|
|
5e1abf2b4e | ||
|
|
946bf84818 | ||
|
|
866e772227 | ||
|
|
583502f0f4 | ||
|
|
43e8b54918 | ||
|
|
2c244f1888 | ||
|
|
ce571f789c | ||
|
|
2faa99e5f1 | ||
|
|
43d43df71d | ||
|
|
01f4c56dc1 | ||
|
|
c78bf74744 | ||
|
|
3bb973efae | ||
|
|
1ffdb5f3f5 | ||
|
|
86d745853a | ||
|
|
e4f8154cb4 | ||
|
|
68475af653 | ||
|
|
730c43e7b0 | ||
|
|
d33929c8fa | ||
|
|
33c6578f5a | ||
|
|
08532d6b1f | ||
|
|
a5edc14b97 | ||
|
|
9004019800 | ||
|
|
4e854f8ee3 | ||
|
|
ecdff7ad2d | ||
|
|
9779eb7271 | ||
|
|
a3cf97f2fa | ||
|
|
d9686faf35 | ||
|
|
0eec25d14b | ||
|
|
437d5272b5 | ||
|
|
42d65465a4 | ||
|
|
5253e94262 | ||
|
|
7de8c51f4e | ||
|
|
05ecd873d4 | ||
|
|
5c55e7d855 | ||
|
|
594929b795 | ||
|
|
e92f74f01d | ||
|
|
ff94490992 | ||
|
|
9ce739796e | ||
|
|
bae7975757 | ||
|
|
cf63dcd39f | ||
|
|
40e9e9660b | ||
|
|
e74d5207dc | ||
|
|
9bb22caa22 | ||
|
|
6f5a3319ee | ||
|
|
a72e2a2f0e | ||
|
|
af3e75fece | ||
|
|
90053d15aa | ||
|
|
5f5781b17e | ||
|
|
56a251eb13 | ||
|
|
9afb6d76de | ||
|
|
d3673b1509 | ||
|
|
c4cc7954ce | ||
|
|
a2f8952d05 | ||
|
|
e25672ed4f | ||
|
|
f0215473e3 | ||
|
|
b9d61e5701 | ||
|
|
9b97ba09de | ||
|
|
d65881a2f0 | ||
|
|
f548f5ce6c | ||
|
|
cb35d7e661 | ||
|
|
da0f954d9b | ||
|
|
b807990c8e | ||
|
|
782924fcb7 | ||
|
|
8bfe19245b | ||
|
|
bf1cd95868 | ||
|
|
bd60381883 | ||
|
|
2025408979 | ||
|
|
1c79addec1 | ||
|
|
a244200097 | ||
|
|
3a8ba56c61 | ||
|
|
9240aeb9ff | ||
|
|
24fd51b186 | ||
|
|
580c2b1dd7 | ||
|
|
ce38c8d74f | ||
|
|
4d04a997ce | ||
|
|
cd1725565a | ||
|
|
f227d04f79 | ||
|
|
b5abc95fe4 | ||
|
|
7c5bd06dfe | ||
|
|
95bdb96f16 | ||
|
|
7733984e78 | ||
|
|
238278aa41 | ||
|
|
4ae51c1d5e | ||
|
|
ba7451cd31 | ||
|
|
1fe38223ac | ||
|
|
fb450342bc | ||
|
|
8c01418247 | ||
|
|
2e531f35e5 | ||
|
|
0605ee689f | ||
|
|
d746be2ca3 | ||
|
|
4be192a4b8 | ||
|
|
dc1a8a4f39 | ||
|
|
e2e0752489 | ||
|
|
95b4b7422d | ||
|
|
502222d224 | ||
|
|
4a56e505aa | ||
|
|
16d731ebda | ||
|
|
7da32100a8 | ||
|
|
3dde7d9955 | ||
|
|
68b0ad8199 | ||
|
|
1e655afcab | ||
|
|
ddc7e466e5 | ||
|
|
09e179804c | ||
|
|
9403347d13 | ||
|
|
c12e1dcd91 | ||
|
|
d35f892b7f | ||
|
|
cafe59b72e | ||
|
|
41a0b3a9e8 | ||
|
|
1a6fd4e17d | ||
|
|
cfed77c227 | ||
|
|
4b7812296e | ||
|
|
39ddc8db3d | ||
|
|
dd77fb8016 | ||
|
|
2f82becb9c | ||
|
|
d1d36f4121 | ||
|
|
22dc3e5954 | ||
|
|
1c2f7664c3 | ||
|
|
69dd4feb31 | ||
|
|
123e8286ac | ||
|
|
d99cf24400 | ||
|
|
43f598fd4e | ||
|
|
ce62f9c57f | ||
|
|
28d218b3a2 | ||
|
|
51b58f3897 | ||
|
|
a6592eb188 | ||
|
|
7bbbf6e97f | ||
|
|
0354c43f62 | ||
|
|
fc6db37b6a | ||
|
|
897cd7a1b7 | ||
|
|
900e0af776 | ||
|
|
bbde190f06 | ||
|
|
17dcb6f793 | ||
|
|
eda6e4b510 | ||
|
|
a79baaa031 | ||
|
|
3b8cc9fcc8 | ||
|
|
bdfa466bd9 | ||
|
|
9198e36c7c | ||
|
|
fb536df24d | ||
|
|
689c262b37 | ||
|
|
83afec2d52 | ||
|
|
655e171d9a | ||
|
|
c32c00aeb2 | ||
|
|
3cae8d1d73 | ||
|
|
4fffbaa1c7 | ||
|
|
59fe284c7d | ||
|
|
8912d5d269 | ||
|
|
e2f2177b60 | ||
|
|
91a4290f50 | ||
|
|
7d50e879da | ||
|
|
1fc1b75007 | ||
|
|
805d5532e5 | ||
|
|
f537c84ebb | ||
|
|
a60519eb63 | ||
|
|
f90f33b6a9 | ||
|
|
f49af8d995 | ||
|
|
b2784363a1 | ||
|
|
bff5891073 | ||
|
|
a67a323959 | ||
|
|
05821d4e62 | ||
|
|
d989d4033f | ||
|
|
45cbb4f7d6 | ||
|
|
042f634c7a | ||
|
|
1ac9c72ea3 | ||
|
|
a7af5b76cf | ||
|
|
4cdad5aae6 | ||
|
|
66a8fa1181 | ||
|
|
690e656048 | ||
|
|
47a00d283d | ||
|
|
c98a4f7a58 | ||
|
|
74da721249 | ||
|
|
b6603122d7 | ||
|
|
e0364da4eb | ||
|
|
ed9a25ad49 | ||
|
|
4d1596fe3b | ||
|
|
f48a54da09 | ||
|
|
57ee531c63 | ||
|
|
78e05bdc95 | ||
|
|
a195b4849e | ||
|
|
6b0b34098e | ||
|
|
c5851e77aa | ||
|
|
3cd26c6d0b | ||
|
|
c8003301bc | ||
|
|
b347ceb195 | ||
|
|
6285a15cf3 | ||
|
|
65a6ab7fa4 | ||
|
|
92236010fd | ||
|
|
938f2ec884 | ||
|
|
e43e8ae82b | ||
|
|
18e9c14642 | ||
|
|
f5a795988b |
No files matched your search
@@ -0,0 +1,17 @@
|
||||
# Changes to these files wait for Ludovic's review once branch protection requires code owners' reviews
|
||||
# (tools/ci/protect_pipeline.sh, docs/security.md). They are what builds, signs and ships releases, the code that
|
||||
# checks downloads before installing them (backend.rs downloads, installs and updates the launcher itself; links.json
|
||||
# says where from; the Cargo.toml files pick the crates that do it), and this file. Everything else merges as soon as
|
||||
# `verify` passes.
|
||||
/.github/ @ludovic111
|
||||
/tools/ci/ @ludovic111
|
||||
/tools/release_train.py @ludovic111
|
||||
/tools/release_channels.py @ludovic111
|
||||
/crates/horizon-release-sign/ @ludovic111
|
||||
/crates/horizon-server/src/update.rs @ludovic111
|
||||
/crates/horizon-server/Cargo.toml @ludovic111
|
||||
/launcher/src/signed.rs @ludovic111
|
||||
/launcher/src/backend.rs @ludovic111
|
||||
/launcher/links.json @ludovic111
|
||||
/launcher/Cargo.toml @ludovic111
|
||||
/packaging/ @ludovic111
|
||||
@@ -0,0 +1,27 @@
|
||||
name: cancel-on-close
|
||||
# A closed or merged pull request's queued and running checks free the runners at once.
|
||||
# pull_request_target so the token can cancel runs of pull requests from forks too;
|
||||
# it checks out nothing and runs no code from the pull request.
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [closed]
|
||||
permissions:
|
||||
actions: write
|
||||
jobs:
|
||||
cancel:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Cancel the pull request's unfinished runs
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
BRANCH: ${{ github.event.pull_request.head.ref }}
|
||||
SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
for status in queued in_progress waiting pending requested; do
|
||||
gh run list -R "$REPO" --branch "$BRANCH" --status "$status" --limit 100 \
|
||||
--json databaseId,event,headSha \
|
||||
-q ".[] | select(.event == \"pull_request\" and .headSha == \"$SHA\") | .databaseId"
|
||||
done | sort -u | while read -r id; do
|
||||
gh run cancel -R "$REPO" "$id" || true
|
||||
done
|
||||
+15
-38
@@ -1,23 +1,25 @@
|
||||
name: checks
|
||||
# verify: every pull request and every push to main, on GitHub's runners (the repository is public).
|
||||
# verify: every pull request and every push to main, on GitHub's runners (the repository is public). It is the
|
||||
# check main requires, so it never has a condition: a skipped job counts as passed, and every run of this workflow
|
||||
# must report a real verify (a pull request that changes no Rust finishes it in seconds instead).
|
||||
# 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.
|
||||
# maintainer's own Minecraft files (Horizon never downloads the game); .github/workflows/parity.yml. A fork's pull
|
||||
# request runs it only when a maintainer adds the run-parity label (.github/workflows/run-parity.yml), so labels
|
||||
# never start this workflow. See docs/release-flow.md.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, labeled]
|
||||
types: [opened, synchronize, reopened]
|
||||
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' }}
|
||||
group: checks-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
verify:
|
||||
if: github.event.action != 'labeled'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
@@ -56,42 +58,17 @@ jobs:
|
||||
run: |
|
||||
cargo +stable fmt --all -- --check
|
||||
cargo +stable clippy --workspace --all-targets -- -D warnings
|
||||
# The server build (released as horizon-engine): the game without sound or controllers.
|
||||
cargo +stable clippy -p horizon-client --all-targets --no-default-features -- -D warnings
|
||||
- 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 2400 # world_spawn takes ~1480 s on a busy runner
|
||||
- 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
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: ./.github/workflows/parity.yml
|
||||
with:
|
||||
base: ${{ github.event.pull_request.base.sha }}
|
||||
head: ${{ github.event.pull_request.head.sha }}
|
||||
# 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
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
name: launcher
|
||||
# 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),
|
||||
# push to main that changes the launcher or a crate it builds in (the paths below),
|
||||
# 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
|
||||
@@ -14,10 +13,15 @@ on:
|
||||
push:
|
||||
branches: [main, launcher, 'launcher-*']
|
||||
paths:
|
||||
# Every crate the launcher builds in, as tools/release_train.py's LAUNCHER_PATHS.
|
||||
- 'launcher/**'
|
||||
- 'crates/horizon-bench/**'
|
||||
- 'crates/horizon-friends/**'
|
||||
- 'crates/horizon-mods/**'
|
||||
- 'crates/horizon-port-pack/**'
|
||||
- 'crates/horizon-prepare/**'
|
||||
- 'crates/horizon-theme/**'
|
||||
- 'crates/horizon-telemetry/**'
|
||||
- 'crates/horizon-theme/**'
|
||||
- '.github/workflows/launcher.yml'
|
||||
schedule:
|
||||
- cron: '7-59/10 * * * *'
|
||||
@@ -116,8 +120,9 @@ jobs:
|
||||
with:
|
||||
ref: ${{ needs.decide.outputs.head }}
|
||||
persist-credentials: false
|
||||
# The Mac mini's runner keeps its target/ between runs.
|
||||
clean: ${{ runner.environment == 'github-hosted' }}
|
||||
# Always from a clean checkout, the Mac mini's runner too: its work folder is shared with pull requests'
|
||||
# parity jobs, and nothing they leave may end up in a launcher release (target/ is rebuilt from scratch).
|
||||
clean: true
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: runner.environment == 'github-hosted'
|
||||
@@ -138,7 +143,8 @@ jobs:
|
||||
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"}
|
||||
# Every test target (the library, the binary, tests/), on each system the launcher ships for.
|
||||
- run: cargo test --release --locked ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
|
||||
- run: cargo build --release --locked ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
|
||||
- name: The version is the release's
|
||||
run: |
|
||||
@@ -213,10 +219,49 @@ jobs:
|
||||
path: launcher/out/*
|
||||
retention-days: 3
|
||||
|
||||
release:
|
||||
sign:
|
||||
needs: [decide, build]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
# The only job that holds the release signing key, a secret of the `release` environment, which only main's real
|
||||
# releases enter (dry runs sign with a throwaway key). It runs no code from the repository but
|
||||
# tools/ci/sign_release.sh, which calls only the runner's sha256sum and OpenSSL (docs/release-flow.md).
|
||||
environment: ${{ needs.decide.outputs.dry_run != 'true' && 'release' || '' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: tools/ci/sign_release.sh
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
pattern: horizon-launcher-*
|
||||
merge-multiple: true
|
||||
- name: Sign the checksums (SHA256SUMS, SHA256SUMS.sig)
|
||||
env:
|
||||
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
|
||||
RELEASE_SIGNING_KEY_NEXT: ${{ secrets.RELEASE_SIGNING_KEY_NEXT }}
|
||||
EPHEMERAL: ${{ needs.decide.outputs.dry_run == 'true' && 'yes' || '' }}
|
||||
TAG: ${{ needs.decide.outputs.tag }}
|
||||
run: bash tools/ci/sign_release.sh "$TAG" dist/horizon-launcher-*
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: signature
|
||||
path: dist/SHA256SUMS*
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: needs.decide.outputs.dry_run == 'true'
|
||||
with:
|
||||
name: ephemeral.pub
|
||||
path: dist/ephemeral.pub
|
||||
retention-days: 3
|
||||
|
||||
release:
|
||||
needs: [decide, build, sign]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
@@ -234,6 +279,13 @@ jobs:
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
- name: The signature, checked as launchers check it
|
||||
env:
|
||||
TAG: ${{ needs.decide.outputs.tag }}
|
||||
EPHEMERAL: ${{ needs.decide.outputs.dry_run == 'true' && 'yes' || '' }}
|
||||
run: |
|
||||
key=(); [[ -n "$EPHEMERAL" ]] && key=(--key "$(cat dist/ephemeral.pub)")
|
||||
cargo run --locked --quiet -p horizon-release-sign -- verify --tag "$TAG" --publishing "${key[@]}" dist
|
||||
# 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'
|
||||
@@ -241,8 +293,9 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.decide.outputs.tag }}
|
||||
run: |
|
||||
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
|
||||
gh release create "$TAG" --target "${{ needs.decide.outputs.head }}" --title "$(cat notes/title.txt)" \
|
||||
--notes-file notes/body.md --latest=false dist/horizon-launcher-*
|
||||
--notes-file notes/body.md --latest=false dist/horizon-launcher-* dist/SHA256SUMS "${signature[@]}"
|
||||
- name: Dry run, the release that would be published
|
||||
if: needs.decide.outputs.dry_run == 'true'
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
name: 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). Called by ci.yml for pull requests from this repository
|
||||
# and by run-parity.yml when a maintainer labels a fork's pull request run-parity. See docs/release-flow.md.
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
base:
|
||||
description: The pull request's base commit (the harnesses its changes since then can affect)
|
||||
type: string
|
||||
required: true
|
||||
head:
|
||||
description: The commit to check out and test
|
||||
type: string
|
||||
required: true
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
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: ${{ inputs.head }}
|
||||
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: ''
|
||||
BASE_SHA: ${{ inputs.base }}
|
||||
run: python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400 # world_spawn takes ~1480 s on a busy runner
|
||||
- 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
|
||||
@@ -0,0 +1,52 @@
|
||||
name: promote
|
||||
# Beta game releases become stable (docs/release-flow.md, "Channels"): every 30 minutes tools/release_channels.py
|
||||
# promotes the newest beta that has been out 6 hours, passed its release smoke test and isn't covered by an issue
|
||||
# labelled release-blocker. By hand, from GitHub's app too (Actions > promote > Run workflow):
|
||||
# gh workflow run promote -f tag=v2026.10.19.03 # that beta now, without the wait
|
||||
# gh workflow run promote -f tag=v2026.10.19.03 -f force=true # even blocked or without its smoke test
|
||||
# gh workflow run promote -f dry_run=true # say what would be promoted
|
||||
# Holding betas back: label an issue release-blocker. While it is open it holds every beta; closed as completed it
|
||||
# still holds the betas published before it closed. The issue's text is never read (its author could edit it).
|
||||
# When the oldest waiting beta is 24 hours old, an issue "Promotion to stable has stalled" says so; it closes itself
|
||||
# after the next promotion.
|
||||
on:
|
||||
schedule:
|
||||
- cron: '13,43 * * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: The beta to promote now (empty = the newest beta that is due)
|
||||
type: string
|
||||
default: ''
|
||||
force:
|
||||
description: Promote that tag even if a release-blocker covers it or its smoke test didn't run
|
||||
type: boolean
|
||||
default: false
|
||||
dry_run:
|
||||
description: Say what would be promoted, change nothing
|
||||
type: boolean
|
||||
default: false
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
concurrency:
|
||||
group: promote
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
promote:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# The notes of a promoted release cover every pull request since the last stable one.
|
||||
fetch-depth: 0
|
||||
# gh uses GH_TOKEN below; nothing needs git credentials on disk.
|
||||
persist-credentials: false
|
||||
- name: Promote
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
FORCE: ${{ inputs.force && 'yes' || '' }}
|
||||
DRY_RUN: ${{ (inputs.dry_run || github.ref != 'refs/heads/main') && 'yes' || '' }}
|
||||
run: python3 tools/release_channels.py promote ${TAG:+--tag "$TAG"} ${FORCE:+--force} ${DRY_RUN:+--dry-run}
|
||||
@@ -6,7 +6,8 @@ name: release
|
||||
# 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.
|
||||
# publishes vYYYY.MM.DD[.N] with notes from the merged pull requests, as a beta (a pre-release) that promote.yml makes
|
||||
# stable after 6 hours.
|
||||
# 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:
|
||||
@@ -76,6 +77,9 @@ jobs:
|
||||
os: ubuntu-22.04
|
||||
dir: horizon-linux-x86_64
|
||||
asset: horizon-linux-x86_64.tar.gz
|
||||
# The server build too: packaging/server/install.sh installs it on Linux.
|
||||
server_dir: horizon-server-linux-x86_64
|
||||
server_asset: horizon-server-linux-x86_64.tar.gz
|
||||
- name: windows
|
||||
os: windows-latest
|
||||
dir: horizon-windows-x64
|
||||
@@ -99,8 +103,11 @@ jobs:
|
||||
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' }}
|
||||
# Always from a clean checkout, the Mac mini's runner too: its work folder is shared with pull requests'
|
||||
# parity jobs, and nothing they leave (target/, an untracked .cargo/config.toml, a build script's output)
|
||||
# may end up in a release. target/ is rebuilt from scratch; .local/ is relinked by vanilla-data.sh, which
|
||||
# keeps its preparations outside the checkout.
|
||||
clean: true
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: runner.environment == 'github-hosted'
|
||||
@@ -165,6 +172,15 @@ jobs:
|
||||
or not report.get("meshed_sections") or not report.get("player"):
|
||||
sys.exit("the smoke run did not draw a world")
|
||||
EOF
|
||||
# Published with the release: a beta becomes stable only with it (tools/release_channels.py).
|
||||
cp "$home/smoke.json" "$RUNNER_TEMP/smoke-test.json"
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: runner.environment == 'self-hosted'
|
||||
with:
|
||||
name: smoke-test.json
|
||||
path: ${{ runner.temp }}/smoke-test.json
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- name: Package
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -174,7 +190,14 @@ jobs:
|
||||
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 ;;
|
||||
else
|
||||
# Without resource forks and extended attributes: plain ditto adds an
|
||||
# AppleDouble `._` file beside every file that has any.
|
||||
ditto -c -k --norsrc --noextattr --noqtn --keepParent "${{ matrix.dir }}" "${{ matrix.asset }}"
|
||||
if unzip -Z1 "${{ matrix.asset }}" | grep -E '(^|/)(\._|__MACOSX/)'; then
|
||||
echo "::error::macOS metadata files in ${{ matrix.asset }}"; exit 1
|
||||
fi
|
||||
fi ;;
|
||||
esac
|
||||
ls -l "${{ matrix.asset }}"
|
||||
- uses: actions/upload-artifact@v4
|
||||
@@ -183,11 +206,75 @@ jobs:
|
||||
path: dist/${{ matrix.asset }}
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- name: The server build (the game without sound or controllers, as horizon-engine)
|
||||
if: matrix.server_asset
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# After the client is packaged: target/release/horizon-client is rebuilt without the audio and
|
||||
# controllers features, so it needs neither libasound nor libudev (minimal server images lack them).
|
||||
cargo build --release --locked -p horizon-client --no-default-features --bin horizon-client
|
||||
cargo run --locked -p xtask -- package-server target/release/horizon-client "dist/${{ matrix.server_dir }}"
|
||||
folder="dist/${{ matrix.server_dir }}"
|
||||
python3 tools/check_imports.py --server "$folder/horizon-engine" "$folder/horizon-server" "$folder/horizon-prepare"
|
||||
"$folder/horizon-engine" --help | grep -q -- '--resources'
|
||||
"$folder/horizon-server" --version
|
||||
cd dist
|
||||
rm -f "${{ matrix.server_asset }}"
|
||||
tar czf "${{ matrix.server_asset }}" "${{ matrix.server_dir }}"
|
||||
ls -l "${{ matrix.server_asset }}"
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: matrix.server_asset
|
||||
with:
|
||||
name: ${{ matrix.server_asset }}
|
||||
path: dist/${{ matrix.server_asset }}
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
publish:
|
||||
sign:
|
||||
needs: [decide, build]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
# The only job that holds the release signing key, a secret of the `release` environment, which only main's real
|
||||
# releases enter (dry runs sign with a throwaway key). It runs no code from the repository but
|
||||
# tools/ci/sign_release.sh, which calls only the runner's sha256sum and OpenSSL (docs/release-flow.md).
|
||||
environment: ${{ needs.decide.outputs.dry_run != 'true' && 'release' || '' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: tools/ci/sign_release.sh
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
pattern: horizon-*
|
||||
merge-multiple: true
|
||||
- name: Sign the checksums (SHA256SUMS, SHA256SUMS.sig)
|
||||
env:
|
||||
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
|
||||
RELEASE_SIGNING_KEY_NEXT: ${{ secrets.RELEASE_SIGNING_KEY_NEXT }}
|
||||
EPHEMERAL: ${{ needs.decide.outputs.dry_run == 'true' && 'yes' || '' }}
|
||||
TAG: ${{ needs.decide.outputs.tag }}
|
||||
run: |
|
||||
bash tools/ci/sign_release.sh "$TAG" dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
|
||||
dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: signature
|
||||
path: dist/SHA256SUMS*
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: needs.decide.outputs.dry_run == 'true'
|
||||
with:
|
||||
name: ephemeral.pub
|
||||
path: dist/ephemeral.pub
|
||||
retention-days: 3
|
||||
|
||||
publish:
|
||||
needs: [decide, build, sign]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
@@ -205,15 +292,26 @@ jobs:
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
- name: The signature, checked as launchers check it
|
||||
env:
|
||||
TAG: ${{ needs.decide.outputs.tag }}
|
||||
EPHEMERAL: ${{ needs.decide.outputs.dry_run == 'true' && 'yes' || '' }}
|
||||
run: |
|
||||
key=(); [[ -n "$EPHEMERAL" ]] && key=(--key "$(cat dist/ephemeral.pub)")
|
||||
cargo run --locked --quiet -p horizon-release-sign -- verify --tag "$TAG" --publishing "${key[@]}" dist
|
||||
- name: Publish ${{ needs.decide.outputs.tag }}
|
||||
if: needs.decide.outputs.dry_run != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.decide.outputs.tag }}
|
||||
run: |
|
||||
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
|
||||
smoke=(); [[ -f dist/smoke-test.json ]] && smoke=(dist/smoke-test.json)
|
||||
# A beta (pre-release): promote.yml makes it stable and latest after 6 hours (docs/release-flow.md).
|
||||
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
|
||||
--notes-file notes/body.md --prerelease --latest=false dist/horizon-linux-x86_64.tar.gz \
|
||||
dist/horizon-windows-x64.zip dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz \
|
||||
dist/SHA256SUMS "${signature[@]}" "${smoke[@]}"
|
||||
- name: Dry run, the release that would be published
|
||||
if: needs.decide.outputs.dry_run == 'true'
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
name: run-parity
|
||||
# A fork's pull request runs the parity job (parity.yml) on the Mac mini only when a maintainer who has read the
|
||||
# change adds the run-parity label: once, on the commit the pull request has then. Remove the label and add it
|
||||
# again after new pushes. A separate workflow, so that labels never start `checks`: there a label's run reported a
|
||||
# skipped `verify`, and GitHub counts a skipped required check as passed.
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: run-parity-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
parity:
|
||||
if: github.event.label.name == 'run-parity'
|
||||
uses: ./.github/workflows/parity.yml
|
||||
with:
|
||||
base: ${{ github.event.pull_request.base.sha }}
|
||||
head: ${{ github.event.pull_request.head.sha }}
|
||||
@@ -66,6 +66,7 @@ your change can affect. Without the vanilla data on your machine it lists those
|
||||
`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.
|
||||
How agents, checks, releases and signed updates keep this safe: [security](docs/security.md).
|
||||
|
||||
## Parity checks on forks
|
||||
|
||||
|
||||
Generated
+87
-3
@@ -155,7 +155,7 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.60.2",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
@@ -1024,6 +1024,16 @@ version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.14"
|
||||
@@ -1293,6 +1303,7 @@ dependencies = [
|
||||
"log",
|
||||
"presser",
|
||||
"thiserror 2.0.21",
|
||||
"windows 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1420,21 +1431,25 @@ dependencies = [
|
||||
"glam",
|
||||
"horizon-assets",
|
||||
"horizon-bench",
|
||||
"horizon-companion",
|
||||
"horizon-core",
|
||||
"horizon-dualsense",
|
||||
"horizon-friends",
|
||||
"horizon-mods",
|
||||
"horizon-protocol",
|
||||
"horizon-senses",
|
||||
"horizon-server-files",
|
||||
"horizon-shaderpack",
|
||||
"horizon-telemetry",
|
||||
"horizon-theme",
|
||||
"horizon-threads",
|
||||
"horizon-world",
|
||||
"log",
|
||||
"miniz_oxide 0.8.9",
|
||||
"naga",
|
||||
"png",
|
||||
"pollster",
|
||||
"rustix 1.1.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
@@ -1447,6 +1462,22 @@ dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-companion"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
"libc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tar",
|
||||
"ureq",
|
||||
"windows-sys 0.61.2",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-core"
|
||||
version = "0.1.0"
|
||||
@@ -1539,6 +1570,7 @@ dependencies = [
|
||||
"horizon-world",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1550,17 +1582,42 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-release-sign"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"ring",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-senses"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"getrandom 0.3.4",
|
||||
"horizon-companion",
|
||||
"libc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"ureq",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-server"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"horizon-bench",
|
||||
"horizon-server-files",
|
||||
"libc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"toml",
|
||||
"toml_edit",
|
||||
"ureq",
|
||||
"windows-sys 0.61.2",
|
||||
"zip",
|
||||
]
|
||||
@@ -1569,6 +1626,7 @@ dependencies = [
|
||||
name = "horizon-server-files"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"horizon-core",
|
||||
"horizon-protocol",
|
||||
"horizon-world",
|
||||
@@ -1601,6 +1659,14 @@ dependencies = [
|
||||
name = "horizon-theme"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "horizon-threads"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-world"
|
||||
version = "0.1.0"
|
||||
@@ -3014,6 +3080,12 @@ dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "range-alloc"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08"
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@@ -3630,6 +3702,16 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.13.5"
|
||||
@@ -4588,12 +4670,11 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "wgpu-hal"
|
||||
version = "30.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"arrayvec",
|
||||
"ash",
|
||||
"bit-set 0.10.0",
|
||||
"bitflags 2.13.2",
|
||||
"block2 0.6.2",
|
||||
"bytemuck",
|
||||
@@ -4612,11 +4693,13 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"objc2-metal 0.3.2",
|
||||
"objc2-quartz-core 0.3.2",
|
||||
"once_cell",
|
||||
"ordered-float",
|
||||
"parking_lot",
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
"profiling",
|
||||
"range-alloc",
|
||||
"raw-window-handle",
|
||||
"raw-window-metal",
|
||||
"renderdoc-sys",
|
||||
@@ -4626,6 +4709,7 @@ dependencies = [
|
||||
"wgpu-naga-bridge",
|
||||
"wgpu-types",
|
||||
"windows 0.62.2",
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+10
-2
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
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"]
|
||||
exclude = ["launcher", "crates/horizon-telemetry-server", "third_party/wgpu-hal"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/voxel-kernels", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-companion", "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-senses", "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-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
@@ -16,6 +16,14 @@ type_complexity = "allow"
|
||||
needless_range_loop = "allow"
|
||||
large_enum_variant = "allow"
|
||||
|
||||
# wgpu-hal 30.0.1 with gfx-rs/wgpu#10124: wgpu 30 creates a Vulkan descriptor
|
||||
# pool with no pool sizes while it creates the device, which Vulkan allows only
|
||||
# since 1.3.215, and Intel's Windows driver for 6th-10th gen iGPUs (HD 520/530,
|
||||
# UHD 620/630) crashes on it with an access violation (#746). Drop this and the
|
||||
# folder once a wgpu release has the fix.
|
||||
[patch.crates-io]
|
||||
wgpu-hal = { path = "third_party/wgpu-hal" }
|
||||
|
||||
# Line tables keep file:line in backtraces; full debug info made every
|
||||
# example and test binary hundreds of megabytes and slow to link.
|
||||
[profile.dev]
|
||||
|
||||
@@ -14,104 +14,64 @@
|
||||
<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>
|
||||
<a href="https://playhorizon.dev/docs/"><b>Documentation</b></a> ·
|
||||
<a href="https://github.com/ludovic111/horizon/releases"><b>Release notes</b></a> ·
|
||||
<a href="#project-status"><b>Project status</b></a> ·
|
||||
<a href="#contributing"><b>Contributing</b></a>
|
||||
</p>
|
||||
|
||||
# Horizon
|
||||
|
||||
A Rust rewrite of Minecraft: Java Edition 26.3 built for one-to-one vanilla parity, checked against the game itself with differential tests.
|
||||
|
||||
Every subsystem matches vanilla in all 281 parity harnesses, on Linux and macOS: 100% of what the harnesses compare (see [Project status](#project-status)). The native client is playable on Linux, macOS (Apple Silicon) and Windows.
|
||||
Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that reads the same worlds, follows the same rules and generates the same terrain as the Java game, and is checked against it by 281 differential harnesses. You play it through a small launcher, on the files from your own copy of Minecraft. It is free, open source and unofficial.
|
||||
|
||||
> 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
|
||||
|
||||
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.
|
||||
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/ludovic111/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs and updates the game, prepares its files from your own copy of Minecraft, and updates itself.
|
||||
|
||||
**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.
|
||||
|
||||
### The launcher
|
||||
**You need** Minecraft: Java Edition 26.3, played once (the official launcher, Prism, MultiMC and other launchers all work); a Java 25 JDK, for the one-time preparation; and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<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>
|
||||
<td width="50%"><img src="docs/media/launcher-play.webp" alt="The launcher's Play page: a version ready to play, its summary, Client and Server, the version picker and Play"></td>
|
||||
<td width="50%"><img src="docs/media/launcher-patch-notes.webp" alt="The launcher's Patch notes page"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
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).
|
||||
## What works today
|
||||
|
||||
## Screenshots
|
||||
The [documentation](https://playhorizon.dev/docs/) covers each of these.
|
||||
|
||||
Captured in the Rust client itself, with vanilla assets and no mods or shaders.
|
||||
- **The game:** singleplayer with vanilla's world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on saves in vanilla's format. Worlds from 1.8 to 26.2 are upgraded as vanilla does. [Status table](#project-status).
|
||||
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
|
||||
- **Resource packs, shader packs and data packs:** resource packs work, Java shader packs run their full-screen effects (experimental), and data packs load functions, predicates and item modifiers.
|
||||
- **Playing with others:** invite friends from the pause menu with a code, Open to LAN, and joining offline-mode servers. Horizon cannot sign in to a Microsoft account yet, so it cannot join servers that check accounts. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
|
||||
- **Mods:** Horizon has its own mod system (Rust compiled to WebAssembly). Java mod jars do not run. See [Mods and data packs](https://playhorizon.dev/docs/mods).
|
||||
|
||||
### Menus and settings
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<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/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
|
||||
Screenshots, captured in the Rust client itself with vanilla assets and no mods or shaders:
|
||||
|
||||
<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/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>
|
||||
<td width="50%"><img src="docs/media/menu-title.webp" alt="Horizon's title screen over a veiled snowy landscape"></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>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/media/village.webp" alt="A plains village among savanna hills, with farms, villagers and an iron golem"></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>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## What it does
|
||||
## Host a server
|
||||
|
||||
Horizon reimplements the Minecraft Java client and its game logic in Rust, one subsystem at a time, and compares each piece with the vanilla game. Each `tools/test_*_parity.py` harness drives the same inputs through Horizon and through the vanilla client and server bytecode from your own copy of the game, then checks that the outputs are identical. It is for players and developers interested in game reimplementation, and for anyone who wants to see how far a behaviour-exact native rewrite can go.
|
||||
The launcher's **Servers** page creates, starts, backs up and updates a Horizon server with buttons, and invites friends over Tailscale (it arrives with the next launcher release). On a VPS, `horizon-server` installs with one command and runs without Java. Guide: [Host a server](https://playhorizon.dev/docs/hosting) ([reference](docs/hosting.md)).
|
||||
|
||||
The [native client](docs/native-client.md) opens on a vanilla-style title screen with world creation and selection, and generates worlds with the Rust port of the vanilla generators: noise terrain, aquifers, surface rules, carvers and placed features, all checked against vanilla. It streams and lights chunks on worker threads and renders every vanilla block model with smooth lighting, biome tints, the vanilla sky, sun, moon, stars and clouds. Gameplay includes:
|
||||
## Modpacks
|
||||
|
||||
- validated player physics, health and hunger;
|
||||
- mining with vanilla tool rules and loot tables;
|
||||
- placing, crafting, chests and furnaces;
|
||||
- dropped items, particles and sounds;
|
||||
- passive and hostile mobs with combat and vanilla AI, including guardians, shulkers, illagers, ravagers, the wither, the ender dragon fight, the breeze, the creaking, the sniffer, the happy ghast, llamas and the copper golem;
|
||||
- villagers that take jobs, trade, restock, meet at the bell and sleep in beds, on a port of vanilla's brain;
|
||||
- raids and pillager patrols;
|
||||
- dispensers, crafters, the loom and the recipe book;
|
||||
- every command, scoreboards and teams, the player list, subtitles and the HUD overlays;
|
||||
- boats, minecarts, item frames, paintings and armor stands;
|
||||
- shields, maps, books, fireworks, beacons, advancements and statistics;
|
||||
- Anvil saves, joining vanilla servers, and opening a world to LAN.
|
||||
The launcher's **Modpacks** page imports a Modrinth or CurseForge pack, shows which of its mods Horizon can carry over, and plays the ones it can as their own profile. Today that means content mods made of data and assets; mods with Java code are not ported yet. See [Modpacks](https://playhorizon.dev/docs/modpacks).
|
||||
|
||||
## Agent control
|
||||
|
||||
@@ -147,7 +107,18 @@ 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, 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).
|
||||
The client starts on the title screen. Command-line options (`--world`, `--seed`, `--creative`, `--render-distance`, `--dedicated`), controls, scripting through `horizon-ctl` and the architecture are described in [the native client guide](docs/native-client.md). The rest of the developer documentation is indexed in [docs/README.md](docs/README.md), including [the parity workflow](docs/parity-workflow.md).
|
||||
|
||||
## Contributing
|
||||
|
||||
Issues and pull requests are welcome: see [CONTRIBUTING.md](CONTRIBUTING.md) for how we work (vanilla first, small pull requests, labels), or [the contributing guide](https://playhorizon.dev/docs/contributing) on the site. Questions and ideas: [Discord](https://discord.gg/HcTeMZxe5E). Before opening a pull request, run the self-check and paste the block it prints:
|
||||
|
||||
```sh
|
||||
git config core.hooksPath .githooks
|
||||
python3 tools/pr_ready.py
|
||||
```
|
||||
|
||||
It runs rustfmt, clippy with warnings denied, the tests of the crates you touched, the Python tests, the public-tree check, and the vanilla harnesses your change can affect. CI runs the same checks on GitHub's Linux runners; the vanilla harnesses need the game, which Horizon never downloads, so they run on a maintainer's computer holding their own copy. Never commit game files, decompiled source or prepared resources: `tools/check_public_tree.py` rejects them, and tracked Java is allowed only under the project's own `dev/horizonlab` namespace.
|
||||
|
||||
## Project status
|
||||
|
||||
@@ -182,18 +153,7 @@ them too (Rust uses Java's own float maths on each platform).
|
||||
|
||||
### Known issues
|
||||
|
||||
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: 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 tools/pr_ready.py
|
||||
```
|
||||
|
||||
It runs rustfmt, clippy with warnings denied, the tests of the crates you touched, the Python tests, the public-tree check, and the vanilla harnesses your change can affect. CI runs the same checks on GitHub's Linux runners; the vanilla harnesses need the game, which Horizon never downloads, so they run on a 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.
|
||||
Horizon can't sign in to a Microsoft account yet, so it can't join online-mode servers (it joins offline-mode ones); skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; the creative hotbar keys (C/X) aren't wired to the hotbar loader; item tooltips lack attribute lines.
|
||||
|
||||
## Licence
|
||||
|
||||
|
||||
@@ -29,3 +29,10 @@ 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.
|
||||
```
|
||||
|
||||
## wgpu-hal
|
||||
|
||||
`third_party/wgpu-hal` is [wgpu-hal](https://github.com/gfx-rs/wgpu) 30.0.1 by the gfx-rs
|
||||
developers, with one upstream fix applied
|
||||
([gfx-rs/wgpu#10124](https://github.com/gfx-rs/wgpu/pull/10124)). It is licensed under MIT or
|
||||
Apache-2.0; both licenses are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
|
||||
@@ -0,0 +1,174 @@
|
||||
# Dedicated server against vanilla 26.3, with bots (2026-10-17)
|
||||
|
||||
`horizon-client --dedicated` (run by `horizon-server`) against the pinned vanilla 26.3 `server.jar`, on `main`
|
||||
at `7bbbf6e9` (in release v2026.10.17.17). That build has the movement checks and chunk pacing of #586, the
|
||||
equal-work method of #670, and the server performance PRs #701–#707 and #713.
|
||||
|
||||
This replaces the data first published here, which came from a Mac mini overloaded by other work (load 55–141 on
|
||||
14 threads). An earlier run of this method, on `main` at `002f14ce`, is kept in `earlier-002f14ce/` and compared
|
||||
at the end.
|
||||
|
||||
## Setup
|
||||
|
||||
- **Server machine:** ThinkPad, Intel i7-7500U (2 cores, 4 threads), 32 GB, NixOS. Nothing else heavy ran on it:
|
||||
its load average was 0.4 when idle. Both servers ran under `nice -n 19`.
|
||||
- **Bots:** `horizon-bots` on another machine (Mac mini M4 Pro), over the tailnet. That machine was shared with
|
||||
other work (load 36–170 on 14 threads, mean 78). The bots need little CPU, but a busy bot host can answer later.
|
||||
Both servers saw the same conditions, since they took turns at each bot count.
|
||||
- **Horizon:** release build of `7bbbf6e9` (`horizon-server`, `horizon-client`).
|
||||
- **Vanilla:** the pinned 26.3 `server.jar` on OpenJDK 25.0.4.1, with `-Xms4g -Xmx4g`.
|
||||
- **Properties:**
|
||||
- `level-seed=12345`, view and simulation distance 10, creative (forced);
|
||||
- `online-mode=false`, `difficulty=easy`, `max-players=200`;
|
||||
- default game rules on both (the locator bar is on).
|
||||
- **Each run:** a fresh server, bots joining one per second, a 60 s warm-up, then 180 s measured.
|
||||
- **Commands** (on the server machine):
|
||||
```
|
||||
python3 tools/server_bench.py --servers horizon,vanilla --nice 19 --warmup 60 --seconds 180 \
|
||||
--bots-via "ssh <bot host>" --bots-bin <horizon-bots> --connect <server address> \
|
||||
--bots 1,10,25,50,100 --behaviour walk,explore
|
||||
python3 tools/server_bench.py ...same... --bots 1,10,25,50 --behaviour explore --explore-speed 0.8
|
||||
```
|
||||
- **Columns:**
|
||||
- **MSPT** (mean) and **P95** come from `/tick query`, in ms. **TPS** is the tick rate.
|
||||
- **RSS** is the mean over the run. **CPU** is the whole process tree, as a percentage of one core.
|
||||
- **Chunks/s** is what the bots received. **New chunks/s** counts each chunk once per bot.
|
||||
- **Pulled back** counts the times the server moved a bot back (vanilla's "moved too quickly" and similar).
|
||||
- **Load** is the mean load average during that run: the server machine (which includes the server's own
|
||||
threads) and the bot host.
|
||||
|
||||
## Results
|
||||
|
||||
### Equal work: bots explore at the same pace (`--explore-speed 0.8`)
|
||||
|
||||
Each bot flies outwards at 0.8 blocks a second, which both servers keep up with. They send the same chunks, so
|
||||
the tick times compare the same work.
|
||||
|
||||
| Bots | Server | MSPT | P95 | TPS | RSS | CPU | Chunks/s | New chunks/s | Pulled back | Load (server / bots) |
|
||||
|---:|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||
| 1 | Horizon | 3.16 | 5.78 | 20.0 | 0.76 GB | 22 % | 2 | 1.27 | 0 | 2.8 / 63 |
|
||||
| 1 | Vanilla | 16.82 | 27.3 | 20.0 | 3.37 GB | 69 % | 2 | 1.35 | 0 | 1.8 / 94 |
|
||||
| 10 | Horizon | 9.83 | 19.01 | 20.0 | 0.86 GB | 77 % | 14 | 12.31 | 1051 | 2.2 / 74 |
|
||||
| 10 | Vanilla | 24.38 | 39.58 | 20.0 | 3.57 GB | 132 % | 14 | 12.32 | 1027 | 2.5 / 64 |
|
||||
| 25 | Horizon | 16.87 | 30.88 | 20.0 | 0.89 GB | 111 % | 34 | 29.92 | 3770 | 5.1 / 51 |
|
||||
| 25 | Vanilla | 30.29 | 43.47 | 20.0 | 3.61 GB | 176 % | 33 | 29.29 | 5139 | 5.2 / 55 |
|
||||
| 50 | Horizon | 23.57 | 47.53 | 20.0 | 0.92 GB | 127 % | 67 | 59.42 | 7434 | 8.5 / 64 |
|
||||
| 50 | Vanilla | 42.44 | 54.84 | 20.0 | 3.67 GB | 201 % | 65 | 58.11 | 10604 | 5.1 / 59 |
|
||||
|
||||
### `walk`: bots walk around the spawn
|
||||
|
||||
| Bots | Server | MSPT | P95 | TPS | RSS | CPU | Chunks/s | New chunks/s | Pulled back | Load (server / bots) |
|
||||
|---:|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||
| 1 | Horizon | 4.01 | 8.19 | 20.0 | 0.78 GB | 59 % | 10 | 0.43 | 0 | 1.4 / 70 |
|
||||
| 1 | Vanilla | 18.22 | 29.34 | 20.0 | 3.43 GB | 69 % | 10 | 0.47 | 0 | 1.9 / 63 |
|
||||
| 10 | Horizon | 8.79 | 17.35 | 20.0 | 0.78 GB | 119 % | 95 | 3.58 | 0 | 2.9 / 69 |
|
||||
| 10 | Vanilla | 18.67 | 38.26 | 20.0 | 3.50 GB | 101 % | 101 | 4.38 | 5 | 3.2 / 59 |
|
||||
| 25 | Horizon | 15.01 | 31.12 | 20.0 | 0.80 GB | 161 % | 234 | 8.49 | 0 | 6.3 / 43 |
|
||||
| 25 | Vanilla | 18.78 | 28.16 | 20.0 | 3.52 GB | 155 % | 252 | 11.89 | 10 | 6.7 / 57 |
|
||||
| 50 | Horizon | 22.72 | 60.19 | 20.0 | 0.81 GB | 180 % | 465 | 16.94 | 0 | 14.7 / 50 |
|
||||
| 50 | Vanilla | 34.3 | 55.55 | 20.0 | 3.58 GB | 267 % | 497 | 26.74 | 39 | 9.5 / 56 |
|
||||
| 100 | Horizon | 63.33 | 125.04 | 15.79 | 0.87 GB | 246 % | 678 | 31.8 | 0 | 32.9 / 95 |
|
||||
| 100 | Vanilla | stopped by its watchdog (a 60 s tick) | | | | | | | | 15.1 / 120 |
|
||||
|
||||
Both servers sent each walking bot the same number of distinct chunks over the whole run (680–690). Horizon's
|
||||
"new chunks/s" inside the measured window is lower, so compare the totals, not that column.
|
||||
|
||||
### `explore` at full speed: unequal work
|
||||
|
||||
Bots fly outwards as fast as chunks arrive (`horizon-bots`' default, up to 22 blocks a second). Both servers now pull them back ("moved too quickly"), but Horizon sends
|
||||
more new terrain, so this pass doesn't compare equal work. The server-machine load of Horizon's 1-bot run is
|
||||
estimated from the time between the runs around it: its log was overwritten before it was read.
|
||||
|
||||
| Bots | Server | MSPT | P95 | TPS | RSS | CPU | Chunks/s | New chunks/s | Pulled back | Load (server / bots) |
|
||||
|---:|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||
| 1 | Horizon | 9.73 | 20.99 | 20.0 | 0.84 GB | 330 % | 41 | 35.96 | 0 | 4.7 / 129 |
|
||||
| 1 | Vanilla | 9.71 | 20.37 | 20.0 | 3.61 GB | 247 % | 35 | 33.61 | 0 | 4.3 / 156 |
|
||||
| 10 | Horizon | 21.27 | 39.15 | 20.0 | 1.18 GB | 322 % | 46 | 46.11 | 9856 | 5.0 / 114 |
|
||||
| 10 | Vanilla | 16.94 | 30.28 | 20.0 | 3.95 GB | 254 % | 25 | 25.01 | 11123 | 5.5 / 89 |
|
||||
| 25 | Horizon | 25.76 | 45.01 | 20.0 | 1.17 GB | 314 % | 61 | 60.7 | 31264 | 6.6 / 82 |
|
||||
| 25 | Vanilla | 24.93 | 37.09 | 20.0 | 4.04 GB | 305 % | 38 | 37.84 | 33467 | 6.1 / 91 |
|
||||
| 50 | Horizon | 39.31 | 65.33 | 20.0 | 1.25 GB | 325 % | 115 | 112.25 | 64163 | 6.9 / 95 |
|
||||
| 50 | Vanilla | stopped by its watchdog (a 60 s tick) | | | | | | | | 7.7 / 86 |
|
||||
| 100 | Horizon | 52.7 | 85.73 | 18.97 | 1.36 GB | 328 % | 154 | 151.83 | 116161 | 13.4 / 79 |
|
||||
| 100 | Vanilla | stopped by its watchdog (a 60 s tick) | | | | | | | | 10.0 / 72 |
|
||||
|
||||
Vanilla's watchdog stopped it after a 60-second tick in three runs (walk 100, explore 50 and 100). Each time its
|
||||
server thread was in entity tracking (`ChunkMap$TrackedEntity.updatePlayer`).
|
||||
|
||||
## What it shows
|
||||
|
||||
- **Same work, 1 to 50 players:** Horizon's mean tick is 44–81 % shorter than vanilla's, its 95th percentile is
|
||||
lower, and its process uses 32–63 % of vanilla's CPU.
|
||||
- **Walking, 1 to 50 players:** Horizon's mean tick is shorter at every count (4.0 against 18.2 ms with 1 bot,
|
||||
22.7 against 34.3 ms with 50).
|
||||
- **Where vanilla wins:**
|
||||
- With 25 and 50 walking bots, vanilla's 95th percentile is lower (28.2 against 31.1 ms, 55.6 against
|
||||
60.2 ms). Horizon's slow ticks are spikier.
|
||||
- In the full-speed explore pass, vanilla's mean tick is shorter with 10 and 25 bots (16.9 against 21.3 ms,
|
||||
24.9 against 25.8 ms), while Horizon sends 1.6–1.8 times as many new chunks.
|
||||
- With 50 walking bots, Horizon makes the server machine busier (load 14.7 against 9.5). Its connection
|
||||
writer threads compress every packet (below).
|
||||
- **100 players:** Horizon keeps 15.8 TPS walking and 19.0 TPS exploring at full speed. Vanilla's watchdog
|
||||
stopped it in both.
|
||||
- **Memory:** Horizon used 0.76–0.92 GB in every walk and equal-work run, and 1.36 GB at most when 100 bots
|
||||
explored at full speed. Vanilla used 3.4–4.0 GB with its 4 GB heap.
|
||||
- **Start:** Horizon's fresh world was ready in 9.2–9.9 s, vanilla's in 14.9–17.2 s.
|
||||
|
||||
## Where Horizon's time goes (50 walking bots)
|
||||
|
||||
`perf record -e cycles:u --call-graph lbr` on the engine for 30 s, during a separate 50-bot `walk` run on the same
|
||||
build and machine (MSPT 21.7 ms, 178 % CPU). Shares of all the engine's CPU samples:
|
||||
|
||||
| Thread | Share | Mostly |
|
||||
|---|---:|---|
|
||||
| Connection writers (`horizon-lan-out`) | 55 % | 53 % in `miniz_oxide` deflate, from `horizon_protocol::frame::encode` |
|
||||
| World generation workers | 33 % | noise, surface rules, aquifers |
|
||||
| Server thread (the tick) | 12 % | see below |
|
||||
|
||||
Inside the tick:
|
||||
- the locator bar (`Waypoints::tick`): a third of the tick;
|
||||
- `Hub::tick`: a quarter;
|
||||
- block entities, creatures, chunk encoding (`ChunkEncoder::encode_in`) and `Streaming::update`: 7–10 % each;
|
||||
- SipHash of `u128` keys (the default `HashMap` hasher): about 10 % of the tick.
|
||||
|
||||
**The next biggest cost is compressing packets.** Every chunk packet is compressed again for each player who gets
|
||||
it, with `flate2`'s default `miniz_oxide` backend at level 6. On this 4-thread CPU that competes with the tick
|
||||
and with world generation. Two fixes, which can be combined:
|
||||
- the `zlib-rs` backend for `flate2`: 2.2–10 times faster than `miniz_oxide` at level 6 in a quick test, with
|
||||
output about 3 % larger;
|
||||
- compressing a chunk packet once and sending the same bytes to every player who needs it.
|
||||
|
||||
## Before and after the performance PRs
|
||||
|
||||
The earlier run, on `main` at `002f14ce` with #670's bench changes (build `a9ee56a8`), before #586, #701–#707 and
|
||||
#713. Same machines and settings. MSPT mean / P95, in ms:
|
||||
|
||||
Walk:
|
||||
|
||||
| Bots | Horizon before | Horizon now | Vanilla before | Vanilla now |
|
||||
|---:|---|---|---|---|
|
||||
| 1 | 5.77 / 11.04 | 4.01 / 8.19 | 18.84 / 29.55 | 18.22 / 29.34 |
|
||||
| 10 | 16.23 / 29.32 | 8.79 / 17.35 | 18.02 / 37.62 | 18.67 / 38.26 |
|
||||
| 25 | 23.98 / 49.18 | 15.01 / 31.12 | 19.19 / 33.84 | 18.78 / 28.16 |
|
||||
| 50 | 35.07 / 73.24 | 22.72 / 60.19 | 35.23 / 60.87 | 34.3 / 55.55 |
|
||||
| 100 | 104.8 / 158.8 | 63.33 / 125.04 | watchdog | watchdog |
|
||||
|
||||
Equal work:
|
||||
|
||||
| Bots | Horizon before | Horizon now | Vanilla before | Vanilla now |
|
||||
|---:|---|---|---|---|
|
||||
| 1 | 4.78 / 7.3 | 3.16 / 5.78 | 16.63 / 26.23 | 16.82 / 27.3 |
|
||||
| 10 | 15.19 / 25.66 | 9.83 / 19.01 | 24.65 / 40.81 | 24.38 / 39.58 |
|
||||
| 25 | 23.37 / 42.59 | 16.87 / 30.88 | 30.28 / 44.81 | 30.29 / 43.47 |
|
||||
| 50 | 30.59 / 67.94 | 23.57 / 47.53 | 43.52 / 56.18 | 42.44 / 54.84 |
|
||||
|
||||
Vanilla's numbers repeat within a few percent, so the runs are comparable. In the earlier run, 25 walking bots
|
||||
were faster on vanilla (19.2 against 24.0 ms). A profile then put a third of the tick in advancement updates,
|
||||
which #701 fixed.
|
||||
|
||||
## Files
|
||||
|
||||
- `walk-explore.json`: the `walk` and full-speed `explore` passes. `explore-equal-rate.json`: the equal-work pass.
|
||||
- `load.txt`: both machines' load averages every 30 s during the runs.
|
||||
- `earlier-002f14ce/`: the same files for the earlier run.
|
||||
- The server's tailnet address is replaced by `<server address>` in the JSON.
|
||||
@@ -0,0 +1,341 @@
|
||||
{
|
||||
"kind": "horizon-server-benchmark",
|
||||
"date": "2026-10-17T15:37:03+00:00",
|
||||
"machine": {
|
||||
"system": "Linux",
|
||||
"machine": "x86_64",
|
||||
"cpus": 4
|
||||
},
|
||||
"commit": "a9ee56a8b143999c9bc7056850ffcd622a9214aa",
|
||||
"java": [
|
||||
"openjdk 25.0.4.1 2026-08-18"
|
||||
],
|
||||
"settings": {
|
||||
"servers": "horizon,vanilla",
|
||||
"bots": "1,10,25,50",
|
||||
"behaviour": "explore",
|
||||
"join_every": 1.0,
|
||||
"warmup": 60,
|
||||
"seconds": 180,
|
||||
"seed": 12345,
|
||||
"explore_speed": 0.8,
|
||||
"gamerule": "",
|
||||
"heap": "4g",
|
||||
"out": "/home/ludovic/code/horizon/.claude/worktrees/server-host-4/.local/bench-out/explore-equal-rate.json",
|
||||
"nice": 19,
|
||||
"bots_via": "ssh macmini",
|
||||
"bots_bin": "/Users/ludovicmarie/code/horizon/.claude/worktrees/server-host-4/target/release/horizon-bots",
|
||||
"connect": "<server address>",
|
||||
"properties": {
|
||||
"level-seed": "12345",
|
||||
"view-distance": "10",
|
||||
"simulation-distance": "10",
|
||||
"online-mode": "false",
|
||||
"white-list": "false",
|
||||
"gamemode": "creative",
|
||||
"force-gamemode": "true",
|
||||
"spawn-protection": "0",
|
||||
"max-players": "200",
|
||||
"difficulty": "easy",
|
||||
"sync-chunk-writes": "true"
|
||||
}
|
||||
},
|
||||
"runs": [
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 1,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 9.21,
|
||||
"mspt": {
|
||||
"average": 4.78,
|
||||
"p50": 4.59,
|
||||
"p95": 7.3,
|
||||
"p99": 13.53
|
||||
},
|
||||
"mspt_worst_p99": 36.5,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 759.91,
|
||||
"max": 771.3
|
||||
},
|
||||
"cpu_percent": 24.49,
|
||||
"chunks_per_second": 1.64,
|
||||
"unique_per_second": 1.27,
|
||||
"forgotten_per_second": 1.54,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 841,
|
||||
"chunks_per_bot": {
|
||||
"explore": 841.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 781.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 1,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 1,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 14.35,
|
||||
"mspt": {
|
||||
"average": 16.63,
|
||||
"p50": 16.73,
|
||||
"p95": 26.23,
|
||||
"p99": 35.39
|
||||
},
|
||||
"mspt_worst_p99": 68.5,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 3431.12,
|
||||
"max": 3459.2
|
||||
},
|
||||
"cpu_percent": 69.76,
|
||||
"chunks_per_second": 1.66,
|
||||
"unique_per_second": 1.37,
|
||||
"forgotten_per_second": 1.67,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 840,
|
||||
"chunks_per_bot": {
|
||||
"explore": 840.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 780.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 1,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 10,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 9.31,
|
||||
"mspt": {
|
||||
"average": 15.19,
|
||||
"p50": 14.45,
|
||||
"p95": 25.66,
|
||||
"p99": 38.89
|
||||
},
|
||||
"mspt_worst_p99": 57.6,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 859.86,
|
||||
"max": 920.2
|
||||
},
|
||||
"cpu_percent": 84.95,
|
||||
"chunks_per_second": 14.22,
|
||||
"unique_per_second": 12.7,
|
||||
"forgotten_per_second": 14.11,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 7962,
|
||||
"chunks_per_bot": {
|
||||
"explore": 796.2
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 768.2
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 10,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 10,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 14.74,
|
||||
"mspt": {
|
||||
"average": 24.65,
|
||||
"p50": 21.82,
|
||||
"p95": 40.81,
|
||||
"p99": 70.23
|
||||
},
|
||||
"mspt_worst_p99": 119.1,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 3528.77,
|
||||
"max": 3643.2
|
||||
},
|
||||
"cpu_percent": 133.1,
|
||||
"chunks_per_second": 13.62,
|
||||
"unique_per_second": 12.32,
|
||||
"forgotten_per_second": 13.62,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 7746,
|
||||
"chunks_per_bot": {
|
||||
"explore": 774.6
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 748.7
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 1150,
|
||||
"moved_too_quickly": 5,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 10,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 25,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 9.42,
|
||||
"mspt": {
|
||||
"average": 23.37,
|
||||
"p50": 21.61,
|
||||
"p95": 42.59,
|
||||
"p99": 60.21
|
||||
},
|
||||
"mspt_worst_p99": 102.1,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 889.21,
|
||||
"max": 950.7
|
||||
},
|
||||
"cpu_percent": 117.59,
|
||||
"chunks_per_second": 35.34,
|
||||
"unique_per_second": 31.62,
|
||||
"forgotten_per_second": 35.12,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 20137,
|
||||
"chunks_per_bot": {
|
||||
"explore": 805.48
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 774.52
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 25,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 25,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 15.14,
|
||||
"mspt": {
|
||||
"average": 30.28,
|
||||
"p50": 28.17,
|
||||
"p95": 44.81,
|
||||
"p99": 71.77
|
||||
},
|
||||
"mspt_worst_p99": 158.1,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 3574.32,
|
||||
"max": 3692.7
|
||||
},
|
||||
"cpu_percent": 173.43,
|
||||
"chunks_per_second": 32.76,
|
||||
"unique_per_second": 29.38,
|
||||
"forgotten_per_second": 33.02,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 19417,
|
||||
"chunks_per_bot": {
|
||||
"explore": 776.68
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 747.36
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 4728,
|
||||
"moved_too_quickly": 15,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 25,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 50,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 9.11,
|
||||
"mspt": {
|
||||
"average": 30.59,
|
||||
"p50": 25.19,
|
||||
"p95": 67.94,
|
||||
"p99": 90.77
|
||||
},
|
||||
"mspt_worst_p99": 122.1,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 915.93,
|
||||
"max": 978.0
|
||||
},
|
||||
"cpu_percent": 132.13,
|
||||
"chunks_per_second": 71.31,
|
||||
"unique_per_second": 63.56,
|
||||
"forgotten_per_second": 71.19,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 41365,
|
||||
"chunks_per_bot": {
|
||||
"explore": 827.3
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 793.14
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 50,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 50,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 15.35,
|
||||
"mspt": {
|
||||
"average": 43.52,
|
||||
"p50": 41.69,
|
||||
"p95": 56.18,
|
||||
"p99": 84.55
|
||||
},
|
||||
"mspt_worst_p99": 155.1,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 3801.29,
|
||||
"max": 3920.7
|
||||
},
|
||||
"cpu_percent": 198.97,
|
||||
"chunks_per_second": 65.86,
|
||||
"unique_per_second": 58.63,
|
||||
"forgotten_per_second": 66.12,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 39790,
|
||||
"chunks_per_bot": {
|
||||
"explore": 795.8
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 764.48
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 8392,
|
||||
"moved_too_quickly": 38,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 50,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
2026-10-17T13:17:13Z thinkpad: 2.76 2.26 2.04 | macmini (bots): { 73.08 70.73 75.98 }
|
||||
2026-10-17T13:17:43Z thinkpad: 2.90 2.33 2.07 | macmini (bots): { 75.66 71.62 76.12 }
|
||||
2026-10-17T13:18:13Z thinkpad: 3.02 2.43 2.11 | macmini (bots): { 65.77 69.61 75.24 }
|
||||
2026-10-17T13:18:44Z thinkpad: 2.15 2.27 2.07 | macmini (bots): { 59.85 67.75 74.37 }
|
||||
2026-10-17T13:19:14Z thinkpad: 1.45 2.09 2.02 | macmini (bots): { 63.86 67.87 74.17 }
|
||||
2026-10-17T13:19:44Z thinkpad: 1.28 1.98 1.98 | macmini (bots): { 63.51 67.54 73.84 }
|
||||
2026-10-17T13:20:14Z thinkpad: 1.14 1.89 1.95 | macmini (bots): { 65.82 67.85 73.71 }
|
||||
2026-10-17T13:20:45Z thinkpad: 0.90 1.77 1.91 | macmini (bots): { 63.62 67.08 73.22 }
|
||||
2026-10-17T13:21:15Z thinkpad: 0.55 1.60 1.85 | macmini (bots): { 62.45 66.37 72.75 }
|
||||
2026-10-17T13:21:45Z thinkpad: 0.79 1.54 1.82 | macmini (bots): { 67.04 67.04 72.76 }
|
||||
2026-10-17T13:22:15Z thinkpad: 1.96 1.75 1.88 | macmini (bots): { 65.53 66.60 72.40 }
|
||||
2026-10-17T13:22:46Z thinkpad: 1.82 1.75 1.88 | macmini (bots): { 61.52 65.55 71.82 }
|
||||
2026-10-17T13:23:16Z thinkpad: 1.29 1.63 1.83 | macmini (bots): { 62.36 65.36 71.53 }
|
||||
2026-10-17T13:23:46Z thinkpad: 0.86 1.49 1.78 | macmini (bots): { 68.76 66.51 71.73 }
|
||||
2026-10-17T13:24:16Z thinkpad: 1.29 1.52 1.78 | macmini (bots): { 77.68 68.77 72.36 }
|
||||
2026-10-17T13:24:46Z thinkpad: 1.82 1.62 1.80 | macmini (bots): { 74.03 68.88 72.28 }
|
||||
2026-10-17T13:25:17Z thinkpad: 1.50 1.56 1.78 | macmini (bots): { 71.53 68.76 72.12 }
|
||||
2026-10-17T13:25:47Z thinkpad: 0.96 1.42 1.72 | macmini (bots): { 74.46 69.85 72.40 }
|
||||
2026-10-17T13:26:17Z thinkpad: 0.87 1.35 1.69 | macmini (bots): { 80.85 71.79 73.01 }
|
||||
2026-10-17T13:26:47Z thinkpad: 3.95 2.02 1.90 | macmini (bots): { 74.77 71.31 72.81 }
|
||||
2026-10-17T13:27:18Z thinkpad: 2.98 1.97 1.89 | macmini (bots): { 70.52 70.52 72.46 }
|
||||
2026-10-17T13:27:48Z thinkpad: 3.78 2.26 1.99 | macmini (bots): { 67.62 69.91 72.18 }
|
||||
2026-10-17T13:28:18Z thinkpad: 3.05 2.23 1.99 | macmini (bots): { 72.51 70.83 72.43 }
|
||||
2026-10-17T13:28:48Z thinkpad: 2.05 2.07 1.94 | macmini (bots): { 79.07 72.64 73.03 }
|
||||
2026-10-17T13:29:19Z thinkpad: 2.09 2.11 1.96 | macmini (bots): { 90.42 75.94 74.21 }
|
||||
2026-10-17T13:29:49Z thinkpad: 1.61 1.99 1.93 | macmini (bots): { 102.68 80.15 75.78 }
|
||||
2026-10-17T13:30:19Z thinkpad: 2.31 2.12 1.97 | macmini (bots): { 111.15 84.19 77.39 }
|
||||
2026-10-17T13:30:49Z thinkpad: 4.14 2.61 2.14 | macmini (bots): { 115.67 87.82 78.94 }
|
||||
2026-10-17T13:31:20Z thinkpad: 4.84 2.92 2.26 | macmini (bots): { 122.95 92.31 80.88 }
|
||||
2026-10-17T13:31:50Z thinkpad: 6.84 3.60 2.51 | macmini (bots): { 136.25 98.41 83.48 }
|
||||
2026-10-17T13:32:20Z thinkpad: 4.85 3.43 2.49 | macmini (bots): { 128.59 100.39 84.73 }
|
||||
2026-10-17T13:32:50Z thinkpad: 3.48 3.25 2.46 | macmini (bots): { 111.05 98.73 84.66 }
|
||||
2026-10-17T13:33:20Z thinkpad: 3.09 3.15 2.45 | macmini (bots): { 102.40 98.03 84.98 }
|
||||
2026-10-17T13:33:51Z thinkpad: 2.47 2.99 2.42 | macmini (bots): { 94.57 96.54 84.89 }
|
||||
2026-10-17T13:34:21Z thinkpad: 2.42 2.94 2.42 | macmini (bots): { 86.26 94.36 84.50 }
|
||||
2026-10-17T13:34:51Z thinkpad: 2.95 3.04 2.47 | macmini (bots): { 76.90 91.56 83.84 }
|
||||
2026-10-17T13:35:21Z thinkpad: 2.66 2.96 2.46 | macmini (bots): { 65.57 87.42 82.61 }
|
||||
2026-10-17T13:35:52Z thinkpad: 2.47 2.88 2.45 | macmini (bots): { 64.13 85.05 81.92 }
|
||||
2026-10-17T13:36:22Z thinkpad: 3.89 3.17 2.56 | macmini (bots): { 58.65 81.74 80.83 }
|
||||
2026-10-17T13:36:52Z thinkpad: 6.43 3.83 2.80 | macmini (bots): { 66.32 81.32 80.71 }
|
||||
2026-10-17T13:37:22Z thinkpad: 8.73 4.60 3.09 | macmini (bots): { 67.30 80.14 80.30 }
|
||||
2026-10-17T13:37:53Z thinkpad: 9.03 5.10 3.30 | macmini (bots): { 63.66 78.24 79.62 }
|
||||
2026-10-17T13:38:23Z thinkpad: 9.49 5.57 3.52 | macmini (bots): { 61.25 76.23 78.84 }
|
||||
2026-10-17T13:38:53Z thinkpad: 7.55 5.50 3.56 | macmini (bots): { 62.30 74.88 78.25 }
|
||||
2026-10-17T13:39:23Z thinkpad: 8.62 5.99 3.79 | macmini (bots): { 57.34 72.56 77.29 }
|
||||
2026-10-17T13:39:53Z thinkpad: 6.65 5.81 3.80 | macmini (bots): { 51.24 69.62 76.06 }
|
||||
2026-10-17T13:40:24Z thinkpad: 5.55 5.62 3.80 | macmini (bots): { 48.87 67.26 74.98 }
|
||||
2026-10-17T13:40:54Z thinkpad: 6.11 5.71 3.89 | macmini (bots): { 51.16 66.08 74.29 }
|
||||
2026-10-17T13:41:24Z thinkpad: 8.93 6.43 4.19 | macmini (bots): { 48.67 64.00 73.24 }
|
||||
2026-10-17T13:41:54Z thinkpad: 9.05 6.74 4.36 | macmini (bots): { 46.47 61.96 72.18 }
|
||||
2026-10-17T13:42:25Z thinkpad: 7.70 6.65 4.41 | macmini (bots): { 44.24 60.05 71.14 }
|
||||
2026-10-17T13:42:55Z thinkpad: 5.64 6.24 4.34 | macmini (bots): { 49.30 59.83 70.68 }
|
||||
2026-10-17T13:43:25Z thinkpad: 4.83 6.02 4.33 | macmini (bots): { 47.39 58.24 69.72 }
|
||||
2026-10-17T13:43:55Z thinkpad: 3.57 5.61 4.25 | macmini (bots): { 47.32 57.23 68.96 }
|
||||
2026-10-17T13:44:25Z thinkpad: 3.41 5.40 4.23 | macmini (bots): { 47.86 56.49 68.28 }
|
||||
2026-10-17T13:44:55Z thinkpad: 3.37 5.21 4.20 | macmini (bots): { 50.10 56.19 67.77 }
|
||||
2026-10-17T13:45:26Z thinkpad: 3.78 5.16 4.22 | macmini (bots): { 51.91 55.98 67.28 }
|
||||
2026-10-17T13:45:56Z thinkpad: 4.34 5.12 4.23 | macmini (bots): { 49.16 54.85 66.42 }
|
||||
2026-10-17T13:46:26Z thinkpad: 10.04 6.46 4.71 | macmini (bots): { 45.01 53.33 65.47 }
|
||||
2026-10-17T13:46:56Z thinkpad: 18.10 8.73 5.52 | macmini (bots): { 46.41 52.87 64.88 }
|
||||
2026-10-17T13:47:27Z thinkpad: 14.99 8.91 5.68 | macmini (bots): { 51.12 53.21 64.57 }
|
||||
2026-10-17T13:47:57Z thinkpad: 13.46 9.27 5.92 | macmini (bots): { 53.84 53.81 64.40 }
|
||||
2026-10-17T13:48:27Z thinkpad: 12.76 9.41 6.07 | macmini (bots): { 51.94 53.27 63.84 }
|
||||
2026-10-17T13:48:58Z thinkpad: 10.20 9.11 6.08 | macmini (bots): { 50.70 52.83 63.31 }
|
||||
2026-10-17T13:49:28Z thinkpad: 9.95 9.25 6.23 | macmini (bots): { 51.51 52.89 62.97 }
|
||||
2026-10-17T13:49:58Z thinkpad: 11.98 9.87 6.53 | macmini (bots): { 50.98 52.73 62.57 }
|
||||
2026-10-17T13:50:29Z thinkpad: 14.96 10.76 6.94 | macmini (bots): { 49.68 52.31 62.08 }
|
||||
2026-10-17T13:50:59Z thinkpad: 10.54 10.09 6.83 | macmini (bots): { 53.34 53.01 62.00 }
|
||||
2026-10-17T13:51:29Z thinkpad: 10.21 10.01 6.91 | macmini (bots): { 51.61 52.69 61.57 }
|
||||
2026-10-17T13:52:00Z thinkpad: 11.86 10.42 7.14 | macmini (bots): { 48.94 51.89 60.97 }
|
||||
2026-10-17T13:52:30Z thinkpad: 10.88 10.33 7.22 | macmini (bots): { 48.46 51.44 60.49 }
|
||||
2026-10-17T13:53:01Z thinkpad: 10.94 10.41 7.35 | macmini (bots): { 54.27 52.41 60.47 }
|
||||
2026-10-17T13:53:31Z thinkpad: 10.77 10.42 7.45 | macmini (bots): { 57.13 53.30 60.51 }
|
||||
2026-10-17T13:54:02Z thinkpad: 10.62 10.40 7.53 | macmini (bots): { 53.78 52.84 60.09 }
|
||||
2026-10-17T13:54:32Z thinkpad: 10.85 10.48 7.66 | macmini (bots): { 52.01 52.51 59.73 }
|
||||
2026-10-17T13:55:02Z thinkpad: 10.36 10.39 7.73 | macmini (bots): { 53.44 52.83 59.59 }
|
||||
2026-10-17T13:55:32Z thinkpad: 8.58 9.95 7.67 | macmini (bots): { 58.66 54.12 59.82 }
|
||||
2026-10-17T13:56:03Z thinkpad: 7.29 9.52 7.60 | macmini (bots): { 60.98 55.05 59.96 }
|
||||
2026-10-17T13:56:33Z thinkpad: 5.02 8.75 7.40 | macmini (bots): { 65.14 56.71 60.39 }
|
||||
2026-10-17T13:57:03Z thinkpad: 5.03 8.38 7.32 | macmini (bots): { 57.51 55.73 59.91 }
|
||||
2026-10-17T13:57:34Z thinkpad: 7.39 8.66 7.45 | macmini (bots): { 52.57 54.73 59.41 }
|
||||
2026-10-17T13:58:04Z thinkpad: 10.56 9.45 7.76 | macmini (bots): { 53.11 54.60 59.20 }
|
||||
2026-10-17T13:58:34Z thinkpad: 28.87 14.20 9.41 | macmini (bots): { 54.53 54.93 59.16 }
|
||||
2026-10-17T13:59:05Z thinkpad: 39.33 17.79 10.73 | macmini (bots): { 62.67 56.86 59.71 }
|
||||
2026-10-17T13:59:35Z thinkpad: 34.07 18.33 11.12 | macmini (bots): { 61.78 57.22 59.75 }
|
||||
2026-10-17T14:00:06Z thinkpad: 34.42 19.72 11.80 | macmini (bots): { 53.10 55.55 59.03 }
|
||||
2026-10-17T14:00:36Z thinkpad: 33.60 20.94 12.46 | macmini (bots): { 48.70 54.23 58.43 }
|
||||
2026-10-17T14:01:07Z thinkpad: 29.29 20.99 12.74 | macmini (bots): { 50.25 54.00 58.20 }
|
||||
2026-10-17T14:01:37Z thinkpad: 20.83 19.79 12.61 | macmini (bots): { 50.48 53.74 57.96 }
|
||||
2026-10-17T14:02:08Z thinkpad: 21.74 20.44 13.11 | macmini (bots): { 48.65 53.04 57.56 }
|
||||
2026-10-17T14:02:38Z thinkpad: 17.68 19.67 13.09 | macmini (bots): { 54.17 53.85 57.69 }
|
||||
2026-10-17T14:03:08Z thinkpad: 13.97 18.55 12.93 | macmini (bots): { 53.39 53.84 57.56 }
|
||||
2026-10-17T14:03:39Z thinkpad: 14.10 18.17 12.98 | macmini (bots): { 50.71 53.09 57.16 }
|
||||
2026-10-17T14:04:09Z thinkpad: 13.60 17.65 12.97 | macmini (bots): { 52.30 53.28 57.08 }
|
||||
2026-10-17T14:04:39Z thinkpad: 12.09 16.91 12.88 | macmini (bots): { 51.52 53.03 56.87 }
|
||||
2026-10-17T14:05:10Z thinkpad: 10.15 15.98 12.69 | macmini (bots): { 54.53 53.57 56.91 }
|
||||
2026-10-17T14:05:42Z thinkpad: 7.77 14.86 12.42 | macmini (bots): { 63.58 55.55 57.49 }
|
||||
2026-10-17T14:06:12Z thinkpad: 6.58 13.89 12.18 | macmini (bots): { 75.29 59.41 58.84 }
|
||||
2026-10-17T14:06:42Z thinkpad: 5.96 13.02 11.94 | macmini (bots): { 78.06 61.64 59.67 }
|
||||
2026-10-17T14:07:12Z thinkpad: 4.86 12.08 11.66 | macmini (bots): { 72.26 61.85 59.81 }
|
||||
2026-10-17T14:07:42Z thinkpad: 4.49 11.18 11.38 | macmini (bots): { 67.84 61.73 59.84 }
|
||||
2026-10-17T14:08:13Z thinkpad: 4.37 10.51 11.15 | macmini (bots): { 64.25 61.45 59.80 }
|
||||
2026-10-17T14:08:43Z thinkpad: 4.57 9.97 10.94 | macmini (bots): { 57.86 60.14 59.38 }
|
||||
2026-10-17T14:09:13Z thinkpad: 4.60 9.46 10.74 | macmini (bots): { 60.21 60.39 59.49 }
|
||||
2026-10-17T14:09:43Z thinkpad: 4.20 8.89 10.51 | macmini (bots): { 57.11 59.57 59.22 }
|
||||
2026-10-17T14:10:14Z thinkpad: 3.80 8.36 10.28 | macmini (bots): { 66.05 61.48 59.92 }
|
||||
2026-10-17T14:10:44Z thinkpad: 4.19 8.00 10.10 | macmini (bots): { 78.51 64.91 61.22 }
|
||||
2026-10-17T14:11:14Z thinkpad: 4.27 7.65 9.92 | macmini (bots): { 74.15 65.33 61.51 }
|
||||
2026-10-17T14:11:44Z thinkpad: 4.35 7.35 9.74 | macmini (bots): { 62.47 63.48 60.98 }
|
||||
2026-10-17T14:12:14Z thinkpad: 4.98 7.22 9.62 | macmini (bots): { 56.73 61.86 60.46 }
|
||||
2026-10-17T14:12:45Z thinkpad: 4.60 6.91 9.44 | macmini (bots): { 57.78 61.49 60.37 }
|
||||
2026-10-17T14:13:15Z thinkpad: 4.15 6.57 9.25 | macmini (bots): { 56.21 60.71 60.12 }
|
||||
2026-10-17T14:13:45Z thinkpad: 3.87 6.25 9.05 | macmini (bots): { 54.75 60.00 59.89 }
|
||||
2026-10-17T14:14:15Z thinkpad: 3.86 6.02 8.89 | macmini (bots): { 52.21 58.88 59.49 }
|
||||
2026-10-17T14:14:46Z thinkpad: 3.14 5.64 8.67 | macmini (bots): { 58.81 59.59 59.71 }
|
||||
2026-10-17T14:15:16Z thinkpad: 3.76 5.54 8.54 | macmini (bots): { 64.13 60.86 60.16 }
|
||||
2026-10-17T14:15:46Z thinkpad: 3.91 5.41 8.40 | macmini (bots): { 59.02 59.97 59.87 }
|
||||
2026-10-17T14:16:16Z thinkpad: 4.33 5.35 8.28 | macmini (bots): { 64.48 60.97 60.22 }
|
||||
2026-10-17T14:16:46Z thinkpad: 5.20 5.44 8.21 | macmini (bots): { 79.56 64.98 61.70 }
|
||||
2026-10-17T14:17:17Z thinkpad: 4.88 5.36 8.10 | macmini (bots): { 94.21 69.99 63.63 }
|
||||
2026-10-17T14:17:47Z thinkpad: 4.80 5.30 8.00 | macmini (bots): { 85.75 70.51 64.08 }
|
||||
2026-10-17T14:18:17Z thinkpad: 4.82 5.25 7.89 | macmini (bots): { 82.33 71.25 64.57 }
|
||||
2026-10-17T14:18:48Z thinkpad: 4.50 5.12 7.75 | macmini (bots): { 77.67 71.11 64.75 }
|
||||
2026-10-17T14:19:18Z thinkpad: 3.76 4.88 7.58 | macmini (bots): { 77.29 71.67 65.17 }
|
||||
2026-10-17T14:19:48Z thinkpad: 3.68 4.75 7.45 | macmini (bots): { 71.16 70.71 65.05 }
|
||||
2026-10-17T14:20:18Z thinkpad: 4.25 4.82 7.39 | macmini (bots): { 66.97 69.63 64.85 }
|
||||
2026-10-17T14:20:49Z thinkpad: 4.72 4.89 7.33 | macmini (bots): { 59.23 67.50 64.24 }
|
||||
2026-10-17T14:21:19Z thinkpad: 5.12 4.98 7.28 | macmini (bots): { 52.38 65.10 63.48 }
|
||||
2026-10-17T14:21:49Z thinkpad: 5.69 5.13 7.26 | macmini (bots): { 59.43 65.53 63.69 }
|
||||
2026-10-17T14:22:19Z thinkpad: 5.78 5.19 7.21 | macmini (bots): { 62.28 65.65 63.80 }
|
||||
2026-10-17T14:22:50Z thinkpad: 5.34 5.14 7.13 | macmini (bots): { 57.40 64.12 63.30 }
|
||||
2026-10-17T14:23:20Z thinkpad: 4.89 5.04 7.03 | macmini (bots): { 58.39 63.61 63.14 }
|
||||
2026-10-17T14:23:50Z thinkpad: 5.00 5.07 6.98 | macmini (bots): { 64.88 64.63 63.52 }
|
||||
2026-10-17T14:24:20Z thinkpad: 3.88 4.80 6.83 | macmini (bots): { 80.70 68.30 64.88 }
|
||||
2026-10-17T14:24:51Z thinkpad: 2.90 4.47 6.65 | macmini (bots): { 94.09 72.75 66.61 }
|
||||
2026-10-17T14:25:21Z thinkpad: 2.63 4.25 6.51 | macmini (bots): { 93.13 74.91 67.63 }
|
||||
2026-10-17T14:25:51Z thinkpad: 6.66 5.07 6.71 | macmini (bots): { 72.87 71.72 66.72 }
|
||||
2026-10-17T14:26:21Z thinkpad: 7.15 5.34 6.74 | macmini (bots): { 67.03 70.37 66.40 }
|
||||
2026-10-17T14:26:51Z thinkpad: 6.20 5.29 6.68 | macmini (bots): { 58.68 68.08 65.71 }
|
||||
2026-10-17T14:27:22Z thinkpad: 6.11 5.35 6.66 | macmini (bots): { 55.26 66.28 65.14 }
|
||||
2026-10-17T14:27:52Z thinkpad: 5.45 5.26 6.59 | macmini (bots): { 56.51 65.74 64.99 }
|
||||
2026-10-17T14:28:22Z thinkpad: 6.11 5.45 6.61 | macmini (bots): { 52.24 63.77 64.30 }
|
||||
2026-10-17T14:28:52Z thinkpad: 6.06 5.50 6.59 | macmini (bots): { 55.56 63.38 64.13 }
|
||||
2026-10-17T14:29:23Z thinkpad: 6.11 5.58 6.58 | macmini (bots): { 59.66 63.63 64.20 }
|
||||
2026-10-17T14:29:53Z thinkpad: 5.52 5.54 6.53 | macmini (bots): { 63.71 64.30 64.42 }
|
||||
2026-10-17T14:30:23Z thinkpad: 4.79 5.35 6.43 | macmini (bots): { 69.84 65.61 64.89 }
|
||||
2026-10-17T14:30:53Z thinkpad: 6.21 5.63 6.49 | macmini (bots): { 62.76 64.46 64.50 }
|
||||
2026-10-17T14:31:23Z thinkpad: 8.36 6.21 6.66 | macmini (bots): { 53.51 62.09 63.64 }
|
||||
2026-10-17T14:31:54Z thinkpad: 8.49 6.45 6.72 | macmini (bots): { 52.80 60.98 63.18 }
|
||||
2026-10-17T14:32:24Z thinkpad: 8.28 6.59 6.77 | macmini (bots): { 48.98 59.20 62.45 }
|
||||
2026-10-17T14:32:54Z thinkpad: 8.02 6.70 6.79 | macmini (bots): { 48.95 58.30 62.02 }
|
||||
2026-10-17T14:33:25Z thinkpad: 7.02 6.59 6.76 | macmini (bots): { 51.06 57.70 61.66 }
|
||||
2026-10-17T14:33:55Z thinkpad: 7.09 6.62 6.76 | macmini (bots): { 70.89 61.81 63.00 }
|
||||
2026-10-17T14:34:25Z thinkpad: 6.18 6.44 6.69 | macmini (bots): { 69.38 62.46 63.21 }
|
||||
2026-10-17T14:34:55Z thinkpad: 6.67 6.52 6.72 | macmini (bots): { 76.87 64.84 64.04 }
|
||||
2026-10-17T14:35:25Z thinkpad: 4.76 6.08 6.56 | macmini (bots): { 86.19 68.04 65.21 }
|
||||
2026-10-17T14:35:56Z thinkpad: 3.47 5.64 6.40 | macmini (bots): { 101.03 73.28 67.21 }
|
||||
2026-10-17T14:36:26Z thinkpad: 2.68 5.24 6.24 | macmini (bots): { 106.05 77.09 68.79 }
|
||||
2026-10-17T14:36:56Z thinkpad: 2.41 4.91 6.10 | macmini (bots): { 107.57 80.20 70.20 }
|
||||
2026-10-17T14:37:26Z thinkpad: 3.74 4.99 6.08 | macmini (bots): { 101.16 81.38 70.98 }
|
||||
2026-10-17T14:37:57Z thinkpad: 5.96 5.39 6.18 | macmini (bots): { 86.51 79.61 70.70 }
|
||||
2026-10-17T14:38:27Z thinkpad: 7.91 5.98 6.35 | macmini (bots): { 81.13 79.06 70.81 }
|
||||
2026-10-17T14:38:57Z thinkpad: 8.45 6.25 6.43 | macmini (bots): { 79.56 79.01 71.08 }
|
||||
2026-10-17T14:39:27Z thinkpad: 8.75 6.52 6.52 | macmini (bots): { 71.92 77.22 70.71 }
|
||||
2026-10-17T14:39:58Z thinkpad: 8.86 6.76 6.60 | macmini (bots): { 71.38 76.58 70.70 }
|
||||
2026-10-17T14:40:28Z thinkpad: 8.74 6.98 6.68 | macmini (bots): { 71.25 76.12 70.74 }
|
||||
2026-10-17T14:40:58Z thinkpad: 7.99 6.96 6.68 | macmini (bots): { 70.49 75.51 70.71 }
|
||||
2026-10-17T14:41:28Z thinkpad: 7.64 7.01 6.70 | macmini (bots): { 68.93 74.58 70.56 }
|
||||
2026-10-17T14:41:59Z thinkpad: 6.57 6.84 6.66 | macmini (bots): { 68.35 73.75 70.39 }
|
||||
2026-10-17T14:42:29Z thinkpad: 5.83 6.62 6.59 | macmini (bots): { 63.57 72.20 69.95 }
|
||||
2026-10-17T14:42:59Z thinkpad: 7.40 6.91 6.69 | macmini (bots): { 56.77 69.69 69.12 }
|
||||
2026-10-17T14:43:29Z thinkpad: 9.07 7.37 6.85 | macmini (bots): { 49.53 66.71 68.06 }
|
||||
2026-10-17T14:44:00Z thinkpad: 9.39 7.63 6.96 | macmini (bots): { 43.53 63.68 66.91 }
|
||||
2026-10-17T14:44:30Z thinkpad: 9.16 7.73 7.01 | macmini (bots): { 41.12 61.23 65.92 }
|
||||
2026-10-17T14:45:00Z thinkpad: 7.96 7.57 6.98 | macmini (bots): { 40.62 59.23 65.03 }
|
||||
2026-10-17T14:45:30Z thinkpad: 6.34 7.24 6.89 | macmini (bots): { 38.17 56.80 63.94 }
|
||||
2026-10-17T14:46:01Z thinkpad: 6.39 7.18 6.88 | macmini (bots): { 40.35 55.54 63.24 }
|
||||
2026-10-17T14:46:31Z thinkpad: 5.63 6.95 6.82 | macmini (bots): { 48.66 56.25 63.24 }
|
||||
2026-10-17T14:47:01Z thinkpad: 4.73 6.60 6.70 | macmini (bots): { 46.68 55.20 62.62 }
|
||||
2026-10-17T14:47:31Z thinkpad: 9.18 7.55 7.02 | macmini (bots): { 41.45 53.19 61.64 }
|
||||
2026-10-17T14:48:01Z thinkpad: 10.40 8.02 7.19 | macmini (bots): { 40.76 51.87 60.87 }
|
||||
2026-10-17T14:48:32Z thinkpad: 8.24 7.73 7.12 | macmini (bots): { 44.46 51.67 60.48 }
|
||||
2026-10-17T14:49:02Z thinkpad: 9.60 8.09 7.26 | macmini (bots): { 38.75 49.66 59.45 }
|
||||
2026-10-17T14:49:32Z thinkpad: 8.90 8.10 7.29 | macmini (bots): { 34.93 47.72 58.41 }
|
||||
2026-10-17T14:50:02Z thinkpad: 11.17 8.66 7.50 | macmini (bots): { 35.70 46.68 57.66 }
|
||||
2026-10-17T14:50:33Z thinkpad: 12.06 9.19 7.72 | macmini (bots): { 43.94 47.43 57.54 }
|
||||
2026-10-17T14:51:03Z thinkpad: 12.50 9.49 7.87 | macmini (bots): { 40.76 46.36 56.80 }
|
||||
2026-10-17T14:51:33Z thinkpad: 10.35 9.24 7.83 | macmini (bots): { 44.81 46.69 56.55 }
|
||||
2026-10-17T14:52:03Z thinkpad: 9.32 9.09 7.83 | macmini (bots): { 54.94 48.97 57.04 }
|
||||
2026-10-17T14:52:34Z thinkpad: 7.07 8.58 7.70 | macmini (bots): { 54.59 49.54 56.92 }
|
||||
2026-10-17T14:53:04Z thinkpad: 5.33 8.01 7.54 | macmini (bots): { 67.48 53.27 58.02 }
|
||||
2026-10-17T14:53:34Z thinkpad: 7.22 8.20 7.62 | macmini (bots): { 57.82 52.33 57.52 }
|
||||
2026-10-17T14:54:04Z thinkpad: 9.69 8.73 7.81 | macmini (bots): { 46.67 50.20 56.57 }
|
||||
2026-10-17T14:54:35Z thinkpad: 9.14 8.66 7.82 | macmini (bots): { 49.88 50.61 56.49 }
|
||||
2026-10-17T14:55:05Z thinkpad: 9.75 8.87 7.91 | macmini (bots): { 46.88 49.95 56.06 }
|
||||
2026-10-17T14:55:35Z thinkpad: 8.51 8.67 7.88 | macmini (bots): { 42.90 48.80 55.43 }
|
||||
2026-10-17T14:56:06Z thinkpad: 8.46 8.62 7.89 | macmini (bots): { 49.23 49.72 55.53 }
|
||||
2026-10-17T14:56:36Z thinkpad: 6.19 8.07 7.73 | macmini (bots): { 54.41 50.86 55.74 }
|
||||
2026-10-17T14:57:06Z thinkpad: 4.69 7.53 7.56 | macmini (bots): { 60.26 52.69 56.24 }
|
||||
2026-10-17T14:57:36Z thinkpad: 3.21 6.90 7.35 | macmini (bots): { 67.66 55.21 57.03 }
|
||||
2026-10-17T14:58:06Z thinkpad: 1.94 6.24 7.11 | macmini (bots): { 75.73 58.41 58.13 }
|
||||
2026-10-17T14:58:37Z thinkpad: 1.18 5.64 6.89 | macmini (bots): { 74.69 59.90 58.68 }
|
||||
2026-10-17T14:59:07Z thinkpad: 0.78 5.12 6.67 | macmini (bots): { 77.10 61.82 59.42 }
|
||||
2026-10-17T14:59:37Z thinkpad: 0.88 4.72 6.49 | macmini (bots): { 87.05 65.59 60.86 }
|
||||
2026-10-17T15:00:07Z thinkpad: 0.53 4.27 6.28 | macmini (bots): { 78.18 65.51 60.99 }
|
||||
2026-10-17T15:00:38Z thinkpad: 0.38 3.88 6.09 | macmini (bots): { 79.38 67.00 61.69 }
|
||||
2026-10-17T15:01:08Z thinkpad: 1.50 3.80 5.99 | macmini (bots): { 78.36 67.86 62.18 }
|
||||
2026-10-17T15:01:38Z thinkpad: 2.97 3.93 5.95 | macmini (bots): { 74.18 67.90 62.39 }
|
||||
2026-10-17T15:02:08Z thinkpad: 2.61 3.77 5.83 | macmini (bots): { 68.63 67.00 62.25 }
|
||||
2026-10-17T15:02:39Z thinkpad: 2.10 3.55 5.69 | macmini (bots): { 69.84 67.50 62.60 }
|
||||
2026-10-17T15:03:09Z thinkpad: 1.44 3.25 5.53 | macmini (bots): { 67.10 67.06 62.61 }
|
||||
2026-10-17T15:03:39Z thinkpad: 1.39 3.08 5.40 | macmini (bots): { 60.83 65.50 62.19 }
|
||||
2026-10-17T15:04:09Z thinkpad: 1.28 2.88 5.26 | macmini (bots): { 75.40 68.61 63.46 }
|
||||
2026-10-17T15:04:40Z thinkpad: 2.30 2.99 5.22 | macmini (bots): { 106.08 76.54 66.50 }
|
||||
2026-10-17T15:05:10Z thinkpad: 2.65 3.02 5.15 | macmini (bots): { 111.96 81.09 68.52 }
|
||||
2026-10-17T15:05:40Z thinkpad: 2.41 2.92 5.05 | macmini (bots): { 104.19 82.08 69.31 }
|
||||
2026-10-17T15:06:10Z thinkpad: 2.71 2.95 4.99 | macmini (bots): { 116.20 87.04 71.54 }
|
||||
2026-10-17T15:06:40Z thinkpad: 2.51 2.87 4.90 | macmini (bots): { 136.68 94.24 74.66 }
|
||||
2026-10-17T15:07:11Z thinkpad: 3.68 3.11 4.91 | macmini (bots): { 132.76 97.43 76.50 }
|
||||
2026-10-17T15:07:41Z thinkpad: 3.77 3.19 4.88 | macmini (bots): { 132.73 100.97 78.51 }
|
||||
2026-10-17T15:08:11Z thinkpad: 3.00 3.06 4.79 | macmini (bots): { 116.92 100.33 79.07 }
|
||||
2026-10-17T15:08:41Z thinkpad: 2.40 2.91 4.68 | macmini (bots): { 109.83 100.07 79.70 }
|
||||
2026-10-17T15:09:12Z thinkpad: 2.18 2.81 4.59 | macmini (bots): { 113.21 101.71 81.00 }
|
||||
2026-10-17T15:09:42Z thinkpad: 1.67 2.62 4.47 | macmini (bots): { 131.87 107.11 83.66 }
|
||||
2026-10-17T15:10:12Z thinkpad: 1.90 2.58 4.39 | macmini (bots): { 133.45 109.92 85.49 }
|
||||
2026-10-17T15:10:42Z thinkpad: 4.00 3.02 4.48 | macmini (bots): { 130.14 111.67 86.99 }
|
||||
2026-10-17T15:11:13Z thinkpad: 4.77 3.32 4.54 | macmini (bots): { 118.78 110.28 87.32 }
|
||||
2026-10-17T15:11:43Z thinkpad: 3.86 3.24 4.47 | macmini (bots): { 124.01 112.36 88.87 }
|
||||
2026-10-17T15:12:13Z thinkpad: 3.61 3.26 4.44 | macmini (bots): { 122.21 112.94 89.88 }
|
||||
2026-10-17T15:12:43Z thinkpad: 3.12 3.17 4.36 | macmini (bots): { 111.62 111.10 90.00 }
|
||||
2026-10-17T15:13:14Z thinkpad: 3.07 3.14 4.31 | macmini (bots): { 122.97 113.95 91.77 }
|
||||
2026-10-17T15:13:44Z thinkpad: 3.33 3.20 4.30 | macmini (bots): { 102.64 109.89 91.07 }
|
||||
2026-10-17T15:14:14Z thinkpad: 2.85 3.10 4.23 | macmini (bots): { 94.46 107.15 90.72 }
|
||||
2026-10-17T15:14:44Z thinkpad: 2.66 3.03 4.17 | macmini (bots): { 94.94 106.07 90.90 }
|
||||
2026-10-17T15:15:14Z thinkpad: 2.54 2.96 4.11 | macmini (bots): { 94.52 104.93 91.01 }
|
||||
2026-10-17T15:15:45Z thinkpad: 3.20 3.08 4.11 | macmini (bots): { 84.00 101.47 90.25 }
|
||||
2026-10-17T15:16:15Z thinkpad: 5.56 3.61 4.25 | macmini (bots): { 83.16 99.76 90.02 }
|
||||
2026-10-17T15:16:45Z thinkpad: 5.66 3.84 4.30 | macmini (bots): { 69.71 94.66 88.56 }
|
||||
2026-10-17T15:17:15Z thinkpad: 5.04 3.90 4.31 | macmini (bots): { 59.31 89.78 87.00 }
|
||||
2026-10-17T15:17:46Z thinkpad: 4.29 3.87 4.29 | macmini (bots): { 58.57 86.47 85.89 }
|
||||
2026-10-17T15:18:16Z thinkpad: 4.16 3.91 4.29 | macmini (bots): { 56.43 83.29 84.75 }
|
||||
2026-10-17T15:18:46Z thinkpad: 2.80 3.60 4.18 | macmini (bots): { 50.60 79.22 83.22 }
|
||||
2026-10-17T15:19:16Z thinkpad: 1.85 3.28 4.05 | macmini (bots): { 54.97 77.50 82.46 }
|
||||
2026-10-17T15:19:47Z thinkpad: 1.82 3.14 3.98 | macmini (bots): { 56.82 75.80 81.67 }
|
||||
2026-10-17T15:20:17Z thinkpad: 2.19 3.10 3.94 | macmini (bots): { 59.30 74.46 80.97 }
|
||||
2026-10-17T15:20:47Z thinkpad: 4.44 3.54 4.06 | macmini (bots): { 55.22 72.11 79.90 }
|
||||
2026-10-17T15:21:17Z thinkpad: 5.99 4.03 4.21 | macmini (bots): { 49.41 69.01 78.50 }
|
||||
2026-10-17T15:21:48Z thinkpad: 4.85 3.95 4.18 | macmini (bots): { 46.89 66.58 77.29 }
|
||||
2026-10-17T15:22:18Z thinkpad: 4.31 3.92 4.16 | macmini (bots): { 45.13 64.31 76.10 }
|
||||
2026-10-17T15:22:48Z thinkpad: 4.28 3.95 4.16 | macmini (bots): { 51.82 63.98 75.56 }
|
||||
2026-10-17T15:23:18Z thinkpad: 4.75 4.11 4.21 | macmini (bots): { 50.35 62.51 74.63 }
|
||||
2026-10-17T15:23:48Z thinkpad: 4.08 4.02 4.17 | macmini (bots): { 44.62 60.01 73.31 }
|
||||
2026-10-17T15:24:19Z thinkpad: 3.93 4.00 4.16 | macmini (bots): { 46.02 58.72 72.37 }
|
||||
2026-10-17T15:24:49Z thinkpad: 3.67 3.92 4.13 | macmini (bots): { 47.23 57.90 71.60 }
|
||||
2026-10-17T15:25:19Z thinkpad: 2.79 3.68 4.04 | macmini (bots): { 50.80 57.78 71.09 }
|
||||
2026-10-17T15:25:49Z thinkpad: 2.79 3.58 3.99 | macmini (bots): { 47.66 56.35 70.11 }
|
||||
2026-10-17T15:26:19Z thinkpad: 5.04 3.97 4.11 | macmini (bots): { 45.15 54.84 69.08 }
|
||||
2026-10-17T15:26:50Z thinkpad: 8.46 4.86 4.40 | macmini (bots): { 43.59 53.51 68.10 }
|
||||
2026-10-17T15:27:20Z thinkpad: 6.84 4.81 4.39 | macmini (bots): { 42.38 52.39 67.19 }
|
||||
2026-10-17T15:27:50Z thinkpad: 8.24 5.33 4.58 | macmini (bots): { 49.87 53.02 66.82 }
|
||||
2026-10-17T15:28:21Z thinkpad: 7.11 5.34 4.61 | macmini (bots): { 61.30 55.60 67.28 }
|
||||
2026-10-17T15:28:51Z thinkpad: 6.11 5.27 4.61 | macmini (bots): { 67.60 57.74 67.66 }
|
||||
2026-10-17T15:29:21Z thinkpad: 5.33 5.17 4.59 | macmini (bots): { 63.72 57.79 67.33 }
|
||||
2026-10-17T15:29:51Z thinkpad: 3.57 4.75 4.47 | macmini (bots): { 60.80 57.62 66.94 }
|
||||
2026-10-17T15:30:22Z thinkpad: 4.74 4.88 4.52 | macmini (bots): { 59.69 57.64 66.62 }
|
||||
2026-10-17T15:30:52Z thinkpad: 3.44 4.54 4.42 | macmini (bots): { 66.89 59.49 66.97 }
|
||||
2026-10-17T15:31:22Z thinkpad: 4.57 4.69 4.47 | macmini (bots): { 83.43 64.16 68.40 }
|
||||
2026-10-17T15:31:52Z thinkpad: 7.17 5.30 4.68 | macmini (bots): { 77.62 64.77 68.49 }
|
||||
2026-10-17T15:32:23Z thinkpad: 8.55 5.82 4.88 | macmini (bots): { 74.20 65.09 68.47 }
|
||||
2026-10-17T15:32:53Z thinkpad: 7.49 5.85 4.92 | macmini (bots): { 82.07 67.70 69.29 }
|
||||
2026-10-17T15:33:23Z thinkpad: 5.65 5.59 4.86 | macmini (bots): { 79.44 68.47 69.51 }
|
||||
2026-10-17T15:33:54Z thinkpad: 5.73 5.64 4.91 | macmini (bots): { 78.94 69.26 69.76 }
|
||||
2026-10-17T15:34:24Z thinkpad: 6.84 5.92 5.02 | macmini (bots): { 76.33 69.71 69.91 }
|
||||
2026-10-17T15:34:54Z thinkpad: 5.11 5.59 4.94 | macmini (bots): { 86.09 72.63 70.95 }
|
||||
2026-10-17T15:35:24Z thinkpad: 6.68 5.93 5.08 | macmini (bots): { 72.77 70.70 70.31 }
|
||||
2026-10-17T15:35:55Z thinkpad: 5.75 5.75 5.04 | macmini (bots): { 71.50 70.49 70.24 }
|
||||
2026-10-17T15:36:25Z thinkpad: 4.31 5.41 4.95 | macmini (bots): { 80.75 72.67 71.03 }
|
||||
2026-10-17T15:36:55Z thinkpad: 3.15 5.02 4.83 | macmini (bots): { 89.83 75.59 72.14 }
|
||||
@@ -0,0 +1,695 @@
|
||||
{
|
||||
"kind": "horizon-server-benchmark",
|
||||
"date": "2026-10-17T14:56:24+00:00",
|
||||
"machine": {
|
||||
"system": "Linux",
|
||||
"machine": "x86_64",
|
||||
"cpus": 4
|
||||
},
|
||||
"commit": "a9ee56a8b143999c9bc7056850ffcd622a9214aa",
|
||||
"java": [
|
||||
"openjdk 25.0.4.1 2026-08-18"
|
||||
],
|
||||
"settings": {
|
||||
"servers": "horizon,vanilla",
|
||||
"bots": "1,10,25,50,100",
|
||||
"behaviour": "walk,explore",
|
||||
"join_every": 1.0,
|
||||
"warmup": 60,
|
||||
"seconds": 180,
|
||||
"seed": 12345,
|
||||
"explore_speed": 0,
|
||||
"gamerule": "",
|
||||
"heap": "4g",
|
||||
"out": "/home/ludovic/code/horizon/.claude/worktrees/server-host-4/.local/bench-out/walk-explore.json",
|
||||
"nice": 19,
|
||||
"bots_via": "ssh macmini",
|
||||
"bots_bin": "/Users/ludovicmarie/code/horizon/.claude/worktrees/server-host-4/target/release/horizon-bots",
|
||||
"connect": "<server address>",
|
||||
"properties": {
|
||||
"level-seed": "12345",
|
||||
"view-distance": "10",
|
||||
"simulation-distance": "10",
|
||||
"online-mode": "false",
|
||||
"white-list": "false",
|
||||
"gamemode": "creative",
|
||||
"force-gamemode": "true",
|
||||
"spawn-protection": "0",
|
||||
"max-players": "200",
|
||||
"difficulty": "easy",
|
||||
"sync-chunk-writes": "true"
|
||||
}
|
||||
},
|
||||
"runs": [
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 1,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 9.51,
|
||||
"mspt": {
|
||||
"average": 5.77,
|
||||
"p50": 5.15,
|
||||
"p95": 11.04,
|
||||
"p99": 28.99
|
||||
},
|
||||
"mspt_worst_p99": 45.9,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 777.78,
|
||||
"max": 779.8
|
||||
},
|
||||
"cpu_percent": 59.02,
|
||||
"chunks_per_second": 9.94,
|
||||
"unique_per_second": 0.45,
|
||||
"forgotten_per_second": 9.99,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 2763,
|
||||
"chunks_per_bot": {
|
||||
"walk": 2763.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 697.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 1,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 1,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 15.35,
|
||||
"mspt": {
|
||||
"average": 18.84,
|
||||
"p50": 18.87,
|
||||
"p95": 29.55,
|
||||
"p99": 51.32
|
||||
},
|
||||
"mspt_worst_p99": 456.4,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 3442.06,
|
||||
"max": 3462.1
|
||||
},
|
||||
"cpu_percent": 70.63,
|
||||
"chunks_per_second": 10.47,
|
||||
"unique_per_second": 0.47,
|
||||
"forgotten_per_second": 10.53,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 2763,
|
||||
"chunks_per_bot": {
|
||||
"walk": 2763.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 697.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 1,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 10,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 9.31,
|
||||
"mspt": {
|
||||
"average": 16.23,
|
||||
"p50": 15.6,
|
||||
"p95": 29.32,
|
||||
"p99": 44.43
|
||||
},
|
||||
"mspt_worst_p99": 100.6,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 781.84,
|
||||
"max": 782.6
|
||||
},
|
||||
"cpu_percent": 136.59,
|
||||
"chunks_per_second": 93.82,
|
||||
"unique_per_second": 3.33,
|
||||
"forgotten_per_second": 93.61,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 26481,
|
||||
"chunks_per_bot": {
|
||||
"walk": 2648.1
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 679.5
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 10,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 10,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 14.65,
|
||||
"mspt": {
|
||||
"average": 18.02,
|
||||
"p50": 15.22,
|
||||
"p95": 37.62,
|
||||
"p99": 54.5
|
||||
},
|
||||
"mspt_worst_p99": 73.8,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 3408.62,
|
||||
"max": 3423.6
|
||||
},
|
||||
"cpu_percent": 100.57,
|
||||
"chunks_per_second": 101.12,
|
||||
"unique_per_second": 4.25,
|
||||
"forgotten_per_second": 101.17,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 26763,
|
||||
"chunks_per_bot": {
|
||||
"walk": 2676.3
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 687.8
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 7,
|
||||
"moved_too_quickly": 7,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 10,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 25,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 9.31,
|
||||
"mspt": {
|
||||
"average": 23.98,
|
||||
"p50": 20.89,
|
||||
"p95": 49.18,
|
||||
"p99": 65.87
|
||||
},
|
||||
"mspt_worst_p99": 109.8,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 786.18,
|
||||
"max": 786.5
|
||||
},
|
||||
"cpu_percent": 159.81,
|
||||
"chunks_per_second": 235.75,
|
||||
"unique_per_second": 7.84,
|
||||
"forgotten_per_second": 235.72,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 67251,
|
||||
"chunks_per_bot": {
|
||||
"walk": 2690.04
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 683.92
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 25,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 25,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 14.95,
|
||||
"mspt": {
|
||||
"average": 19.19,
|
||||
"p50": 17.34,
|
||||
"p95": 33.84,
|
||||
"p99": 56.21
|
||||
},
|
||||
"mspt_worst_p99": 79.2,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 3531.46,
|
||||
"max": 3582.1
|
||||
},
|
||||
"cpu_percent": 153.78,
|
||||
"chunks_per_second": 252.35,
|
||||
"unique_per_second": 11.73,
|
||||
"forgotten_per_second": 251.58,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 66759,
|
||||
"chunks_per_bot": {
|
||||
"walk": 2670.36
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 689.76
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 12,
|
||||
"moved_too_quickly": 12,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 25,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 50,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 9.21,
|
||||
"mspt": {
|
||||
"average": 35.07,
|
||||
"p50": 30.55,
|
||||
"p95": 73.24,
|
||||
"p99": 99.38
|
||||
},
|
||||
"mspt_worst_p99": 117.2,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 816.58,
|
||||
"max": 818.5
|
||||
},
|
||||
"cpu_percent": 187.42,
|
||||
"chunks_per_second": 469.11,
|
||||
"unique_per_second": 15.92,
|
||||
"forgotten_per_second": 468.62,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 136962,
|
||||
"chunks_per_bot": {
|
||||
"walk": 2739.24
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 685.68
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 50,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 50,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 15.95,
|
||||
"mspt": {
|
||||
"average": 35.23,
|
||||
"p50": 31.39,
|
||||
"p95": 60.87,
|
||||
"p99": 104.2
|
||||
},
|
||||
"mspt_worst_p99": 443.7,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 3562.57,
|
||||
"max": 3598.9
|
||||
},
|
||||
"cpu_percent": 263.27,
|
||||
"chunks_per_second": 497.51,
|
||||
"unique_per_second": 30.67,
|
||||
"forgotten_per_second": 495.7,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 131116,
|
||||
"chunks_per_bot": {
|
||||
"walk": 2622.32
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 687.78
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 96,
|
||||
"moved_too_quickly": 96,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 50,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 100,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 9.21,
|
||||
"mspt": {
|
||||
"average": 104.8,
|
||||
"p50": 100.6,
|
||||
"p95": 158.8,
|
||||
"p99": 198.61
|
||||
},
|
||||
"mspt_worst_p99": 259.8,
|
||||
"tps": 9.54,
|
||||
"rss_mb": {
|
||||
"mean": 850.05,
|
||||
"max": 851.2
|
||||
},
|
||||
"cpu_percent": 198.95,
|
||||
"chunks_per_second": 371.72,
|
||||
"unique_per_second": 26.27,
|
||||
"forgotten_per_second": 365.57,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 144465,
|
||||
"chunks_per_bot": {
|
||||
"walk": 1444.65
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 571.76
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 100,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 34
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 100,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 15.25,
|
||||
"error": "[Errno 32] Broken pipe"
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 1,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 9.41,
|
||||
"mspt": {
|
||||
"average": 11.42,
|
||||
"p50": 10.15,
|
||||
"p95": 24.66,
|
||||
"p99": 37.27
|
||||
},
|
||||
"mspt_worst_p99": 60.3,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 838.61,
|
||||
"max": 888.0
|
||||
},
|
||||
"cpu_percent": 326.85,
|
||||
"chunks_per_second": 41.83,
|
||||
"unique_per_second": 36.39,
|
||||
"forgotten_per_second": 41.75,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 9845,
|
||||
"chunks_per_bot": {
|
||||
"explore": 9845.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 8718.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 1,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 1,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 15.44,
|
||||
"mspt": {
|
||||
"average": 9.83,
|
||||
"p50": 7.72,
|
||||
"p95": 20.36,
|
||||
"p99": 84.65
|
||||
},
|
||||
"mspt_worst_p99": 385.9,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 3598.02,
|
||||
"max": 3658.9
|
||||
},
|
||||
"cpu_percent": 244.86,
|
||||
"chunks_per_second": 35.28,
|
||||
"unique_per_second": 33.34,
|
||||
"forgotten_per_second": 35.13,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 7881,
|
||||
"chunks_per_bot": {
|
||||
"explore": 7881.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 7540.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 1,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 10,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 9.52,
|
||||
"mspt": {
|
||||
"average": 23.54,
|
||||
"p50": 21.18,
|
||||
"p95": 43.4,
|
||||
"p99": 60.85
|
||||
},
|
||||
"mspt_worst_p99": 109.1,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 1139.51,
|
||||
"max": 1301.0
|
||||
},
|
||||
"cpu_percent": 331.74,
|
||||
"chunks_per_second": 43.86,
|
||||
"unique_per_second": 43.09,
|
||||
"forgotten_per_second": 43.82,
|
||||
"waited_ticks_per_second": 172.35,
|
||||
"chunks_total": 14140,
|
||||
"chunks_per_bot": {
|
||||
"explore": 1414.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 1395.9
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 10,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 10,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 15.78,
|
||||
"mspt": {
|
||||
"average": 16.42,
|
||||
"p50": 14.01,
|
||||
"p95": 30.29,
|
||||
"p99": 96.17
|
||||
},
|
||||
"mspt_worst_p99": 1144.3,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 3964.49,
|
||||
"max": 4248.3
|
||||
},
|
||||
"cpu_percent": 256.86,
|
||||
"chunks_per_second": 25.43,
|
||||
"unique_per_second": 25.33,
|
||||
"forgotten_per_second": 25.96,
|
||||
"waited_ticks_per_second": 76.05,
|
||||
"chunks_total": 7991,
|
||||
"chunks_per_bot": {
|
||||
"explore": 799.1
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 797.7
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 11028,
|
||||
"moved_too_quickly": 17,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 10,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 25,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 9.31,
|
||||
"mspt": {
|
||||
"average": 36.87,
|
||||
"p50": 33.92,
|
||||
"p95": 63.19,
|
||||
"p99": 84.17
|
||||
},
|
||||
"mspt_worst_p99": 124.2,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 1236.2,
|
||||
"max": 1438.4
|
||||
},
|
||||
"cpu_percent": 345.03,
|
||||
"chunks_per_second": 91.07,
|
||||
"unique_per_second": 88.7,
|
||||
"forgotten_per_second": 92.51,
|
||||
"waited_ticks_per_second": 443.88,
|
||||
"chunks_total": 30035,
|
||||
"chunks_per_bot": {
|
||||
"explore": 1201.4
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 1176.84
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 25,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 25,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 14.55,
|
||||
"mspt": {
|
||||
"average": 24.31,
|
||||
"p50": 21.76,
|
||||
"p95": 36.55,
|
||||
"p99": 155.88
|
||||
},
|
||||
"mspt_worst_p99": 1535.8,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 4047.76,
|
||||
"max": 4459.2
|
||||
},
|
||||
"cpu_percent": 305.24,
|
||||
"chunks_per_second": 37.94,
|
||||
"unique_per_second": 37.82,
|
||||
"forgotten_per_second": 38.87,
|
||||
"waited_ticks_per_second": 261.79,
|
||||
"chunks_total": 16604,
|
||||
"chunks_per_bot": {
|
||||
"explore": 664.16
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 660.92
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 33246,
|
||||
"moved_too_quickly": 20,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 25,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 50,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 9.21,
|
||||
"mspt": {
|
||||
"average": 60.06,
|
||||
"p50": 57.31,
|
||||
"p95": 88.38,
|
||||
"p99": 112.89
|
||||
},
|
||||
"mspt_worst_p99": 172.4,
|
||||
"tps": 16.65,
|
||||
"rss_mb": {
|
||||
"mean": 1192.47,
|
||||
"max": 1372.5
|
||||
},
|
||||
"cpu_percent": 362.42,
|
||||
"chunks_per_second": 157.28,
|
||||
"unique_per_second": 154.2,
|
||||
"forgotten_per_second": 156.07,
|
||||
"waited_ticks_per_second": 890.09,
|
||||
"chunks_total": 54551,
|
||||
"chunks_per_bot": {
|
||||
"explore": 1091.02
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 1078.5
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 50,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 50,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 15.64,
|
||||
"error": "[Errno 32] Broken pipe"
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 100,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 9.21,
|
||||
"mspt": {
|
||||
"average": 124.25,
|
||||
"p50": 120.1,
|
||||
"p95": 168.33,
|
||||
"p99": 204.64
|
||||
},
|
||||
"mspt_worst_p99": 262.2,
|
||||
"tps": 8.05,
|
||||
"rss_mb": {
|
||||
"mean": 1283.26,
|
||||
"max": 1418.6
|
||||
},
|
||||
"cpu_percent": 334.14,
|
||||
"chunks_per_second": 185.47,
|
||||
"unique_per_second": 184.54,
|
||||
"forgotten_per_second": 179.79,
|
||||
"waited_ticks_per_second": 1864.45,
|
||||
"chunks_total": 78556,
|
||||
"chunks_per_bot": {
|
||||
"explore": 785.56
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 784.88
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 100,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 100,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 15.44,
|
||||
"error": "[Errno 32] Broken pipe"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
{
|
||||
"kind": "horizon-server-benchmark",
|
||||
"date": "2026-10-17T18:12:19+00:00",
|
||||
"machine": {
|
||||
"system": "Linux",
|
||||
"machine": "x86_64",
|
||||
"cpus": 4
|
||||
},
|
||||
"commit": "7bbbf6e97fb53683eae0a01ce2c134e53cb649a7",
|
||||
"java": [
|
||||
"openjdk 25.0.4.1 2026-08-18"
|
||||
],
|
||||
"settings": {
|
||||
"servers": "horizon,vanilla",
|
||||
"bots": "1,10,25,50",
|
||||
"behaviour": "explore",
|
||||
"join_every": 1.0,
|
||||
"warmup": 60,
|
||||
"seconds": 180,
|
||||
"seed": 12345,
|
||||
"explore_speed": 0.8,
|
||||
"gamerule": "",
|
||||
"heap": "4g",
|
||||
"out": "/home/ludovic/code/horizon/.claude/worktrees/server-host-4/.local/bench-out/explore-equal-rate.json",
|
||||
"nice": 19,
|
||||
"bots_via": "ssh macmini",
|
||||
"bots_bin": "/Users/ludovicmarie/code/horizon/.claude/worktrees/server-host-4/target/release/horizon-bots",
|
||||
"connect": "<server address>",
|
||||
"properties": {
|
||||
"level-seed": "12345",
|
||||
"view-distance": "10",
|
||||
"simulation-distance": "10",
|
||||
"online-mode": "false",
|
||||
"white-list": "false",
|
||||
"gamemode": "creative",
|
||||
"force-gamemode": "true",
|
||||
"spawn-protection": "0",
|
||||
"max-players": "200",
|
||||
"difficulty": "easy",
|
||||
"sync-chunk-writes": "true"
|
||||
}
|
||||
},
|
||||
"runs": [
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 1,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 9.42,
|
||||
"mspt": {
|
||||
"average": 3.16,
|
||||
"p50": 2.98,
|
||||
"p95": 5.78,
|
||||
"p99": 12.17
|
||||
},
|
||||
"mspt_worst_p99": 38.1,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 759.26,
|
||||
"max": 769.5
|
||||
},
|
||||
"cpu_percent": 21.74,
|
||||
"chunks_per_second": 1.64,
|
||||
"unique_per_second": 1.27,
|
||||
"forgotten_per_second": 1.54,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 841,
|
||||
"chunks_per_bot": {
|
||||
"explore": 841.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 781.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 1,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 1,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 17.17,
|
||||
"mspt": {
|
||||
"average": 16.82,
|
||||
"p50": 16.67,
|
||||
"p95": 27.3,
|
||||
"p99": 52.16
|
||||
},
|
||||
"mspt_worst_p99": 453.4,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 3373.16,
|
||||
"max": 3405.9
|
||||
},
|
||||
"cpu_percent": 69.22,
|
||||
"chunks_per_second": 1.66,
|
||||
"unique_per_second": 1.35,
|
||||
"forgotten_per_second": 1.54,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 838,
|
||||
"chunks_per_bot": {
|
||||
"explore": 838.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 779.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 1,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 10,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 9.82,
|
||||
"mspt": {
|
||||
"average": 9.83,
|
||||
"p50": 8.65,
|
||||
"p95": 19.01,
|
||||
"p99": 32.9
|
||||
},
|
||||
"mspt_worst_p99": 64.0,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 861.49,
|
||||
"max": 922.2
|
||||
},
|
||||
"cpu_percent": 77.43,
|
||||
"chunks_per_second": 13.71,
|
||||
"unique_per_second": 12.31,
|
||||
"forgotten_per_second": 13.59,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 7831,
|
||||
"chunks_per_bot": {
|
||||
"explore": 783.1
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 757.1
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 1051,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 10,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 10,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 15.06,
|
||||
"mspt": {
|
||||
"average": 24.38,
|
||||
"p50": 21.55,
|
||||
"p95": 39.58,
|
||||
"p99": 66.64
|
||||
},
|
||||
"mspt_worst_p99": 107.1,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 3569.41,
|
||||
"max": 3685.1
|
||||
},
|
||||
"cpu_percent": 132.09,
|
||||
"chunks_per_second": 13.54,
|
||||
"unique_per_second": 12.32,
|
||||
"forgotten_per_second": 13.62,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 7753,
|
||||
"chunks_per_bot": {
|
||||
"explore": 775.3
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 749.2
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 1027,
|
||||
"moved_too_quickly": 6,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 10,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 25,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 9.31,
|
||||
"mspt": {
|
||||
"average": 16.87,
|
||||
"p50": 15.4,
|
||||
"p95": 30.88,
|
||||
"p99": 46.73
|
||||
},
|
||||
"mspt_worst_p99": 84.9,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 887.11,
|
||||
"max": 945.5
|
||||
},
|
||||
"cpu_percent": 111.2,
|
||||
"chunks_per_second": 33.51,
|
||||
"unique_per_second": 29.92,
|
||||
"forgotten_per_second": 33.33,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 19718,
|
||||
"chunks_per_bot": {
|
||||
"explore": 788.72
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 759.08
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 3770,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 25,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 25,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 14.94,
|
||||
"mspt": {
|
||||
"average": 30.29,
|
||||
"p50": 28.25,
|
||||
"p95": 43.47,
|
||||
"p99": 68.56
|
||||
},
|
||||
"mspt_worst_p99": 165.2,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 3608.97,
|
||||
"max": 3732.0
|
||||
},
|
||||
"cpu_percent": 176.28,
|
||||
"chunks_per_second": 32.98,
|
||||
"unique_per_second": 29.29,
|
||||
"forgotten_per_second": 32.84,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 19442,
|
||||
"chunks_per_bot": {
|
||||
"explore": 777.68
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 748.24
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 5139,
|
||||
"moved_too_quickly": 11,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 25,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 50,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 9.41,
|
||||
"mspt": {
|
||||
"average": 23.57,
|
||||
"p50": 20.43,
|
||||
"p95": 47.53,
|
||||
"p99": 67.23
|
||||
},
|
||||
"mspt_worst_p99": 106.2,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 924.41,
|
||||
"max": 984.3
|
||||
},
|
||||
"cpu_percent": 127.14,
|
||||
"chunks_per_second": 66.56,
|
||||
"unique_per_second": 59.42,
|
||||
"forgotten_per_second": 66.43,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 39950,
|
||||
"chunks_per_bot": {
|
||||
"explore": 799.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 767.56
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 7434,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 50,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 50,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 15.04,
|
||||
"mspt": {
|
||||
"average": 42.44,
|
||||
"p50": 40.71,
|
||||
"p95": 54.84,
|
||||
"p99": 94.23
|
||||
},
|
||||
"mspt_worst_p99": 212.3,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 3667.85,
|
||||
"max": 3780.8
|
||||
},
|
||||
"cpu_percent": 200.85,
|
||||
"chunks_per_second": 65.17,
|
||||
"unique_per_second": 58.11,
|
||||
"forgotten_per_second": 65.22,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 39772,
|
||||
"chunks_per_bot": {
|
||||
"explore": 795.44
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 764.02
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 10604,
|
||||
"moved_too_quickly": 38,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 50,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
2026-10-17T15:51:54Z thinkpad: 1.30 2.47 3.85 | macmini (bots): { 67.44 76.34 77.44 }
|
||||
2026-10-17T15:52:25Z thinkpad: 2.07 2.54 3.83 | macmini (bots): { 64.78 74.76 76.82 }
|
||||
2026-10-17T15:52:55Z thinkpad: 2.08 2.51 3.78 | macmini (bots): { 62.50 73.29 76.22 }
|
||||
2026-10-17T15:53:25Z thinkpad: 1.46 2.31 3.67 | macmini (bots): { 63.70 72.67 75.90 }
|
||||
2026-10-17T15:53:55Z thinkpad: 1.12 2.15 3.58 | macmini (bots): { 69.06 73.27 76.01 }
|
||||
2026-10-17T15:54:26Z thinkpad: 1.34 2.09 3.51 | macmini (bots): { 72.55 73.63 76.04 }
|
||||
2026-10-17T15:54:56Z thinkpad: 1.11 1.97 3.42 | macmini (bots): { 77.53 74.77 76.37 }
|
||||
2026-10-17T15:55:26Z thinkpad: 1.02 1.87 3.34 | macmini (bots): { 77.61 74.90 76.36 }
|
||||
2026-10-17T15:55:56Z thinkpad: 1.09 1.81 3.27 | macmini (bots): { 71.56 73.69 75.86 }
|
||||
2026-10-17T15:56:26Z thinkpad: 2.23 1.99 3.29 | macmini (bots): { 65.21 71.93 75.15 }
|
||||
2026-10-17T15:56:57Z thinkpad: 2.60 2.12 3.29 | macmini (bots): { 58.16 69.40 74.10 }
|
||||
2026-10-17T15:57:27Z thinkpad: 2.57 2.18 3.27 | macmini (bots): { 57.54 68.26 73.52 }
|
||||
2026-10-17T15:57:57Z thinkpad: 2.37 2.17 3.23 | macmini (bots): { 56.15 66.85 72.83 }
|
||||
2026-10-17T15:58:27Z thinkpad: 1.90 2.08 3.17 | macmini (bots): { 59.28 66.57 72.52 }
|
||||
2026-10-17T15:58:58Z thinkpad: 1.71 2.02 3.11 | macmini (bots): { 55.64 64.94 71.72 }
|
||||
2026-10-17T15:59:28Z thinkpad: 1.66 1.97 3.06 | macmini (bots): { 66.06 66.49 72.04 }
|
||||
2026-10-17T15:59:58Z thinkpad: 1.18 1.83 2.98 | macmini (bots): { 78.15 69.43 72.92 }
|
||||
2026-10-17T16:00:28Z thinkpad: 0.83 1.68 2.89 | macmini (bots): { 70.77 68.57 72.49 }
|
||||
2026-10-17T16:00:58Z thinkpad: 1.27 1.70 2.86 | macmini (bots): { 71.95 68.97 72.49 }
|
||||
2026-10-17T16:01:29Z thinkpad: 1.85 1.79 2.85 | macmini (bots): { 83.43 72.22 73.56 }
|
||||
2026-10-17T16:01:59Z thinkpad: 2.51 1.95 2.86 | macmini (bots): { 70.49 70.18 72.77 }
|
||||
2026-10-17T16:02:29Z thinkpad: 1.98 1.87 2.81 | macmini (bots): { 64.00 68.64 72.12 }
|
||||
2026-10-17T16:02:59Z thinkpad: 1.77 1.84 2.77 | macmini (bots): { 59.65 67.09 71.44 }
|
||||
2026-10-17T16:03:30Z thinkpad: 4.04 2.35 2.91 | macmini (bots): { 62.67 67.15 71.31 }
|
||||
2026-10-17T16:04:00Z thinkpad: 4.94 2.72 3.01 | macmini (bots): { 67.63 67.82 71.40 }
|
||||
2026-10-17T16:04:30Z thinkpad: 4.22 2.78 3.02 | macmini (bots): { 70.41 68.39 71.48 }
|
||||
2026-10-17T16:05:00Z thinkpad: 3.81 2.80 3.02 | macmini (bots): { 66.59 67.83 71.18 }
|
||||
2026-10-17T16:05:31Z thinkpad: 3.20 2.75 3.00 | macmini (bots): { 72.53 69.01 71.48 }
|
||||
2026-10-17T16:06:01Z thinkpad: 4.22 3.04 3.08 | macmini (bots): { 71.30 69.27 71.50 }
|
||||
2026-10-17T16:06:31Z thinkpad: 5.43 3.47 3.23 | macmini (bots): { 68.37 68.63 71.19 }
|
||||
2026-10-17T16:07:01Z thinkpad: 4.35 3.42 3.22 | macmini (bots): { 60.20 66.68 70.39 }
|
||||
2026-10-17T16:07:31Z thinkpad: 3.47 3.31 3.19 | macmini (bots): { 58.03 65.51 69.84 }
|
||||
2026-10-17T16:08:02Z thinkpad: 2.30 3.04 3.11 | macmini (bots): { 59.05 65.13 69.55 }
|
||||
2026-10-17T16:08:32Z thinkpad: 1.88 2.86 3.04 | macmini (bots): { 51.77 62.75 68.53 }
|
||||
2026-10-17T16:09:02Z thinkpad: 1.38 2.65 2.97 | macmini (bots): { 49.05 61.01 67.70 }
|
||||
2026-10-17T16:09:32Z thinkpad: 3.10 2.95 3.06 | macmini (bots): { 48.91 59.82 67.03 }
|
||||
2026-10-17T16:10:02Z thinkpad: 2.91 2.91 3.04 | macmini (bots): { 47.56 58.44 66.25 }
|
||||
2026-10-17T16:10:33Z thinkpad: 2.58 2.83 3.01 | macmini (bots): { 49.23 57.69 65.70 }
|
||||
2026-10-17T16:11:03Z thinkpad: 8.38 4.15 3.44 | macmini (bots): { 44.16 55.72 64.71 }
|
||||
2026-10-17T16:11:33Z thinkpad: 9.94 4.92 3.72 | macmini (bots): { 44.50 54.71 64.03 }
|
||||
2026-10-17T16:12:03Z thinkpad: 10.08 5.42 3.92 | macmini (bots): { 42.15 53.21 63.17 }
|
||||
2026-10-17T16:12:33Z thinkpad: 6.79 5.06 3.85 | macmini (bots): { 36.13 50.73 61.92 }
|
||||
2026-10-17T16:13:04Z thinkpad: 4.61 4.72 3.77 | macmini (bots): { 41.28 50.64 61.51 }
|
||||
2026-10-17T16:13:34Z thinkpad: 3.81 4.50 3.73 | macmini (bots): { 42.92 50.15 60.95 }
|
||||
2026-10-17T16:14:04Z thinkpad: 3.39 4.35 3.71 | macmini (bots): { 41.56 49.16 60.22 }
|
||||
2026-10-17T16:14:35Z thinkpad: 7.53 5.17 4.00 | macmini (bots): { 47.48 49.82 60.07 }
|
||||
2026-10-17T16:15:05Z thinkpad: 9.73 6.01 4.32 | macmini (bots): { 51.43 50.41 59.92 }
|
||||
2026-10-17T16:15:35Z thinkpad: 8.07 5.94 4.35 | macmini (bots): { 69.31 54.81 61.18 }
|
||||
2026-10-17T16:16:05Z thinkpad: 9.27 6.45 4.57 | macmini (bots): { 71.85 56.92 61.73 }
|
||||
2026-10-17T16:16:35Z thinkpad: 8.69 6.59 4.68 | macmini (bots): { 64.77 56.72 61.50 }
|
||||
2026-10-17T16:17:06Z thinkpad: 6.11 6.17 4.60 | macmini (bots): { 57.92 55.86 61.02 }
|
||||
2026-10-17T16:17:36Z thinkpad: 6.79 6.33 4.70 | macmini (bots): { 51.44 54.51 60.36 }
|
||||
2026-10-17T16:18:06Z thinkpad: 5.16 5.99 4.64 | macmini (bots): { 51.33 54.23 60.05 }
|
||||
2026-10-17T16:18:36Z thinkpad: 5.30 5.93 4.66 | macmini (bots): { 55.22 54.87 60.08 }
|
||||
2026-10-17T16:19:07Z thinkpad: 4.78 5.72 4.63 | macmini (bots): { 51.62 53.94 59.56 }
|
||||
2026-10-17T16:19:37Z thinkpad: 5.25 5.78 4.69 | macmini (bots): { 48.77 53.04 59.04 }
|
||||
2026-10-17T16:20:07Z thinkpad: 4.63 5.60 4.67 | macmini (bots): { 54.59 53.92 59.14 }
|
||||
2026-10-17T16:20:37Z thinkpad: 5.46 5.67 4.72 | macmini (bots): { 59.07 55.03 59.36 }
|
||||
2026-10-17T16:21:07Z thinkpad: 8.07 6.19 4.91 | macmini (bots): { 52.53 53.83 58.77 }
|
||||
2026-10-17T16:21:38Z thinkpad: 15.01 8.05 5.57 | macmini (bots): { 48.85 52.78 58.19 }
|
||||
2026-10-17T16:22:08Z thinkpad: 19.08 9.69 6.20 | macmini (bots): { 54.73 53.61 58.29 }
|
||||
2026-10-17T16:22:39Z thinkpad: 27.72 12.63 7.29 | macmini (bots): { 54.23 53.61 58.13 }
|
||||
2026-10-17T16:23:09Z thinkpad: 23.10 12.88 7.53 | macmini (bots): { 51.63 53.06 57.77 }
|
||||
2026-10-17T16:23:39Z thinkpad: 20.51 13.53 7.96 | macmini (bots): { 45.29 51.38 57.00 }
|
||||
2026-10-17T16:24:10Z thinkpad: 12.70 12.30 7.72 | macmini (bots): { 45.61 50.91 56.63 }
|
||||
2026-10-17T16:24:40Z thinkpad: 8.67 11.35 7.55 | macmini (bots): { 45.18 50.35 56.23 }
|
||||
2026-10-17T16:25:10Z thinkpad: 6.35 10.53 7.40 | macmini (bots): { 41.10 48.88 55.50 }
|
||||
2026-10-17T16:25:40Z thinkpad: 6.26 10.16 7.38 | macmini (bots): { 49.86 50.24 55.76 }
|
||||
2026-10-17T16:26:11Z thinkpad: 7.71 10.11 7.45 | macmini (bots): { 53.93 51.11 55.88 }
|
||||
2026-10-17T16:26:41Z thinkpad: 9.50 10.32 7.61 | macmini (bots): { 47.61 49.86 55.26 }
|
||||
2026-10-17T16:27:11Z thinkpad: 10.26 10.39 7.72 | macmini (bots): { 46.43 49.36 54.89 }
|
||||
2026-10-17T16:27:42Z thinkpad: 10.61 10.48 7.83 | macmini (bots): { 48.88 49.66 54.80 }
|
||||
2026-10-17T16:28:12Z thinkpad: 10.39 10.45 7.91 | macmini (bots): { 58.42 51.74 55.37 }
|
||||
2026-10-17T16:28:42Z thinkpad: 10.36 10.44 7.99 | macmini (bots): { 59.59 52.67 55.58 }
|
||||
2026-10-17T16:29:13Z thinkpad: 9.63 10.25 8.00 | macmini (bots): { 55.30 52.38 55.38 }
|
||||
2026-10-17T16:29:43Z thinkpad: 10.33 10.37 8.12 | macmini (bots): { 57.79 53.13 55.52 }
|
||||
2026-10-17T16:30:13Z thinkpad: 9.68 10.18 8.12 | macmini (bots): { 57.83 53.60 55.61 }
|
||||
2026-10-17T16:30:43Z thinkpad: 9.72 10.17 8.19 | macmini (bots): { 78.94 59.18 57.56 }
|
||||
2026-10-17T16:31:14Z thinkpad: 6.60 9.36 7.98 | macmini (bots): { 87.29 62.98 58.99 }
|
||||
2026-10-17T16:31:44Z thinkpad: 6.77 9.08 7.93 | macmini (bots): { 81.82 64.16 59.57 }
|
||||
2026-10-17T16:32:14Z thinkpad: 13.15 10.41 8.41 | macmini (bots): { 81.20 65.92 60.37 }
|
||||
2026-10-17T16:32:45Z thinkpad: 27.80 14.12 9.71 | macmini (bots): { 75.41 66.22 60.69 }
|
||||
2026-10-17T16:33:15Z thinkpad: 44.59 19.25 11.54 | macmini (bots): { 66.92 65.08 60.47 }
|
||||
2026-10-17T16:33:46Z thinkpad: 44.87 21.89 12.67 | macmini (bots): { 61.47 63.94 60.22 }
|
||||
2026-10-17T16:34:16Z thinkpad: 38.98 22.67 13.23 | macmini (bots): { 62.13 63.78 60.28 }
|
||||
2026-10-17T16:34:47Z thinkpad: 47.64 26.08 14.65 | macmini (bots): { 81.23 68.06 61.94 }
|
||||
2026-10-17T16:35:17Z thinkpad: 47.83 28.02 15.65 | macmini (bots): { 116.14 78.43 65.98 }
|
||||
2026-10-17T16:35:48Z thinkpad: 40.43 28.18 16.10 | macmini (bots): { 125.61 84.41 68.58 }
|
||||
2026-10-17T16:36:19Z thinkpad: 41.34 29.71 17.00 | macmini (bots): { 143.61 92.86 72.20 }
|
||||
2026-10-17T16:36:49Z thinkpad: 34.40 29.19 17.29 | macmini (bots): { 156.05 100.88 75.83 }
|
||||
2026-10-17T16:37:20Z thinkpad: 25.34 27.65 17.17 | macmini (bots): { 151.90 105.26 78.29 }
|
||||
2026-10-17T16:37:50Z thinkpad: 18.36 25.71 16.85 | macmini (bots): { 141.10 107.25 79.96 }
|
||||
2026-10-17T16:38:20Z thinkpad: 16.19 24.47 16.72 | macmini (bots): { 131.21 107.92 81.13 }
|
||||
2026-10-17T16:38:51Z thinkpad: 13.36 23.00 16.48 | macmini (bots): { 122.08 107.78 82.00 }
|
||||
2026-10-17T16:39:21Z thinkpad: 11.27 21.57 16.21 | macmini (bots): { 107.07 105.50 82.07 }
|
||||
2026-10-17T16:39:51Z thinkpad: 11.19 20.56 16.04 | macmini (bots): { 96.38 103.12 82.02 }
|
||||
2026-10-17T16:40:22Z thinkpad: 10.06 19.37 15.79 | macmini (bots): { 89.06 100.79 81.91 }
|
||||
2026-10-17T16:40:52Z thinkpad: 7.38 17.86 15.40 | macmini (bots): { 79.35 97.41 81.34 }
|
||||
2026-10-17T16:41:23Z thinkpad: 6.18 16.57 15.05 | macmini (bots): { 81.70 96.20 81.46 }
|
||||
2026-10-17T16:41:53Z thinkpad: 5.56 15.44 14.72 | macmini (bots): { 86.51 95.88 81.84 }
|
||||
2026-10-17T16:42:23Z thinkpad: 4.90 14.33 14.37 | macmini (bots): { 99.20 97.69 83.05 }
|
||||
2026-10-17T16:42:53Z thinkpad: 5.01 13.47 14.08 | macmini (bots): { 116.29 101.87 85.07 }
|
||||
2026-10-17T16:43:24Z thinkpad: 4.34 12.50 13.74 | macmini (bots): { 131.32 106.77 87.42 }
|
||||
2026-10-17T16:43:54Z thinkpad: 4.08 11.65 13.42 | macmini (bots): { 155.44 114.77 90.98 }
|
||||
2026-10-17T16:44:24Z thinkpad: 4.00 10.90 13.11 | macmini (bots): { 167.86 121.62 94.28 }
|
||||
2026-10-17T16:44:54Z thinkpad: 3.98 10.13 12.77 | macmini (bots): { 160.36 124.31 96.20 }
|
||||
2026-10-17T16:45:25Z thinkpad: 4.20 9.59 12.50 | macmini (bots): { 163.23 128.59 98.74 }
|
||||
2026-10-17T16:45:55Z thinkpad: 4.54 9.15 12.27 | macmini (bots): { 169.93 133.73 101.65 }
|
||||
2026-10-17T16:46:25Z thinkpad: 4.44 8.69 12.01 | macmini (bots): { 162.40 135.22 103.29 }
|
||||
2026-10-17T16:46:55Z thinkpad: 4.06 8.20 11.74 | macmini (bots): { 164.16 138.31 105.52 }
|
||||
2026-10-17T16:47:26Z thinkpad: 3.96 7.78 11.49 | macmini (bots): { 157.92 139.31 107.02 }
|
||||
2026-10-17T16:47:56Z thinkpad: 3.71 7.35 11.23 | macmini (bots): { 155.72 140.55 108.59 }
|
||||
2026-10-17T16:48:26Z thinkpad: 4.93 7.32 11.09 | macmini (bots): { 153.39 141.52 110.05 }
|
||||
2026-10-17T16:48:56Z thinkpad: 4.61 7.03 10.88 | macmini (bots): { 137.54 138.87 110.18 }
|
||||
2026-10-17T16:49:27Z thinkpad: 4.01 6.65 10.63 | macmini (bots): { 136.53 138.36 110.98 }
|
||||
2026-10-17T16:49:57Z thinkpad: 3.19 6.19 10.35 | macmini (bots): { 141.12 139.18 112.22 }
|
||||
2026-10-17T16:50:27Z thinkpad: 5.31 6.43 10.30 | macmini (bots): { 122.76 135.27 111.75 }
|
||||
2026-10-17T16:50:57Z thinkpad: 5.95 6.48 10.19 | macmini (bots): { 114.26 132.02 111.39 }
|
||||
2026-10-17T16:51:28Z thinkpad: 5.76 6.41 10.05 | macmini (bots): { 116.04 130.87 111.69 }
|
||||
2026-10-17T16:51:58Z thinkpad: 5.11 6.18 9.86 | macmini (bots): { 115.06 129.17 111.74 }
|
||||
2026-10-17T16:52:28Z thinkpad: 5.09 6.07 9.70 | macmini (bots): { 109.91 126.59 111.41 }
|
||||
2026-10-17T16:52:58Z thinkpad: 4.74 5.90 9.53 | macmini (bots): { 101.90 123.11 110.67 }
|
||||
2026-10-17T16:53:29Z thinkpad: 4.89 5.84 9.39 | macmini (bots): { 83.29 116.38 108.74 }
|
||||
2026-10-17T16:53:59Z thinkpad: 5.10 5.82 9.27 | macmini (bots): { 119.16 122.10 111.09 }
|
||||
2026-10-17T16:54:29Z thinkpad: 4.57 5.67 9.10 | macmini (bots): { 114.87 120.76 110.98 }
|
||||
2026-10-17T16:55:00Z thinkpad: 4.74 5.58 8.96 | macmini (bots): { 129.84 123.74 112.39 }
|
||||
2026-10-17T16:55:30Z thinkpad: 6.40 5.90 8.96 | macmini (bots): { 100.10 117.29 110.46 }
|
||||
2026-10-17T16:56:00Z thinkpad: 6.96 6.08 8.92 | macmini (bots): { 80.18 110.75 108.32 }
|
||||
2026-10-17T16:56:30Z thinkpad: 6.07 5.95 8.79 | macmini (bots): { 74.41 106.39 106.82 }
|
||||
2026-10-17T16:57:01Z thinkpad: 6.63 6.13 8.75 | macmini (bots): { 81.71 104.99 106.29 }
|
||||
2026-10-17T16:57:31Z thinkpad: 6.39 6.14 8.68 | macmini (bots): { 101.10 107.55 107.18 }
|
||||
2026-10-17T16:58:01Z thinkpad: 6.32 6.16 8.60 | macmini (bots): { 94.45 105.66 106.52 }
|
||||
2026-10-17T16:58:31Z thinkpad: 5.19 5.90 8.44 | macmini (bots): { 78.76 100.83 104.75 }
|
||||
2026-10-17T16:59:02Z thinkpad: 5.24 5.83 8.33 | macmini (bots): { 78.19 98.42 103.73 }
|
||||
2026-10-17T16:59:32Z thinkpad: 3.95 5.48 8.13 | macmini (bots): { 77.57 96.42 102.82 }
|
||||
2026-10-17T17:00:02Z thinkpad: 3.12 5.13 7.93 | macmini (bots): { 85.23 96.29 102.54 }
|
||||
2026-10-17T17:00:32Z thinkpad: 3.17 4.93 7.77 | macmini (bots): { 99.84 99.07 103.35 }
|
||||
2026-10-17T17:01:02Z thinkpad: 6.09 5.50 7.88 | macmini (bots): { 80.56 94.49 101.54 }
|
||||
2026-10-17T17:01:33Z thinkpad: 9.23 6.29 8.06 | macmini (bots): { 79.70 93.03 100.77 }
|
||||
2026-10-17T17:02:03Z thinkpad: 9.09 6.54 8.09 | macmini (bots): { 76.63 90.94 99.74 }
|
||||
2026-10-17T17:02:33Z thinkpad: 7.86 6.52 8.04 | macmini (bots): { 93.08 93.50 100.36 }
|
||||
2026-10-17T17:03:03Z thinkpad: 7.81 6.67 8.04 | macmini (bots): { 83.14 91.09 99.25 }
|
||||
2026-10-17T17:03:34Z thinkpad: 6.69 6.51 7.94 | macmini (bots): { 79.37 89.64 98.45 }
|
||||
2026-10-17T17:04:04Z thinkpad: 5.63 6.27 7.81 | macmini (bots): { 73.68 87.23 97.27 }
|
||||
2026-10-17T17:04:34Z thinkpad: 5.75 6.21 7.75 | macmini (bots): { 75.89 86.23 96.54 }
|
||||
2026-10-17T17:05:04Z thinkpad: 5.05 6.01 7.63 | macmini (bots): { 79.29 86.13 96.10 }
|
||||
2026-10-17T17:05:35Z thinkpad: 3.88 5.62 7.44 | macmini (bots): { 76.07 84.79 95.28 }
|
||||
2026-10-17T17:06:05Z thinkpad: 6.47 6.07 7.53 | macmini (bots): { 70.59 82.62 94.13 }
|
||||
2026-10-17T17:06:35Z thinkpad: 8.20 6.53 7.63 | macmini (bots): { 69.09 81.18 93.21 }
|
||||
2026-10-17T17:07:05Z thinkpad: 7.59 6.54 7.60 | macmini (bots): { 77.55 82.11 93.13 }
|
||||
2026-10-17T17:07:36Z thinkpad: 6.93 6.49 7.55 | macmini (bots): { 84.23 83.41 93.22 }
|
||||
2026-10-17T17:08:06Z thinkpad: 7.21 6.61 7.56 | macmini (bots): { 84.31 83.68 92.99 }
|
||||
2026-10-17T17:08:36Z thinkpad: 6.73 6.57 7.51 | macmini (bots): { 81.04 82.99 92.42 }
|
||||
2026-10-17T17:09:06Z thinkpad: 6.12 6.43 7.44 | macmini (bots): { 86.55 83.77 92.35 }
|
||||
2026-10-17T17:09:36Z thinkpad: 6.77 6.58 7.46 | macmini (bots): { 95.19 86.31 92.99 }
|
||||
2026-10-17T17:10:07Z thinkpad: 7.13 6.68 7.46 | macmini (bots): { 111.30 91.11 94.50 }
|
||||
2026-10-17T17:10:37Z thinkpad: 5.09 6.23 7.29 | macmini (bots): { 118.51 95.17 95.87 }
|
||||
2026-10-17T17:11:07Z thinkpad: 4.00 5.86 7.13 | macmini (bots): { 121.12 98.01 96.88 }
|
||||
2026-10-17T17:11:38Z thinkpad: 3.50 5.57 6.99 | macmini (bots): { 109.57 97.67 96.81 }
|
||||
2026-10-17T17:12:08Z thinkpad: 2.92 5.22 6.83 | macmini (bots): { 108.43 98.44 97.11 }
|
||||
2026-10-17T17:12:38Z thinkpad: 3.42 5.12 6.74 | macmini (bots): { 101.61 97.86 96.95 }
|
||||
2026-10-17T17:13:09Z thinkpad: 5.64 5.51 6.82 | macmini (bots): { 101.66 98.17 97.09 }
|
||||
2026-10-17T17:13:39Z thinkpad: 7.06 5.89 6.91 | macmini (bots): { 88.43 95.36 96.11 }
|
||||
2026-10-17T17:14:09Z thinkpad: 7.61 6.13 6.96 | macmini (bots): { 79.03 92.54 95.07 }
|
||||
2026-10-17T17:14:39Z thinkpad: 9.06 6.68 7.12 | macmini (bots): { 92.55 94.57 95.72 }
|
||||
2026-10-17T17:15:10Z thinkpad: 9.81 7.06 7.22 | macmini (bots): { 93.71 94.49 95.64 }
|
||||
2026-10-17T17:15:40Z thinkpad: 9.98 7.41 7.34 | macmini (bots): { 92.26 93.86 95.35 }
|
||||
2026-10-17T17:16:10Z thinkpad: 7.60 7.08 7.23 | macmini (bots): { 88.61 92.91 94.96 }
|
||||
2026-10-17T17:16:40Z thinkpad: 6.81 6.93 7.17 | macmini (bots): { 96.96 94.47 95.45 }
|
||||
2026-10-17T17:17:11Z thinkpad: 6.31 6.81 7.12 | macmini (bots): { 102.92 96.14 96.02 }
|
||||
2026-10-17T17:17:41Z thinkpad: 4.81 6.38 6.97 | macmini (bots): { 91.20 94.16 95.32 }
|
||||
2026-10-17T17:18:11Z thinkpad: 7.36 6.81 7.09 | macmini (bots): { 93.60 94.31 95.32 }
|
||||
2026-10-17T17:18:41Z thinkpad: 9.42 7.38 7.27 | macmini (bots): { 90.86 93.57 95.02 }
|
||||
2026-10-17T17:19:11Z thinkpad: 9.95 7.72 7.39 | macmini (bots): { 88.94 92.94 94.75 }
|
||||
2026-10-17T17:19:42Z thinkpad: 9.51 7.85 7.45 | macmini (bots): { 94.97 93.93 95.03 }
|
||||
2026-10-17T17:20:12Z thinkpad: 8.97 7.90 7.48 | macmini (bots): { 80.46 90.39 93.71 }
|
||||
2026-10-17T17:20:42Z thinkpad: 7.56 7.65 7.41 | macmini (bots): { 74.38 87.95 92.71 }
|
||||
2026-10-17T17:21:12Z thinkpad: 6.39 7.36 7.32 | macmini (bots): { 77.48 87.26 92.28 }
|
||||
2026-10-17T17:21:43Z thinkpad: 5.21 6.98 7.19 | macmini (bots): { 82.05 87.48 92.19 }
|
||||
2026-10-17T17:22:13Z thinkpad: 3.87 6.49 7.02 | macmini (bots): { 88.15 88.45 92.38 }
|
||||
2026-10-17T17:22:43Z thinkpad: 3.86 6.22 6.91 | macmini (bots): { 100.23 91.12 93.19 }
|
||||
2026-10-17T17:23:13Z thinkpad: 8.32 7.05 7.16 | macmini (bots): { 84.55 88.39 92.15 }
|
||||
2026-10-17T17:23:43Z thinkpad: 13.84 8.37 7.59 | macmini (bots): { 73.74 85.38 90.92 }
|
||||
2026-10-17T17:24:14Z thinkpad: 21.02 10.71 8.40 | macmini (bots): { 71.36 83.72 90.13 }
|
||||
2026-10-17T17:24:44Z thinkpad: 20.73 11.82 8.85 | macmini (bots): { 82.27 85.04 90.38 }
|
||||
2026-10-17T17:25:14Z thinkpad: 16.80 11.77 8.94 | macmini (bots): { 78.63 83.92 89.78 }
|
||||
2026-10-17T17:25:44Z thinkpad: 14.62 11.68 8.99 | macmini (bots): { 82.33 84.06 89.61 }
|
||||
2026-10-17T17:26:15Z thinkpad: 14.51 11.94 9.17 | macmini (bots): { 74.13 81.89 88.63 }
|
||||
2026-10-17T17:26:45Z thinkpad: 12.48 11.74 9.20 | macmini (bots): { 75.65 81.46 88.24 }
|
||||
2026-10-17T17:27:15Z thinkpad: 15.24 12.56 9.56 | macmini (bots): { 71.35 79.78 87.35 }
|
||||
2026-10-17T17:27:45Z thinkpad: 15.70 12.87 9.76 | macmini (bots): { 68.21 78.17 86.51 }
|
||||
2026-10-17T17:28:16Z thinkpad: 12.80 12.47 9.72 | macmini (bots): { 79.74 80.10 86.92 }
|
||||
2026-10-17T17:28:46Z thinkpad: 9.05 11.58 9.51 | macmini (bots): { 101.01 85.27 88.56 }
|
||||
2026-10-17T17:29:16Z thinkpad: 9.38 11.41 9.53 | macmini (bots): { 93.76 85.18 88.43 }
|
||||
2026-10-17T17:29:46Z thinkpad: 11.08 11.62 9.65 | macmini (bots): { 74.91 81.56 87.01 }
|
||||
2026-10-17T17:30:17Z thinkpad: 10.98 11.53 9.69 | macmini (bots): { 61.10 77.50 85.34 }
|
||||
2026-10-17T17:30:47Z thinkpad: 9.83 11.21 9.64 | macmini (bots): { 51.19 73.56 83.64 }
|
||||
2026-10-17T17:31:18Z thinkpad: 9.65 11.03 9.63 | macmini (bots): { 49.64 71.03 82.37 }
|
||||
2026-10-17T17:31:48Z thinkpad: 7.22 10.33 9.44 | macmini (bots): { 53.62 69.90 81.56 }
|
||||
2026-10-17T17:32:18Z thinkpad: 5.79 9.68 9.25 | macmini (bots): { 61.07 70.36 81.34 }
|
||||
2026-10-17T17:32:48Z thinkpad: 3.70 8.80 8.97 | macmini (bots): { 60.39 69.24 80.55 }
|
||||
2026-10-17T17:33:19Z thinkpad: 2.72 8.08 8.73 | macmini (bots): { 57.06 67.51 79.53 }
|
||||
2026-10-17T17:33:49Z thinkpad: 1.97 7.37 8.47 | macmini (bots): { 65.72 68.41 79.42 }
|
||||
2026-10-17T17:34:19Z thinkpad: 1.25 6.68 8.20 | macmini (bots): { 62.60 67.40 78.67 }
|
||||
2026-10-17T17:34:49Z thinkpad: 0.94 6.09 7.96 | macmini (bots): { 71.83 69.24 78.95 }
|
||||
2026-10-17T17:35:20Z thinkpad: 0.72 5.54 7.71 | macmini (bots): { 66.16 68.07 78.19 }
|
||||
2026-10-17T17:35:50Z thinkpad: 0.44 5.01 7.47 | macmini (bots): { 70.96 68.91 78.13 }
|
||||
2026-10-17T17:36:20Z thinkpad: 1.70 4.79 7.30 | macmini (bots): { 79.74 71.45 78.74 }
|
||||
2026-10-17T17:36:50Z thinkpad: 3.03 4.80 7.22 | macmini (bots): { 79.71 72.75 78.95 }
|
||||
2026-10-17T17:37:21Z thinkpad: 3.07 4.67 7.10 | macmini (bots): { 85.13 74.60 79.40 }
|
||||
2026-10-17T17:37:51Z thinkpad: 2.32 4.33 6.91 | macmini (bots): { 84.85 75.70 79.64 }
|
||||
2026-10-17T17:38:21Z thinkpad: 1.83 4.03 6.73 | macmini (bots): { 86.47 76.79 79.88 }
|
||||
2026-10-17T17:38:51Z thinkpad: 1.11 3.64 6.51 | macmini (bots): { 106.96 82.74 81.94 }
|
||||
2026-10-17T17:39:22Z thinkpad: 1.03 3.37 6.33 | macmini (bots): { 114.52 87.01 83.52 }
|
||||
2026-10-17T17:39:52Z thinkpad: 1.11 3.16 6.16 | macmini (bots): { 103.99 87.05 83.65 }
|
||||
2026-10-17T17:40:22Z thinkpad: 1.31 3.02 6.02 | macmini (bots): { 100.27 87.59 83.95 }
|
||||
2026-10-17T17:40:52Z thinkpad: 1.36 2.87 5.87 | macmini (bots): { 98.17 88.44 84.40 }
|
||||
2026-10-17T17:41:22Z thinkpad: 2.01 2.88 5.78 | macmini (bots): { 96.27 88.67 84.60 }
|
||||
2026-10-17T17:41:53Z thinkpad: 1.96 2.77 5.65 | macmini (bots): { 96.31 89.44 85.03 }
|
||||
2026-10-17T17:42:23Z thinkpad: 3.06 2.97 5.62 | macmini (bots): { 80.49 86.30 84.04 }
|
||||
2026-10-17T17:42:53Z thinkpad: 2.38 2.81 5.49 | macmini (bots): { 66.40 82.24 82.64 }
|
||||
2026-10-17T17:43:24Z thinkpad: 2.44 2.81 5.40 | macmini (bots): { 56.70 78.37 81.22 }
|
||||
2026-10-17T17:43:54Z thinkpad: 1.93 2.65 5.26 | macmini (bots): { 61.56 77.39 80.77 }
|
||||
2026-10-17T17:44:24Z thinkpad: 1.96 2.59 5.16 | macmini (bots): { 58.25 75.14 79.83 }
|
||||
2026-10-17T17:44:54Z thinkpad: 2.25 2.60 5.08 | macmini (bots): { 56.32 73.21 78.98 }
|
||||
2026-10-17T17:45:24Z thinkpad: 1.79 2.46 4.95 | macmini (bots): { 62.87 73.14 78.75 }
|
||||
2026-10-17T17:45:55Z thinkpad: 2.78 2.64 4.93 | macmini (bots): { 62.58 72.22 78.23 }
|
||||
2026-10-17T17:46:25Z thinkpad: 4.82 3.14 5.03 | macmini (bots): { 58.89 70.44 77.38 }
|
||||
2026-10-17T17:46:55Z thinkpad: 3.10 2.90 4.88 | macmini (bots): { 61.68 70.08 77.01 }
|
||||
2026-10-17T17:47:25Z thinkpad: 2.27 2.71 4.75 | macmini (bots): { 70.43 71.40 77.25 }
|
||||
2026-10-17T17:47:56Z thinkpad: 1.71 2.53 4.62 | macmini (bots): { 70.51 71.56 77.12 }
|
||||
2026-10-17T17:48:26Z thinkpad: 1.59 2.42 4.52 | macmini (bots): { 68.98 70.98 76.71 }
|
||||
2026-10-17T17:48:56Z thinkpad: 2.09 2.47 4.47 | macmini (bots): { 67.75 70.60 76.38 }
|
||||
2026-10-17T17:49:26Z thinkpad: 2.14 2.44 4.39 | macmini (bots): { 61.53 69.01 75.58 }
|
||||
2026-10-17T17:49:56Z thinkpad: 2.22 2.43 4.33 | macmini (bots): { 58.49 67.45 74.78 }
|
||||
2026-10-17T17:50:27Z thinkpad: 1.98 2.36 4.24 | macmini (bots): { 66.15 68.33 74.84 }
|
||||
2026-10-17T17:50:57Z thinkpad: 2.38 2.42 4.20 | macmini (bots): { 61.65 67.22 74.22 }
|
||||
2026-10-17T17:51:27Z thinkpad: 3.62 2.72 4.24 | macmini (bots): { 54.05 64.88 73.13 }
|
||||
2026-10-17T17:51:57Z thinkpad: 3.25 2.70 4.19 | macmini (bots): { 49.14 62.62 72.02 }
|
||||
2026-10-17T17:52:28Z thinkpad: 2.45 2.55 4.09 | macmini (bots): { 43.72 60.06 70.77 }
|
||||
2026-10-17T17:52:58Z thinkpad: 2.85 2.62 4.06 | macmini (bots): { 42.53 58.22 69.73 }
|
||||
2026-10-17T17:53:28Z thinkpad: 8.44 3.97 4.46 | macmini (bots): { 45.21 57.34 69.01 }
|
||||
2026-10-17T17:53:58Z thinkpad: 10.75 4.91 4.76 | macmini (bots): { 44.86 56.01 68.11 }
|
||||
2026-10-17T17:54:29Z thinkpad: 8.36 4.90 4.76 | macmini (bots): { 51.73 56.60 67.91 }
|
||||
2026-10-17T17:54:59Z thinkpad: 7.41 4.98 4.79 | macmini (bots): { 55.80 57.05 67.67 }
|
||||
2026-10-17T17:55:29Z thinkpad: 5.45 4.73 4.71 | macmini (bots): { 50.40 55.67 66.81 }
|
||||
2026-10-17T17:55:59Z thinkpad: 6.26 4.97 4.79 | macmini (bots): { 57.22 56.88 66.87 }
|
||||
2026-10-17T17:56:30Z thinkpad: 8.22 5.52 4.98 | macmini (bots): { 48.94 54.86 65.78 }
|
||||
2026-10-17T17:57:00Z thinkpad: 6.71 5.42 4.96 | macmini (bots): { 50.54 54.70 65.35 }
|
||||
2026-10-17T17:57:30Z thinkpad: 4.94 5.14 4.89 | macmini (bots): { 58.02 56.25 65.55 }
|
||||
2026-10-17T17:58:01Z thinkpad: 4.54 5.04 4.86 | macmini (bots): { 53.98 55.38 64.90 }
|
||||
2026-10-17T17:58:31Z thinkpad: 4.65 5.04 4.87 | macmini (bots): { 49.96 54.23 64.16 }
|
||||
2026-10-17T17:59:01Z thinkpad: 5.29 5.16 4.92 | macmini (bots): { 49.63 53.68 63.61 }
|
||||
2026-10-17T17:59:31Z thinkpad: 4.23 4.94 4.85 | macmini (bots): { 57.15 55.10 63.78 }
|
||||
2026-10-17T18:00:01Z thinkpad: 3.57 4.72 4.78 | macmini (bots): { 61.70 56.26 63.89 }
|
||||
2026-10-17T18:00:32Z thinkpad: 3.16 4.52 4.71 | macmini (bots): { 62.10 56.86 63.79 }
|
||||
2026-10-17T18:01:02Z thinkpad: 2.81 4.29 4.63 | macmini (bots): { 76.29 60.79 64.98 }
|
||||
2026-10-17T18:01:32Z thinkpad: 7.77 5.26 4.93 | macmini (bots): { 75.36 62.16 65.34 }
|
||||
2026-10-17T18:02:02Z thinkpad: 11.60 6.45 5.34 | macmini (bots): { 70.83 62.28 65.27 }
|
||||
2026-10-17T18:02:33Z thinkpad: 11.54 6.97 5.56 | macmini (bots): { 65.66 61.87 65.01 }
|
||||
2026-10-17T18:03:03Z thinkpad: 10.80 7.33 5.73 | macmini (bots): { 63.09 61.54 64.78 }
|
||||
2026-10-17T18:03:33Z thinkpad: 10.52 7.56 5.85 | macmini (bots): { 60.31 61.12 64.52 }
|
||||
2026-10-17T18:04:04Z thinkpad: 10.27 7.76 5.97 | macmini (bots): { 60.99 61.21 64.44 }
|
||||
2026-10-17T18:04:34Z thinkpad: 8.77 7.69 6.01 | macmini (bots): { 56.72 60.19 63.96 }
|
||||
2026-10-17T18:05:05Z thinkpad: 5.77 7.06 5.85 | macmini (bots): { 56.14 59.70 63.65 }
|
||||
2026-10-17T18:05:35Z thinkpad: 5.16 6.79 5.80 | macmini (bots): { 51.25 58.17 62.95 }
|
||||
2026-10-17T18:06:05Z thinkpad: 4.16 6.36 5.69 | macmini (bots): { 52.03 57.64 62.59 }
|
||||
2026-10-17T18:06:35Z thinkpad: 4.28 6.18 5.65 | macmini (bots): { 64.66 60.00 63.27 }
|
||||
2026-10-17T18:07:06Z thinkpad: 7.47 6.76 5.86 | macmini (bots): { 56.20 58.45 62.59 }
|
||||
2026-10-17T18:07:36Z thinkpad: 6.95 6.74 5.89 | macmini (bots): { 51.92 57.20 62.00 }
|
||||
2026-10-17T18:08:06Z thinkpad: 7.70 6.99 6.00 | macmini (bots): { 51.14 56.65 61.64 }
|
||||
2026-10-17T18:08:36Z thinkpad: 5.83 6.60 5.90 | macmini (bots): { 48.08 55.33 60.98 }
|
||||
2026-10-17T18:09:07Z thinkpad: 4.89 6.30 5.82 | macmini (bots): { 53.80 56.00 61.00 }
|
||||
2026-10-17T18:09:37Z thinkpad: 4.93 6.19 5.80 | macmini (bots): { 60.49 57.24 61.27 }
|
||||
2026-10-17T18:10:07Z thinkpad: 4.61 5.98 5.74 | macmini (bots): { 58.95 57.21 61.12 }
|
||||
2026-10-17T18:10:37Z thinkpad: 4.74 5.91 5.73 | macmini (bots): { 60.84 57.65 61.13 }
|
||||
2026-10-17T18:11:07Z thinkpad: 3.96 5.61 5.63 | macmini (bots): { 64.23 58.82 61.43 }
|
||||
2026-10-17T18:11:38Z thinkpad: 4.16 5.53 5.61 | macmini (bots): { 75.59 62.26 62.60 }
|
||||
2026-10-17T18:12:08Z thinkpad: 2.97 5.11 5.46 | macmini (bots): { 75.25 63.65 63.10 }
|
||||
@@ -0,0 +1,695 @@
|
||||
{
|
||||
"kind": "horizon-server-benchmark",
|
||||
"date": "2026-10-17T17:31:40+00:00",
|
||||
"machine": {
|
||||
"system": "Linux",
|
||||
"machine": "x86_64",
|
||||
"cpus": 4
|
||||
},
|
||||
"commit": "7bbbf6e97fb53683eae0a01ce2c134e53cb649a7",
|
||||
"java": [
|
||||
"openjdk 25.0.4.1 2026-08-18"
|
||||
],
|
||||
"settings": {
|
||||
"servers": "horizon,vanilla",
|
||||
"bots": "1,10,25,50,100",
|
||||
"behaviour": "walk,explore",
|
||||
"join_every": 1.0,
|
||||
"warmup": 60,
|
||||
"seconds": 180,
|
||||
"seed": 12345,
|
||||
"explore_speed": 0,
|
||||
"gamerule": "",
|
||||
"heap": "4g",
|
||||
"out": "/home/ludovic/code/horizon/.claude/worktrees/server-host-4/.local/bench-out/walk-explore.json",
|
||||
"nice": 19,
|
||||
"bots_via": "ssh macmini",
|
||||
"bots_bin": "/Users/ludovicmarie/code/horizon/.claude/worktrees/server-host-4/target/release/horizon-bots",
|
||||
"connect": "<server address>",
|
||||
"properties": {
|
||||
"level-seed": "12345",
|
||||
"view-distance": "10",
|
||||
"simulation-distance": "10",
|
||||
"online-mode": "false",
|
||||
"white-list": "false",
|
||||
"gamemode": "creative",
|
||||
"force-gamemode": "true",
|
||||
"spawn-protection": "0",
|
||||
"max-players": "200",
|
||||
"difficulty": "easy",
|
||||
"sync-chunk-writes": "true"
|
||||
}
|
||||
},
|
||||
"runs": [
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 1,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 9.62,
|
||||
"mspt": {
|
||||
"average": 4.01,
|
||||
"p50": 3.54,
|
||||
"p95": 8.19,
|
||||
"p99": 25.59
|
||||
},
|
||||
"mspt_worst_p99": 47.5,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 782.52,
|
||||
"max": 785.3
|
||||
},
|
||||
"cpu_percent": 58.87,
|
||||
"chunks_per_second": 9.6,
|
||||
"unique_per_second": 0.43,
|
||||
"forgotten_per_second": 9.65,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 2697,
|
||||
"chunks_per_bot": {
|
||||
"walk": 2697.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 693.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 1,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 1,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 14.85,
|
||||
"mspt": {
|
||||
"average": 18.22,
|
||||
"p50": 17.99,
|
||||
"p95": 29.34,
|
||||
"p99": 50.06
|
||||
},
|
||||
"mspt_worst_p99": 395.5,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 3429.06,
|
||||
"max": 3446.3
|
||||
},
|
||||
"cpu_percent": 68.81,
|
||||
"chunks_per_second": 10.47,
|
||||
"unique_per_second": 0.47,
|
||||
"forgotten_per_second": 10.53,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 2755,
|
||||
"chunks_per_bot": {
|
||||
"walk": 2755.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 697.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 1,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 10,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 9.21,
|
||||
"mspt": {
|
||||
"average": 8.79,
|
||||
"p50": 8.05,
|
||||
"p95": 17.35,
|
||||
"p99": 30.47
|
||||
},
|
||||
"mspt_worst_p99": 48.8,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 781.88,
|
||||
"max": 782.8
|
||||
},
|
||||
"cpu_percent": 119.16,
|
||||
"chunks_per_second": 94.63,
|
||||
"unique_per_second": 3.58,
|
||||
"forgotten_per_second": 94.31,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 26003,
|
||||
"chunks_per_bot": {
|
||||
"walk": 2600.3
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 679.9
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 10,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 10,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 15.14,
|
||||
"mspt": {
|
||||
"average": 18.67,
|
||||
"p50": 15.8,
|
||||
"p95": 38.26,
|
||||
"p99": 55.93
|
||||
},
|
||||
"mspt_worst_p99": 101.8,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 3497.35,
|
||||
"max": 3499.4
|
||||
},
|
||||
"cpu_percent": 100.8,
|
||||
"chunks_per_second": 101.1,
|
||||
"unique_per_second": 4.38,
|
||||
"forgotten_per_second": 101.15,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 26537,
|
||||
"chunks_per_bot": {
|
||||
"walk": 2653.7
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 687.5
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 5,
|
||||
"moved_too_quickly": 5,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 10,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 25,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 9.82,
|
||||
"mspt": {
|
||||
"average": 15.01,
|
||||
"p50": 13.12,
|
||||
"p95": 31.12,
|
||||
"p99": 50.62
|
||||
},
|
||||
"mspt_worst_p99": 101.6,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 799.52,
|
||||
"max": 799.7
|
||||
},
|
||||
"cpu_percent": 161.02,
|
||||
"chunks_per_second": 234.3,
|
||||
"unique_per_second": 8.49,
|
||||
"forgotten_per_second": 234.35,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 65926,
|
||||
"chunks_per_bot": {
|
||||
"walk": 2637.04
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 683.96
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 25,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 25,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 14.95,
|
||||
"mspt": {
|
||||
"average": 18.78,
|
||||
"p50": 17.47,
|
||||
"p95": 28.16,
|
||||
"p99": 51.03
|
||||
},
|
||||
"mspt_worst_p99": 107.6,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 3520.17,
|
||||
"max": 3573.8
|
||||
},
|
||||
"cpu_percent": 154.9,
|
||||
"chunks_per_second": 252.39,
|
||||
"unique_per_second": 11.89,
|
||||
"forgotten_per_second": 251.75,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 66469,
|
||||
"chunks_per_bot": {
|
||||
"walk": 2658.76
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 689.68
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 10,
|
||||
"moved_too_quickly": 10,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 25,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 50,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 9.52,
|
||||
"mspt": {
|
||||
"average": 22.72,
|
||||
"p50": 16.98,
|
||||
"p95": 60.19,
|
||||
"p99": 84.16
|
||||
},
|
||||
"mspt_worst_p99": 105.4,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 813.5,
|
||||
"max": 815.8
|
||||
},
|
||||
"cpu_percent": 179.85,
|
||||
"chunks_per_second": 464.75,
|
||||
"unique_per_second": 16.94,
|
||||
"forgotten_per_second": 464.69,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 134839,
|
||||
"chunks_per_bot": {
|
||||
"walk": 2696.78
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 685.48
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 50,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 50,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 16.36,
|
||||
"mspt": {
|
||||
"average": 34.3,
|
||||
"p50": 31.07,
|
||||
"p95": 55.55,
|
||||
"p99": 92.32
|
||||
},
|
||||
"mspt_worst_p99": 452.1,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 3577.02,
|
||||
"max": 3611.2
|
||||
},
|
||||
"cpu_percent": 267.05,
|
||||
"chunks_per_second": 496.77,
|
||||
"unique_per_second": 26.74,
|
||||
"forgotten_per_second": 496.2,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 133505,
|
||||
"chunks_per_bot": {
|
||||
"walk": 2670.1
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 688.8
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 39,
|
||||
"moved_too_quickly": 39,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 50,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 100,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 9.51,
|
||||
"mspt": {
|
||||
"average": 63.33,
|
||||
"p50": 58.89,
|
||||
"p95": 125.04,
|
||||
"p99": 175.12
|
||||
},
|
||||
"mspt_worst_p99": 261.5,
|
||||
"tps": 15.79,
|
||||
"rss_mb": {
|
||||
"mean": 868.12,
|
||||
"max": 869.9
|
||||
},
|
||||
"cpu_percent": 245.64,
|
||||
"chunks_per_second": 678.18,
|
||||
"unique_per_second": 31.8,
|
||||
"forgotten_per_second": 676.4,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 219787,
|
||||
"chunks_per_bot": {
|
||||
"walk": 2197.87
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 637.91
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 100,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 34
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 100,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 15.14,
|
||||
"error": "[Errno 32] Broken pipe"
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 1,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 9.72,
|
||||
"mspt": {
|
||||
"average": 9.73,
|
||||
"p50": 8.75,
|
||||
"p95": 20.99,
|
||||
"p99": 34.17
|
||||
},
|
||||
"mspt_worst_p99": 73.9,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 836.18,
|
||||
"max": 891.0
|
||||
},
|
||||
"cpu_percent": 329.64,
|
||||
"chunks_per_second": 41.11,
|
||||
"unique_per_second": 35.96,
|
||||
"forgotten_per_second": 41.08,
|
||||
"waited_ticks_per_second": 0.16,
|
||||
"chunks_total": 9460,
|
||||
"chunks_per_bot": {
|
||||
"explore": 9460.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 8479.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 1,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 1,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 15.54,
|
||||
"mspt": {
|
||||
"average": 9.71,
|
||||
"p50": 7.86,
|
||||
"p95": 20.37,
|
||||
"p99": 76.15
|
||||
},
|
||||
"mspt_worst_p99": 388.6,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 3614.97,
|
||||
"max": 3688.5
|
||||
},
|
||||
"cpu_percent": 247.15,
|
||||
"chunks_per_second": 35.47,
|
||||
"unique_per_second": 33.61,
|
||||
"forgotten_per_second": 35.45,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 7912,
|
||||
"chunks_per_bot": {
|
||||
"explore": 7912.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 7580.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 1,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 10,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 9.31,
|
||||
"mspt": {
|
||||
"average": 21.27,
|
||||
"p50": 19.5,
|
||||
"p95": 39.15,
|
||||
"p99": 54.23
|
||||
},
|
||||
"mspt_worst_p99": 105.5,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 1177.66,
|
||||
"max": 1392.9
|
||||
},
|
||||
"cpu_percent": 321.97,
|
||||
"chunks_per_second": 46.47,
|
||||
"unique_per_second": 46.11,
|
||||
"forgotten_per_second": 43.12,
|
||||
"waited_ticks_per_second": 111.25,
|
||||
"chunks_total": 14103,
|
||||
"chunks_per_bot": {
|
||||
"explore": 1410.3
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 1402.4
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 9856,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 10,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 10,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 15.24,
|
||||
"mspt": {
|
||||
"average": 16.94,
|
||||
"p50": 14.24,
|
||||
"p95": 30.28,
|
||||
"p99": 109.7
|
||||
},
|
||||
"mspt_worst_p99": 1861.2,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 3953.29,
|
||||
"max": 4232.0
|
||||
},
|
||||
"cpu_percent": 254.11,
|
||||
"chunks_per_second": 24.99,
|
||||
"unique_per_second": 25.01,
|
||||
"forgotten_per_second": 25.33,
|
||||
"waited_ticks_per_second": 77.93,
|
||||
"chunks_total": 7707,
|
||||
"chunks_per_bot": {
|
||||
"explore": 770.7
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 769.5
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 11123,
|
||||
"moved_too_quickly": 20,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 10,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 25,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 9.92,
|
||||
"mspt": {
|
||||
"average": 25.76,
|
||||
"p50": 23.58,
|
||||
"p95": 45.01,
|
||||
"p99": 61.43
|
||||
},
|
||||
"mspt_worst_p99": 108.8,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 1172.1,
|
||||
"max": 1482.3
|
||||
},
|
||||
"cpu_percent": 314.47,
|
||||
"chunks_per_second": 61.38,
|
||||
"unique_per_second": 60.7,
|
||||
"forgotten_per_second": 55.11,
|
||||
"waited_ticks_per_second": 304.54,
|
||||
"chunks_total": 24085,
|
||||
"chunks_per_bot": {
|
||||
"explore": 963.4
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 954.76
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 31264,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 25,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 25,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 15.76,
|
||||
"mspt": {
|
||||
"average": 24.93,
|
||||
"p50": 22.04,
|
||||
"p95": 37.09,
|
||||
"p99": 168.46
|
||||
},
|
||||
"mspt_worst_p99": 1724.8,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 4040.42,
|
||||
"max": 4447.6
|
||||
},
|
||||
"cpu_percent": 305.47,
|
||||
"chunks_per_second": 37.91,
|
||||
"unique_per_second": 37.84,
|
||||
"forgotten_per_second": 38.39,
|
||||
"waited_ticks_per_second": 258.66,
|
||||
"chunks_total": 16865,
|
||||
"chunks_per_bot": {
|
||||
"explore": 674.6
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 671.28
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 33467,
|
||||
"moved_too_quickly": 23,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 25,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 50,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 9.41,
|
||||
"mspt": {
|
||||
"average": 39.31,
|
||||
"p50": 36.98,
|
||||
"p95": 65.33,
|
||||
"p99": 88.36
|
||||
},
|
||||
"mspt_worst_p99": 157.6,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 1250.32,
|
||||
"max": 1517.1
|
||||
},
|
||||
"cpu_percent": 324.77,
|
||||
"chunks_per_second": 114.99,
|
||||
"unique_per_second": 112.25,
|
||||
"forgotten_per_second": 104.41,
|
||||
"waited_ticks_per_second": 543.77,
|
||||
"chunks_total": 50402,
|
||||
"chunks_per_bot": {
|
||||
"explore": 1008.04
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 997.42
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 64163,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 50,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 50,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 14.55,
|
||||
"error": "[Errno 32] Broken pipe"
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 100,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 9.31,
|
||||
"mspt": {
|
||||
"average": 52.7,
|
||||
"p50": 49.49,
|
||||
"p95": 85.73,
|
||||
"p99": 115.34
|
||||
},
|
||||
"mspt_worst_p99": 219.0,
|
||||
"tps": 18.97,
|
||||
"rss_mb": {
|
||||
"mean": 1358.32,
|
||||
"max": 3047.2
|
||||
},
|
||||
"cpu_percent": 327.95,
|
||||
"chunks_per_second": 154.2,
|
||||
"unique_per_second": 151.83,
|
||||
"forgotten_per_second": 141.65,
|
||||
"waited_ticks_per_second": 1132.9,
|
||||
"chunks_total": 84433,
|
||||
"chunks_per_bot": {
|
||||
"explore": 844.33
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 839.47
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 116161,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 100,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 35
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 100,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 16.47,
|
||||
"error": "[Errno 32] Broken pipe"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
# Horizon's server against vanilla 26.3: mixed bots, equal work (2026-10-18)
|
||||
|
||||
This reruns `../server-mixed-2026-10-18/` with the bots' load made the same on both servers. In that run the
|
||||
`explore` bots flew as fast as each server sent them chunks. Horizon kept up and vanilla didn't, so Horizon
|
||||
sent 2.9 to 5.9 times as many chunks a second, and vanilla's walking bots got a fifth of the chunks Horizon's
|
||||
got. The comparison of tick times wasn't fair.
|
||||
|
||||
## Summary
|
||||
|
||||
- **The work was equal** at 1 and 10 bots. At 25 bots it was equal in chunks sent a second (158.6 against 157.6);
|
||||
vanilla was still catching up on chunks its bots hadn't had yet, see "Equal work" below. Every bot joined, none
|
||||
was dropped, no chunk was sent twice, and no bot ever waited for the chunk it stood in.
|
||||
- **Tick time (MSPT), same work:**
|
||||
- 1 bot: 1.8 ms on Horizon against 11.3 on vanilla;
|
||||
- 10 bots: 4.7 against 34.3 ms;
|
||||
- 25 bots: 11.7 against 50.5 ms. Vanilla dropped just under 20 TPS (19.8) and its worst 5-second P99 was
|
||||
1388 ms, against Horizon's 130;
|
||||
- 50 bots: Horizon ran at 23.8 ms (20 TPS). Vanilla's watchdog stopped its server 30 s after the last bot
|
||||
joined ("A single server tick took 60.01 seconds").
|
||||
- **Memory:** Horizon's RSS was 4.1 to 4.9 times smaller: 0.69 against 3.40 GB with one bot, 0.95 against
|
||||
3.87 GB with 25, and 1.03 GB with 50. Vanilla's heap is fixed at 4 GB, so its RSS is mostly that heap.
|
||||
- **CPU** (100 % is one of the machine's 4 threads): 30 against 128 % at 10 bots, 58 against 192 % at 25.
|
||||
- **What changed since the first run:** the first run's vanilla held 50 bots at 29.7 ms because its walking
|
||||
bots got 640 to 1100 chunks each. With the explore bots paced, vanilla serves the walking bots in full
|
||||
(5400 to 6100 chunks each, against Horizon's 6100 to 6600), and that is the load it can't carry at 50.
|
||||
|
||||
## Results
|
||||
|
||||
MSPT is milliseconds per tick from each server's own `/tick query` (50 is the limit for 20 TPS). P95 and P99
|
||||
are over the 300 measured seconds. "Worst P99" is the highest P99 of a single 5-second query. RSS and CPU
|
||||
cover the server's process tree. "First-time chunks/s" counts chunks a bot hadn't received before (summed
|
||||
over the bots). "Waited" is ticks a second that bots stood still waiting for their own chunk. "Pulled back"
|
||||
counts position packets that moved a bot back.
|
||||
|
||||
| Bots | Server | MSPT | P95 | P99 | Worst P99 | TPS | RSS (mean / max) | CPU | Start | Chunks/s | First-time chunks/s | Waited | Pulled back |
|
||||
|---:|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||
| 1 | Horizon | **1.77** | 4.21 | 13.04 | 47.5 | 20 | **0.69 / 0.69 GB** | 28 % | 19.7 s | 9.94 | 0.01 | 0 | 0 |
|
||||
| 1 | vanilla | 11.34 | 17.12 | 33.22 | 119.1 | 20 | 3.40 / 3.42 GB | 36 % | 20.4 s | 9.98 | 0.01 | 0 | 0 |
|
||||
| 10 | Horizon | **4.74** | 10.48 | 19.69 | 120.1 | 20 | **0.86 / 0.88 GB** | 30 % | 19.5 s | 61.35 | 1.79 | 0 | 4159 |
|
||||
| 10 | vanilla | 34.30 | 53.72 | 89.08 | 375.8 | 20 | 3.71 / 3.75 GB | 128 % | 19.9 s | 61.66 | 1.93 | 0 | 3976 |
|
||||
| 25 | Horizon | **11.72** | 24.45 | 43.79 | 129.8 | 20 | **0.95 / 1.09 GB** | 58 % | 9.6 s | 158.57 | 5.78 | 0 | 3496 |
|
||||
| 25 | vanilla | 50.45 | 74.09 | 191.21 | 1388.1 | 19.82 | 3.87 / 4.75 GB | 192 % | 37.8 s | 157.63 | 8.25 | 0 | 3629 |
|
||||
| 50 | Horizon | **23.82** | 50.98 | 74.64 | 195.9 | 20 | **1.03 / 1.20 GB** | 98 % | 14.9 s | 302.81 | 10.96 | 0 | 7565 |
|
||||
| 50 | vanilla | stopped by its watchdog | | | | | | | 11.2 s | | | | |
|
||||
|
||||
Chunks each bot received over the whole run, by behaviour (mean):
|
||||
|
||||
| Bots | Server | walk | explore | idle |
|
||||
|---:|---|---:|---:|---:|
|
||||
| 1 | Horizon | 6092 | | |
|
||||
| 1 | vanilla | 6084 | | |
|
||||
| 10 | Horizon | 6248 | 1165 | 473 |
|
||||
| 10 | vanilla | 5729 | 1124 | 473 |
|
||||
| 25 | Horizon | 6629 | 1267 | 473 |
|
||||
| 25 | vanilla | 5387 | 1119 | 473 |
|
||||
| 50 | Horizon | 6497 | 1256 | 473 |
|
||||
|
||||
Both servers pulled bots back about as often (`moved too quickly` 20 and 296 times on vanilla at 10 and 25
|
||||
bots).
|
||||
|
||||
## Equal work
|
||||
|
||||
`tools/server_bench.py` now prints a work table after the runs. At each bot count it compares every server's
|
||||
chunks sent a second and first-time chunks a second with the busiest server's. Work counts as equal when
|
||||
both are within 10 % (or within a chunk a second when there are few). The report keeps the table as `work`.
|
||||
|
||||
| Bots | Chunks/s (Horizon, vanilla) | First-time chunks/s (Horizon, vanilla) | Equal |
|
||||
|---:|---|---|---|
|
||||
| 1 | 9.94, 9.98 | 0.01, 0.01 | yes |
|
||||
| 10 | 61.35, 61.66 | 1.79, 1.93 | yes |
|
||||
| 25 | 158.57, 157.63 | 5.78, 8.25 | no: vanilla sent 2.5 more first-time chunks a second |
|
||||
|
||||
Two settings made the load the same:
|
||||
|
||||
- **Explore bots paced at 0.8 blocks a second** (`--explore-speed 0.8`), the rate the 2026-10-17 run showed
|
||||
both servers keep up with. Every bot then follows the same path on both servers. The path is deterministic
|
||||
per bot, and a bot only stops while the chunk it stands in hasn't arrived, which never happened here
|
||||
(`Waited` 0). So both servers had the same chunks to send.
|
||||
- **A 300-second warm-up** before the 300 measured seconds. A first pass with the old 60-second warm-up
|
||||
(aborted after 10 bots) sent the same chunks a second on both (63.1 against 61.4). But vanilla was still
|
||||
delivering the bots' first views during the measurement (12.9 first-time chunks a second against Horizon's
|
||||
4.1), so its window held more work than Horizon's. With 300 seconds both were in steady state at 1 and 10
|
||||
bots. At 25, vanilla still sent 2.5 more first-time chunks a second than Horizon: 1.6 % of all it sent, and
|
||||
its walking bots ended the run with 19 % fewer chunks. Read the 25-bot tick times with that in mind: the
|
||||
extra first-time chunks are work vanilla had still to do because it fell behind.
|
||||
|
||||
## Method
|
||||
|
||||
`tools/server_bench.py` ran each configuration on a fresh server:
|
||||
|
||||
- the same `server.properties` on both: seed 12345, view and simulation distance 10, offline, creative,
|
||||
difficulty easy, `sync-chunk-writes=true`, white-list off;
|
||||
- `gamerule respawn_radius 0`, and otherwise vanilla's default game rules (the locator bar on for both);
|
||||
- the server waits for `Done`, the bots join one a second, warm up for 300 seconds and are measured for 300,
|
||||
with `/tick query` every 5 seconds and RSS and CPU every second.
|
||||
|
||||
The bots are `horizon-bots --behaviour mixed`: 60 % walk, 20 % explore and 20 % idle, each choice deterministic
|
||||
per bot. All are creative and fly 24 blocks above spawn. `walk` flies to random points within 48 blocks at
|
||||
5.6 blocks a second, `explore` flies straight out at 0.8 blocks a second, and `idle` hovers.
|
||||
|
||||
**Machine.** The ThinkPad: Intel Core i7-7500U (2 cores, 4 threads), 32 GB, NixOS. The desktop (24 threads)
|
||||
was meant to run it, but it didn't wake (`~/fleet/power wake desktop` timed out, as it did for the first run).
|
||||
No other session ran on the ThinkPad during the runs (`load.txt`, a sample a minute). The servers ran under
|
||||
`nice -n 19`. The bots ran on the Mac mini (M4 Pro, shared with other work at 70 to 90 % CPU) and reached the
|
||||
ThinkPad over the tailnet.
|
||||
|
||||
**What ran.**
|
||||
|
||||
- Horizon: the server build from #916 (`horizon-client --no-default-features`, no sound or controllers) at
|
||||
519ec121, packaged with `xtask package-server` and installed with `install.sh --archive`, as Linux owners
|
||||
will get it. `horizon-server` ran it as `horizon-engine`. The game files were prepared from the owner's own
|
||||
Minecraft. `horizon-bots` and `server_bench.py` came from this PR.
|
||||
- Vanilla: the pinned 26.3 server jar (the owner's own copy) on OpenJDK 25.0.4.1, `-Xms4g -Xmx4g`.
|
||||
|
||||
`server_bench.py` stopped after vanilla's 50-bot server crashed and before it wrote its report.
|
||||
`mixed-equal.json` holds the run lines it printed. The vanilla 50 entry and its error come from that server's
|
||||
log, and the `work` table was computed with `server_bench.work`. `load.txt` is the fleet monitor's view of the
|
||||
machines during the runs.
|
||||
|
||||
## Reproduce
|
||||
|
||||
curl -fsSL https://raw.githubusercontent.com/ludovic111/horizon/main/packaging/server/install.sh | sh
|
||||
horizon-prepare --prepare --server-jar /path/to/server.jar # your own copy
|
||||
cargo build --release -p horizon-bots # on the machine that runs the bots
|
||||
# in a checkout whose .local/minecraft holds the pinned vanilla jars (tools/lab.py prepare):
|
||||
HORIZON_BENCH_BIN=~/.local/share/horizon/current \
|
||||
python3 tools/server_bench.py --servers horizon,vanilla --bots 1,10,25,50 --behaviour mixed \
|
||||
--explore-speed 0.8 --warmup 300 --nice 19 \
|
||||
--bots-via 'ssh <bots machine>' --bots-bin /path/to/horizon-bots --connect <this machine's address> \
|
||||
--out mixed-equal.json
|
||||
|
||||
## Next
|
||||
|
||||
- One pass per configuration ran. Vanilla at 50 bots, in particular, needs a second pass, ideally on the
|
||||
desktop (24 threads), where neither server is CPU-bound.
|
||||
- 100 bots, and Paper and Fabric + Lithium (docs/server.md section 9).
|
||||
- A longer warm-up, or measuring from when the first-time chunk rate settles, so the 25-bot work is equal too.
|
||||
@@ -0,0 +1,702 @@
|
||||
2026-10-18T19:32:39Z
|
||||
ludothinkpad ok: 27.2 GB free, 4 threads 17% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 25.9 GB free, 14 threads 73% busy, 1 sessions
|
||||
macbook offline
|
||||
RAM 27.0 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 42% busy (agents 0.0, rest 1.6 cores) · 80°C · load 5.9
|
||||
· disk 289.5 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T19:33:44Z
|
||||
ludothinkpad ok: 26.7 GB free, 4 threads 19% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.7 GB free, 14 threads 93% busy, 1 sessions
|
||||
macbook offline
|
||||
RAM 26.7 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 34% busy (agents 0.0, rest 1.7 cores) · 81°C · load 4.0
|
||||
· disk 289.5 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T19:34:48Z
|
||||
ludothinkpad ok: 26.4 GB free, 4 threads 56% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.9 GB free, 14 threads 90% busy, 1 sessions
|
||||
macbook offline
|
||||
RAM 26.4 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 54% busy (agents 0.0, rest 2.2 cores) · 79°C · load 2.8
|
||||
· disk 289.5 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T19:35:52Z
|
||||
ludothinkpad ok: 26.7 GB free, 4 threads 27% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 25.4 GB free, 14 threads 85% busy, 1 sessions
|
||||
macbook offline
|
||||
RAM 26.6 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 30% busy (agents 0.0, rest 1.1 cores) · 82°C · load 2.2
|
||||
· disk 289.5 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T19:36:57Z
|
||||
ludothinkpad ok: 26.7 GB free, 4 threads 25% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 25.4 GB free, 14 threads 93% busy, 1 sessions
|
||||
macbook offline
|
||||
RAM 26.7 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 37% busy (agents 0.0, rest 1.2 cores) · 80°C · load 1.5
|
||||
· disk 289.5 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T19:38:01Z
|
||||
ludothinkpad ok: 26.6 GB free, 4 threads 54% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 26.7 GB free, 14 threads 64% busy, 1 sessions
|
||||
macbook offline
|
||||
RAM 26.6 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 21% busy (agents 0.0, rest 1.2 cores) · 81°C · load 1.9
|
||||
· disk 289.5 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T19:39:05Z
|
||||
ludothinkpad ok: 26.7 GB free, 4 threads 20% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 26.1 GB free, 14 threads 70% busy, 1 sessions
|
||||
macbook offline
|
||||
RAM 26.7 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 28% busy (agents 0.0, rest 1.0 cores) · 81°C · load 1.3
|
||||
· disk 289.5 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T19:40:10Z
|
||||
ludothinkpad ok: 26.7 GB free, 4 threads 39% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 29.2 GB free, 14 threads 73% busy, 1 sessions
|
||||
macbook offline
|
||||
RAM 26.6 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 32% busy (agents 0.0, rest 1.7 cores) · 80°C · load 0.9
|
||||
· disk 289.5 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T19:41:14Z
|
||||
ludothinkpad ok: 26.7 GB free, 4 threads 31% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 28.8 GB free, 14 threads 77% busy, 1 sessions
|
||||
macbook offline
|
||||
RAM 26.7 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 15% busy (agents 0.0, rest 1.1 cores) · 82°C · load 1.3
|
||||
· disk 289.5 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T19:42:19Z
|
||||
ludothinkpad ok: 26.5 GB free, 4 threads 26% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.1 GB free, 14 threads 94% busy, 1 sessions
|
||||
macbook offline
|
||||
RAM 26.2 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 30% busy (agents 0.0, rest 1.3 cores) · 78°C · load 1.1
|
||||
· disk 289.6 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T19:43:23Z
|
||||
ludothinkpad busy: 26.7 GB free, 4 threads 99% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 28.2 GB free, 14 threads 83% busy, 1 sessions
|
||||
macbook offline
|
||||
RAM 26.3 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 99% busy (agents 0.0, rest 3.8 cores) · 86°C · load 2.4
|
||||
· disk 289.6 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T19:44:27Z
|
||||
ludothinkpad busy: 25.2 GB free, 4 threads 98% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 29.0 GB free, 14 threads 73% busy, 1 sessions
|
||||
macbook offline
|
||||
RAM 24.9 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 83% busy (agents 0.0, rest 3.7 cores) · 83°C · load 5.5
|
||||
· disk 289.5 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T19:45:31Z
|
||||
ludothinkpad ok: 24.5 GB free, 4 threads 39% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 27.0 GB free, 14 threads 93% busy, 1 sessions
|
||||
macbook offline
|
||||
RAM 24.6 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 34% busy (agents 0.0, rest 1.5 cores) · 84°C · load 3.5
|
||||
· disk 289.5 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T19:46:35Z
|
||||
ludothinkpad ok: 23.7 GB free, 4 threads 69% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.2 GB free, 14 threads 90% busy, 1 sessions
|
||||
macbook offline
|
||||
RAM 23.8 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 75% busy (agents 0.0, rest 2.8 cores) · 87°C · load 2.7
|
||||
· disk 289.5 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T19:47:40Z
|
||||
ludothinkpad ok: 23.8 GB free, 4 threads 66% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 25.9 GB free, 14 threads 85% busy, 1 sessions
|
||||
macbook offline
|
||||
RAM 23.8 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 59% busy (agents 0.0, rest 2.5 cores) · 83°C · load 2.9
|
||||
· disk 291.7 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T19:48:44Z
|
||||
ludothinkpad ok: 24.2 GB free, 4 threads 61% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 29.0 GB free, 14 threads 90% busy, 1 sessions
|
||||
macbook offline
|
||||
RAM 24.3 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 62% busy (agents 0.0, rest 2.5 cores) · 84°C · load 3.2
|
||||
· disk 291.6 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T19:49:48Z
|
||||
ludothinkpad ok: 25.1 GB free, 4 threads 59% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 31.1 GB free, 14 threads 81% busy, 1 sessions
|
||||
macbook offline
|
||||
RAM 25.1 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 57% busy (agents 0.0, rest 2.4 cores) · 85°C · load 2.9
|
||||
· disk 291.6 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T19:50:52Z
|
||||
ludothinkpad ok: 25.7 GB free, 4 threads 39% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 28.4 GB free, 14 threads 88% busy, 2 sessions
|
||||
macbook offline
|
||||
RAM 25.8 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 18% busy (agents 0.0, rest 1.4 cores) · 67°C · load 2.4
|
||||
· disk 291.6 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T19:51:57Z
|
||||
ludothinkpad ok: 23.7 GB free, 4 threads 40% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 27.9 GB free, 14 threads 83% busy, 1 sessions
|
||||
macbook offline
|
||||
RAM 23.7 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 23% busy (agents 0.0, rest 1.4 cores) · 81°C · load 1.8
|
||||
· disk 291.6 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T19:53:01Z
|
||||
ludothinkpad ok: 25.3 GB free, 4 threads 25% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 27.1 GB free, 14 threads 84% busy, 1 sessions
|
||||
macbook offline
|
||||
RAM 25.1 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 31% busy (agents 0.0, rest 1.2 cores) · 80°C · load 1.8
|
||||
· disk 291.6 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T19:54:05Z
|
||||
ludothinkpad ok: 26.5 GB free, 4 threads 75% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 27.1 GB free, 14 threads 91% busy, 1 sessions
|
||||
macbook offline
|
||||
RAM 26.5 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 69% busy (agents 0.0, rest 2.5 cores) · 78°C · load 2.3
|
||||
· disk 291.6 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T19:55:09Z
|
||||
ludothinkpad busy: 26.4 GB free, 4 threads 89% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.3 GB free, 14 threads 86% busy, 1 sessions
|
||||
macbook offline
|
||||
RAM 26.3 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 32% busy (agents 0.0, rest 2.4 cores) · 79°C · load 2.9
|
||||
· disk 291.6 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T19:56:14Z
|
||||
ludothinkpad ok: 26.4 GB free, 4 threads 39% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 27.1 GB free, 14 threads 68% busy, 1 sessions
|
||||
macbook offline
|
||||
RAM 26.4 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 63% busy (agents 0.0, rest 1.8 cores) · 82°C · load 2.3
|
||||
· disk 291.6 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T19:57:18Z
|
||||
ludothinkpad ok: 26.3 GB free, 4 threads 20% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 29.2 GB free, 14 threads 85% busy, 1 sessions
|
||||
macbook offline
|
||||
RAM 26.3 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 19% busy (agents 0.0, rest 1.0 cores) · 80°C · load 1.8
|
||||
· disk 291.6 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T19:58:22Z
|
||||
ludothinkpad ok: 26.3 GB free, 4 threads 28% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 27.3 GB free, 14 threads 88% busy, 1 sessions
|
||||
macbook offline
|
||||
RAM 26.1 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 93% busy (agents 0.0, rest 2.5 cores) · 80°C · load 2.0
|
||||
· disk 291.6 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T19:59:27Z
|
||||
ludothinkpad ok: 26.2 GB free, 4 threads 52% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 35.0 GB free, 14 threads 85% busy, 1 sessions
|
||||
macbook offline
|
||||
RAM 26.2 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 22% busy (agents 0.0, rest 1.8 cores) · 80°C · load 3.6
|
||||
· disk 291.4 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:00:31Z
|
||||
ludothinkpad busy: 25.8 GB free, 4 threads 87% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 33.0 GB free, 14 threads 87% busy, 1 sessions
|
||||
macbook offline
|
||||
RAM 27.5 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 51% busy (agents 0.0, rest 2.9 cores) · 79°C · load 3.6
|
||||
· disk 291.5 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:01:35Z
|
||||
ludothinkpad ok: 25.6 GB free, 4 threads 35% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 32.4 GB free, 14 threads 81% busy, 1 sessions
|
||||
macbook ok: 7.2 GB free, 12 threads 32% busy, 0 sessions
|
||||
RAM 25.6 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 34% busy (agents 0.0, rest 1.3 cores) · 81°C · load 2.5
|
||||
· disk 291.6 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:02:40Z
|
||||
ludothinkpad ok: 26.6 GB free, 4 threads 35% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 29.2 GB free, 14 threads 79% busy, 1 sessions
|
||||
macbook ok: 6.5 GB free, 12 threads 39% busy, 0 sessions
|
||||
RAM 26.5 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 33% busy (agents 0.0, rest 1.3 cores) · 81°C · load 2.7
|
||||
· disk 291.6 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:03:44Z
|
||||
ludothinkpad ok: 25.5 GB free, 4 threads 56% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 31.1 GB free, 14 threads 89% busy, 1 sessions
|
||||
macbook ok: 6.8 GB free, 12 threads 6% busy, 0 sessions
|
||||
RAM 25.2 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 66% busy (agents 0.0, rest 2.5 cores) · 87°C · load 2.5
|
||||
· disk 291.3 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:04:48Z
|
||||
ludothinkpad busy: 26.2 GB free, 4 threads 99% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 30.0 GB free, 14 threads 89% busy, 1 sessions
|
||||
macbook ok: 6.7 GB free, 12 threads 31% busy, 0 sessions
|
||||
RAM 25.8 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 84% busy (agents 0.0, rest 3.7 cores) · 85°C · load 3.4
|
||||
· disk 291.3 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:05:52Z
|
||||
ludothinkpad busy: 24.5 GB free, 4 threads 100% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 34.1 GB free, 14 threads 89% busy, 1 sessions
|
||||
macbook ok: 6.6 GB free, 12 threads 10% busy, 0 sessions
|
||||
RAM 24.6 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 100% busy (agents 0.0, rest 4.0 cores) · 83°C · load 9.0
|
||||
· disk 291.0 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:06:57Z
|
||||
ludothinkpad busy: 23.5 GB free, 4 threads 100% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 28.3 GB free, 14 threads 88% busy, 1 sessions
|
||||
macbook ok: 6.7 GB free, 12 threads 5% busy, 0 sessions
|
||||
RAM 23.6 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 99% busy (agents 0.0, rest 4.0 cores) · 82°C · load 10.1
|
||||
· disk 309.2 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:08:01Z
|
||||
ludothinkpad busy: 23.4 GB free, 4 threads 88% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 29.6 GB free, 14 threads 83% busy, 1 sessions
|
||||
macbook ok: 6.6 GB free, 12 threads 2% busy, 0 sessions
|
||||
RAM 23.4 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 77% busy (agents 0.0, rest 3.4 cores) · 84°C · load 7.5
|
||||
· disk 309.2 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:09:05Z
|
||||
ludothinkpad busy: 24.0 GB free, 4 threads 89% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 25.9 GB free, 14 threads 83% busy, 1 sessions
|
||||
macbook ok: 7.1 GB free, 12 threads 19% busy, 0 sessions
|
||||
RAM 23.9 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 91% busy (agents 0.0, rest 3.6 cores) · 82°C · load 5.9
|
||||
· disk 309.1 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:10:09Z
|
||||
ludothinkpad busy: 24.0 GB free, 4 threads 98% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 27.1 GB free, 14 threads 88% busy, 1 sessions
|
||||
macbook ok: 6.7 GB free, 12 threads 19% busy, 0 sessions
|
||||
RAM 24.0 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 92% busy (agents 0.0, rest 3.7 cores) · 84°C · load 6.6
|
||||
· disk 309.1 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:11:14Z
|
||||
ludothinkpad busy: 24.3 GB free, 4 threads 92% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 26.3 GB free, 14 threads 84% busy, 1 sessions
|
||||
macbook ok: 6.6 GB free, 12 threads 18% busy, 0 sessions
|
||||
RAM 24.4 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 99% busy (agents 0.0, rest 3.7 cores) · 85°C · load 6.5
|
||||
· disk 309.0 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:12:18Z
|
||||
ludothinkpad busy: 24.7 GB free, 4 threads 89% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 28.6 GB free, 14 threads 78% busy, 1 sessions
|
||||
macbook ok: 7.3 GB free, 12 threads 20% busy, 0 sessions
|
||||
RAM 24.7 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 82% busy (agents 0.0, rest 3.4 cores) · 84°C · load 6.1
|
||||
· disk 309.0 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:13:22Z
|
||||
ludothinkpad ok: 23.4 GB free, 4 threads 53% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 28.1 GB free, 14 threads 77% busy, 1 sessions
|
||||
macbook ok: 7.3 GB free, 12 threads 20% busy, 0 sessions
|
||||
RAM 23.4 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 48% busy (agents 0.0, rest 2.4 cores) · 80°C · load 4.2
|
||||
· disk 309.0 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:14:27Z
|
||||
ludothinkpad ok: 24.0 GB free, 4 threads 44% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 28.0 GB free, 14 threads 71% busy, 1 sessions
|
||||
macbook ok: 6.9 GB free, 12 threads 2% busy, 0 sessions
|
||||
RAM 24.1 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 23% busy (agents 0.0, rest 2.0 cores) · 77°C · load 4.3
|
||||
· disk 309.0 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:15:31Z
|
||||
ludothinkpad ok: 22.7 GB free, 4 threads 31% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 27.2 GB free, 14 threads 86% busy, 1 sessions
|
||||
macbook ok: 6.9 GB free, 12 threads 19% busy, 0 sessions
|
||||
RAM 22.5 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 30% busy (agents 0.0, rest 1.3 cores) · 77°C · load 2.6
|
||||
· disk 309.1 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:16:35Z
|
||||
ludothinkpad ok: 26.8 GB free, 4 threads 37% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 28.6 GB free, 14 threads 82% busy, 1 sessions
|
||||
macbook ok: 6.8 GB free, 12 threads 9% busy, 0 sessions
|
||||
RAM 26.8 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 42% busy (agents 0.0, rest 1.7 cores) · 81°C · load 2.3
|
||||
· disk 309.1 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:17:39Z
|
||||
ludothinkpad ok: 26.3 GB free, 4 threads 63% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 26.8 GB free, 14 threads 78% busy, 1 sessions
|
||||
macbook ok: 7.0 GB free, 12 threads 19% busy, 0 sessions
|
||||
RAM 26.4 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 64% busy (agents 0.0, rest 2.6 cores) · 87°C · load 1.9
|
||||
· disk 309.0 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:18:43Z
|
||||
ludothinkpad ok: 26.9 GB free, 4 threads 37% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 32.2 GB free, 14 threads 87% busy, 1 sessions
|
||||
macbook ok: 7.0 GB free, 12 threads 10% busy, 0 sessions
|
||||
RAM 26.9 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 56% busy (agents 0.0, rest 1.9 cores) · 84°C · load 3.9
|
||||
· disk 309.0 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:19:48Z
|
||||
ludothinkpad ok: 26.0 GB free, 4 threads 77% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 29.2 GB free, 14 threads 83% busy, 1 sessions
|
||||
macbook ok: 7.0 GB free, 12 threads 10% busy, 0 sessions
|
||||
RAM 26.0 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 66% busy (agents 0.0, rest 2.9 cores) · 86°C · load 3.2
|
||||
· disk 309.0 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:20:52Z
|
||||
ludothinkpad ok: 25.9 GB free, 4 threads 80% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 26.8 GB free, 14 threads 84% busy, 1 sessions
|
||||
macbook ok: 7.0 GB free, 12 threads 17% busy, 0 sessions
|
||||
RAM 25.9 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 68% busy (agents 0.0, rest 3.2 cores) · 86°C · load 4.4
|
||||
· disk 309.0 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:21:56Z
|
||||
ludothinkpad ok: 26.3 GB free, 4 threads 63% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 26.3 GB free, 14 threads 77% busy, 1 sessions
|
||||
macbook ok: 6.8 GB free, 12 threads 14% busy, 0 sessions
|
||||
RAM 26.3 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 66% busy (agents 0.0, rest 2.8 cores) · 84°C · load 3.1
|
||||
· disk 309.0 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:23:00Z
|
||||
ludothinkpad ok: 26.8 GB free, 4 threads 74% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 26.1 GB free, 14 threads 82% busy, 1 sessions
|
||||
macbook ok: 6.7 GB free, 12 threads 11% busy, 0 sessions
|
||||
RAM 26.7 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 65% busy (agents 0.0, rest 2.8 cores) · 84°C · load 5.0
|
||||
· disk 308.9 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:24:05Z
|
||||
ludothinkpad ok: 26.9 GB free, 4 threads 64% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 28.3 GB free, 14 threads 90% busy, 1 sessions
|
||||
macbook ok: 6.8 GB free, 12 threads 8% busy, 0 sessions
|
||||
RAM 26.9 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 64% busy (agents 0.0, rest 2.7 cores) · 86°C · load 4.9
|
||||
· disk 308.9 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:25:09Z
|
||||
ludothinkpad ok: 26.0 GB free, 4 threads 56% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.2 GB free, 14 threads 88% busy, 1 sessions
|
||||
macbook ok: 6.7 GB free, 12 threads 12% busy, 0 sessions
|
||||
RAM 26.0 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 49% busy (agents 0.0, rest 2.1 cores) · 72°C · load 5.5
|
||||
· disk 309.0 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:26:13Z
|
||||
ludothinkpad ok: 25.8 GB free, 4 threads 47% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 27.8 GB free, 14 threads 78% busy, 1 sessions
|
||||
macbook ok: 6.7 GB free, 12 threads 8% busy, 0 sessions
|
||||
RAM 25.8 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 56% busy (agents 0.0, rest 2.1 cores) · 78°C · load 3.1
|
||||
· disk 308.9 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:27:18Z
|
||||
ludothinkpad busy: 25.5 GB free, 4 threads 94% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 26.6 GB free, 14 threads 82% busy, 1 sessions
|
||||
macbook ok: 6.7 GB free, 12 threads 17% busy, 0 sessions
|
||||
RAM 25.4 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 100% busy (agents 0.0, rest 3.9 cores) · 78°C · load 4.7
|
||||
· disk 308.9 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:28:22Z
|
||||
ludothinkpad busy: 24.6 GB free, 4 threads 100% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 26.4 GB free, 14 threads 80% busy, 1 sessions
|
||||
macbook ok: 6.7 GB free, 12 threads 8% busy, 0 sessions
|
||||
RAM 24.2 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 99% busy (agents 0.0, rest 4.0 cores) · 77°C · load 10.5
|
||||
· disk 308.9 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:29:27Z
|
||||
ludothinkpad busy: 24.3 GB free, 4 threads 100% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 29.8 GB free, 14 threads 80% busy, 1 sessions
|
||||
macbook ok: 6.7 GB free, 12 threads 8% busy, 0 sessions
|
||||
RAM 24.0 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 99% busy (agents 0.0, rest 4.0 cores) · 82°C · load 10.0
|
||||
· disk 308.9 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:30:31Z
|
||||
ludothinkpad busy: 23.7 GB free, 4 threads 91% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 30.1 GB free, 14 threads 71% busy, 1 sessions
|
||||
macbook ok: 6.7 GB free, 12 threads 9% busy, 0 sessions
|
||||
RAM 23.7 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 94% busy (agents 0.0, rest 3.8 cores) · 80°C · load 10.3
|
||||
· disk 308.9 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:31:36Z
|
||||
ludothinkpad busy: 23.6 GB free, 4 threads 93% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 28.0 GB free, 14 threads 81% busy, 1 sessions
|
||||
macbook ok: 6.6 GB free, 12 threads 14% busy, 0 sessions
|
||||
RAM 23.6 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 95% busy (agents 0.0, rest 3.7 cores) · 80°C · load 10.7
|
||||
· disk 308.9 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:32:40Z
|
||||
ludothinkpad busy: 23.6 GB free, 4 threads 94% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 26.9 GB free, 14 threads 76% busy, 1 sessions
|
||||
macbook ok: 6.8 GB free, 12 threads 22% busy, 0 sessions
|
||||
RAM 23.6 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 84% busy (agents 0.0, rest 3.5 cores) · 80°C · load 9.7
|
||||
· disk 308.9 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:33:45Z
|
||||
ludothinkpad ok: 25.2 GB free, 4 threads 51% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 27.1 GB free, 14 threads 82% busy, 1 sessions
|
||||
macbook ok: 6.7 GB free, 12 threads 17% busy, 0 sessions
|
||||
RAM 25.2 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 48% busy (agents 0.0, rest 2.1 cores) · 87°C · load 6.5
|
||||
· disk 309.0 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:34:49Z
|
||||
ludothinkpad ok: 25.1 GB free, 4 threads 56% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 28.2 GB free, 14 threads 87% busy, 1 sessions
|
||||
macbook ok: 6.7 GB free, 12 threads 17% busy, 0 sessions
|
||||
RAM 24.5 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 78% busy (agents 0.0, rest 2.7 cores) · 88°C · load 4.0
|
||||
· disk 309.0 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:35:53Z
|
||||
ludothinkpad busy: 23.6 GB free, 4 threads 86% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 27.9 GB free, 14 threads 86% busy, 1 sessions
|
||||
macbook ok: 6.7 GB free, 12 threads 17% busy, 0 sessions
|
||||
RAM 23.7 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 88% busy (agents 0.0, rest 3.3 cores) · 85°C · load 4.9
|
||||
· disk 309.0 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:36:58Z
|
||||
ludothinkpad ok: 23.6 GB free, 4 threads 75% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 29.4 GB free, 14 threads 84% busy, 1 sessions
|
||||
macbook ok: 6.5 GB free, 12 threads 18% busy, 0 sessions
|
||||
RAM 23.6 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 85% busy (agents 0.0, rest 3.2 cores) · 85°C · load 5.1
|
||||
· disk 309.0 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:38:02Z
|
||||
ludothinkpad ok: 22.6 GB free, 4 threads 55% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 26.5 GB free, 14 threads 80% busy, 1 sessions
|
||||
macbook ok: 6.7 GB free, 12 threads 10% busy, 0 sessions
|
||||
RAM 22.3 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 54% busy (agents 0.0, rest 2.2 cores) · 85°C · load 4.5
|
||||
· disk 308.9 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:39:06Z
|
||||
ludothinkpad ok: 27.2 GB free, 4 threads 55% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 29.5 GB free, 14 threads 85% busy, 1 sessions
|
||||
macbook ok: 6.7 GB free, 12 threads 9% busy, 0 sessions
|
||||
RAM 27.3 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 48% busy (agents 0.0, rest 2.2 cores) · 87°C · load 4.0
|
||||
· disk 308.9 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:40:10Z
|
||||
ludothinkpad ok: 26.2 GB free, 4 threads 72% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 27.7 GB free, 14 threads 84% busy, 1 sessions
|
||||
macbook ok: 6.6 GB free, 12 threads 19% busy, 0 sessions
|
||||
RAM 26.3 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 80% busy (agents 0.0, rest 3.1 cores) · 86°C · load 10.8
|
||||
· disk 308.9 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:41:15Z
|
||||
ludothinkpad busy: 26.4 GB free, 4 threads 86% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 28.5 GB free, 14 threads 79% busy, 1 sessions
|
||||
macbook ok: 6.6 GB free, 12 threads 10% busy, 0 sessions
|
||||
RAM 26.4 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 90% busy (agents 0.0, rest 3.4 cores) · 86°C · load 12.8
|
||||
· disk 308.9 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:42:19Z
|
||||
ludothinkpad ok: 26.2 GB free, 4 threads 82% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 26.9 GB free, 14 threads 78% busy, 1 sessions
|
||||
macbook ok: 6.6 GB free, 12 threads 11% busy, 0 sessions
|
||||
RAM 26.2 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 74% busy (agents 0.0, rest 3.3 cores) · 85°C · load 8.8
|
||||
· disk 308.9 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:43:23Z
|
||||
ludothinkpad busy: 26.7 GB free, 4 threads 93% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 27.3 GB free, 14 threads 81% busy, 1 sessions
|
||||
macbook ok: 6.6 GB free, 12 threads 10% busy, 0 sessions
|
||||
RAM 26.7 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 88% busy (agents 0.0, rest 3.4 cores) · 85°C · load 5.4
|
||||
· disk 308.9 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:44:27Z
|
||||
ludothinkpad ok: 27.0 GB free, 4 threads 82% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.9 GB free, 14 threads 87% busy, 1 sessions
|
||||
macbook ok: 6.7 GB free, 12 threads 13% busy, 0 sessions
|
||||
RAM 27.0 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 75% busy (agents 0.0, rest 3.3 cores) · 82°C · load 9.8
|
||||
· disk 308.8 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:45:32Z
|
||||
ludothinkpad ok: 27.2 GB free, 4 threads 76% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 26.8 GB free, 14 threads 80% busy, 1 sessions
|
||||
macbook ok: 6.7 GB free, 12 threads 10% busy, 0 sessions
|
||||
RAM 27.2 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 84% busy (agents 0.0, rest 3.2 cores) · 85°C · load 6.9
|
||||
· disk 308.8 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:46:36Z
|
||||
ludothinkpad busy: 27.1 GB free, 4 threads 87% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.8 GB free, 14 threads 92% busy, 1 sessions
|
||||
macbook ok: 6.7 GB free, 12 threads 9% busy, 0 sessions
|
||||
RAM 26.8 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 85% busy (agents 0.0, rest 3.2 cores) · 82°C · load 4.4
|
||||
· disk 308.8 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:47:40Z
|
||||
ludothinkpad busy: 25.3 GB free, 4 threads 86% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 26.5 GB free, 14 threads 72% busy, 1 sessions
|
||||
macbook ok: 6.7 GB free, 12 threads 9% busy, 0 sessions
|
||||
RAM 25.5 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 85% busy (agents 0.0, rest 3.4 cores) · 86°C · load 3.7
|
||||
· disk 308.8 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:48:45Z
|
||||
ludothinkpad busy: 26.1 GB free, 4 threads 100% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 28.5 GB free, 14 threads 84% busy, 1 sessions
|
||||
macbook ok: 6.7 GB free, 12 threads 15% busy, 0 sessions
|
||||
RAM 26.1 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 98% busy (agents 0.0, rest 3.9 cores) · 80°C · load 6.5
|
||||
· disk 308.8 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:49:50Z
|
||||
ludothinkpad ok: 26.8 GB free, 4 threads 71% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 27.7 GB free, 14 threads 93% busy, 1 sessions
|
||||
macbook ok: 6.7 GB free, 12 threads 10% busy, 0 sessions
|
||||
RAM 26.6 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 92% busy (agents 0.0, rest 3.4 cores) · 81°C · load 5.2
|
||||
· disk 308.7 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:50:54Z
|
||||
ludothinkpad busy: 25.8 GB free, 4 threads 100% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 31.3 GB free, 14 threads 95% busy, 1 sessions
|
||||
macbook ok: 6.7 GB free, 12 threads 11% busy, 0 sessions
|
||||
RAM 25.8 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 99% busy (agents 0.0, rest 4.0 cores) · 82°C · load 8.1
|
||||
· disk 308.7 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:51:59Z
|
||||
ludothinkpad ok: 27.2 GB free, 4 threads 12% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 31.2 GB free, 14 threads 76% busy, 1 sessions
|
||||
macbook ok: 6.7 GB free, 12 threads 8% busy, 0 sessions
|
||||
RAM 27.2 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 15% busy (agents 0.0, rest 1.3 cores) · 81°C · load 7.9
|
||||
· disk 308.7 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:53:03Z
|
||||
ludothinkpad ok: 27.3 GB free, 4 threads 42% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 27.8 GB free, 14 threads 87% busy, 1 sessions
|
||||
macbook ok: 6.7 GB free, 12 threads 9% busy, 0 sessions
|
||||
RAM 27.2 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 14% busy (agents 0.0, rest 0.8 cores) · 82°C · load 3.1
|
||||
· disk 308.7 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:54:08Z
|
||||
ludothinkpad ok: 28.9 GB free, 4 threads 4% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 29.2 GB free, 14 threads 80% busy, 1 sessions
|
||||
macbook ok: 6.7 GB free, 12 threads 9% busy, 0 sessions
|
||||
RAM 28.9 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 2% busy (agents 0.0, rest 0.3 cores) · 82°C · load 1.3
|
||||
· disk 308.7 GB free · battery 96% · Ludovic: away
|
||||
|
||||
2026-10-18T20:55:12Z
|
||||
ludothinkpad ok: 27.7 GB free, 4 threads 27% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 31.2 GB free, 14 threads 70% busy, 1 sessions
|
||||
macbook ok: 6.7 GB free, 12 threads 10% busy, 0 sessions
|
||||
RAM 27.6 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 26% busy (agents 0.0, rest 1.2 cores) · 69°C · load 1.0
|
||||
· disk 308.8 GB free · battery 96% · Ludovic: away
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
{
|
||||
"kind": "horizon-server-benchmark",
|
||||
"date": "2026-10-18T19:32:00+00:00",
|
||||
"machine": {
|
||||
"system": "Linux",
|
||||
"machine": "x86_64",
|
||||
"cpus": 4,
|
||||
"name": "ThinkPad, Intel Core i7-7500U, 32 GB, NixOS"
|
||||
},
|
||||
"commit": "f1d5bf520986b00a5b968a84d85809fca1a039fa",
|
||||
"engine": "horizon-engine: the server build of #916 (horizon-client --no-default-features) at 519ec121, as install.sh --archive installed it",
|
||||
"java": [
|
||||
"openjdk 25.0.4.1 2026-08-18"
|
||||
],
|
||||
"settings": {
|
||||
"servers": "horizon,vanilla",
|
||||
"bots": "1,10,25,50",
|
||||
"behaviour": "mixed",
|
||||
"join_every": 1.0,
|
||||
"warmup": 300,
|
||||
"seconds": 300,
|
||||
"seed": 12345,
|
||||
"explore_speed": 0.8,
|
||||
"gamerule": "",
|
||||
"heap": "4g",
|
||||
"nice": 19,
|
||||
"bots_via": "ssh macmini",
|
||||
"connect": "the ThinkPad's tailnet address",
|
||||
"properties": {
|
||||
"level-seed": "12345",
|
||||
"view-distance": "10",
|
||||
"simulation-distance": "10",
|
||||
"online-mode": "false",
|
||||
"white-list": "false",
|
||||
"gamemode": "creative",
|
||||
"force-gamemode": "true",
|
||||
"spawn-protection": "0",
|
||||
"max-players": "200",
|
||||
"difficulty": "easy",
|
||||
"sync-chunk-writes": "true"
|
||||
}
|
||||
},
|
||||
"note": "server_bench.py was stopped after vanilla's 50-bot server crashed, before it wrote this file; the runs are the lines it printed, and the vanilla 50 entry is taken from that server's log.",
|
||||
"runs": [
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 1,
|
||||
"behaviour": "mixed",
|
||||
"start_seconds": 19.73,
|
||||
"mspt": {
|
||||
"average": 1.77,
|
||||
"p50": 1.34,
|
||||
"p95": 4.21,
|
||||
"p99": 13.04
|
||||
},
|
||||
"mspt_worst_p99": 47.5,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 686.54,
|
||||
"max": 686.7
|
||||
},
|
||||
"cpu_percent": 27.92,
|
||||
"chunks_per_second": 9.94,
|
||||
"unique_per_second": 0.01,
|
||||
"forgotten_per_second": 9.9,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 6092,
|
||||
"chunks_per_bot": {
|
||||
"walk": 6092.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 699.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 1,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 1,
|
||||
"behaviour": "mixed",
|
||||
"start_seconds": 20.45,
|
||||
"mspt": {
|
||||
"average": 11.34,
|
||||
"p50": 10.6,
|
||||
"p95": 17.12,
|
||||
"p99": 33.22
|
||||
},
|
||||
"mspt_worst_p99": 119.1,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 3404.16,
|
||||
"max": 3416.3
|
||||
},
|
||||
"cpu_percent": 35.93,
|
||||
"chunks_per_second": 9.98,
|
||||
"unique_per_second": 0.01,
|
||||
"forgotten_per_second": 9.93,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 6084,
|
||||
"chunks_per_bot": {
|
||||
"walk": 6084.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 699.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 1,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 59
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 10,
|
||||
"behaviour": "mixed",
|
||||
"start_seconds": 19.54,
|
||||
"mspt": {
|
||||
"average": 4.74,
|
||||
"p50": 3.83,
|
||||
"p95": 10.48,
|
||||
"p99": 19.69
|
||||
},
|
||||
"mspt_worst_p99": 120.1,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 855.26,
|
||||
"max": 882.7
|
||||
},
|
||||
"cpu_percent": 29.78,
|
||||
"chunks_per_second": 61.35,
|
||||
"unique_per_second": 1.79,
|
||||
"forgotten_per_second": 61.47,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 40762,
|
||||
"chunks_per_bot": {
|
||||
"walk": 6247.67,
|
||||
"explore": 1165.0,
|
||||
"idle": 473.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 701.83,
|
||||
"explore": 1086.0,
|
||||
"idle": 473.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 4159,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 10,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 10,
|
||||
"behaviour": "mixed",
|
||||
"start_seconds": 19.95,
|
||||
"mspt": {
|
||||
"average": 34.3,
|
||||
"p50": 32.2,
|
||||
"p95": 53.72,
|
||||
"p99": 89.08
|
||||
},
|
||||
"mspt_worst_p99": 375.8,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 3713.5,
|
||||
"max": 3754.9
|
||||
},
|
||||
"cpu_percent": 128.07,
|
||||
"chunks_per_second": 61.66,
|
||||
"unique_per_second": 1.93,
|
||||
"forgotten_per_second": 61.71,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 37568,
|
||||
"chunks_per_bot": {
|
||||
"walk": 5729.0,
|
||||
"explore": 1124.0,
|
||||
"idle": 473.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 701.17,
|
||||
"explore": 1055.0,
|
||||
"idle": 473.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 3976,
|
||||
"moved_too_quickly": 20,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 10,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 25,
|
||||
"behaviour": "mixed",
|
||||
"start_seconds": 9.61,
|
||||
"mspt": {
|
||||
"average": 11.72,
|
||||
"p50": 9.88,
|
||||
"p95": 24.45,
|
||||
"p99": 43.79
|
||||
},
|
||||
"mspt_worst_p99": 129.8,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 947.35,
|
||||
"max": 1086.1
|
||||
},
|
||||
"cpu_percent": 57.83,
|
||||
"chunks_per_second": 158.57,
|
||||
"unique_per_second": 5.78,
|
||||
"forgotten_per_second": 158.45,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 108126,
|
||||
"chunks_per_bot": {
|
||||
"walk": 6628.53,
|
||||
"explore": 1266.6,
|
||||
"idle": 473.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 704.67,
|
||||
"explore": 1189.2,
|
||||
"idle": 473.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 3496,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 25,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 25,
|
||||
"behaviour": "mixed",
|
||||
"start_seconds": 37.78,
|
||||
"mspt": {
|
||||
"average": 50.45,
|
||||
"p50": 46.39,
|
||||
"p95": 74.09,
|
||||
"p99": 191.21
|
||||
},
|
||||
"mspt_worst_p99": 1388.1,
|
||||
"tps": 19.82,
|
||||
"rss_mb": {
|
||||
"mean": 3866.18,
|
||||
"max": 4745.0
|
||||
},
|
||||
"cpu_percent": 192.42,
|
||||
"chunks_per_second": 157.63,
|
||||
"unique_per_second": 8.25,
|
||||
"forgotten_per_second": 156.12,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 88763,
|
||||
"chunks_per_bot": {
|
||||
"walk": 5387.0,
|
||||
"explore": 1118.6,
|
||||
"idle": 473.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 699.0,
|
||||
"explore": 1075.0,
|
||||
"idle": 473.0
|
||||
},
|
||||
"resent": 2,
|
||||
"pulled_back": 3629,
|
||||
"moved_too_quickly": 296,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 25,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 50,
|
||||
"behaviour": "mixed",
|
||||
"start_seconds": 14.93,
|
||||
"mspt": {
|
||||
"average": 23.82,
|
||||
"p50": 20.11,
|
||||
"p95": 50.98,
|
||||
"p99": 74.64
|
||||
},
|
||||
"mspt_worst_p99": 195.9,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 1031.41,
|
||||
"max": 1201.2
|
||||
},
|
||||
"cpu_percent": 98.21,
|
||||
"chunks_per_second": 302.81,
|
||||
"unique_per_second": 10.96,
|
||||
"forgotten_per_second": 303.47,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 212181,
|
||||
"chunks_per_bot": {
|
||||
"walk": 6496.53,
|
||||
"explore": 1255.5,
|
||||
"idle": 473.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 701.9,
|
||||
"explore": 1184.7,
|
||||
"idle": 473.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 7565,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 50,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 50,
|
||||
"behaviour": "mixed",
|
||||
"start_seconds": 11.16,
|
||||
"error": "stopped by its watchdog 30 s after the last bot joined: \"A single server tick took 60.01 seconds (should be max 0.05)\" (Player.tick)"
|
||||
}
|
||||
],
|
||||
"work": [
|
||||
{
|
||||
"behaviour": "mixed",
|
||||
"bots": 1,
|
||||
"servers": {
|
||||
"horizon": {
|
||||
"chunks_per_second": {
|
||||
"value": 9.94,
|
||||
"share": 0.996
|
||||
},
|
||||
"unique_per_second": {
|
||||
"value": 0.01,
|
||||
"share": 1.0
|
||||
},
|
||||
"waited_ticks_per_second": 0.0
|
||||
},
|
||||
"vanilla": {
|
||||
"chunks_per_second": {
|
||||
"value": 9.98,
|
||||
"share": 1.0
|
||||
},
|
||||
"unique_per_second": {
|
||||
"value": 0.01,
|
||||
"share": 1.0
|
||||
},
|
||||
"waited_ticks_per_second": 0.0
|
||||
}
|
||||
},
|
||||
"equal": true
|
||||
},
|
||||
{
|
||||
"behaviour": "mixed",
|
||||
"bots": 10,
|
||||
"servers": {
|
||||
"horizon": {
|
||||
"chunks_per_second": {
|
||||
"value": 61.35,
|
||||
"share": 0.995
|
||||
},
|
||||
"unique_per_second": {
|
||||
"value": 1.79,
|
||||
"share": 0.927
|
||||
},
|
||||
"waited_ticks_per_second": 0.0
|
||||
},
|
||||
"vanilla": {
|
||||
"chunks_per_second": {
|
||||
"value": 61.66,
|
||||
"share": 1.0
|
||||
},
|
||||
"unique_per_second": {
|
||||
"value": 1.93,
|
||||
"share": 1.0
|
||||
},
|
||||
"waited_ticks_per_second": 0.0
|
||||
}
|
||||
},
|
||||
"equal": true
|
||||
},
|
||||
{
|
||||
"behaviour": "mixed",
|
||||
"bots": 25,
|
||||
"servers": {
|
||||
"horizon": {
|
||||
"chunks_per_second": {
|
||||
"value": 158.57,
|
||||
"share": 1.0
|
||||
},
|
||||
"unique_per_second": {
|
||||
"value": 5.78,
|
||||
"share": 0.701
|
||||
},
|
||||
"waited_ticks_per_second": 0.0
|
||||
},
|
||||
"vanilla": {
|
||||
"chunks_per_second": {
|
||||
"value": 157.63,
|
||||
"share": 0.994
|
||||
},
|
||||
"unique_per_second": {
|
||||
"value": 8.25,
|
||||
"share": 1.0
|
||||
},
|
||||
"waited_ticks_per_second": 0.0
|
||||
}
|
||||
},
|
||||
"equal": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
# Horizon's server against vanilla 26.3, equal work (2026-10-17)
|
||||
|
||||
The same bots on Horizon's dedicated server and on vanilla 26.3's, one server at a time, on the same
|
||||
machine, with the work each server does made the same before the numbers are compared. This replaces the
|
||||
first run in docs/server.md section 9, where the work wasn't equal.
|
||||
|
||||
## What was made equal
|
||||
|
||||
The first run (PR #556) had three differences that made the comparison unfair. Each is fixed in the code
|
||||
that ran here:
|
||||
|
||||
- **Refused moves.** Vanilla pulls a player back when a move is too fast ("moved too quickly") or ends
|
||||
inside a block ("moved wrongly"). Horizon now runs both checks the way vanilla does (PR #586), so a bot
|
||||
that flies too fast is pulled back on both servers.
|
||||
- **Chunk sending.** Vanilla paces chunks by the client's acknowledgements (PlayerChunkSender: one
|
||||
unacknowledged batch at first, then ten, each at most the client's desired chunks per tick). Horizon used
|
||||
to send twice as many per batch; it now paces them the same way (PR #586), and the bots acknowledge
|
||||
batches and ask for a rate the way vanilla's client does, so both servers send at the pace the client
|
||||
asks for. Chunks sent again while the bot still held
|
||||
them (`resent`) are counted: 0 on both servers in every run.
|
||||
- **Movement gated on chunks.** A bot doesn't move while the chunk it stands in hasn't arrived (vanilla's
|
||||
client waits the same way), so a slower server isn't asked to teleport bots into terrain it hasn't sent.
|
||||
|
||||
One difference isn't in Horizon yet: vanilla 26.3's locator bar (waypoints), which costs vanilla O(players²)
|
||||
work per move. Every vanilla run here sets `gamerule locator_bar false`, so neither server does it.
|
||||
|
||||
## Method
|
||||
|
||||
`tools/server_bench.py` runs each configuration on a fresh server:
|
||||
|
||||
- the same `server.properties` on both (seed 12345, view and simulation distance 10, offline, creative,
|
||||
difficulty easy, `sync-chunk-writes=true`), and `gamerule respawn_radius 0`;
|
||||
- the server waits for `Done`, the bots join one a second, a 60-second warm-up, then 300 seconds of
|
||||
measurement;
|
||||
- MSPT (average, P50, P95, P99) comes from each server's own `/tick query`, asked every 5 seconds on the
|
||||
console, so both measure themselves the same way (vanilla's ServerTickRateManager numbers, which Horizon
|
||||
reproduces); TPS is 20 while the average fits in 50 ms, else 1000 / MSPT;
|
||||
- RSS and CPU are the server's whole process tree, once a second (100 % is one core);
|
||||
- chunk counts come from the bots: chunks received per second, chunks no bot had before (new terrain), and
|
||||
pull-backs (position packets that move a bot back).
|
||||
|
||||
The bots are `horizon-bots` (creative, flying 24 blocks above spawn, 20 moves a second):
|
||||
|
||||
- `walk`: flies to random points within 48 blocks of spawn at sprint speed (5.6 blocks a second);
|
||||
- `explore`: flies straight outwards, by default at about 22 blocks a second, as fast as the server sends
|
||||
chunks.
|
||||
|
||||
Machine: the ThinkPad (Intel Core i7-7500U, 2 cores / 4 threads, 32 GB, NixOS), servers under `nice -n 19`
|
||||
because it also runs other services; vanilla on OpenJDK 25.0.4.1 with a 4 GB heap (`-Xms4g -Xmx4g`). The
|
||||
bots ran on the Mac mini and reached the ThinkPad over the tailnet, so they took none of the server's CPU.
|
||||
|
||||
What ran: Horizon from `origin/main` at bf851bfe with #586 (move checks and chunk pacing), the bots and
|
||||
`server_bench.py` from this PR, #623 (sections encoded once, its first commit) and #649 (a tick's packets
|
||||
handed to each connection together). The ThinkPad copy wasn't a git checkout, so the JSON reports have an
|
||||
empty `commit`. Vanilla: the pinned 26.3 server jar.
|
||||
|
||||
## Results
|
||||
|
||||
MSPT is the average milliseconds per tick (50 is the limit for 20 TPS); P95 is the 95th percentile. CPU is
|
||||
the whole process tree. "New chunks/s" are chunks no bot had before.
|
||||
|
||||
### Walk (the same work on both servers)
|
||||
|
||||
| Bots | Server | MSPT | P95 | TPS | RSS | CPU | Chunks/s | New chunks/s |
|
||||
|---:|---|---:|---:|---:|---:|---:|---:|---:|
|
||||
| 1 | Horizon | 5.5 | 12.1 | 20.0 | 1.37 GB | 57 % | 9 | 0.3 |
|
||||
| 1 | Vanilla | 18.3 | 27.3 | 20.0 | 3.35 GB | 66 % | 10 | 0.3 |
|
||||
| 10 | Horizon | 11.5 | 22.1 | 20.0 | 1.41 GB | 124 % | 94 | 2.4 |
|
||||
| 10 | Vanilla | 16.8 | 30.5 | 20.0 | 3.41 GB | 84 % | 100 | 2.8 |
|
||||
| 25 | Horizon | 20.7 | 40.4 | 20.0 | 1.45 GB | 170 % | 236 | 5.7 |
|
||||
| 25 | Vanilla | 16.0 | 26.4 | 20.0 | 3.47 GB | 115 % | 252 | 6.7 |
|
||||
| 50 | Horizon | 31.4 | 79.0 | 20.0 | 1.52 GB | 173 % | 451 | 10.3 |
|
||||
| 50 | Vanilla | 20.5 | 29.7 | 20.0 | 3.55 GB | 194 % | 502 | 12.2 |
|
||||
| 100 | Horizon | 114.5 | 256.2 | 8.7 | 1.33 GB | 181 % | 484 | 30.8 |
|
||||
| 100 | Vanilla | stopped by its watchdog | | | | | | |
|
||||
|
||||
Both servers sent the same chunks (about 10 a second per bot, the churn of flying around) and sent none
|
||||
twice. Vanilla pulled back a few bots for moving too quickly when a tick ran long (5 to 33 per run);
|
||||
Horizon none. At 100 bots vanilla's watchdog stopped the server (a tick ran over 60 s, in
|
||||
`ChunkMap$TrackedEntity.updatePlayer`); Horizon kept running at 8.7 TPS.
|
||||
|
||||
### Explore at a rate both servers keep up with (equal work)
|
||||
|
||||
The bots fly outwards at 0.8 blocks a second (`--explore-speed 0.8`), slow enough that neither server makes
|
||||
them wait for terrain, so both generate and send the same new chunks.
|
||||
|
||||
| Bots | Server | MSPT | P95 | TPS | RSS | CPU | Chunks/s | New chunks/s |
|
||||
|---:|---|---:|---:|---:|---:|---:|---:|---:|
|
||||
| 1 | Horizon | 4.0 | 6.6 | 20.0 | 1.36 GB | 25 % | 2 | 1.3 |
|
||||
| 1 | Vanilla | 18.1 | 27.1 | 20.0 | 3.32 GB | 73 % | 2 | 1.4 |
|
||||
| 10 | Horizon | 13.5 | 24.8 | 20.0 | 1.64 GB | 89 % | 13 | 11.8 |
|
||||
| 10 | Vanilla | 29.2 | 43.0 | 20.0 | 3.52 GB | 143 % | 13 | 11.9 |
|
||||
| 25 | Horizon | 20.2 | 36.8 | 20.0 | 1.74 GB | 121 % | 32 | 28.9 |
|
||||
| 25 | Vanilla | 37.0 | 49.4 | 20.0 | 3.73 GB | 169 % | 32 | 28.9 |
|
||||
|
||||
### Explore as fast as each server sends chunks (not equal work)
|
||||
|
||||
The default 22 blocks a second is faster than either server can generate, so each server sets the pace and
|
||||
a faster server is given more work: from 10 bots Horizon generated 1.5 to 2 times vanilla's new chunks a
|
||||
second. Read it as throughput, not as a like-for-like tick time.
|
||||
|
||||
| Bots | Server | MSPT | P95 | TPS | RSS | CPU | Chunks/s | New chunks/s |
|
||||
|---:|---|---:|---:|---:|---:|---:|---:|---:|
|
||||
| 1 | Horizon | 10.6 | 24.6 | 20.0 | 1.34 GB | 322 % | 39 | 34.6 |
|
||||
| 1 | Vanilla | 9.9 | 21.3 | 20.0 | 3.48 GB | 245 % | 36 | 33.4 |
|
||||
| 10 | Horizon | 30.4 | 52.3 | 20.0 | 2.18 GB | 345 % | 47 | 46.7 |
|
||||
| 10 | Vanilla | 17.0 | 29.6 | 20.0 | 4.04 GB | 242 % | 22 | 22.4 |
|
||||
| 25 | Horizon | 50.7 | 89.3 | 19.7 | 2.30 GB | 286 % | 50 | 49.7 |
|
||||
| 25 | Vanilla | 24.2 | 36.5 | 20.0 | 4.40 GB | 320 % | 34 | 33.7 |
|
||||
| 50 | Horizon | 50.7 | 102.7 | 19.7 | 1.72 GB | 213 % | 97 | 96.5 |
|
||||
| 50 | Vanilla | stopped by its watchdog | | | | | | |
|
||||
| 100 | Horizon | 92.9 | 139.6 | 10.8 | 1.70 GB | 322 % | 114 | 113.6 |
|
||||
| 100 | Vanilla | stopped by its watchdog | | | | | | |
|
||||
|
||||
Vanilla's watchdog stopped it at 50 bots (in `ClientboundLightUpdatePacketData`, copying light for a chunk
|
||||
packet) and at 100 (in `Entity.move` from a move packet).
|
||||
|
||||
## Findings
|
||||
|
||||
- Horizon's RSS is 1.9 to 2.6 times smaller in every run, and it starts in 11 s against vanilla's 15 s.
|
||||
Vanilla's heap is fixed at 4 GB (`-Xms4g`), so part of its RSS is that setting rather than need.
|
||||
- With 1 bot Horizon's tick is 3.3 times shorter walking and 4.5 times shorter exploring at equal work;
|
||||
at 10 bots it is still shorter in both.
|
||||
- At equal exploring work Horizon's tick is about half of vanilla's at 10 and 25 bots.
|
||||
- When the bots only fly around spawn (`walk`), vanilla's tick hardly grows with players (16 to 20 ms from
|
||||
1 to 50 bots) while Horizon's grows by about 0.5 ms per bot, so vanilla is ahead from about 20 bots: 16.0
|
||||
against 20.7 ms at 25 and 20.5 against 31.4 ms at 50. Both keep 20 TPS up to 50.
|
||||
- With 100 bots vanilla's watchdog stops it in every pass, and Horizon keeps running at 9 to 11 TPS.
|
||||
|
||||
### Where Horizon's walk tick goes at 25 bots
|
||||
|
||||
Profiled on the ThinkPad with `perf` (the server thread, 20 s, self time):
|
||||
|
||||
| Share | What | Whose |
|
||||
|---:|---|---|
|
||||
| 18 % | `Streaming::update`: every tick it walks all loaded columns and proto chunks and tests each against every player's area (unloading, dropping protos, finishing and decoration candidates), so the cost grows with players times chunks | streaming / engine tick |
|
||||
| 20 % | `PalettedContainer::get` and `World::block`, mostly from block entity ticks: on the Mac profile `tick_hoppers` looks up the block under every block entity in the loaded world each tick and compares names as strings, and spawners rebuild their display id | engine tick |
|
||||
| 8 % | `light_data` when a chunk is sent: the light layers were built twice per chunk; a follow-up commit on #623 builds them once and keeps each section's packed layers | server (this work) |
|
||||
| 7 % | encoding chunk sections and heightmaps for the chunks each bot newly sees | server |
|
||||
| 4 % | `Hub::tick` (moves, tracking) | server |
|
||||
| 2 % | the moved-wrongly collision check | server |
|
||||
|
||||
The first two grow with the number of players but aren't specific to the server: they are the
|
||||
engine's tick and streaming, reported to perf-server-2. Before #623 and #649, encoding every chunk section
|
||||
once per player and waking each connection's writer thread once per packet cost more than everything above
|
||||
(at 50 walking bots the tick was 281 ms; see the earlier runs).
|
||||
|
||||
## Earlier runs (same machine and method)
|
||||
|
||||
- `run-1-before-chunk-cache.json`: before #623, with vanilla's locator bar on. Horizon's walk tick was
|
||||
27.5, 40.7, 281 and 467 ms at 10, 25, 50 and 100 bots, and vanilla's 33.2 ms at 50 (the locator bar).
|
||||
- `run-2-chunk-cache.json`: Horizon with #623 only: 13.2, 23.5, 31.4 and 84.4 ms walking at 10 to 100
|
||||
bots.
|
||||
|
||||
## Files
|
||||
|
||||
- `horizon.json`, `vanilla.json`: the walk and explore passes (vanilla with `locator_bar=false`).
|
||||
- `explore-equal-rate.json`: the explore pass at 0.8 blocks a second, both servers.
|
||||
- `run-1-before-chunk-cache.json`, `run-2-chunk-cache.json`: the earlier runs.
|
||||
|
||||
To run it again (bots on another machine reached at `--connect`):
|
||||
|
||||
cargo build --release -p horizon-client --bin horizon-client -p horizon-server -p horizon-bots
|
||||
python3 tools/server_bench.py --servers horizon,vanilla --bots 1,10,25,50,100 --behaviour walk,explore \
|
||||
--gamerule locator_bar=false --nice 19 --bots-via 'ssh macmini' --bots-bin /path/to/horizon-bots \
|
||||
--connect <this machine's address> --out results.json
|
||||
python3 tools/server_bench.py --servers horizon,vanilla --bots 1,10,25 --behaviour explore \
|
||||
--explore-speed 0.8 --gamerule locator_bar=false --nice 19 ... --out explore-equal-rate.json
|
||||
@@ -0,0 +1,267 @@
|
||||
{
|
||||
"kind": "horizon-server-benchmark",
|
||||
"date": "2026-10-17T05:47:12+00:00",
|
||||
"machine": {
|
||||
"system": "Linux",
|
||||
"machine": "x86_64",
|
||||
"cpus": 4
|
||||
},
|
||||
"commit": "",
|
||||
"java": [
|
||||
"openjdk 25.0.4.1 2026-08-18"
|
||||
],
|
||||
"settings": {
|
||||
"servers": "horizon,vanilla",
|
||||
"bots": "1,10,25",
|
||||
"behaviour": "explore",
|
||||
"join_every": 1.0,
|
||||
"warmup": 60,
|
||||
"seconds": 300,
|
||||
"seed": 12345,
|
||||
"explore_speed": 0.8,
|
||||
"gamerule": "locator_bar=false",
|
||||
"heap": "4g",
|
||||
"out": "/home/ludovic/scratch/sh2-bench5.json",
|
||||
"nice": 19,
|
||||
"bots_via": "ssh -o BatchMode=yes macmini",
|
||||
"bots_bin": "/tmp/sh2-horizon-bots",
|
||||
"connect": "100.72.51.8",
|
||||
"properties": {
|
||||
"level-seed": "12345",
|
||||
"view-distance": "10",
|
||||
"simulation-distance": "10",
|
||||
"online-mode": "false",
|
||||
"white-list": "false",
|
||||
"gamemode": "creative",
|
||||
"force-gamemode": "true",
|
||||
"spawn-protection": "0",
|
||||
"max-players": "200",
|
||||
"difficulty": "easy",
|
||||
"sync-chunk-writes": "true"
|
||||
}
|
||||
},
|
||||
"runs": [
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 1,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 10.91,
|
||||
"mspt": {
|
||||
"average": 4.02,
|
||||
"p50": 3.75,
|
||||
"p95": 6.63,
|
||||
"p99": 14.55
|
||||
},
|
||||
"mspt_worst_p99": 63.3,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 1392.42,
|
||||
"max": 1746.1
|
||||
},
|
||||
"cpu_percent": 24.52,
|
||||
"chunks_per_second": 1.69,
|
||||
"unique_per_second": 1.34,
|
||||
"forgotten_per_second": 1.62,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 1048,
|
||||
"chunks_per_bot": {
|
||||
"explore": 1048.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 952.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 1,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 1,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 15.13,
|
||||
"mspt": {
|
||||
"average": 18.12,
|
||||
"p50": 18.78,
|
||||
"p95": 27.07,
|
||||
"p99": 60.81
|
||||
},
|
||||
"mspt_worst_p99": 1397.6,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 3397.31,
|
||||
"max": 3430.2
|
||||
},
|
||||
"cpu_percent": 72.99,
|
||||
"chunks_per_second": 1.69,
|
||||
"unique_per_second": 1.38,
|
||||
"forgotten_per_second": 1.62,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 1048,
|
||||
"chunks_per_bot": {
|
||||
"explore": 1048.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 952.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 1,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 10,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 11.12,
|
||||
"mspt": {
|
||||
"average": 13.49,
|
||||
"p50": 12.09,
|
||||
"p95": 24.81,
|
||||
"p99": 40.9
|
||||
},
|
||||
"mspt_worst_p99": 101.4,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 1684.04,
|
||||
"max": 2660.6
|
||||
},
|
||||
"cpu_percent": 89.04,
|
||||
"chunks_per_second": 13.08,
|
||||
"unique_per_second": 11.84,
|
||||
"forgotten_per_second": 13.02,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 9277,
|
||||
"chunks_per_bot": {
|
||||
"explore": 927.7
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 889.9
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 3381,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 10,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 10,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 14.84,
|
||||
"mspt": {
|
||||
"average": 29.18,
|
||||
"p50": 26.76,
|
||||
"p95": 43.02,
|
||||
"p99": 80.16
|
||||
},
|
||||
"mspt_worst_p99": 425.5,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 3609.51,
|
||||
"max": 3791.6
|
||||
},
|
||||
"cpu_percent": 142.79,
|
||||
"chunks_per_second": 13.1,
|
||||
"unique_per_second": 11.88,
|
||||
"forgotten_per_second": 13.12,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 9245,
|
||||
"chunks_per_bot": {
|
||||
"explore": 924.5
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 885.3
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 3388,
|
||||
"moved_too_quickly": 6,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 10,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 59
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 25,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 11.02,
|
||||
"mspt": {
|
||||
"average": 20.25,
|
||||
"p50": 18.73,
|
||||
"p95": 36.78,
|
||||
"p99": 55.76
|
||||
},
|
||||
"mspt_worst_p99": 92.3,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 1782.26,
|
||||
"max": 2741.9
|
||||
},
|
||||
"cpu_percent": 120.8,
|
||||
"chunks_per_second": 32.22,
|
||||
"unique_per_second": 28.89,
|
||||
"forgotten_per_second": 32.19,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 23357,
|
||||
"chunks_per_bot": {
|
||||
"explore": 934.28
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 890.32
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 11580,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 25,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 25,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 14.54,
|
||||
"mspt": {
|
||||
"average": 37.04,
|
||||
"p50": 34.77,
|
||||
"p95": 49.42,
|
||||
"p99": 101.67
|
||||
},
|
||||
"mspt_worst_p99": 735.2,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 3815.46,
|
||||
"max": 4202.0
|
||||
},
|
||||
"cpu_percent": 169.15,
|
||||
"chunks_per_second": 32.34,
|
||||
"unique_per_second": 28.93,
|
||||
"forgotten_per_second": 32.31,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 23306,
|
||||
"chunks_per_bot": {
|
||||
"explore": 932.24
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 886.96
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 11539,
|
||||
"moved_too_quickly": 6,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 25,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 59
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
{
|
||||
"kind": "horizon-server-benchmark",
|
||||
"date": "2026-10-17T05:04:57+00:00",
|
||||
"machine": {
|
||||
"system": "Linux",
|
||||
"machine": "x86_64",
|
||||
"cpus": 4
|
||||
},
|
||||
"commit": "",
|
||||
"java": [
|
||||
"openjdk 25.0.4.1 2026-08-18"
|
||||
],
|
||||
"settings": {
|
||||
"servers": "horizon",
|
||||
"bots": "1,10,25,50,100",
|
||||
"behaviour": "walk,explore",
|
||||
"join_every": 1.0,
|
||||
"warmup": 60,
|
||||
"seconds": 300,
|
||||
"seed": 12345,
|
||||
"explore_speed": 0,
|
||||
"gamerule": "locator_bar=false",
|
||||
"heap": "4g",
|
||||
"out": "/home/ludovic/scratch/sh2-bench4.json",
|
||||
"nice": 19,
|
||||
"bots_via": "ssh -o BatchMode=yes macmini",
|
||||
"bots_bin": "/tmp/sh2-horizon-bots",
|
||||
"connect": "100.72.51.8",
|
||||
"properties": {
|
||||
"level-seed": "12345",
|
||||
"view-distance": "10",
|
||||
"simulation-distance": "10",
|
||||
"online-mode": "false",
|
||||
"white-list": "false",
|
||||
"gamemode": "creative",
|
||||
"force-gamemode": "true",
|
||||
"spawn-protection": "0",
|
||||
"max-players": "200",
|
||||
"difficulty": "easy",
|
||||
"sync-chunk-writes": "true"
|
||||
}
|
||||
},
|
||||
"runs": [
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 1,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 11.02,
|
||||
"mspt": {
|
||||
"average": 5.45,
|
||||
"p50": 4.63,
|
||||
"p95": 12.14,
|
||||
"p99": 30.63
|
||||
},
|
||||
"mspt_worst_p99": 91.5,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 1398.5,
|
||||
"max": 1708.4
|
||||
},
|
||||
"cpu_percent": 57.03,
|
||||
"chunks_per_second": 9.17,
|
||||
"unique_per_second": 0.26,
|
||||
"forgotten_per_second": 9.2,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 3708,
|
||||
"chunks_per_bot": {
|
||||
"walk": 3708.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 694.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 1,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 10,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 11.02,
|
||||
"mspt": {
|
||||
"average": 11.51,
|
||||
"p50": 10.6,
|
||||
"p95": 22.13,
|
||||
"p99": 36.92
|
||||
},
|
||||
"mspt_worst_p99": 71.9,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 1440.2,
|
||||
"max": 1832.4
|
||||
},
|
||||
"cpu_percent": 124.19,
|
||||
"chunks_per_second": 94.04,
|
||||
"unique_per_second": 2.38,
|
||||
"forgotten_per_second": 93.95,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 37269,
|
||||
"chunks_per_bot": {
|
||||
"walk": 3726.9
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 688.9
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 10,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 25,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 10.91,
|
||||
"mspt": {
|
||||
"average": 20.74,
|
||||
"p50": 19.27,
|
||||
"p95": 40.42,
|
||||
"p99": 57.12
|
||||
},
|
||||
"mspt_worst_p99": 102.0,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 1485.4,
|
||||
"max": 1871.9
|
||||
},
|
||||
"cpu_percent": 169.52,
|
||||
"chunks_per_second": 235.81,
|
||||
"unique_per_second": 5.68,
|
||||
"forgotten_per_second": 235.83,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 94456,
|
||||
"chunks_per_bot": {
|
||||
"walk": 3778.24
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 692.96
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 25,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 50,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 11.12,
|
||||
"mspt": {
|
||||
"average": 31.41,
|
||||
"p50": 25.39,
|
||||
"p95": 78.97,
|
||||
"p99": 110.73
|
||||
},
|
||||
"mspt_worst_p99": 192.9,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 1558.34,
|
||||
"max": 1915.3
|
||||
},
|
||||
"cpu_percent": 173.27,
|
||||
"chunks_per_second": 450.85,
|
||||
"unique_per_second": 10.3,
|
||||
"forgotten_per_second": 451.86,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 186885,
|
||||
"chunks_per_bot": {
|
||||
"walk": 3737.7
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 688.9
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 50,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 100,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 18.16,
|
||||
"mspt": {
|
||||
"average": 114.55,
|
||||
"p50": 101.37,
|
||||
"p95": 256.19,
|
||||
"p99": 338.64
|
||||
},
|
||||
"mspt_worst_p99": 442.6,
|
||||
"tps": 8.73,
|
||||
"rss_mb": {
|
||||
"mean": 1357.65,
|
||||
"max": 1358.4
|
||||
},
|
||||
"cpu_percent": 181.21,
|
||||
"chunks_per_second": 484.16,
|
||||
"unique_per_second": 30.76,
|
||||
"forgotten_per_second": 478.77,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 212947,
|
||||
"chunks_per_bot": {
|
||||
"walk": 2129.47
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 583.02
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 100,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 57
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 1,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 17.45,
|
||||
"mspt": {
|
||||
"average": 10.62,
|
||||
"p50": 9.2,
|
||||
"p95": 24.61,
|
||||
"p99": 38.18
|
||||
},
|
||||
"mspt_worst_p99": 81.1,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 1372.73,
|
||||
"max": 1568.7
|
||||
},
|
||||
"cpu_percent": 322.38,
|
||||
"chunks_per_second": 39.21,
|
||||
"unique_per_second": 34.6,
|
||||
"forgotten_per_second": 38.55,
|
||||
"waited_ticks_per_second": 0.51,
|
||||
"chunks_total": 12549,
|
||||
"chunks_per_bot": {
|
||||
"explore": 12549.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 11168.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 1,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 10,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 11.32,
|
||||
"mspt": {
|
||||
"average": 30.38,
|
||||
"p50": 27.84,
|
||||
"p95": 52.31,
|
||||
"p99": 77.89
|
||||
},
|
||||
"mspt_worst_p99": 158.3,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 2231.56,
|
||||
"max": 4022.6
|
||||
},
|
||||
"cpu_percent": 344.99,
|
||||
"chunks_per_second": 47.34,
|
||||
"unique_per_second": 46.73,
|
||||
"forgotten_per_second": 44.57,
|
||||
"waited_ticks_per_second": 112.27,
|
||||
"chunks_total": 19870,
|
||||
"chunks_per_bot": {
|
||||
"explore": 1987.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 1966.5
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 16130,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 10,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 25,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 11.42,
|
||||
"mspt": {
|
||||
"average": 50.67,
|
||||
"p50": 47.61,
|
||||
"p95": 89.3,
|
||||
"p99": 119.15
|
||||
},
|
||||
"mspt_worst_p99": 222.3,
|
||||
"tps": 19.73,
|
||||
"rss_mb": {
|
||||
"mean": 2358.4,
|
||||
"max": 4273.7
|
||||
},
|
||||
"cpu_percent": 286.32,
|
||||
"chunks_per_second": 50.39,
|
||||
"unique_per_second": 49.67,
|
||||
"forgotten_per_second": 47.45,
|
||||
"waited_ticks_per_second": 311.85,
|
||||
"chunks_total": 28954,
|
||||
"chunks_per_bot": {
|
||||
"explore": 1158.16
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 1147.56
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 36319,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 25,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 50,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 20.06,
|
||||
"mspt": {
|
||||
"average": 50.69,
|
||||
"p50": 47.48,
|
||||
"p95": 102.67,
|
||||
"p99": 133.27
|
||||
},
|
||||
"mspt_worst_p99": 274.4,
|
||||
"tps": 19.73,
|
||||
"rss_mb": {
|
||||
"mean": 1764.01,
|
||||
"max": 2542.0
|
||||
},
|
||||
"cpu_percent": 212.83,
|
||||
"chunks_per_second": 97.33,
|
||||
"unique_per_second": 96.47,
|
||||
"forgotten_per_second": 76.74,
|
||||
"waited_ticks_per_second": 750.49,
|
||||
"chunks_total": 38538,
|
||||
"chunks_per_bot": {
|
||||
"explore": 770.76
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 763.84
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 39880,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 50,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 100,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 11.82,
|
||||
"mspt": {
|
||||
"average": 92.93,
|
||||
"p50": 87.84,
|
||||
"p95": 139.6,
|
||||
"p99": 178.38
|
||||
},
|
||||
"mspt_worst_p99": 272.9,
|
||||
"tps": 10.76,
|
||||
"rss_mb": {
|
||||
"mean": 1736.38,
|
||||
"max": 1855.7
|
||||
},
|
||||
"cpu_percent": 321.53,
|
||||
"chunks_per_second": 113.89,
|
||||
"unique_per_second": 113.56,
|
||||
"forgotten_per_second": 101.68,
|
||||
"waited_ticks_per_second": 1179.4,
|
||||
"chunks_total": 84617,
|
||||
"chunks_per_bot": {
|
||||
"explore": 846.17
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 845.01
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 128104,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 100,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,693 @@
|
||||
{
|
||||
"kind": "horizon-server-benchmark",
|
||||
"date": "2026-10-17T00:37:35+00:00",
|
||||
"machine": {
|
||||
"system": "Linux",
|
||||
"machine": "x86_64",
|
||||
"cpus": 4
|
||||
},
|
||||
"commit": "",
|
||||
"java": [
|
||||
"openjdk 25.0.4.1 2026-08-18"
|
||||
],
|
||||
"settings": {
|
||||
"servers": "horizon,vanilla",
|
||||
"bots": "1,10,25,50,100",
|
||||
"behaviour": "walk,explore",
|
||||
"join_every": 1.0,
|
||||
"warmup": 60,
|
||||
"seconds": 300,
|
||||
"seed": 12345,
|
||||
"heap": "4g",
|
||||
"out": "/home/ludovic/scratch/sh2-bench.json",
|
||||
"nice": 19,
|
||||
"bots_via": "ssh -o BatchMode=yes macmini",
|
||||
"bots_bin": "/tmp/sh2-horizon-bots",
|
||||
"connect": "100.72.51.8",
|
||||
"properties": {
|
||||
"level-seed": "12345",
|
||||
"view-distance": "10",
|
||||
"simulation-distance": "10",
|
||||
"online-mode": "false",
|
||||
"white-list": "false",
|
||||
"gamemode": "creative",
|
||||
"force-gamemode": "true",
|
||||
"spawn-protection": "0",
|
||||
"max-players": "200",
|
||||
"difficulty": "easy",
|
||||
"sync-chunk-writes": "true"
|
||||
}
|
||||
},
|
||||
"runs": [
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 1,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 11.12,
|
||||
"mspt": {
|
||||
"average": 7.63,
|
||||
"p50": 5.51,
|
||||
"p95": 17.46,
|
||||
"p99": 36.79
|
||||
},
|
||||
"mspt_worst_p99": 79.6,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 1391.94,
|
||||
"max": 1700.7
|
||||
},
|
||||
"cpu_percent": 60.18,
|
||||
"chunks_per_second": 9.15,
|
||||
"unique_per_second": 0.29,
|
||||
"forgotten_per_second": 9.18,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 3682,
|
||||
"chunks_per_bot": {
|
||||
"walk": 3682.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 692.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 1,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 1,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 15.35,
|
||||
"mspt": {
|
||||
"average": 18.08,
|
||||
"p50": 18.66,
|
||||
"p95": 28.05,
|
||||
"p99": 46.94
|
||||
},
|
||||
"mspt_worst_p99": 456.4,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 3455.72,
|
||||
"max": 3471.7
|
||||
},
|
||||
"cpu_percent": 65.67,
|
||||
"chunks_per_second": 9.89,
|
||||
"unique_per_second": 0.29,
|
||||
"forgotten_per_second": 9.92,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 3827,
|
||||
"chunks_per_bot": {
|
||||
"walk": 3827.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 699.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 1,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 10,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 11.22,
|
||||
"mspt": {
|
||||
"average": 27.53,
|
||||
"p50": 24.93,
|
||||
"p95": 53.93,
|
||||
"p99": 77.03
|
||||
},
|
||||
"mspt_worst_p99": 111.4,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 1433.85,
|
||||
"max": 1831.9
|
||||
},
|
||||
"cpu_percent": 138.65,
|
||||
"chunks_per_second": 95.98,
|
||||
"unique_per_second": 2.38,
|
||||
"forgotten_per_second": 95.78,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 38041,
|
||||
"chunks_per_bot": {
|
||||
"walk": 3804.1
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 690.8
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 10,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 10,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 14.83,
|
||||
"mspt": {
|
||||
"average": 18.92,
|
||||
"p50": 16.02,
|
||||
"p95": 39.0,
|
||||
"p99": 56.13
|
||||
},
|
||||
"mspt_worst_p99": 180.3,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 3479.45,
|
||||
"max": 3535.7
|
||||
},
|
||||
"cpu_percent": 99.39,
|
||||
"chunks_per_second": 99.93,
|
||||
"unique_per_second": 2.94,
|
||||
"forgotten_per_second": 100.04,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 38411,
|
||||
"chunks_per_bot": {
|
||||
"walk": 3841.1
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 697.1
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 7,
|
||||
"moved_too_quickly": 7,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 10,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 59
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 25,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 11.52,
|
||||
"mspt": {
|
||||
"average": 40.69,
|
||||
"p50": 37.28,
|
||||
"p95": 81.16,
|
||||
"p99": 108.42
|
||||
},
|
||||
"mspt_worst_p99": 149.9,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 1444.12,
|
||||
"max": 1882.6
|
||||
},
|
||||
"cpu_percent": 176.94,
|
||||
"chunks_per_second": 244.11,
|
||||
"unique_per_second": 6.51,
|
||||
"forgotten_per_second": 243.96,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 95219,
|
||||
"chunks_per_bot": {
|
||||
"walk": 3808.76
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 695.64
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 25,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 25,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 15.23,
|
||||
"mspt": {
|
||||
"average": 18.81,
|
||||
"p50": 17.26,
|
||||
"p95": 30.16,
|
||||
"p99": 53.9
|
||||
},
|
||||
"mspt_worst_p99": 390.0,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 3517.36,
|
||||
"max": 3555.7
|
||||
},
|
||||
"cpu_percent": 148.52,
|
||||
"chunks_per_second": 252.34,
|
||||
"unique_per_second": 7.75,
|
||||
"forgotten_per_second": 251.95,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 96645,
|
||||
"chunks_per_bot": {
|
||||
"walk": 3865.8
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 698.4
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 11,
|
||||
"moved_too_quickly": 11,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 25,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 59
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 50,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 12.72,
|
||||
"mspt": {
|
||||
"average": 281.42,
|
||||
"p50": 278.28,
|
||||
"p95": 343.64,
|
||||
"p99": 384.23
|
||||
},
|
||||
"mspt_worst_p99": 475.1,
|
||||
"tps": 3.55,
|
||||
"rss_mb": {
|
||||
"mean": 1334.32,
|
||||
"max": 1335.4
|
||||
},
|
||||
"cpu_percent": 197.11,
|
||||
"chunks_per_second": 391.5,
|
||||
"unique_per_second": 21.24,
|
||||
"forgotten_per_second": 388.33,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 153136,
|
||||
"chunks_per_bot": {
|
||||
"walk": 3062.72
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 661.82
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 50,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 57
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 50,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 15.74,
|
||||
"mspt": {
|
||||
"average": 33.21,
|
||||
"p50": 29.9,
|
||||
"p95": 53.37,
|
||||
"p99": 109.14
|
||||
},
|
||||
"mspt_worst_p99": 833.3,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 3568.07,
|
||||
"max": 3594.9
|
||||
},
|
||||
"cpu_percent": 266.59,
|
||||
"chunks_per_second": 496.3,
|
||||
"unique_per_second": 18.64,
|
||||
"forgotten_per_second": 495.77,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 191965,
|
||||
"chunks_per_bot": {
|
||||
"walk": 3839.3
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 696.22
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 184,
|
||||
"moved_too_quickly": 183,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 50,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 100,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 11.22,
|
||||
"mspt": {
|
||||
"average": 467.37,
|
||||
"p50": 464.62,
|
||||
"p95": 524.07,
|
||||
"p99": 548.77
|
||||
},
|
||||
"mspt_worst_p99": 581.1,
|
||||
"tps": 2.14,
|
||||
"rss_mb": {
|
||||
"mean": 1353.68,
|
||||
"max": 1354.9
|
||||
},
|
||||
"cpu_percent": 191.96,
|
||||
"chunks_per_second": 377.62,
|
||||
"unique_per_second": 55.8,
|
||||
"forgotten_per_second": 348.88,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 165719,
|
||||
"chunks_per_bot": {
|
||||
"walk": 1657.19
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 549.8
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 100,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 56
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 100,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 14.84,
|
||||
"error": "[Errno 32] Broken pipe"
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 1,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 11.12,
|
||||
"mspt": {
|
||||
"average": 17.07,
|
||||
"p50": 16.6,
|
||||
"p95": 32.82,
|
||||
"p99": 50.3
|
||||
},
|
||||
"mspt_worst_p99": 113.9,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 1391.72,
|
||||
"max": 1566.6
|
||||
},
|
||||
"cpu_percent": 333.8,
|
||||
"chunks_per_second": 39.42,
|
||||
"unique_per_second": 34.77,
|
||||
"forgotten_per_second": 39.34,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 13860,
|
||||
"chunks_per_bot": {
|
||||
"explore": 13860.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 12379.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 192,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 1,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 1,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 16.03,
|
||||
"mspt": {
|
||||
"average": 9.9,
|
||||
"p50": 8.04,
|
||||
"p95": 20.41,
|
||||
"p99": 63.62
|
||||
},
|
||||
"mspt_worst_p99": 351.1,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 3709.92,
|
||||
"max": 3776.2
|
||||
},
|
||||
"cpu_percent": 246.64,
|
||||
"chunks_per_second": 36.06,
|
||||
"unique_per_second": 33.33,
|
||||
"forgotten_per_second": 35.73,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 12369,
|
||||
"chunks_per_bot": {
|
||||
"explore": 12369.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 11559.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 222,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 1,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 59
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 10,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 11.42,
|
||||
"mspt": {
|
||||
"average": 33.72,
|
||||
"p50": 31.08,
|
||||
"p95": 61.07,
|
||||
"p99": 89.09
|
||||
},
|
||||
"mspt_worst_p99": 142.6,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 2239.97,
|
||||
"max": 4108.8
|
||||
},
|
||||
"cpu_percent": 350.83,
|
||||
"chunks_per_second": 46.49,
|
||||
"unique_per_second": 46.08,
|
||||
"forgotten_per_second": 44.33,
|
||||
"waited_ticks_per_second": 111.28,
|
||||
"chunks_total": 19915,
|
||||
"chunks_per_bot": {
|
||||
"explore": 1991.5
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 1974.4
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 17516,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 10,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 10,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 15.15,
|
||||
"mspt": {
|
||||
"average": 16.25,
|
||||
"p50": 13.67,
|
||||
"p95": 28.92,
|
||||
"p99": 115.9
|
||||
},
|
||||
"mspt_worst_p99": 1202.1,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 4202.72,
|
||||
"max": 4502.7
|
||||
},
|
||||
"cpu_percent": 247.47,
|
||||
"chunks_per_second": 22.3,
|
||||
"unique_per_second": 22.25,
|
||||
"forgotten_per_second": 22.64,
|
||||
"waited_ticks_per_second": 78.89,
|
||||
"chunks_total": 10092,
|
||||
"chunks_per_bot": {
|
||||
"explore": 1009.2
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 1007.4
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 16286,
|
||||
"moved_too_quickly": 12,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 10,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 59
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 25,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 11.82,
|
||||
"mspt": {
|
||||
"average": 48.03,
|
||||
"p50": 44.99,
|
||||
"p95": 83.06,
|
||||
"p99": 109.63
|
||||
},
|
||||
"mspt_worst_p99": 191.8,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 2214.16,
|
||||
"max": 4598.6
|
||||
},
|
||||
"cpu_percent": 356.03,
|
||||
"chunks_per_second": 64.2,
|
||||
"unique_per_second": 63.5,
|
||||
"forgotten_per_second": 60.9,
|
||||
"waited_ticks_per_second": 301.65,
|
||||
"chunks_total": 32247,
|
||||
"chunks_per_bot": {
|
||||
"explore": 1289.88
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 1279.4
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 42159,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 25,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 25,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 14.73,
|
||||
"mspt": {
|
||||
"average": 25.73,
|
||||
"p50": 22.75,
|
||||
"p95": 37.72,
|
||||
"p99": 201.98
|
||||
},
|
||||
"mspt_worst_p99": 4051.5,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 4355.9,
|
||||
"max": 4983.2
|
||||
},
|
||||
"cpu_percent": 309.3,
|
||||
"chunks_per_second": 33.33,
|
||||
"unique_per_second": 33.21,
|
||||
"forgotten_per_second": 34.22,
|
||||
"waited_ticks_per_second": 258.68,
|
||||
"chunks_total": 19426,
|
||||
"chunks_per_bot": {
|
||||
"explore": 777.04
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 772.92
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 53918,
|
||||
"moved_too_quickly": 93,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 25,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 50,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 11.01,
|
||||
"mspt": {
|
||||
"average": 108.61,
|
||||
"p50": 107.37,
|
||||
"p95": 151.96,
|
||||
"p99": 177.41
|
||||
},
|
||||
"mspt_worst_p99": 271.6,
|
||||
"tps": 9.21,
|
||||
"rss_mb": {
|
||||
"mean": 1775.98,
|
||||
"max": 1956.9
|
||||
},
|
||||
"cpu_percent": 359.7,
|
||||
"chunks_per_second": 100.32,
|
||||
"unique_per_second": 98.91,
|
||||
"forgotten_per_second": 95.42,
|
||||
"waited_ticks_per_second": 518.15,
|
||||
"chunks_total": 55956,
|
||||
"chunks_per_bot": {
|
||||
"explore": 1119.12
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 1111.42
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 69967,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 50,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 50,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 14.74,
|
||||
"error": "[Errno 32] Broken pipe"
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 100,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 11.12,
|
||||
"mspt": {
|
||||
"average": 244.43,
|
||||
"p50": 243.58,
|
||||
"p95": 308.39,
|
||||
"p99": 360.12
|
||||
},
|
||||
"mspt_worst_p99": 470.2,
|
||||
"tps": 4.09,
|
||||
"rss_mb": {
|
||||
"mean": 1806.85,
|
||||
"max": 2084.5
|
||||
},
|
||||
"cpu_percent": 310.46,
|
||||
"chunks_per_second": 186.87,
|
||||
"unique_per_second": 182.99,
|
||||
"forgotten_per_second": 161.75,
|
||||
"waited_ticks_per_second": 1131.65,
|
||||
"chunks_total": 100389,
|
||||
"chunks_per_bot": {
|
||||
"explore": 1003.89
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 991.9
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 56791,
|
||||
"moved_too_quickly": 344,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 100,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 57
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 100,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 14.83,
|
||||
"error": "[Errno 32] Broken pipe"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
{
|
||||
"kind": "horizon-server-benchmark",
|
||||
"date": "2026-10-17T02:22:59+00:00",
|
||||
"machine": {
|
||||
"system": "Linux",
|
||||
"machine": "x86_64",
|
||||
"cpus": 4
|
||||
},
|
||||
"commit": "",
|
||||
"java": [
|
||||
"openjdk 25.0.4.1 2026-08-18"
|
||||
],
|
||||
"settings": {
|
||||
"servers": "horizon",
|
||||
"bots": "1,10,25,50,100",
|
||||
"behaviour": "walk,explore",
|
||||
"join_every": 1.0,
|
||||
"warmup": 60,
|
||||
"seconds": 300,
|
||||
"seed": 12345,
|
||||
"heap": "4g",
|
||||
"out": "/home/ludovic/scratch/sh2-bench2.json",
|
||||
"nice": 19,
|
||||
"bots_via": "ssh -o BatchMode=yes macmini",
|
||||
"bots_bin": "/tmp/sh2-horizon-bots",
|
||||
"connect": "100.72.51.8",
|
||||
"properties": {
|
||||
"level-seed": "12345",
|
||||
"view-distance": "10",
|
||||
"simulation-distance": "10",
|
||||
"online-mode": "false",
|
||||
"white-list": "false",
|
||||
"gamemode": "creative",
|
||||
"force-gamemode": "true",
|
||||
"spawn-protection": "0",
|
||||
"max-players": "200",
|
||||
"difficulty": "easy",
|
||||
"sync-chunk-writes": "true"
|
||||
}
|
||||
},
|
||||
"runs": [
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 1,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 11.12,
|
||||
"mspt": {
|
||||
"average": 5.41,
|
||||
"p50": 4.63,
|
||||
"p95": 11.61,
|
||||
"p99": 30.5
|
||||
},
|
||||
"mspt_worst_p99": 64.6,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 1398.84,
|
||||
"max": 1708.5
|
||||
},
|
||||
"cpu_percent": 56.84,
|
||||
"chunks_per_second": 9.19,
|
||||
"unique_per_second": 0.27,
|
||||
"forgotten_per_second": 9.22,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 3713,
|
||||
"chunks_per_bot": {
|
||||
"walk": 3713.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 694.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 1,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 10,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 10.92,
|
||||
"mspt": {
|
||||
"average": 13.18,
|
||||
"p50": 11.74,
|
||||
"p95": 26.5,
|
||||
"p99": 44.82
|
||||
},
|
||||
"mspt_worst_p99": 87.6,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 1439.83,
|
||||
"max": 1831.6
|
||||
},
|
||||
"cpu_percent": 132.94,
|
||||
"chunks_per_second": 93.87,
|
||||
"unique_per_second": 2.38,
|
||||
"forgotten_per_second": 93.77,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 37240,
|
||||
"chunks_per_bot": {
|
||||
"walk": 3724.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 688.7
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 10,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 25,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 10.92,
|
||||
"mspt": {
|
||||
"average": 23.52,
|
||||
"p50": 21.92,
|
||||
"p95": 44.7,
|
||||
"p99": 63.12
|
||||
},
|
||||
"mspt_worst_p99": 119.1,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 1485.82,
|
||||
"max": 1872.0
|
||||
},
|
||||
"cpu_percent": 173.82,
|
||||
"chunks_per_second": 235.63,
|
||||
"unique_per_second": 5.64,
|
||||
"forgotten_per_second": 235.72,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 94549,
|
||||
"chunks_per_bot": {
|
||||
"walk": 3781.96
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 692.8
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 25,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 50,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 10.91,
|
||||
"mspt": {
|
||||
"average": 31.36,
|
||||
"p50": 26.08,
|
||||
"p95": 70.6,
|
||||
"p99": 92.83
|
||||
},
|
||||
"mspt_worst_p99": 125.5,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 1556.81,
|
||||
"max": 1913.0
|
||||
},
|
||||
"cpu_percent": 192.16,
|
||||
"chunks_per_second": 468.69,
|
||||
"unique_per_second": 10.43,
|
||||
"forgotten_per_second": 468.78,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 193547,
|
||||
"chunks_per_bot": {
|
||||
"walk": 3870.94
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 692.84
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 50,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 100,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 11.02,
|
||||
"mspt": {
|
||||
"average": 84.38,
|
||||
"p50": 81.5,
|
||||
"p95": 132.92,
|
||||
"p99": 168.38
|
||||
},
|
||||
"mspt_worst_p99": 245.5,
|
||||
"tps": 11.85,
|
||||
"rss_mb": {
|
||||
"mean": 1395.64,
|
||||
"max": 1941.3
|
||||
},
|
||||
"cpu_percent": 271.28,
|
||||
"chunks_per_second": 850.05,
|
||||
"unique_per_second": 21.79,
|
||||
"forgotten_per_second": 851.1,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 368464,
|
||||
"chunks_per_bot": {
|
||||
"walk": 3684.64
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 680.58
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 100,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 1,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 11.02,
|
||||
"mspt": {
|
||||
"average": 11.63,
|
||||
"p50": 10.27,
|
||||
"p95": 24.52,
|
||||
"p99": 40.53
|
||||
},
|
||||
"mspt_worst_p99": 99.9,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 1413.81,
|
||||
"max": 1596.1
|
||||
},
|
||||
"cpu_percent": 329.84,
|
||||
"chunks_per_second": 39.47,
|
||||
"unique_per_second": 34.81,
|
||||
"forgotten_per_second": 39.42,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 13798,
|
||||
"chunks_per_bot": {
|
||||
"explore": 13798.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 12330.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 185,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 1,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 10,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 11.02,
|
||||
"mspt": {
|
||||
"average": 28.49,
|
||||
"p50": 26.2,
|
||||
"p95": 48.58,
|
||||
"p99": 72.04
|
||||
},
|
||||
"mspt_worst_p99": 125.7,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 2231.06,
|
||||
"max": 4077.8
|
||||
},
|
||||
"cpu_percent": 344.95,
|
||||
"chunks_per_second": 46.31,
|
||||
"unique_per_second": 45.88,
|
||||
"forgotten_per_second": 44.14,
|
||||
"waited_ticks_per_second": 111.95,
|
||||
"chunks_total": 19722,
|
||||
"chunks_per_bot": {
|
||||
"explore": 1972.2
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 1957.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 18533,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 10,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 25,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 11.12,
|
||||
"mspt": {
|
||||
"average": 40.04,
|
||||
"p50": 37.61,
|
||||
"p95": 62.39,
|
||||
"p99": 83.64
|
||||
},
|
||||
"mspt_worst_p99": 147.0,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 2466.85,
|
||||
"max": 4504.5
|
||||
},
|
||||
"cpu_percent": 340.13,
|
||||
"chunks_per_second": 60.61,
|
||||
"unique_per_second": 59.62,
|
||||
"forgotten_per_second": 56.28,
|
||||
"waited_ticks_per_second": 299.57,
|
||||
"chunks_total": 32877,
|
||||
"chunks_per_bot": {
|
||||
"explore": 1315.08
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 1300.72
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 46272,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 25,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 50,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 11.32,
|
||||
"mspt": {
|
||||
"average": 61.82,
|
||||
"p50": 59.21,
|
||||
"p95": 90.46,
|
||||
"p99": 115.58
|
||||
},
|
||||
"mspt_worst_p99": 320.9,
|
||||
"tps": 16.18,
|
||||
"rss_mb": {
|
||||
"mean": 2634.54,
|
||||
"max": 4883.1
|
||||
},
|
||||
"cpu_percent": 356.96,
|
||||
"chunks_per_second": 95.26,
|
||||
"unique_per_second": 94.31,
|
||||
"forgotten_per_second": 85.86,
|
||||
"waited_ticks_per_second": 528.08,
|
||||
"chunks_total": 59251,
|
||||
"chunks_per_bot": {
|
||||
"explore": 1185.02
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 1173.96
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 99493,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 50,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 100,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 10.81,
|
||||
"mspt": {
|
||||
"average": 103.24,
|
||||
"p50": 100.03,
|
||||
"p95": 144.77,
|
||||
"p99": 178.8
|
||||
},
|
||||
"mspt_worst_p99": 258.6,
|
||||
"tps": 9.69,
|
||||
"rss_mb": {
|
||||
"mean": 1784.48,
|
||||
"max": 1922.0
|
||||
},
|
||||
"cpu_percent": 336.94,
|
||||
"chunks_per_second": 115.06,
|
||||
"unique_per_second": 114.86,
|
||||
"forgotten_per_second": 109.43,
|
||||
"waited_ticks_per_second": 1127.57,
|
||||
"chunks_total": 93839,
|
||||
"chunks_per_bot": {
|
||||
"explore": 938.39
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 935.28
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 133404,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 100,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
{
|
||||
"kind": "horizon-server-benchmark",
|
||||
"date": "2026-10-17T03:29:30+00:00",
|
||||
"machine": {
|
||||
"system": "Linux",
|
||||
"machine": "x86_64",
|
||||
"cpus": 4
|
||||
},
|
||||
"commit": "",
|
||||
"java": [
|
||||
"openjdk 25.0.4.1 2026-08-18"
|
||||
],
|
||||
"settings": {
|
||||
"servers": "vanilla",
|
||||
"bots": "1,10,25,50,100",
|
||||
"behaviour": "walk,explore",
|
||||
"join_every": 1.0,
|
||||
"warmup": 60,
|
||||
"seconds": 300,
|
||||
"seed": 12345,
|
||||
"gamerule": "locator_bar=false",
|
||||
"heap": "4g",
|
||||
"out": "/home/ludovic/scratch/sh2-bench3.json",
|
||||
"nice": 19,
|
||||
"bots_via": "ssh -o BatchMode=yes macmini",
|
||||
"bots_bin": "/tmp/sh2-horizon-bots",
|
||||
"connect": "100.72.51.8",
|
||||
"properties": {
|
||||
"level-seed": "12345",
|
||||
"view-distance": "10",
|
||||
"simulation-distance": "10",
|
||||
"online-mode": "false",
|
||||
"white-list": "false",
|
||||
"gamemode": "creative",
|
||||
"force-gamemode": "true",
|
||||
"spawn-protection": "0",
|
||||
"max-players": "200",
|
||||
"difficulty": "easy",
|
||||
"sync-chunk-writes": "true"
|
||||
}
|
||||
},
|
||||
"runs": [
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 1,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 15.43,
|
||||
"mspt": {
|
||||
"average": 18.32,
|
||||
"p50": 19.26,
|
||||
"p95": 27.34,
|
||||
"p99": 45.48
|
||||
},
|
||||
"mspt_worst_p99": 393.3,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 3428.43,
|
||||
"max": 3437.7
|
||||
},
|
||||
"cpu_percent": 66.47,
|
||||
"chunks_per_second": 9.92,
|
||||
"unique_per_second": 0.29,
|
||||
"forgotten_per_second": 9.95,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 3850,
|
||||
"chunks_per_bot": {
|
||||
"walk": 3850.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 699.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 1,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 10,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 14.76,
|
||||
"mspt": {
|
||||
"average": 16.8,
|
||||
"p50": 14.55,
|
||||
"p95": 30.52,
|
||||
"p99": 50.01
|
||||
},
|
||||
"mspt_worst_p99": 205.2,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 3490.15,
|
||||
"max": 3580.7
|
||||
},
|
||||
"cpu_percent": 83.7,
|
||||
"chunks_per_second": 100.01,
|
||||
"unique_per_second": 2.83,
|
||||
"forgotten_per_second": 100.04,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 38623,
|
||||
"chunks_per_bot": {
|
||||
"walk": 3862.3
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 697.2
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 5,
|
||||
"moved_too_quickly": 5,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 10,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 59
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 25,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 14.84,
|
||||
"mspt": {
|
||||
"average": 16.01,
|
||||
"p50": 14.55,
|
||||
"p95": 26.39,
|
||||
"p99": 51.26
|
||||
},
|
||||
"mspt_worst_p99": 397.8,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 3550.02,
|
||||
"max": 3593.5
|
||||
},
|
||||
"cpu_percent": 114.96,
|
||||
"chunks_per_second": 251.62,
|
||||
"unique_per_second": 6.72,
|
||||
"forgotten_per_second": 251.16,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 98701,
|
||||
"chunks_per_bot": {
|
||||
"walk": 3948.04
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 698.84
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 10,
|
||||
"moved_too_quickly": 10,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 25,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 59
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 50,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 14.43,
|
||||
"mspt": {
|
||||
"average": 20.52,
|
||||
"p50": 19.55,
|
||||
"p95": 29.72,
|
||||
"p99": 49.04
|
||||
},
|
||||
"mspt_worst_p99": 600.4,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 3633.12,
|
||||
"max": 3658.7
|
||||
},
|
||||
"cpu_percent": 193.84,
|
||||
"chunks_per_second": 501.86,
|
||||
"unique_per_second": 12.15,
|
||||
"forgotten_per_second": 501.8,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 203429,
|
||||
"chunks_per_bot": {
|
||||
"walk": 4068.58
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 698.66
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 33,
|
||||
"moved_too_quickly": 32,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 50,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 59
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 100,
|
||||
"behaviour": "walk",
|
||||
"start_seconds": 15.34,
|
||||
"error": "[Errno 32] Broken pipe"
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 1,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 15.55,
|
||||
"mspt": {
|
||||
"average": 9.93,
|
||||
"p50": 8.12,
|
||||
"p95": 21.33,
|
||||
"p99": 60.19
|
||||
},
|
||||
"mspt_worst_p99": 373.0,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 3564.11,
|
||||
"max": 3655.0
|
||||
},
|
||||
"cpu_percent": 245.18,
|
||||
"chunks_per_second": 35.93,
|
||||
"unique_per_second": 33.35,
|
||||
"forgotten_per_second": 35.64,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 12340,
|
||||
"chunks_per_bot": {
|
||||
"explore": 12340.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 11569.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 261,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 1,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 59
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 10,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 15.03,
|
||||
"mspt": {
|
||||
"average": 16.99,
|
||||
"p50": 14.46,
|
||||
"p95": 29.61,
|
||||
"p99": 132.18
|
||||
},
|
||||
"mspt_worst_p99": 1244.0,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 4135.2,
|
||||
"max": 4453.1
|
||||
},
|
||||
"cpu_percent": 242.32,
|
||||
"chunks_per_second": 22.39,
|
||||
"unique_per_second": 22.41,
|
||||
"forgotten_per_second": 22.78,
|
||||
"waited_ticks_per_second": 78.78,
|
||||
"chunks_total": 10177,
|
||||
"chunks_per_bot": {
|
||||
"explore": 1017.7
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 1016.2
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 17314,
|
||||
"moved_too_quickly": 19,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 10,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 59
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 25,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 14.84,
|
||||
"mspt": {
|
||||
"average": 24.19,
|
||||
"p50": 21.22,
|
||||
"p95": 36.51,
|
||||
"p99": 174.82
|
||||
},
|
||||
"mspt_worst_p99": 1532.8,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 4504.55,
|
||||
"max": 4988.9
|
||||
},
|
||||
"cpu_percent": 319.55,
|
||||
"chunks_per_second": 33.78,
|
||||
"unique_per_second": 33.66,
|
||||
"forgotten_per_second": 34.64,
|
||||
"waited_ticks_per_second": 269.22,
|
||||
"chunks_total": 20522,
|
||||
"chunks_per_bot": {
|
||||
"explore": 820.88
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"explore": 815.76
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 46246,
|
||||
"moved_too_quickly": 28,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 25,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 50,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 14.73,
|
||||
"error": "[Errno 32] Broken pipe"
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 100,
|
||||
"behaviour": "explore",
|
||||
"start_seconds": 14.54,
|
||||
"error": "[Errno 32] Broken pipe"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
# Horizon's server against vanilla 26.3, Paper and Fabric + Lithium: mixed bots, equal work (2026-10-19)
|
||||
|
||||
This is the same load as `../server-equal-mixed-2026-10-18/`, now with two more servers: Paper, and vanilla on
|
||||
Fabric with Lithium. It also uses a newer Horizon release (v2026.10.19). That release spawns mobs around every
|
||||
player (#950) and ticks blocks around every player (#931), as vanilla does. The 2026-10-18 build did neither for
|
||||
players away from spawn, so it had less work to do than vanilla, and its tick times there were lower than
|
||||
they would have been with equal work. These numbers replace them.
|
||||
|
||||
## Summary
|
||||
|
||||
- **The work was equal** at every count, on all four servers. Chunks sent a second and first-time chunks a
|
||||
second were within 4 % of each other, and the walking bots got about the same chunks each (6050 to 6510).
|
||||
Every bot joined and none was dropped. Vanilla's 50-bot server is the exception: its watchdog stopped it.
|
||||
- **Memory:** Horizon used 0.72 GB with one player and 1.11 GB with 50. The three Java servers used 3.4 to
|
||||
4.5 GB (a 4 GB heap each), so Horizon was **3.8 to 4.8 times smaller** at every count.
|
||||
- **Start:** **8.2 to 8.5 s** to `Done` for Horizon, against 14.7 to 15.5 s for vanilla, 16.6 to 17.6 s for
|
||||
Fabric + Lithium and 19.0 to 22.4 s for Paper.
|
||||
- **CPU** (100 % is one of the machine's 4 threads): from 10 players up, Horizon used the least: 143 % at 50,
|
||||
against Paper's 210 % and Fabric's 236 %. With one player Paper used less (32 % against 46 %).
|
||||
- **Average tick (MSPT), where Horizon loses:**
|
||||
- With 1 player, Paper (10.3 ms) and Fabric + Lithium (12.5) were faster than Horizon (13.7). Vanilla was
|
||||
slowest (17.3).
|
||||
- With 10, Fabric (20.2) and Paper (20.6) were ahead of Horizon (22.9), which tied vanilla (22.9).
|
||||
- With 25, Paper (28.3) was ahead of Horizon (30.9), and Horizon was ahead of Fabric (31.4) and vanilla (37.4).
|
||||
- With 50, Paper (46.9) and Horizon (47.8) were close. Fabric + Lithium fell to 17.2 TPS (58.3 ms), and
|
||||
vanilla's watchdog stopped its server.
|
||||
- **Slow ticks, where Horizon wins:** Horizon's P99 was the lowest from 10 players up (72.0 ms at 25 and 73.2
|
||||
at 50, against Paper's 90.4 and 109.7). Its worst 5-second P99 was the lowest at every count (133 ms at
|
||||
50, against 213 for Paper and 888 for Fabric).
|
||||
|
||||
So on this 4-thread laptop CPU, Horizon is not yet the fastest on average tick time. Paper is ahead at every
|
||||
count: Horizon's average tick is a third longer than Paper's with one player and 2 % longer with 50. Fabric +
|
||||
Lithium is ahead with 1 and 10 players.
|
||||
Horizon is ahead on memory, start time, CPU from 10 players up, and the steadiness of its ticks (P99). Its
|
||||
average tick is what perf-server-11 is working on.
|
||||
|
||||
## Results
|
||||
|
||||
MSPT is milliseconds per tick from each server's own `/tick query` (50 is the limit for 20 TPS). P95 and P99
|
||||
are means over the 5-second queries of the 300 measured seconds. "Worst P99" is the highest P99 of a single
|
||||
query. RSS and CPU cover the server's process tree. "First-time chunks/s" counts chunks a bot hadn't
|
||||
received before (summed over the bots). "Pulled back" counts position packets that moved a bot back.
|
||||
|
||||
| Bots | Server | MSPT | P95 | P99 | Worst P99 | TPS | RSS (mean / max) | CPU | Start | Chunks/s | First-time chunks/s | Pulled back |
|
||||
|---:|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||
| 1 | Horizon | 13.70 | 19.06 | 27.38 | **43.4** | 20 | **0.72 / 0.72 GB** | 46 % | **8.3 s** | 9.94 | 0.01 | 0 |
|
||||
| 1 | vanilla | 17.31 | 24.68 | 36.43 | 71.0 | 20 | 3.44 / 3.44 GB | 55 % | 14.9 s | 9.99 | 0.00 | 0 |
|
||||
| 1 | Paper | **10.31** | 13.90 | 22.50 | 81.2 | 20 | 4.24 / 4.25 GB | **32 %** | 22.3 s | 9.81 | 0.02 | 0 |
|
||||
| 1 | Fabric + Lithium | 12.46 | 17.69 | 32.74 | 70.1 | 20 | 3.43 / 3.43 GB | 46 % | 17.6 s | 9.98 | 0.00 | 0 |
|
||||
| 10 | Horizon | 22.93 | 39.83 | 53.33 | **89.5** | 20 | **0.92 / 0.97 GB** | **67 %** | **8.2 s** | 61.21 | 1.78 | 3702 |
|
||||
| 10 | vanilla | 22.94 | 36.92 | 56.48 | 196.6 | 20 | 3.71 / 3.76 GB | 101 % | 14.7 s | 61.63 | 1.84 | 3370 |
|
||||
| 10 | Paper | 20.64 | 35.94 | 56.29 | 166.9 | 20 | 4.07 / 4.08 GB | 74 % | 20.5 s | 60.76 | 1.85 | 3974 |
|
||||
| 10 | Fabric + Lithium | **20.18** | 37.04 | 56.08 | 217.9 | 20 | 3.73 / 3.78 GB | 94 % | 16.6 s | 61.65 | 1.84 | 4002 |
|
||||
| 25 | Horizon | 30.92 | 48.25 | **72.00** | **121.1** | 20 | **1.01 / 1.12 GB** | **99 %** | **8.5 s** | 155.81 | 5.78 | 3008 |
|
||||
| 25 | vanilla | 37.35 | 48.20 | 82.66 | 579.8 | 20 | 3.94 / 4.16 GB | 191 % | 15.5 s | 158.52 | 5.71 | 3762 |
|
||||
| 25 | Paper | **28.30** | 61.68 | 90.40 | 124.3 | 20 | 4.16 / 4.25 GB | 124 % | 19.0 s | 156.35 | 5.72 | 4337 |
|
||||
| 25 | Fabric + Lithium | 31.44 | 52.45 | 93.35 | 409.3 | 20 | 3.88 / 4.19 GB | 174 % | 17.5 s | 158.89 | 5.75 | 4194 |
|
||||
| 50 | Horizon | 47.83 | 62.42 | **73.21** | **133.0** | 20 | **1.11 / 1.30 GB** | **143 %** | **8.3 s** | 302.68 | 11.02 | 8562 |
|
||||
| 50 | vanilla | stopped by its watchdog | | | | | | | | | | |
|
||||
| 50 | Paper | **46.86** | 61.16 | 109.72 | 213.4 | 20 | 4.35 / 4.52 GB | 210 % | 19.4 s | 310.51 | 11.16 | 7344 |
|
||||
| 50 | Fabric + Lithium | 58.30 | 83.06 | 174.37 | 887.6 | 17.15 | 4.52 / 4.74 GB | 236 % | 17.5 s | 311.52 | 11.28 | 6350 |
|
||||
|
||||
Vanilla at 50: all 50 bots joined by 05:22:28 UTC. The server fell further and further behind ("Can't keep up!
|
||||
… Running 16229ms or 324 ticks behind"), and at 05:28:17, during the warm-up, its watchdog stopped it: "A
|
||||
single server tick took 60.00 seconds". That is the same as on 2026-10-18.
|
||||
|
||||
Chunks each bot received over the whole run, by behaviour (mean):
|
||||
|
||||
| Bots | Server | walk | explore | idle |
|
||||
|---:|---|---:|---:|---:|
|
||||
| 1 | Horizon | 6280 | | |
|
||||
| 1 | vanilla | 6210 | | |
|
||||
| 1 | Paper | 6049 | | |
|
||||
| 1 | Fabric + Lithium | 6188 | | |
|
||||
| 10 | Horizon | 6460 | 1195 | 473 |
|
||||
| 10 | vanilla | 6272 | 1180 | 473 |
|
||||
| 10 | Paper | 6177 | 1171 | 473 |
|
||||
| 10 | Fabric + Lithium | 6291 | 1181 | 473 |
|
||||
| 25 | Horizon | 6506 | 1262 | 473 |
|
||||
| 25 | vanilla | 6307 | 1236 | 473 |
|
||||
| 25 | Paper | 6280 | 1229 | 473 |
|
||||
| 25 | Fabric + Lithium | 6357 | 1239 | 473 |
|
||||
| 50 | Horizon | 6468 | 1251 | 473 |
|
||||
| 50 | Paper | 6411 | 1224 | 473 |
|
||||
| 50 | Fabric + Lithium | 6332 | 1228 | 473 |
|
||||
|
||||
## Equal work
|
||||
|
||||
`server_bench.py`'s work table compares, at each bot count, every server's chunks sent a second and
|
||||
first-time chunks a second with the busiest server's. The work is equal when both are within 10 % (or within
|
||||
a chunk a second when there are few).
|
||||
|
||||
| Bots | Chunks/s sent, first-time (Horizon; vanilla; Paper; Fabric + Lithium) | Equal |
|
||||
|---:|---|---|
|
||||
| 1 | 9.94, 0.01; 9.99, 0.00; 9.81, 0.02; 9.98, 0.00 | yes |
|
||||
| 10 | 61.21, 1.78; 61.63, 1.84; 60.76, 1.85; 61.65, 1.84 | yes |
|
||||
| 25 | 155.81, 5.78; 158.52, 5.71; 156.35, 5.72; 158.89, 5.75 | yes |
|
||||
| 50 | 302.68, 11.02; (stopped); 310.51, 11.16; 311.52, 11.28 | yes |
|
||||
|
||||
Horizon pulled no bot back for moving too quickly (`moved too quickly`: 0 at every count). The Java servers
|
||||
logged 0 to 46 such lines a run. All four sent position corrections ("Pulled back") at similar rates.
|
||||
|
||||
## Method
|
||||
|
||||
`tools/server_bench.py` ran each configuration on a fresh server:
|
||||
|
||||
- the same `server.properties` on all four: seed 12345, view and simulation distance 10, offline, creative,
|
||||
difficulty easy, `sync-chunk-writes=true`, white-list off;
|
||||
- `gamerule respawn_radius 0`, and otherwise each server's default game rules;
|
||||
- the server waits for `Done`, the bots join one a second, warm up for 300 seconds and are measured for 300,
|
||||
with `/tick query` every 5 seconds and RSS and CPU every second.
|
||||
|
||||
The bots are `horizon-bots --behaviour mixed`: 60 % walk, 20 % explore and 20 % idle, each choice deterministic
|
||||
per bot. All are creative and fly 24 blocks above spawn. `walk` flies to random points within 48 blocks at
|
||||
5.6 blocks a second, `explore` flies straight out at 0.8 blocks a second (`--explore-speed 0.8`), and `idle`
|
||||
hovers.
|
||||
|
||||
**Machine.** The ThinkPad: Intel Core i7-7500U (2 cores, 4 threads), 32 GB, NixOS. The desktop was meant to
|
||||
run it but didn't wake (`~/fleet/power wake desktop` timed out twice). No other session ran on the ThinkPad
|
||||
(`load.txt`, a sample a minute: 0 other sessions throughout). The benchmark ran under `nice -n 19` and
|
||||
`ionice -c3`. The bots ran on the Mac mini (M4 Pro, shared with other work at 51 to 98 % CPU, 82 % on
|
||||
average) and reached the ThinkPad over the tailnet.
|
||||
|
||||
**What ran.**
|
||||
|
||||
- Horizon: release v2026.10.19 (07f2913d), the server archive `horizon-server-linux-x86_64.tar.gz` installed
|
||||
with `install.sh`. `horizon-server` ran its `horizon-engine` with game files prepared from the owner's own
|
||||
Minecraft.
|
||||
- Vanilla: the pinned 26.3 server jar (the owner's own copy) on OpenJDK 25.0.4.1, `-Xms4g -Xmx4g`.
|
||||
- Paper: 26.3 build 205 (`26.3-205-main@a90108f`), from PaperMC's download API, checked against its SHA-256.
|
||||
It ran with Paper's defaults except `connection-throttle: -1` in `bukkit.yml`. Every bot connects from one
|
||||
address, and the default throttle (4 s) refused all but the first. Paperclip patched the pinned server jar
|
||||
from its cache. Same Java and heap.
|
||||
- Fabric + Lithium: the pinned server jar on Fabric loader 0.19.5 (`packs/fabric.lock.json`) with Lithium
|
||||
`mc26.3-0.26.2-fabric` (`packs/mods.lock.json`, checked against its SHA-512). Lithium needs no Fabric API.
|
||||
Same Java and heap.
|
||||
|
||||
The Paper and Fabric jars are dev-only downloads into `.local/server-bench/jars` and are never committed or
|
||||
shipped.
|
||||
|
||||
**How it ran.** The first pass ran all four servers at 1 and 10 bots, but its Paper 10-bot run hit the
|
||||
connection throttle (1 of 10 bots joined), and it was stopped after that. The 1-bot and 10-bot lines for
|
||||
Horizon, vanilla and Fabric come from that pass, along with Paper's 1-bot line. A second pass with the
|
||||
throttle off ran all four at 25 and 50 bots, then Paper at 10. All runs were on the same machine on the
|
||||
same morning (02:57 to 06:02 UTC). `runs.jsonl` holds the 16 run lines used here.
|
||||
`report-25-50.json` and `report-paper-10.json` are the second pass's reports. The first pass, stopped
|
||||
early, wrote no report. Its lines come from its output.
|
||||
|
||||
## Reproduce
|
||||
|
||||
curl -fsSL https://raw.githubusercontent.com/ludovic111/horizon/main/packaging/server/install.sh | sh
|
||||
horizon-prepare --prepare --server-jar /path/to/server.jar # your own copy
|
||||
cargo build --release -p horizon-bots # on the machine that runs the bots
|
||||
# in a checkout whose .local/minecraft holds the pinned vanilla jars (tools/lab.py prepare):
|
||||
HORIZON_BENCH_BIN=~/.local/share/horizon/current \
|
||||
python3 tools/server_bench.py --servers horizon,vanilla,paper,fabric --bots 1,10,25,50 --behaviour mixed \
|
||||
--explore-speed 0.8 --warmup 300 --nice 19 \
|
||||
--bots-via 'ssh <bots machine>' --bots-bin /path/to/horizon-bots --connect <this machine's address> \
|
||||
--out four-way.json
|
||||
|
||||
## Next
|
||||
|
||||
- One pass per configuration ran, on a 4-thread laptop CPU. A rerun on a bigger machine (the desktop's
|
||||
24 threads) would show which server uses more cores best, and give vanilla at 50 a second chance.
|
||||
- Horizon's average tick: where its 1-player tick goes (13.7 ms against Paper's 10.3). That work is
|
||||
perf-server-11's.
|
||||
- 100 bots.
|
||||
@@ -0,0 +1,870 @@
|
||||
2026-10-19T02:57:31Z
|
||||
ludothinkpad ok: 29.0 GB free, 4 threads 1% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 28.7 GB free, 14 threads 89% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T02:58:36Z
|
||||
ludothinkpad ok: 28.3 GB free, 4 threads 20% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.0 GB free, 14 threads 92% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T02:59:40Z
|
||||
ludothinkpad ok: 28.3 GB free, 4 threads 20% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.8 GB free, 14 threads 96% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:00:44Z
|
||||
ludothinkpad ok: 28.3 GB free, 4 threads 15% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 25.8 GB free, 14 threads 97% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:01:48Z
|
||||
ludothinkpad ok: 27.8 GB free, 4 threads 27% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 26.8 GB free, 14 threads 84% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:02:52Z
|
||||
ludothinkpad ok: 28.4 GB free, 4 threads 18% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 25.7 GB free, 14 threads 93% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:03:56Z
|
||||
ludothinkpad ok: 28.4 GB free, 4 threads 14% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 29.6 GB free, 14 threads 96% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:05:00Z
|
||||
ludothinkpad ok: 28.4 GB free, 4 threads 11% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 29.4 GB free, 14 threads 84% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:06:04Z
|
||||
ludothinkpad ok: 28.3 GB free, 4 threads 22% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 26.8 GB free, 14 threads 82% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:07:09Z
|
||||
ludothinkpad ok: 28.4 GB free, 4 threads 11% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.1 GB free, 14 threads 91% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:08:13Z
|
||||
ludothinkpad busy: 26.9 GB free, 4 threads 92% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 26.4 GB free, 14 threads 76% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:09:17Z
|
||||
ludothinkpad ok: 25.8 GB free, 4 threads 19% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 29.5 GB free, 14 threads 87% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:10:21Z
|
||||
ludothinkpad ok: 25.8 GB free, 4 threads 49% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 25.8 GB free, 14 threads 79% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:11:25Z
|
||||
ludothinkpad ok: 25.8 GB free, 4 threads 19% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.7 GB free, 14 threads 85% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:12:29Z
|
||||
ludothinkpad ok: 25.8 GB free, 4 threads 18% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.2 GB free, 14 threads 88% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:13:33Z
|
||||
ludothinkpad ok: 25.8 GB free, 4 threads 17% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 28.0 GB free, 14 threads 84% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:14:37Z
|
||||
ludothinkpad ok: 25.8 GB free, 4 threads 17% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 28.7 GB free, 14 threads 76% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:15:41Z
|
||||
ludothinkpad ok: 25.8 GB free, 4 threads 18% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.2 GB free, 14 threads 87% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:16:46Z
|
||||
ludothinkpad ok: 25.8 GB free, 4 threads 18% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 25.0 GB free, 14 threads 91% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:17:50Z
|
||||
ludothinkpad ok: 25.8 GB free, 4 threads 19% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 25.6 GB free, 14 threads 78% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:18:54Z
|
||||
ludothinkpad ok: 26.7 GB free, 4 threads 71% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.2 GB free, 14 threads 91% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:19:58Z
|
||||
ludothinkpad ok: 25.1 GB free, 4 threads 16% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 25.6 GB free, 14 threads 85% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:21:02Z
|
||||
ludothinkpad ok: 25.1 GB free, 4 threads 15% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 25.7 GB free, 14 threads 87% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:22:06Z
|
||||
ludothinkpad ok: 25.1 GB free, 4 threads 12% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.8 GB free, 14 threads 87% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:23:10Z
|
||||
ludothinkpad ok: 25.1 GB free, 4 threads 12% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 25.6 GB free, 14 threads 81% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:24:14Z
|
||||
ludothinkpad ok: 25.1 GB free, 4 threads 19% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 27.1 GB free, 14 threads 82% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:25:19Z
|
||||
ludothinkpad ok: 25.1 GB free, 4 threads 9% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 28.6 GB free, 14 threads 86% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:26:23Z
|
||||
ludothinkpad ok: 25.1 GB free, 4 threads 13% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 25.9 GB free, 14 threads 71% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:27:27Z
|
||||
ludothinkpad ok: 25.1 GB free, 4 threads 14% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.0 GB free, 14 threads 94% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:28:31Z
|
||||
ludothinkpad ok: 25.1 GB free, 4 threads 13% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 26.1 GB free, 14 threads 79% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:29:35Z
|
||||
ludothinkpad ok: 27.5 GB free, 4 threads 36% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 25.5 GB free, 14 threads 84% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:30:39Z
|
||||
ludothinkpad ok: 25.8 GB free, 4 threads 22% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 26.2 GB free, 14 threads 73% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:31:43Z
|
||||
ludothinkpad ok: 25.8 GB free, 4 threads 20% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.1 GB free, 14 threads 86% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:32:47Z
|
||||
ludothinkpad ok: 25.8 GB free, 4 threads 21% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 28.4 GB free, 14 threads 90% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:33:52Z
|
||||
ludothinkpad ok: 25.8 GB free, 4 threads 18% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 26.9 GB free, 14 threads 84% busy, 2 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:34:56Z
|
||||
ludothinkpad ok: 25.8 GB free, 4 threads 14% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 26.1 GB free, 14 threads 76% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:36:00Z
|
||||
ludothinkpad ok: 25.8 GB free, 4 threads 16% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 27.4 GB free, 14 threads 78% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:37:04Z
|
||||
ludothinkpad ok: 25.8 GB free, 4 threads 15% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 26.6 GB free, 14 threads 77% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:38:08Z
|
||||
ludothinkpad ok: 25.8 GB free, 4 threads 14% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 25.8 GB free, 14 threads 94% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:39:12Z
|
||||
ludothinkpad ok: 25.8 GB free, 4 threads 17% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 27.1 GB free, 14 threads 91% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:40:16Z
|
||||
ludothinkpad ok: 28.4 GB free, 4 threads 73% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 25.5 GB free, 14 threads 78% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:41:20Z
|
||||
ludothinkpad ok: 28.3 GB free, 4 threads 41% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 25.8 GB free, 14 threads 86% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:42:24Z
|
||||
ludothinkpad ok: 28.3 GB free, 4 threads 24% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.9 GB free, 14 threads 88% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:43:29Z
|
||||
ludothinkpad ok: 28.3 GB free, 4 threads 26% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 25.0 GB free, 14 threads 87% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:44:33Z
|
||||
ludothinkpad ok: 28.2 GB free, 4 threads 22% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 25.6 GB free, 14 threads 81% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:45:37Z
|
||||
ludothinkpad ok: 28.2 GB free, 4 threads 27% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 27.8 GB free, 14 threads 76% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:46:41Z
|
||||
ludothinkpad ok: 28.1 GB free, 4 threads 37% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 27.3 GB free, 14 threads 81% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:47:45Z
|
||||
ludothinkpad ok: 28.1 GB free, 4 threads 21% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 27.1 GB free, 14 threads 82% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:48:49Z
|
||||
ludothinkpad ok: 28.1 GB free, 4 threads 32% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 26.2 GB free, 14 threads 82% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:49:53Z
|
||||
ludothinkpad ok: 28.1 GB free, 4 threads 25% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 25.7 GB free, 14 threads 85% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:50:57Z
|
||||
ludothinkpad busy: 26.6 GB free, 4 threads 94% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 25.1 GB free, 14 threads 85% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:52:01Z
|
||||
ludothinkpad ok: 25.8 GB free, 4 threads 28% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 25.1 GB free, 14 threads 91% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:53:06Z
|
||||
ludothinkpad ok: 25.8 GB free, 4 threads 50% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.4 GB free, 14 threads 87% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:54:10Z
|
||||
ludothinkpad ok: 25.7 GB free, 4 threads 41% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 25.8 GB free, 14 threads 87% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:55:14Z
|
||||
ludothinkpad ok: 25.6 GB free, 4 threads 27% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 31.4 GB free, 14 threads 90% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:56:18Z
|
||||
ludothinkpad ok: 25.6 GB free, 4 threads 26% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 31.0 GB free, 14 threads 88% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:57:22Z
|
||||
ludothinkpad ok: 25.6 GB free, 4 threads 33% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.5 GB free, 14 threads 91% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:58:26Z
|
||||
ludothinkpad ok: 25.5 GB free, 4 threads 45% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.7 GB free, 14 threads 89% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T03:59:30Z
|
||||
ludothinkpad ok: 25.5 GB free, 4 threads 24% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 25.6 GB free, 14 threads 77% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:00:34Z
|
||||
ludothinkpad ok: 25.5 GB free, 4 threads 31% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 25.0 GB free, 14 threads 81% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:01:38Z
|
||||
ludothinkpad busy: 27.0 GB free, 4 threads 99% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 28.3 GB free, 14 threads 82% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:02:42Z
|
||||
ludothinkpad ok: 26.2 GB free, 4 threads 19% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.5 GB free, 14 threads 87% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:03:47Z
|
||||
ludothinkpad ok: 25.9 GB free, 4 threads 14% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 27.9 GB free, 14 threads 78% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:04:51Z
|
||||
ludothinkpad ok: 25.7 GB free, 4 threads 11% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 30.0 GB free, 14 threads 91% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:05:55Z
|
||||
ludothinkpad ok: 25.3 GB free, 4 threads 10% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 27.5 GB free, 14 threads 88% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:06:59Z
|
||||
ludothinkpad ok: 25.3 GB free, 4 threads 13% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 26.9 GB free, 14 threads 77% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:08:03Z
|
||||
ludothinkpad ok: 25.3 GB free, 4 threads 12% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 25.8 GB free, 14 threads 81% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:09:07Z
|
||||
ludothinkpad ok: 25.3 GB free, 4 threads 10% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.9 GB free, 14 threads 86% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:10:11Z
|
||||
ludothinkpad ok: 25.3 GB free, 4 threads 8% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.6 GB free, 14 threads 92% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:11:15Z
|
||||
ludothinkpad ok: 25.3 GB free, 4 threads 9% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 27.3 GB free, 14 threads 77% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:12:20Z
|
||||
ludothinkpad busy: 27.5 GB free, 4 threads 88% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.5 GB free, 14 threads 86% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:13:24Z
|
||||
ludothinkpad ok: 25.8 GB free, 4 threads 44% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 27.6 GB free, 14 threads 90% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:14:28Z
|
||||
ludothinkpad ok: 25.7 GB free, 4 threads 28% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 27.8 GB free, 14 threads 87% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:15:32Z
|
||||
ludothinkpad ok: 25.7 GB free, 4 threads 26% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 26.7 GB free, 14 threads 73% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:16:36Z
|
||||
ludothinkpad ok: 25.6 GB free, 4 threads 28% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 29.3 GB free, 14 threads 80% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:17:40Z
|
||||
ludothinkpad ok: 25.6 GB free, 4 threads 32% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.3 GB free, 14 threads 90% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:18:44Z
|
||||
ludothinkpad ok: 25.5 GB free, 4 threads 76% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 28.4 GB free, 14 threads 84% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:19:48Z
|
||||
ludothinkpad ok: 25.5 GB free, 4 threads 26% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 30.4 GB free, 14 threads 79% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:20:52Z
|
||||
ludothinkpad ok: 25.5 GB free, 4 threads 26% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 28.1 GB free, 14 threads 73% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:21:57Z
|
||||
ludothinkpad ok: 25.5 GB free, 4 threads 28% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 31.0 GB free, 14 threads 84% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:23:01Z
|
||||
ludothinkpad ok: 25.5 GB free, 4 threads 10% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 31.7 GB free, 14 threads 83% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:24:05Z
|
||||
ludothinkpad ok: 28.3 GB free, 4 threads 41% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 27.1 GB free, 14 threads 71% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:25:15Z
|
||||
ludothinkpad ok: 28.8 GB free, 4 threads 4% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 25.7 GB free, 14 threads 98% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:26:19Z
|
||||
ludothinkpad ok: 28.3 GB free, 4 threads 40% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 32.1 GB free, 14 threads 65% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:27:23Z
|
||||
ludothinkpad ok: 28.2 GB free, 4 threads 41% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 29.4 GB free, 14 threads 89% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:28:27Z
|
||||
ludothinkpad ok: 28.2 GB free, 4 threads 44% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 28.9 GB free, 14 threads 77% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:29:32Z
|
||||
ludothinkpad ok: 28.1 GB free, 4 threads 33% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 25.3 GB free, 14 threads 71% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:30:36Z
|
||||
ludothinkpad ok: 28.1 GB free, 4 threads 31% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 30.0 GB free, 14 threads 63% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:31:40Z
|
||||
ludothinkpad ok: 28.0 GB free, 4 threads 40% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 27.4 GB free, 14 threads 73% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:32:44Z
|
||||
ludothinkpad ok: 28.0 GB free, 4 threads 35% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 29.3 GB free, 14 threads 51% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:33:48Z
|
||||
ludothinkpad ok: 28.0 GB free, 4 threads 31% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 27.5 GB free, 14 threads 67% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:34:52Z
|
||||
ludothinkpad ok: 28.0 GB free, 4 threads 35% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 26.6 GB free, 14 threads 82% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:35:56Z
|
||||
ludothinkpad ok: 27.9 GB free, 4 threads 34% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 28.9 GB free, 14 threads 66% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:37:00Z
|
||||
ludothinkpad busy: 25.8 GB free, 4 threads 98% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 26.9 GB free, 14 threads 75% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:38:04Z
|
||||
ludothinkpad ok: 25.7 GB free, 4 threads 64% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 25.6 GB free, 14 threads 74% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:39:09Z
|
||||
ludothinkpad ok: 25.6 GB free, 4 threads 64% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 26.8 GB free, 14 threads 77% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:40:13Z
|
||||
ludothinkpad ok: 25.5 GB free, 4 threads 51% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 26.3 GB free, 14 threads 70% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:41:17Z
|
||||
ludothinkpad ok: 25.4 GB free, 4 threads 62% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 26.3 GB free, 14 threads 83% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:42:21Z
|
||||
ludothinkpad ok: 25.4 GB free, 4 threads 47% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 26.2 GB free, 14 threads 68% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:43:25Z
|
||||
ludothinkpad ok: 25.4 GB free, 4 threads 67% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.9 GB free, 14 threads 88% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:44:29Z
|
||||
ludothinkpad ok: 25.3 GB free, 4 threads 64% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 30.4 GB free, 14 threads 79% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:45:33Z
|
||||
ludothinkpad ok: 25.2 GB free, 4 threads 66% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 25.0 GB free, 14 threads 69% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:46:37Z
|
||||
ludothinkpad ok: 25.1 GB free, 4 threads 48% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.4 GB free, 14 threads 87% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:47:42Z
|
||||
ludothinkpad ok: 27.5 GB free, 4 threads 69% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 28.0 GB free, 14 threads 83% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:48:46Z
|
||||
ludothinkpad ok: 25.3 GB free, 4 threads 83% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 25.6 GB free, 14 threads 85% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:49:50Z
|
||||
ludothinkpad ok: 25.3 GB free, 4 threads 55% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 25.3 GB free, 14 threads 88% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:50:54Z
|
||||
ludothinkpad ok: 25.2 GB free, 4 threads 51% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 30.9 GB free, 14 threads 74% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:51:58Z
|
||||
ludothinkpad ok: 25.2 GB free, 4 threads 57% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 29.2 GB free, 14 threads 83% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:53:02Z
|
||||
ludothinkpad ok: 25.1 GB free, 4 threads 59% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.2 GB free, 14 threads 88% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:54:06Z
|
||||
ludothinkpad ok: 25.1 GB free, 4 threads 38% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 25.4 GB free, 14 threads 90% busy, 2 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:55:10Z
|
||||
ludothinkpad ok: 25.1 GB free, 4 threads 43% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 25.4 GB free, 14 threads 85% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:56:15Z
|
||||
ludothinkpad ok: 25.1 GB free, 4 threads 42% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 27.6 GB free, 14 threads 80% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:57:19Z
|
||||
ludothinkpad ok: 25.0 GB free, 4 threads 45% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 27.0 GB free, 14 threads 67% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:58:23Z
|
||||
ludothinkpad ok: 25.0 GB free, 4 threads 48% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 27.4 GB free, 14 threads 74% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T04:59:27Z
|
||||
ludothinkpad busy: 26.6 GB free, 4 threads 100% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 27.7 GB free, 14 threads 91% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:00:31Z
|
||||
ludothinkpad ok: 25.6 GB free, 4 threads 53% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 32.2 GB free, 14 threads 64% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:01:35Z
|
||||
ludothinkpad ok: 25.7 GB free, 4 threads 54% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 26.1 GB free, 14 threads 85% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:02:39Z
|
||||
ludothinkpad ok: 25.6 GB free, 4 threads 59% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 25.4 GB free, 14 threads 84% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:03:43Z
|
||||
ludothinkpad ok: 25.6 GB free, 4 threads 55% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 25.3 GB free, 14 threads 87% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:04:48Z
|
||||
ludothinkpad busy: 25.0 GB free, 4 threads 96% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 26.6 GB free, 14 threads 79% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:05:52Z
|
||||
ludothinkpad ok: 25.4 GB free, 4 threads 51% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 27.8 GB free, 14 threads 84% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:06:56Z
|
||||
ludothinkpad ok: 25.4 GB free, 4 threads 46% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 25.9 GB free, 14 threads 92% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:08:00Z
|
||||
ludothinkpad ok: 25.4 GB free, 4 threads 53% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 29.0 GB free, 14 threads 88% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:09:04Z
|
||||
ludothinkpad ok: 25.1 GB free, 4 threads 64% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 27.3 GB free, 14 threads 81% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:10:08Z
|
||||
ludothinkpad ok: 28.6 GB free, 4 threads 28% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 26.8 GB free, 14 threads 78% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:11:12Z
|
||||
ludothinkpad ok: 28.2 GB free, 4 threads 48% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 28.5 GB free, 14 threads 88% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:12:16Z
|
||||
ludothinkpad ok: 28.2 GB free, 4 threads 52% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 27.3 GB free, 14 threads 74% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:13:21Z
|
||||
ludothinkpad ok: 28.1 GB free, 4 threads 41% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 25.9 GB free, 14 threads 87% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:14:25Z
|
||||
ludothinkpad ok: 28.1 GB free, 4 threads 56% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.7 GB free, 14 threads 88% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:15:29Z
|
||||
ludothinkpad ok: 28.0 GB free, 4 threads 50% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 25.4 GB free, 14 threads 90% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:16:33Z
|
||||
ludothinkpad ok: 27.9 GB free, 4 threads 47% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 29.1 GB free, 14 threads 77% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:17:37Z
|
||||
ludothinkpad ok: 27.9 GB free, 4 threads 61% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 27.5 GB free, 14 threads 75% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:18:41Z
|
||||
ludothinkpad ok: 27.9 GB free, 4 threads 68% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 27.3 GB free, 14 threads 76% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:19:45Z
|
||||
ludothinkpad ok: 27.8 GB free, 4 threads 45% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 27.5 GB free, 14 threads 85% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:20:49Z
|
||||
ludothinkpad ok: 27.5 GB free, 4 threads 60% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.6 GB free, 14 threads 95% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:21:53Z
|
||||
ludothinkpad busy: 26.7 GB free, 4 threads 99% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 29.8 GB free, 14 threads 72% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:22:58Z
|
||||
ludothinkpad busy: 25.7 GB free, 4 threads 97% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.6 GB free, 14 threads 86% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:24:02Z
|
||||
ludothinkpad busy: 25.4 GB free, 4 threads 90% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 28.2 GB free, 14 threads 85% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:25:06Z
|
||||
ludothinkpad ok: 25.3 GB free, 4 threads 73% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 27.8 GB free, 14 threads 89% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:26:10Z
|
||||
ludothinkpad ok: 25.2 GB free, 4 threads 69% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 28.8 GB free, 14 threads 74% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:27:14Z
|
||||
ludothinkpad ok: 25.2 GB free, 4 threads 82% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 29.6 GB free, 14 threads 80% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:28:18Z
|
||||
ludothinkpad ok: 25.2 GB free, 4 threads 66% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 33.7 GB free, 14 threads 88% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:29:22Z
|
||||
ludothinkpad busy: 26.4 GB free, 4 threads 99% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 33.7 GB free, 14 threads 81% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:30:27Z
|
||||
ludothinkpad ok: 25.0 GB free, 4 threads 73% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 31.8 GB free, 14 threads 87% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:31:31Z
|
||||
ludothinkpad ok: 24.9 GB free, 4 threads 66% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 29.7 GB free, 14 threads 77% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:32:35Z
|
||||
ludothinkpad busy: 25.0 GB free, 4 threads 94% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 27.8 GB free, 14 threads 87% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:33:39Z
|
||||
ludothinkpad ok: 24.9 GB free, 4 threads 70% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 25.7 GB free, 14 threads 90% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:34:43Z
|
||||
ludothinkpad busy: 24.9 GB free, 4 threads 88% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 32.7 GB free, 14 threads 88% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:35:47Z
|
||||
ludothinkpad ok: 24.9 GB free, 4 threads 72% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 33.3 GB free, 14 threads 86% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:36:51Z
|
||||
ludothinkpad ok: 24.8 GB free, 4 threads 76% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 32.6 GB free, 14 threads 83% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:37:55Z
|
||||
ludothinkpad ok: 24.7 GB free, 4 threads 71% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 31.5 GB free, 14 threads 65% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:39:00Z
|
||||
ludothinkpad busy: 24.7 GB free, 4 threads 92% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 29.7 GB free, 14 threads 69% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:40:04Z
|
||||
ludothinkpad ok: 28.6 GB free, 4 threads 55% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 30.0 GB free, 14 threads 73% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:41:08Z
|
||||
ludothinkpad busy: 25.6 GB free, 4 threads 100% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 28.3 GB free, 14 threads 88% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:42:12Z
|
||||
ludothinkpad busy: 25.5 GB free, 4 threads 97% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 27.6 GB free, 14 threads 80% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:43:16Z
|
||||
ludothinkpad busy: 25.3 GB free, 4 threads 98% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 29.5 GB free, 14 threads 76% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:44:20Z
|
||||
ludothinkpad ok: 25.2 GB free, 4 threads 85% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 28.6 GB free, 14 threads 64% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:45:25Z
|
||||
ludothinkpad ok: 25.2 GB free, 4 threads 74% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 26.6 GB free, 14 threads 78% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:46:29Z
|
||||
ludothinkpad ok: 25.2 GB free, 4 threads 68% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 26.5 GB free, 14 threads 82% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:47:33Z
|
||||
ludothinkpad ok: 24.5 GB free, 4 threads 84% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.5 GB free, 14 threads 90% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:48:37Z
|
||||
ludothinkpad busy: 24.4 GB free, 4 threads 86% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 30.6 GB free, 14 threads 61% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:49:41Z
|
||||
ludothinkpad ok: 24.2 GB free, 4 threads 80% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.9 GB free, 14 threads 85% busy, 3 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:50:45Z
|
||||
ludothinkpad ok: 24.2 GB free, 4 threads 72% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 25.7 GB free, 14 threads 89% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:51:49Z
|
||||
ludothinkpad ok: 24.1 GB free, 4 threads 17% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 29.1 GB free, 14 threads 87% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:52:54Z
|
||||
ludothinkpad ok: 27.5 GB free, 4 threads 83% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 26.9 GB free, 14 threads 76% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:53:58Z
|
||||
ludothinkpad ok: 25.8 GB free, 4 threads 56% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 27.3 GB free, 14 threads 82% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:55:02Z
|
||||
ludothinkpad ok: 25.8 GB free, 4 threads 35% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 27.2 GB free, 14 threads 95% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:56:06Z
|
||||
ludothinkpad ok: 25.0 GB free, 4 threads 52% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 29.6 GB free, 14 threads 56% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:57:10Z
|
||||
ludothinkpad ok: 25.1 GB free, 4 threads 30% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.2 GB free, 14 threads 91% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:58:14Z
|
||||
ludothinkpad ok: 25.1 GB free, 4 threads 36% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 27.8 GB free, 14 threads 94% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T05:59:18Z
|
||||
ludothinkpad ok: 25.0 GB free, 4 threads 27% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 30.3 GB free, 14 threads 51% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T06:00:22Z
|
||||
ludothinkpad ok: 25.0 GB free, 4 threads 24% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.7 GB free, 14 threads 86% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T06:01:26Z
|
||||
ludothinkpad ok: 24.4 GB free, 4 threads 38% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 27.1 GB free, 14 threads 89% busy, 1 sessions
|
||||
macbook offline
|
||||
2026-10-19T06:02:31Z
|
||||
ludothinkpad ok: 25.2 GB free, 4 threads 27% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 29.1 GB free, 14 threads 55% busy, 1 sessions
|
||||
macbook offline
|
||||
@@ -0,0 +1,437 @@
|
||||
{
|
||||
"kind": "horizon-server-benchmark",
|
||||
"date": "2026-10-19T05:52:44+00:00",
|
||||
"machine": {
|
||||
"system": "Linux",
|
||||
"machine": "x86_64",
|
||||
"cpus": 4
|
||||
},
|
||||
"commit": "38505a07b5334f3ea2c467efb4a00e5557f910f8",
|
||||
"java": [
|
||||
"openjdk 25.0.4.1 2026-08-18"
|
||||
],
|
||||
"versions": {
|
||||
"paper_build": 205,
|
||||
"fabric_loader": "0.19.5",
|
||||
"lithium": "mc26.3-0.26.2-fabric"
|
||||
},
|
||||
"settings": {
|
||||
"servers": "horizon,vanilla,paper,fabric",
|
||||
"bots": "25,50",
|
||||
"behaviour": "mixed",
|
||||
"join_every": 1.0,
|
||||
"warmup": 300,
|
||||
"seconds": 300,
|
||||
"seed": 12345,
|
||||
"explore_speed": 0.8,
|
||||
"gamerule": "",
|
||||
"heap": "4g",
|
||||
"out": "/home/ludovic/sh10/out/mixed-equal-25-50.json",
|
||||
"nice": 19,
|
||||
"bots_via": "ssh macmini",
|
||||
"bots_bin": "/Users/ludovicmarie/code/horizon/.claude/worktrees/sh10-bench/target/release/horizon-bots",
|
||||
"connect": "the ThinkPad's tailnet address",
|
||||
"properties": {
|
||||
"level-seed": "12345",
|
||||
"view-distance": "10",
|
||||
"simulation-distance": "10",
|
||||
"online-mode": "false",
|
||||
"white-list": "false",
|
||||
"gamemode": "creative",
|
||||
"force-gamemode": "true",
|
||||
"spawn-protection": "0",
|
||||
"max-players": "200",
|
||||
"difficulty": "easy",
|
||||
"sync-chunk-writes": "true"
|
||||
}
|
||||
},
|
||||
"runs": [
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 25,
|
||||
"behaviour": "mixed",
|
||||
"start_seconds": 8.51,
|
||||
"mspt": {
|
||||
"average": 30.92,
|
||||
"p50": 27.88,
|
||||
"p95": 48.25,
|
||||
"p99": 72.0
|
||||
},
|
||||
"mspt_worst_p99": 121.1,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 1012.01,
|
||||
"max": 1121.3
|
||||
},
|
||||
"cpu_percent": 98.57,
|
||||
"chunks_per_second": 155.81,
|
||||
"unique_per_second": 5.78,
|
||||
"forgotten_per_second": 155.62,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 106268,
|
||||
"chunks_per_bot": {
|
||||
"walk": 6506.33,
|
||||
"explore": 1261.6,
|
||||
"idle": 473.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 702.13,
|
||||
"explore": 1184.4,
|
||||
"idle": 473.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 3008,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 25,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 25,
|
||||
"behaviour": "mixed",
|
||||
"start_seconds": 15.47,
|
||||
"mspt": {
|
||||
"average": 37.35,
|
||||
"p50": 35.64,
|
||||
"p95": 48.2,
|
||||
"p99": 82.66
|
||||
},
|
||||
"mspt_worst_p99": 579.8,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 3935.5,
|
||||
"max": 4157.4
|
||||
},
|
||||
"cpu_percent": 191.44,
|
||||
"chunks_per_second": 158.52,
|
||||
"unique_per_second": 5.71,
|
||||
"forgotten_per_second": 158.39,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 103148,
|
||||
"chunks_per_bot": {
|
||||
"walk": 6306.87,
|
||||
"explore": 1236.0,
|
||||
"idle": 473.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 704.2,
|
||||
"explore": 1160.2,
|
||||
"idle": 473.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 3762,
|
||||
"moved_too_quickly": 11,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 25,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 59
|
||||
},
|
||||
{
|
||||
"server": "paper",
|
||||
"bots": 25,
|
||||
"behaviour": "mixed",
|
||||
"start_seconds": 18.95,
|
||||
"mspt": {
|
||||
"average": 28.3,
|
||||
"p50": 23.7,
|
||||
"p95": 61.68,
|
||||
"p99": 90.4
|
||||
},
|
||||
"mspt_worst_p99": 124.3,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 4160.34,
|
||||
"max": 4246.4
|
||||
},
|
||||
"cpu_percent": 124.24,
|
||||
"chunks_per_second": 156.35,
|
||||
"unique_per_second": 5.72,
|
||||
"forgotten_per_second": 156.25,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 102715,
|
||||
"chunks_per_bot": {
|
||||
"walk": 6280.4,
|
||||
"explore": 1228.8,
|
||||
"idle": 473.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 703.07,
|
||||
"explore": 1155.6,
|
||||
"idle": 473.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 4337,
|
||||
"moved_too_quickly": 9,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 25,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 59
|
||||
},
|
||||
{
|
||||
"server": "fabric",
|
||||
"bots": 25,
|
||||
"behaviour": "mixed",
|
||||
"start_seconds": 17.45,
|
||||
"mspt": {
|
||||
"average": 31.44,
|
||||
"p50": 28.33,
|
||||
"p95": 52.45,
|
||||
"p99": 93.35
|
||||
},
|
||||
"mspt_worst_p99": 409.3,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 3884.61,
|
||||
"max": 4186.4
|
||||
},
|
||||
"cpu_percent": 174.04,
|
||||
"chunks_per_second": 158.89,
|
||||
"unique_per_second": 5.75,
|
||||
"forgotten_per_second": 158.54,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 103913,
|
||||
"chunks_per_bot": {
|
||||
"walk": 6356.8,
|
||||
"explore": 1239.2,
|
||||
"idle": 473.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 704.27,
|
||||
"explore": 1163.0,
|
||||
"idle": 473.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 4194,
|
||||
"moved_too_quickly": 11,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 25,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 59
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 50,
|
||||
"behaviour": "mixed",
|
||||
"start_seconds": 8.31,
|
||||
"mspt": {
|
||||
"average": 47.83,
|
||||
"p50": 46.02,
|
||||
"p95": 62.42,
|
||||
"p99": 73.21
|
||||
},
|
||||
"mspt_worst_p99": 133.0,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 1112.46,
|
||||
"max": 1296.4
|
||||
},
|
||||
"cpu_percent": 143.22,
|
||||
"chunks_per_second": 302.68,
|
||||
"unique_per_second": 11.02,
|
||||
"forgotten_per_second": 303.15,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 211296,
|
||||
"chunks_per_bot": {
|
||||
"walk": 6468.47,
|
||||
"explore": 1251.2,
|
||||
"idle": 473.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 701.67,
|
||||
"explore": 1180.5,
|
||||
"idle": 473.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 8562,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 50,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 50,
|
||||
"behaviour": "mixed",
|
||||
"start_seconds": 15.45,
|
||||
"error": "[Errno 32] Broken pipe"
|
||||
},
|
||||
{
|
||||
"server": "paper",
|
||||
"bots": 50,
|
||||
"behaviour": "mixed",
|
||||
"start_seconds": 19.35,
|
||||
"mspt": {
|
||||
"average": 46.86,
|
||||
"p50": 45.11,
|
||||
"p95": 61.16,
|
||||
"p99": 109.72
|
||||
},
|
||||
"mspt_worst_p99": 213.4,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 4353.02,
|
||||
"max": 4516.1
|
||||
},
|
||||
"cpu_percent": 210.21,
|
||||
"chunks_per_second": 310.51,
|
||||
"unique_per_second": 11.16,
|
||||
"forgotten_per_second": 310.64,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 209303,
|
||||
"chunks_per_bot": {
|
||||
"walk": 6411.0,
|
||||
"explore": 1224.3,
|
||||
"idle": 473.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 703.47,
|
||||
"explore": 1155.8,
|
||||
"idle": 473.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 7344,
|
||||
"moved_too_quickly": 35,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 50,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "fabric",
|
||||
"bots": 50,
|
||||
"behaviour": "mixed",
|
||||
"start_seconds": 17.45,
|
||||
"mspt": {
|
||||
"average": 58.3,
|
||||
"p50": 54.73,
|
||||
"p95": 83.06,
|
||||
"p99": 174.37
|
||||
},
|
||||
"mspt_worst_p99": 887.6,
|
||||
"tps": 17.15,
|
||||
"rss_mb": {
|
||||
"mean": 4521.47,
|
||||
"max": 4744.2
|
||||
},
|
||||
"cpu_percent": 236.04,
|
||||
"chunks_per_second": 311.52,
|
||||
"unique_per_second": 11.28,
|
||||
"forgotten_per_second": 311.74,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 206965,
|
||||
"chunks_per_bot": {
|
||||
"walk": 6331.7,
|
||||
"explore": 1228.4,
|
||||
"idle": 473.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 703.3,
|
||||
"explore": 1160.3,
|
||||
"idle": 473.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 6350,
|
||||
"moved_too_quickly": 46,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 50,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
}
|
||||
],
|
||||
"work": [
|
||||
{
|
||||
"behaviour": "mixed",
|
||||
"bots": 25,
|
||||
"servers": {
|
||||
"horizon": {
|
||||
"chunks_per_second": {
|
||||
"value": 155.81,
|
||||
"share": 0.981
|
||||
},
|
||||
"unique_per_second": {
|
||||
"value": 5.78,
|
||||
"share": 1.0
|
||||
},
|
||||
"waited_ticks_per_second": 0.0
|
||||
},
|
||||
"vanilla": {
|
||||
"chunks_per_second": {
|
||||
"value": 158.52,
|
||||
"share": 0.998
|
||||
},
|
||||
"unique_per_second": {
|
||||
"value": 5.71,
|
||||
"share": 0.988
|
||||
},
|
||||
"waited_ticks_per_second": 0.0
|
||||
},
|
||||
"paper": {
|
||||
"chunks_per_second": {
|
||||
"value": 156.35,
|
||||
"share": 0.984
|
||||
},
|
||||
"unique_per_second": {
|
||||
"value": 5.72,
|
||||
"share": 0.99
|
||||
},
|
||||
"waited_ticks_per_second": 0.0
|
||||
},
|
||||
"fabric": {
|
||||
"chunks_per_second": {
|
||||
"value": 158.89,
|
||||
"share": 1.0
|
||||
},
|
||||
"unique_per_second": {
|
||||
"value": 5.75,
|
||||
"share": 0.995
|
||||
},
|
||||
"waited_ticks_per_second": 0.0
|
||||
}
|
||||
},
|
||||
"equal": true
|
||||
},
|
||||
{
|
||||
"behaviour": "mixed",
|
||||
"bots": 50,
|
||||
"servers": {
|
||||
"horizon": {
|
||||
"chunks_per_second": {
|
||||
"value": 302.68,
|
||||
"share": 0.972
|
||||
},
|
||||
"unique_per_second": {
|
||||
"value": 11.02,
|
||||
"share": 0.977
|
||||
},
|
||||
"waited_ticks_per_second": 0.0
|
||||
},
|
||||
"paper": {
|
||||
"chunks_per_second": {
|
||||
"value": 310.51,
|
||||
"share": 0.997
|
||||
},
|
||||
"unique_per_second": {
|
||||
"value": 11.16,
|
||||
"share": 0.989
|
||||
},
|
||||
"waited_ticks_per_second": 0.0
|
||||
},
|
||||
"fabric": {
|
||||
"chunks_per_second": {
|
||||
"value": 311.52,
|
||||
"share": 1.0
|
||||
},
|
||||
"unique_per_second": {
|
||||
"value": 11.28,
|
||||
"share": 1.0
|
||||
},
|
||||
"waited_ticks_per_second": 0.0
|
||||
}
|
||||
},
|
||||
"equal": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"kind": "horizon-server-benchmark",
|
||||
"date": "2026-10-19T06:03:34+00:00",
|
||||
"machine": {
|
||||
"system": "Linux",
|
||||
"machine": "x86_64",
|
||||
"cpus": 4
|
||||
},
|
||||
"commit": "38505a07b5334f3ea2c467efb4a00e5557f910f8",
|
||||
"java": [
|
||||
"openjdk 25.0.4.1 2026-08-18"
|
||||
],
|
||||
"versions": {
|
||||
"paper_build": 205
|
||||
},
|
||||
"settings": {
|
||||
"servers": "paper",
|
||||
"bots": "10",
|
||||
"behaviour": "mixed",
|
||||
"join_every": 1.0,
|
||||
"warmup": 300,
|
||||
"seconds": 300,
|
||||
"seed": 12345,
|
||||
"explore_speed": 0.8,
|
||||
"gamerule": "",
|
||||
"heap": "4g",
|
||||
"out": "/home/ludovic/sh10/out/mixed-equal-paper-10.json",
|
||||
"nice": 19,
|
||||
"bots_via": "ssh macmini",
|
||||
"bots_bin": "/Users/ludovicmarie/code/horizon/.claude/worktrees/sh10-bench/target/release/horizon-bots",
|
||||
"connect": "the ThinkPad's tailnet address",
|
||||
"properties": {
|
||||
"level-seed": "12345",
|
||||
"view-distance": "10",
|
||||
"simulation-distance": "10",
|
||||
"online-mode": "false",
|
||||
"white-list": "false",
|
||||
"gamemode": "creative",
|
||||
"force-gamemode": "true",
|
||||
"spawn-protection": "0",
|
||||
"max-players": "200",
|
||||
"difficulty": "easy",
|
||||
"sync-chunk-writes": "true"
|
||||
}
|
||||
},
|
||||
"runs": [
|
||||
{
|
||||
"server": "paper",
|
||||
"bots": 10,
|
||||
"behaviour": "mixed",
|
||||
"start_seconds": 20.48,
|
||||
"mspt": {
|
||||
"average": 20.64,
|
||||
"p50": 18.1,
|
||||
"p95": 35.94,
|
||||
"p99": 56.29
|
||||
},
|
||||
"mspt_worst_p99": 166.9,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 4069.94,
|
||||
"max": 4080.2
|
||||
},
|
||||
"cpu_percent": 73.56,
|
||||
"chunks_per_second": 60.76,
|
||||
"unique_per_second": 1.85,
|
||||
"forgotten_per_second": 60.96,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 40348,
|
||||
"chunks_per_bot": {
|
||||
"walk": 6176.67,
|
||||
"explore": 1171.0,
|
||||
"idle": 473.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 701.0,
|
||||
"explore": 1091.5,
|
||||
"idle": 473.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 3974,
|
||||
"moved_too_quickly": 7,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 10,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 59
|
||||
}
|
||||
],
|
||||
"work": []
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{"server": "horizon", "bots": 1, "behaviour": "mixed", "start_seconds": 8.31, "mspt": {"average": 13.7, "p50": 14.18, "p95": 19.06, "p99": 27.38}, "mspt_worst_p99": 43.4, "tps": 20.0, "rss_mb": {"mean": 715.63, "max": 719.6}, "cpu_percent": 46.22, "chunks_per_second": 9.94, "unique_per_second": 0.01, "forgotten_per_second": 9.89, "waited_ticks_per_second": 0.0, "chunks_total": 6280, "chunks_per_bot": {"walk": 6280.0}, "unique_chunks_per_bot": {"walk": 700.0}, "resent": 0, "pulled_back": 0, "moved_too_quickly": 0, "moved_wrongly": 0, "bots_joined": 1, "bots_failed": [], "tick_queries": 58}
|
||||
{"server": "vanilla", "bots": 1, "behaviour": "mixed", "start_seconds": 14.85, "mspt": {"average": 17.31, "p50": 18.3, "p95": 24.68, "p99": 36.43}, "mspt_worst_p99": 71.0, "tps": 20.0, "rss_mb": {"mean": 3442.51, "max": 3444.5}, "cpu_percent": 55.03, "chunks_per_second": 9.99, "unique_per_second": 0.0, "forgotten_per_second": 9.95, "waited_ticks_per_second": 0.0, "chunks_total": 6210, "chunks_per_bot": {"walk": 6210.0}, "unique_chunks_per_bot": {"walk": 700.0}, "resent": 0, "pulled_back": 0, "moved_too_quickly": 0, "moved_wrongly": 0, "bots_joined": 1, "bots_failed": [], "tick_queries": 58}
|
||||
{"server": "paper", "bots": 1, "behaviour": "mixed", "start_seconds": 22.27, "mspt": {"average": 10.31, "p50": 10.4, "p95": 13.9, "p99": 22.5}, "mspt_worst_p99": 81.2, "tps": 20.0, "rss_mb": {"mean": 4239.22, "max": 4245.1}, "cpu_percent": 31.68, "chunks_per_second": 9.81, "unique_per_second": 0.02, "forgotten_per_second": 9.77, "waited_ticks_per_second": 0.0, "chunks_total": 6049, "chunks_per_bot": {"walk": 6049.0}, "unique_chunks_per_bot": {"walk": 698.0}, "resent": 0, "pulled_back": 0, "moved_too_quickly": 0, "moved_wrongly": 0, "bots_joined": 1, "bots_failed": [], "tick_queries": 58}
|
||||
{"server": "fabric", "bots": 1, "behaviour": "mixed", "start_seconds": 17.55, "mspt": {"average": 12.46, "p50": 12.62, "p95": 17.69, "p99": 32.74}, "mspt_worst_p99": 70.1, "tps": 20.0, "rss_mb": {"mean": 3428.55, "max": 3430.2}, "cpu_percent": 45.86, "chunks_per_second": 9.98, "unique_per_second": 0.0, "forgotten_per_second": 9.93, "waited_ticks_per_second": 0.0, "chunks_total": 6188, "chunks_per_bot": {"walk": 6188.0}, "unique_chunks_per_bot": {"walk": 700.0}, "resent": 0, "pulled_back": 0, "moved_too_quickly": 0, "moved_wrongly": 0, "bots_joined": 1, "bots_failed": [], "tick_queries": 58}
|
||||
{"server": "horizon", "bots": 10, "behaviour": "mixed", "start_seconds": 8.21, "mspt": {"average": 22.93, "p50": 20.03, "p95": 39.83, "p99": 53.33}, "mspt_worst_p99": 89.5, "tps": 20.0, "rss_mb": {"mean": 922.28, "max": 973.7}, "cpu_percent": 66.6, "chunks_per_second": 61.21, "unique_per_second": 1.78, "forgotten_per_second": 61.34, "waited_ticks_per_second": 0.0, "chunks_total": 42095, "chunks_per_bot": {"walk": 6460.0, "explore": 1194.5, "idle": 473.0}, "unique_chunks_per_bot": {"walk": 702.17, "explore": 1112.5, "idle": 473.0}, "resent": 0, "pulled_back": 3702, "moved_too_quickly": 0, "moved_wrongly": 0, "bots_joined": 10, "bots_failed": [], "tick_queries": 58}
|
||||
{"server": "vanilla", "bots": 10, "behaviour": "mixed", "start_seconds": 14.65, "mspt": {"average": 22.94, "p50": 20.61, "p95": 36.92, "p99": 56.48}, "mspt_worst_p99": 196.6, "tps": 20.0, "rss_mb": {"mean": 3706.57, "max": 3761.6}, "cpu_percent": 100.83, "chunks_per_second": 61.63, "unique_per_second": 1.84, "forgotten_per_second": 61.68, "waited_ticks_per_second": 0.0, "chunks_total": 40938, "chunks_per_bot": {"walk": 6272.17, "explore": 1179.5, "idle": 473.0}, "unique_chunks_per_bot": {"walk": 702.0, "explore": 1097.0, "idle": 473.0}, "resent": 0, "pulled_back": 3370, "moved_too_quickly": 7, "moved_wrongly": 0, "bots_joined": 10, "bots_failed": [], "tick_queries": 59}
|
||||
{"server": "paper", "bots": 10, "behaviour": "mixed", "start_seconds": 20.48, "mspt": {"average": 20.64, "p50": 18.1, "p95": 35.94, "p99": 56.29}, "mspt_worst_p99": 166.9, "tps": 20.0, "rss_mb": {"mean": 4069.94, "max": 4080.2}, "cpu_percent": 73.56, "chunks_per_second": 60.76, "unique_per_second": 1.85, "forgotten_per_second": 60.96, "waited_ticks_per_second": 0.0, "chunks_total": 40348, "chunks_per_bot": {"walk": 6176.67, "explore": 1171.0, "idle": 473.0}, "unique_chunks_per_bot": {"walk": 701.0, "explore": 1091.5, "idle": 473.0}, "resent": 0, "pulled_back": 3974, "moved_too_quickly": 7, "moved_wrongly": 0, "bots_joined": 10, "bots_failed": [], "tick_queries": 59}
|
||||
{"server": "fabric", "bots": 10, "behaviour": "mixed", "start_seconds": 16.64, "mspt": {"average": 20.18, "p50": 17.63, "p95": 37.04, "p99": 56.08}, "mspt_worst_p99": 217.9, "tps": 20.0, "rss_mb": {"mean": 3732.43, "max": 3776.5}, "cpu_percent": 94.48, "chunks_per_second": 61.65, "unique_per_second": 1.84, "forgotten_per_second": 61.76, "waited_ticks_per_second": 0.0, "chunks_total": 41055, "chunks_per_bot": {"walk": 6291.17, "explore": 1181.0, "idle": 473.0}, "unique_chunks_per_bot": {"walk": 701.83, "explore": 1098.0, "idle": 473.0}, "resent": 0, "pulled_back": 4002, "moved_too_quickly": 3, "moved_wrongly": 0, "bots_joined": 10, "bots_failed": [], "tick_queries": 59}
|
||||
{"server": "horizon", "bots": 25, "behaviour": "mixed", "start_seconds": 8.51, "mspt": {"average": 30.92, "p50": 27.88, "p95": 48.25, "p99": 72.0}, "mspt_worst_p99": 121.1, "tps": 20.0, "rss_mb": {"mean": 1012.01, "max": 1121.3}, "cpu_percent": 98.57, "chunks_per_second": 155.81, "unique_per_second": 5.78, "forgotten_per_second": 155.62, "waited_ticks_per_second": 0.0, "chunks_total": 106268, "chunks_per_bot": {"walk": 6506.33, "explore": 1261.6, "idle": 473.0}, "unique_chunks_per_bot": {"walk": 702.13, "explore": 1184.4, "idle": 473.0}, "resent": 0, "pulled_back": 3008, "moved_too_quickly": 0, "moved_wrongly": 0, "bots_joined": 25, "bots_failed": [], "tick_queries": 58}
|
||||
{"server": "vanilla", "bots": 25, "behaviour": "mixed", "start_seconds": 15.47, "mspt": {"average": 37.35, "p50": 35.64, "p95": 48.2, "p99": 82.66}, "mspt_worst_p99": 579.8, "tps": 20.0, "rss_mb": {"mean": 3935.5, "max": 4157.4}, "cpu_percent": 191.44, "chunks_per_second": 158.52, "unique_per_second": 5.71, "forgotten_per_second": 158.39, "waited_ticks_per_second": 0.0, "chunks_total": 103148, "chunks_per_bot": {"walk": 6306.87, "explore": 1236.0, "idle": 473.0}, "unique_chunks_per_bot": {"walk": 704.2, "explore": 1160.2, "idle": 473.0}, "resent": 0, "pulled_back": 3762, "moved_too_quickly": 11, "moved_wrongly": 0, "bots_joined": 25, "bots_failed": [], "tick_queries": 59}
|
||||
{"server": "paper", "bots": 25, "behaviour": "mixed", "start_seconds": 18.95, "mspt": {"average": 28.3, "p50": 23.7, "p95": 61.68, "p99": 90.4}, "mspt_worst_p99": 124.3, "tps": 20.0, "rss_mb": {"mean": 4160.34, "max": 4246.4}, "cpu_percent": 124.24, "chunks_per_second": 156.35, "unique_per_second": 5.72, "forgotten_per_second": 156.25, "waited_ticks_per_second": 0.0, "chunks_total": 102715, "chunks_per_bot": {"walk": 6280.4, "explore": 1228.8, "idle": 473.0}, "unique_chunks_per_bot": {"walk": 703.07, "explore": 1155.6, "idle": 473.0}, "resent": 0, "pulled_back": 4337, "moved_too_quickly": 9, "moved_wrongly": 0, "bots_joined": 25, "bots_failed": [], "tick_queries": 59}
|
||||
{"server": "fabric", "bots": 25, "behaviour": "mixed", "start_seconds": 17.45, "mspt": {"average": 31.44, "p50": 28.33, "p95": 52.45, "p99": 93.35}, "mspt_worst_p99": 409.3, "tps": 20.0, "rss_mb": {"mean": 3884.61, "max": 4186.4}, "cpu_percent": 174.04, "chunks_per_second": 158.89, "unique_per_second": 5.75, "forgotten_per_second": 158.54, "waited_ticks_per_second": 0.0, "chunks_total": 103913, "chunks_per_bot": {"walk": 6356.8, "explore": 1239.2, "idle": 473.0}, "unique_chunks_per_bot": {"walk": 704.27, "explore": 1163.0, "idle": 473.0}, "resent": 0, "pulled_back": 4194, "moved_too_quickly": 11, "moved_wrongly": 0, "bots_joined": 25, "bots_failed": [], "tick_queries": 59}
|
||||
{"server": "horizon", "bots": 50, "behaviour": "mixed", "start_seconds": 8.31, "mspt": {"average": 47.83, "p50": 46.02, "p95": 62.42, "p99": 73.21}, "mspt_worst_p99": 133.0, "tps": 20.0, "rss_mb": {"mean": 1112.46, "max": 1296.4}, "cpu_percent": 143.22, "chunks_per_second": 302.68, "unique_per_second": 11.02, "forgotten_per_second": 303.15, "waited_ticks_per_second": 0.0, "chunks_total": 211296, "chunks_per_bot": {"walk": 6468.47, "explore": 1251.2, "idle": 473.0}, "unique_chunks_per_bot": {"walk": 701.67, "explore": 1180.5, "idle": 473.0}, "resent": 0, "pulled_back": 8562, "moved_too_quickly": 0, "moved_wrongly": 0, "bots_joined": 50, "bots_failed": [], "tick_queries": 58}
|
||||
{"server": "vanilla", "bots": 50, "behaviour": "mixed", "start_seconds": 15.45, "error": "[Errno 32] Broken pipe"}
|
||||
{"server": "paper", "bots": 50, "behaviour": "mixed", "start_seconds": 19.35, "mspt": {"average": 46.86, "p50": 45.11, "p95": 61.16, "p99": 109.72}, "mspt_worst_p99": 213.4, "tps": 20.0, "rss_mb": {"mean": 4353.02, "max": 4516.1}, "cpu_percent": 210.21, "chunks_per_second": 310.51, "unique_per_second": 11.16, "forgotten_per_second": 310.64, "waited_ticks_per_second": 0.0, "chunks_total": 209303, "chunks_per_bot": {"walk": 6411.0, "explore": 1224.3, "idle": 473.0}, "unique_chunks_per_bot": {"walk": 703.47, "explore": 1155.8, "idle": 473.0}, "resent": 0, "pulled_back": 7344, "moved_too_quickly": 35, "moved_wrongly": 0, "bots_joined": 50, "bots_failed": [], "tick_queries": 58}
|
||||
{"server": "fabric", "bots": 50, "behaviour": "mixed", "start_seconds": 17.45, "mspt": {"average": 58.3, "p50": 54.73, "p95": 83.06, "p99": 174.37}, "mspt_worst_p99": 887.6, "tps": 17.15, "rss_mb": {"mean": 4521.47, "max": 4744.2}, "cpu_percent": 236.04, "chunks_per_second": 311.52, "unique_per_second": 11.28, "forgotten_per_second": 311.74, "waited_ticks_per_second": 0.0, "chunks_total": 206965, "chunks_per_bot": {"walk": 6331.7, "explore": 1228.4, "idle": 473.0}, "unique_chunks_per_bot": {"walk": 703.3, "explore": 1160.3, "idle": 473.0}, "resent": 0, "pulled_back": 6350, "moved_too_quickly": 46, "moved_wrongly": 0, "bots_joined": 50, "bots_failed": [], "tick_queries": 58}
|
||||
@@ -0,0 +1,125 @@
|
||||
# Horizon's server against vanilla 26.3: mixed bots (2026-10-18)
|
||||
|
||||
The first benchmark from docs/server.md section 10 (item 4): the same bots on Horizon's dedicated server, as
|
||||
`install.sh` installs it today, and on vanilla 26.3's, one server at a time on the same quiet machine.
|
||||
There were 1, 10, 25 and 50 bots, all with the `mixed` behaviour.
|
||||
|
||||
## Summary
|
||||
|
||||
- **Both servers held 20 TPS in every run.**
|
||||
- **Memory:** Horizon's RSS was 3.7 to 5.2 times smaller: 0.65 GB against 3.4 GB with one player, and
|
||||
1.0 GB against 4.0 GB with 50. Vanilla runs with the usual fixed heap (`-Xms4g -Xmx4g`), so its RSS
|
||||
is mostly that heap.
|
||||
- **Tick time (MSPT):**
|
||||
- with 1, 10 and 25 players, Horizon's average tick was shorter (2.4 against 17.9 ms, 12.4 against 34.5,
|
||||
24.0 against 31.6);
|
||||
- with 50, it was longer (35.2 against 29.7 ms);
|
||||
- Horizon's P99 was lower at every count, and so were its worst 5-second windows (241 ms at most,
|
||||
against 424 to 2320 ms for vanilla).
|
||||
- **The work was not equal, and the difference counts against Horizon.** In `mixed`, 20 % of the bots
|
||||
fly straight out at 22 blocks a second, as fast as each server sends them chunks. Horizon kept up:
|
||||
- with 10 or more players it sent 2.9 to 5.9 times as many chunks a second, and generated 1.9 to 2.9
|
||||
times as much new terrain a second;
|
||||
- its walking bots each received 3300 to 4000 chunks, against 640 to 1100 on vanilla once 10 or more
|
||||
were online.
|
||||
|
||||
So at 50 players Horizon's tick is 18 % longer while it generates twice the new terrain. The
|
||||
equal-rate explore pass of `../server-equal-work-2026-10-17/` is the fair comparison for exploring.
|
||||
- **Start (fresh world, to `Done`):** 10 to 20 s for Horizon, 14.5 to 28.6 s for vanilla.
|
||||
|
||||
## Results
|
||||
|
||||
MSPT is milliseconds per tick from each server's own `/tick query` (50 is the limit for 20 TPS). P95 and
|
||||
P99 are over the whole run. "Worst P99" is the highest P99 of a single 5-second query. RSS and CPU cover
|
||||
the server's whole process tree (100 % is one thread; the machine has 4). "New chunks/s" counts chunks
|
||||
no bot had before. "Pulled back" counts position packets that moved a bot back.
|
||||
|
||||
| Bots | Server | MSPT | P95 | P99 | Worst P99 | TPS | RSS (mean / max) | CPU | Start | Chunks/s | New chunks/s | Pulled back |
|
||||
|---:|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||
| 1 | Horizon | **2.4** | 5.2 | 18.2 | 45.6 | 20 | **0.65 / 0.66 GB** | 25 % | 10.9 s | 9.9 | 0.3 | 0 |
|
||||
| 1 | vanilla | 17.9 | 27.6 | 47.3 | 423.8 | 20 | 3.38 / 3.39 GB | 64 % | 14.8 s | 9.9 | 0.3 | 0 |
|
||||
| 10 | Horizon | **12.4** | 36.0 | 65.9 | 117.1 | 20 | **0.94 / 1.01 GB** | 210 % | 10.2 s | 98.2 | 36.5 | 6267 |
|
||||
| 10 | vanilla | 34.5 | 63.5 | 138.6 | 884.7 | 20 | 3.59 / 3.66 GB | 217 % | 14.5 s | 24.3 | 19.1 | 5500 |
|
||||
| 25 | Horizon | **24.0** | 49.7 | 80.6 | 168.8 | 20 | **1.01 / 1.18 GB** | 192 % | 14.7 s | 178.2 | 31.9 | 5532 |
|
||||
| 25 | vanilla | 31.6 | 70.0 | 222.7 | 2319.6 | 20 | 3.74 / 4.06 GB | 218 % | 28.6 s | 30.4 | 10.8 | 4524 |
|
||||
| 50 | Horizon | 35.2 | 69.0 | 96.6 | 240.8 | 20 | **1.01 / 1.25 GB** | 218 % | 19.8 s | 273.2 | 34.6 | 8369 |
|
||||
| 50 | vanilla | **29.7** | 62.3 | 137.4 | 489.3 | 20 | 4.00 / 4.60 GB | 294 % | 20.6 s | 94.4 | 17.8 | 12034 |
|
||||
|
||||
Chunks each bot received, by behaviour (mean):
|
||||
|
||||
| Bots | Server | walk | explore | idle |
|
||||
|---:|---|---:|---:|---:|
|
||||
| 10 | Horizon | 3992 | 7360 | 473 |
|
||||
| 10 | vanilla | 680 | 2824 | 358 |
|
||||
| 25 | Horizon | 3928 | 2475 | 473 |
|
||||
| 25 | vanilla | 640 | 738 | 180 |
|
||||
| 50 | Horizon | 3300 | 1192 | 460 |
|
||||
| 50 | vanilla | 1105 | 629 | 220 |
|
||||
|
||||
No bot failed to join or was dropped, and no chunk was sent twice (`resent` 0), on either server. Vanilla
|
||||
also logged "moved too quickly" 12, 324 and 2193 times at 10, 25 and 50 bots.
|
||||
|
||||
## Method
|
||||
|
||||
`tools/server_bench.py`, unchanged, runs each configuration on a fresh server:
|
||||
|
||||
- the same `server.properties` on both: seed 12345, view and simulation distance 10, offline, creative,
|
||||
difficulty easy, `sync-chunk-writes=true`, white-list off;
|
||||
- `gamerule respawn_radius 0`, and otherwise vanilla's default game rules on both. The locator bar is on
|
||||
for both: Horizon's server has run it since v2026.10.18.13. The 2026-10-17 runs turned it off on vanilla
|
||||
because Horizon had none then;
|
||||
- the server waits for `Done`, then the bots join one a second, warm up for 60 seconds, and are measured
|
||||
for 300 seconds, with `/tick query` every 5 seconds and RSS and CPU every second.
|
||||
|
||||
The bots are `horizon-bots --behaviour mixed`: 60 % walk, 20 % explore and 20 % idle, each choice
|
||||
deterministic per bot. All are creative and fly 24 blocks above their spawn:
|
||||
|
||||
- `walk`: random points within 48 blocks at 5.6 blocks a second;
|
||||
- `explore`: straight out at up to 22 blocks a second, as fast as chunks come;
|
||||
- `idle`: hovers.
|
||||
|
||||
A bot doesn't move while the chunk it stands in hasn't arrived, and it acknowledges chunk batches as
|
||||
vanilla's client does.
|
||||
|
||||
**Machine.** The ThinkPad: Intel Core i7-7500U (2 cores, 4 threads), 32 GB, NixOS (Linux 6.18). No other
|
||||
session ran on it (`load.txt`: the fleet monitor shows 0 sessions throughout). The busy periods are the
|
||||
servers themselves, which used 2 to 3 of its 4 threads from 10 bots up, so both servers were CPU-bound
|
||||
there. The servers ran under `nice -n 19`. The desktop was meant to run this (24 threads) but didn't
|
||||
wake. The bots ran on the Mac mini (M4 Pro) and reached the ThinkPad over the tailnet, so they took none
|
||||
of the servers' CPU. The Mac mini was shared with other work (70 to 90 % busy), which only affects the
|
||||
bots' timing.
|
||||
|
||||
**What ran.**
|
||||
|
||||
- Horizon: the published v2026.10.18.15 release, `horizon-client` (the engine) at 6c078b28, as
|
||||
`install.sh` installs it. Its game files were prepared from the owner's own Minecraft by the release's
|
||||
`horizon-prepare`. `horizon-server` (the supervisor, which takes no part in the tick) came from #898
|
||||
(4ea2243e), because the release's copy can't find game files prepared without sounds (#893).
|
||||
`horizon-bots` came from main.
|
||||
- Vanilla: the pinned 26.3 server jar (the owner's own copy) on OpenJDK 25.0.4.1, `-Xms4g -Xmx4g`.
|
||||
|
||||
On Linux the release's engine needs `libasound.so.2` and `libudev.so.1`. The ThinkPad has them only in
|
||||
the Nix store, so the run set `LD_LIBRARY_PATH` to them (see #898).
|
||||
|
||||
## Reproduce
|
||||
|
||||
curl -fsSL https://raw.githubusercontent.com/ludovic111/horizon/main/packaging/server/install.sh | sh
|
||||
horizon-prepare --prepare --server-jar /path/to/server.jar # your own copy
|
||||
cargo build --release -p horizon-bots # on the machine that runs the bots
|
||||
# in a checkout whose .local/minecraft holds the pinned vanilla jars (tools/lab.py prepare):
|
||||
HORIZON_BENCH_BIN=<a folder with horizon-server and horizon-client> \
|
||||
python3 tools/server_bench.py --servers horizon,vanilla --bots 1,10,25,50 --behaviour mixed --nice 19 \
|
||||
--bots-via 'ssh <bots machine>' --bots-bin /path/to/horizon-bots --connect <this machine's address> \
|
||||
--out mixed.json
|
||||
|
||||
`mixed.json` is the report (`horizon-server-benchmark`: machine, versions, settings and every run), and
|
||||
`load.txt` is the ThinkPad's load before and during the run.
|
||||
|
||||
## Next
|
||||
|
||||
- An equal-work `mixed` pass (`--explore-speed` at a rate both servers keep up with), then 100 bots.
|
||||
- The same run on a bigger machine, so neither server is CPU-bound at 25 to 50 bots.
|
||||
- Paper and Fabric + Lithium (docs/server.md section 9).
|
||||
- At 50 players Horizon's tick is longer than vanilla's while it generates and sends far more. The
|
||||
profile of the 2026-10-17 run (streaming and block entity ticks growing with players) is
|
||||
perf-server's to take on.
|
||||
@@ -0,0 +1,286 @@
|
||||
ludothinkpad — OK
|
||||
RAM 29.1 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
|
||||
CPU 4 threads, 2% busy (agents 0.0, rest 0.1 cores) · 67°C · load 2.1
|
||||
· disk 295.5 GB free · battery 96%
|
||||
|
||||
Sessions, most important first:
|
||||
(finished session) 0.0 GB leftovers: node 0.0 GB
|
||||
|
||||
Other machines (≤ 1 min old): desktop offline · macbook offline · macmini ok: 26.6 GB free, 14 threads 69% busy, 1 sessions
|
||||
|
||||
# During the run, once a minute (uptime, then ~/fleet/load fleet)
|
||||
18:25:30
|
||||
18:25:30 up 8 days 3:08, 0 users, load average: 2.13, 1.53, 1.29
|
||||
ludothinkpad ok: 29.1 GB free, 4 threads 2% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 27.6 GB free, 14 threads 87% busy, 1 sessions
|
||||
18:26:34
|
||||
18:26:34 up 8 days 3:09, 0 users, load average: 1.70, 1.56, 1.31
|
||||
ludothinkpad ok: 28.5 GB free, 4 threads 23% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.4 GB free, 14 threads 85% busy, 1 sessions
|
||||
18:27:38
|
||||
18:27:38 up 8 days 3:10, 0 users, load average: 1.10, 1.43, 1.28
|
||||
ludothinkpad ok: 28.5 GB free, 4 threads 4% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 27.7 GB free, 14 threads 80% busy, 1 sessions
|
||||
18:28:42
|
||||
18:28:42 up 8 days 3:11, 0 users, load average: 0.93, 1.29, 1.24
|
||||
ludothinkpad ok: 28.5 GB free, 4 threads 22% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 26.0 GB free, 14 threads 82% busy, 1 sessions
|
||||
18:29:46
|
||||
18:29:46 up 8 days 3:12, 0 users, load average: 0.62, 1.12, 1.18
|
||||
ludothinkpad ok: 28.5 GB free, 4 threads 12% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 25.7 GB free, 14 threads 81% busy, 1 sessions
|
||||
18:30:50
|
||||
18:30:51 up 8 days 3:13, 0 users, load average: 0.79, 1.05, 1.15
|
||||
ludothinkpad ok: 28.5 GB free, 4 threads 7% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 27.0 GB free, 14 threads 88% busy, 1 sessions
|
||||
18:31:55
|
||||
18:31:55 up 8 days 3:14, 0 users, load average: 0.67, 0.96, 1.11
|
||||
ludothinkpad ok: 28.6 GB free, 4 threads 73% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 27.3 GB free, 14 threads 79% busy, 1 sessions
|
||||
18:32:59
|
||||
18:32:59 up 8 days 3:15, 0 users, load average: 4.01, 1.91, 1.44
|
||||
ludothinkpad ok: 25.9 GB free, 4 threads 24% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 26.4 GB free, 14 threads 85% busy, 1 sessions
|
||||
18:34:03
|
||||
18:34:03 up 8 days 3:16, 0 users, load average: 2.24, 1.84, 1.44
|
||||
ludothinkpad ok: 25.9 GB free, 4 threads 21% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 29.2 GB free, 14 threads 86% busy, 1 sessions
|
||||
18:35:07
|
||||
18:35:07 up 8 days 3:17, 0 users, load average: 1.28, 1.63, 1.40
|
||||
ludothinkpad ok: 25.8 GB free, 4 threads 21% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 31.0 GB free, 14 threads 86% busy, 1 sessions
|
||||
18:36:11
|
||||
18:36:11 up 8 days 3:18, 0 users, load average: 0.79, 1.41, 1.33
|
||||
ludothinkpad ok: 25.9 GB free, 4 threads 18% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.3 GB free, 14 threads 93% busy, 1 sessions
|
||||
18:37:15
|
||||
18:37:15 up 8 days 3:20, 0 users, load average: 1.44, 1.47, 1.36
|
||||
ludothinkpad ok: 25.9 GB free, 4 threads 23% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.8 GB free, 14 threads 86% busy, 1 sessions
|
||||
18:38:19
|
||||
18:38:19 up 8 days 3:21, 0 users, load average: 1.37, 1.45, 1.36
|
||||
ludothinkpad ok: 25.9 GB free, 4 threads 13% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.7 GB free, 14 threads 86% busy, 1 sessions
|
||||
18:39:24
|
||||
18:39:24 up 8 days 3:22, 0 users, load average: 2.27, 1.66, 1.44
|
||||
ludothinkpad ok: 28.3 GB free, 4 threads 62% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 27.1 GB free, 14 threads 70% busy, 1 sessions
|
||||
18:40:28
|
||||
18:40:28 up 8 days 3:23, 0 users, load average: 1.95, 1.72, 1.48
|
||||
ludothinkpad ok: 28.3 GB free, 4 threads 63% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 26.7 GB free, 14 threads 84% busy, 1 sessions
|
||||
18:41:32
|
||||
18:41:32 up 8 days 3:24, 0 users, load average: 3.32, 2.22, 1.67
|
||||
ludothinkpad ok: 28.2 GB free, 4 threads 61% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.8 GB free, 14 threads 85% busy, 1 sessions
|
||||
18:42:36
|
||||
18:42:36 up 8 days 3:25, 0 users, load average: 3.13, 2.33, 1.75
|
||||
ludothinkpad ok: 28.2 GB free, 4 threads 60% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 27.1 GB free, 14 threads 91% busy, 1 sessions
|
||||
18:43:40
|
||||
18:43:40 up 8 days 3:26, 0 users, load average: 3.27, 2.48, 1.83
|
||||
ludothinkpad busy: 28.1 GB free, 4 threads 90% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 28.3 GB free, 14 threads 92% busy, 1 sessions
|
||||
18:44:44
|
||||
18:44:44 up 8 days 3:27, 0 users, load average: 2.14, 2.32, 1.82
|
||||
ludothinkpad ok: 28.1 GB free, 4 threads 55% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 30.4 GB free, 14 threads 88% busy, 1 sessions
|
||||
18:45:48
|
||||
18:45:48 up 8 days 3:28, 0 users, load average: 4.94, 3.06, 2.11
|
||||
ludothinkpad busy: 25.9 GB free, 4 threads 86% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 28.7 GB free, 14 threads 84% busy, 1 sessions
|
||||
18:46:53
|
||||
18:46:53 up 8 days 3:29, 0 users, load average: 6.55, 3.92, 2.47
|
||||
ludothinkpad busy: 25.4 GB free, 4 threads 99% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 29.3 GB free, 14 threads 81% busy, 1 sessions
|
||||
18:47:57
|
||||
18:47:57 up 8 days 3:30, 0 users, load average: 8.50, 4.96, 2.93
|
||||
ludothinkpad busy: 24.0 GB free, 4 threads 100% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 27.9 GB free, 14 threads 90% busy, 1 sessions
|
||||
18:49:01
|
||||
18:49:01 up 8 days 3:31, 0 users, load average: 8.59, 5.68, 3.31
|
||||
ludothinkpad busy: 22.7 GB free, 4 threads 99% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 28.2 GB free, 14 threads 85% busy, 1 sessions
|
||||
18:50:05
|
||||
18:50:05 up 8 days 3:32, 0 users, load average: 7.81, 6.07, 3.61
|
||||
ludothinkpad busy: 24.5 GB free, 4 threads 90% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 28.6 GB free, 14 threads 96% busy, 1 sessions
|
||||
18:51:09
|
||||
18:51:09 up 8 days 3:33, 0 users, load average: 8.29, 6.50, 3.93
|
||||
ludothinkpad busy: 24.3 GB free, 4 threads 92% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 28.0 GB free, 14 threads 94% busy, 1 sessions
|
||||
18:52:14
|
||||
18:52:14 up 8 days 3:35, 0 users, load average: 5.58, 6.06, 3.95
|
||||
ludothinkpad busy: 26.0 GB free, 4 threads 100% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.3 GB free, 14 threads 87% busy, 1 sessions
|
||||
18:53:18
|
||||
18:53:18 up 8 days 3:36, 0 users, load average: 4.53, 5.69, 3.97
|
||||
ludothinkpad busy: 27.2 GB free, 4 threads 89% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.8 GB free, 14 threads 87% busy, 1 sessions
|
||||
18:54:22
|
||||
18:54:22 up 8 days 3:37, 0 users, load average: 5.31, 5.64, 4.07
|
||||
ludothinkpad busy: 26.3 GB free, 4 threads 100% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 29.3 GB free, 14 threads 91% busy, 1 sessions
|
||||
18:55:26
|
||||
18:55:26 up 8 days 3:38, 0 users, load average: 10.47, 6.99, 4.63
|
||||
ludothinkpad busy: 26.1 GB free, 4 threads 100% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.7 GB free, 14 threads 90% busy, 1 sessions
|
||||
18:56:30
|
||||
18:56:30 up 8 days 3:39, 0 users, load average: 9.27, 7.45, 4.96
|
||||
ludothinkpad busy: 26.2 GB free, 4 threads 100% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 28.5 GB free, 14 threads 92% busy, 1 sessions
|
||||
18:57:35
|
||||
18:57:35 up 8 days 3:40, 0 users, load average: 7.79, 7.39, 5.10
|
||||
ludothinkpad busy: 26.1 GB free, 4 threads 100% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 27.1 GB free, 14 threads 80% busy, 1 sessions
|
||||
18:58:39
|
||||
18:58:39 up 8 days 3:41, 0 users, load average: 8.06, 7.55, 5.32
|
||||
ludothinkpad busy: 26.4 GB free, 4 threads 100% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 31.8 GB free, 14 threads 95% busy, 1 sessions
|
||||
18:59:43
|
||||
18:59:43 up 8 days 3:42, 0 users, load average: 8.44, 7.65, 5.50
|
||||
ludothinkpad busy: 26.2 GB free, 4 threads 100% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 31.4 GB free, 14 threads 94% busy, 1 sessions
|
||||
19:00:47
|
||||
19:00:47 up 8 days 3:43, 0 users, load average: 11.04, 8.66, 6.00
|
||||
ludothinkpad busy: 24.9 GB free, 4 threads 100% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 31.8 GB free, 14 threads 88% busy, 1 sessions
|
||||
19:01:51
|
||||
19:01:51 up 8 days 3:44, 0 users, load average: 11.91, 9.37, 6.41
|
||||
ludothinkpad busy: 24.4 GB free, 4 threads 100% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 32.9 GB free, 14 threads 77% busy, 1 sessions
|
||||
19:02:56
|
||||
19:02:56 up 8 days 3:45, 0 users, load average: 11.64, 9.85, 6.79
|
||||
ludothinkpad ok: 25.5 GB free, 4 threads 70% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 30.9 GB free, 14 threads 91% busy, 1 sessions
|
||||
19:04:00
|
||||
19:04:00 up 8 days 3:46, 0 users, load average: 11.42, 10.06, 7.06
|
||||
ludothinkpad busy: 23.4 GB free, 4 threads 99% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 28.4 GB free, 14 threads 94% busy, 1 sessions
|
||||
19:05:04
|
||||
19:05:04 up 8 days 3:47, 0 users, load average: 9.83, 9.96, 7.24
|
||||
ludothinkpad busy: 23.2 GB free, 4 threads 100% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 31.4 GB free, 14 threads 80% busy, 1 sessions
|
||||
19:06:09
|
||||
19:06:09 up 8 days 3:48, 0 users, load average: 8.38, 9.68, 7.34
|
||||
ludothinkpad ok: 22.7 GB free, 4 threads 17% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 29.8 GB free, 14 threads 86% busy, 1 sessions
|
||||
19:07:13
|
||||
19:07:13 up 8 days 3:49, 0 users, load average: 4.51, 8.27, 7.00
|
||||
ludothinkpad ok: 25.7 GB free, 4 threads 75% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 26.5 GB free, 14 threads 89% busy, 1 sessions
|
||||
19:08:17
|
||||
19:08:17 up 8 days 3:51, 0 users, load average: 6.77, 8.13, 7.04
|
||||
ludothinkpad busy: 25.8 GB free, 4 threads 95% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 30.8 GB free, 14 threads 91% busy, 1 sessions
|
||||
19:09:22
|
||||
19:09:22 up 8 days 3:52, 0 users, load average: 8.27, 8.25, 7.14
|
||||
ludothinkpad busy: 25.2 GB free, 4 threads 100% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 28.3 GB free, 14 threads 91% busy, 1 sessions
|
||||
19:10:26
|
||||
19:10:26 up 8 days 3:53, 0 users, load average: 8.88, 8.57, 7.34
|
||||
ludothinkpad busy: 24.8 GB free, 4 threads 99% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 32.4 GB free, 14 threads 72% busy, 1 sessions
|
||||
19:11:30
|
||||
19:11:30 up 8 days 3:54, 0 users, load average: 13.33, 10.05, 7.94
|
||||
ludothinkpad busy: 24.7 GB free, 4 threads 97% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 30.1 GB free, 14 threads 87% busy, 1 sessions
|
||||
19:12:35
|
||||
19:12:35 up 8 days 3:55, 0 users, load average: 10.92, 10.13, 8.12
|
||||
ludothinkpad ok: 26.4 GB free, 4 threads 76% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 29.3 GB free, 14 threads 86% busy, 1 sessions
|
||||
19:13:39
|
||||
19:13:39 up 8 days 3:56, 0 users, load average: 9.98, 9.89, 8.16
|
||||
ludothinkpad busy: 25.8 GB free, 4 threads 90% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 28.8 GB free, 14 threads 93% busy, 1 sessions
|
||||
19:14:43
|
||||
19:14:43 up 8 days 3:57, 0 users, load average: 8.95, 9.63, 8.19
|
||||
ludothinkpad busy: 25.1 GB free, 4 threads 100% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 29.6 GB free, 14 threads 71% busy, 1 sessions
|
||||
19:15:47
|
||||
19:15:47 up 8 days 3:58, 0 users, load average: 12.02, 10.38, 8.55
|
||||
ludothinkpad busy: 24.5 GB free, 4 threads 100% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 28.1 GB free, 14 threads 93% busy, 1 sessions
|
||||
19:16:52
|
||||
19:16:52 up 8 days 3:59, 0 users, load average: 13.82, 11.21, 8.95
|
||||
ludothinkpad busy: 23.9 GB free, 4 threads 100% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 28.4 GB free, 14 threads 75% busy, 1 sessions
|
||||
19:17:56
|
||||
19:17:56 up 8 days 4:00, 0 users, load average: 11.95, 11.20, 9.10
|
||||
ludothinkpad busy: 25.0 GB free, 4 threads 99% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 28.4 GB free, 14 threads 81% busy, 1 sessions
|
||||
19:19:00
|
||||
19:19:00 up 8 days 4:01, 0 users, load average: 11.46, 11.14, 9.22
|
||||
ludothinkpad busy: 24.6 GB free, 4 threads 95% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 26.4 GB free, 14 threads 83% busy, 1 sessions
|
||||
19:20:04
|
||||
19:20:04 up 8 days 4:02, 0 users, load average: 11.11, 10.97, 9.28
|
||||
ludothinkpad busy: 24.6 GB free, 4 threads 93% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 26.7 GB free, 14 threads 85% busy, 1 sessions
|
||||
19:21:08
|
||||
19:21:08 up 8 days 4:03, 0 users, load average: 9.81, 10.60, 9.27
|
||||
ludothinkpad busy: 24.4 GB free, 4 threads 91% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 27.8 GB free, 14 threads 75% busy, 1 sessions
|
||||
19:22:13
|
||||
19:22:13 up 8 days 4:04, 0 users, load average: 5.98, 9.51, 9.00
|
||||
ludothinkpad ok: 23.8 GB free, 4 threads 18% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini busy: 29.7 GB free, 14 threads 85% busy, 1 sessions
|
||||
19:23:17
|
||||
19:23:17 up 8 days 4:06, 0 users, load average: 4.01, 8.27, 8.60
|
||||
ludothinkpad ok: 23.1 GB free, 4 threads 56% busy, 0 sessions
|
||||
desktop offline
|
||||
macmini ok: 28.2 GB free, 14 threads 79% busy, 1 sessions
|
||||
@@ -0,0 +1,367 @@
|
||||
{
|
||||
"kind": "horizon-server-benchmark",
|
||||
"date": "2026-10-18T17:23:30+00:00",
|
||||
"machine": {
|
||||
"system": "Linux",
|
||||
"machine": "x86_64",
|
||||
"cpus": 4,
|
||||
"note": "ThinkPad: Intel Core i7-7500U (2 cores, 4 threads), 32 GB, NixOS, Linux 6.18; the bots on the Mac mini (M4 Pro) over the tailnet"
|
||||
},
|
||||
"commit": "4ea2243e4d1a44f910857bd759845db70e3690a8",
|
||||
"java": [
|
||||
"openjdk 25.0.4.1 2026-08-18"
|
||||
],
|
||||
"settings": {
|
||||
"servers": "horizon,vanilla",
|
||||
"bots": "1,10,25,50",
|
||||
"behaviour": "mixed",
|
||||
"join_every": 1.0,
|
||||
"warmup": 60,
|
||||
"seconds": 300,
|
||||
"seed": 12345,
|
||||
"explore_speed": 0,
|
||||
"gamerule": "",
|
||||
"heap": "4g",
|
||||
"out": "mixed.json",
|
||||
"nice": 19,
|
||||
"bots_via": "ssh macmini",
|
||||
"bots_bin": "horizon-bots (on the Mac mini)",
|
||||
"connect": "<the ThinkPad's tailnet address>",
|
||||
"properties": {
|
||||
"level-seed": "12345",
|
||||
"view-distance": "10",
|
||||
"simulation-distance": "10",
|
||||
"online-mode": "false",
|
||||
"white-list": "false",
|
||||
"gamemode": "creative",
|
||||
"force-gamemode": "true",
|
||||
"spawn-protection": "0",
|
||||
"max-players": "200",
|
||||
"difficulty": "easy",
|
||||
"sync-chunk-writes": "true"
|
||||
}
|
||||
},
|
||||
"runs": [
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 1,
|
||||
"behaviour": "mixed",
|
||||
"start_seconds": 10.92,
|
||||
"mspt": {
|
||||
"average": 2.38,
|
||||
"p50": 1.97,
|
||||
"p95": 5.17,
|
||||
"p99": 18.2
|
||||
},
|
||||
"mspt_worst_p99": 45.6,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 653.7,
|
||||
"max": 659.2
|
||||
},
|
||||
"cpu_percent": 24.51,
|
||||
"chunks_per_second": 9.9,
|
||||
"unique_per_second": 0.3,
|
||||
"forgotten_per_second": 9.93,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 3951,
|
||||
"chunks_per_bot": {
|
||||
"walk": 3951.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 699.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 1,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 1,
|
||||
"behaviour": "mixed",
|
||||
"start_seconds": 14.75,
|
||||
"mspt": {
|
||||
"average": 17.9,
|
||||
"p50": 18.39,
|
||||
"p95": 27.58,
|
||||
"p99": 47.28
|
||||
},
|
||||
"mspt_worst_p99": 423.8,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 3382.06,
|
||||
"max": 3394.1
|
||||
},
|
||||
"cpu_percent": 64.02,
|
||||
"chunks_per_second": 9.92,
|
||||
"unique_per_second": 0.29,
|
||||
"forgotten_per_second": 9.95,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 3846,
|
||||
"chunks_per_bot": {
|
||||
"walk": 3846.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 699.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 0,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 1,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 10,
|
||||
"behaviour": "mixed",
|
||||
"start_seconds": 10.22,
|
||||
"mspt": {
|
||||
"average": 12.39,
|
||||
"p50": 8.33,
|
||||
"p95": 35.99,
|
||||
"p99": 65.9
|
||||
},
|
||||
"mspt_worst_p99": 117.1,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 935.85,
|
||||
"max": 1008.6
|
||||
},
|
||||
"cpu_percent": 210.37,
|
||||
"chunks_per_second": 98.22,
|
||||
"unique_per_second": 36.49,
|
||||
"forgotten_per_second": 98.25,
|
||||
"waited_ticks_per_second": 0.0,
|
||||
"chunks_total": 39618,
|
||||
"chunks_per_bot": {
|
||||
"walk": 3992.17,
|
||||
"explore": 7359.5,
|
||||
"idle": 473.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 696.83,
|
||||
"explore": 6869.0,
|
||||
"idle": 473.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 6267,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 10,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 10,
|
||||
"behaviour": "mixed",
|
||||
"start_seconds": 14.54,
|
||||
"mspt": {
|
||||
"average": 34.47,
|
||||
"p50": 29.67,
|
||||
"p95": 63.52,
|
||||
"p99": 138.58
|
||||
},
|
||||
"mspt_worst_p99": 884.7,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 3594.26,
|
||||
"max": 3661.7
|
||||
},
|
||||
"cpu_percent": 217.13,
|
||||
"chunks_per_second": 24.34,
|
||||
"unique_per_second": 19.13,
|
||||
"forgotten_per_second": 20.12,
|
||||
"waited_ticks_per_second": 0.62,
|
||||
"chunks_total": 10443,
|
||||
"chunks_per_bot": {
|
||||
"walk": 679.83,
|
||||
"explore": 2824.0,
|
||||
"idle": 358.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 389.17,
|
||||
"explore": 2824.0,
|
||||
"idle": 358.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 5500,
|
||||
"moved_too_quickly": 12,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 10,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 25,
|
||||
"behaviour": "mixed",
|
||||
"start_seconds": 14.73,
|
||||
"mspt": {
|
||||
"average": 24.0,
|
||||
"p50": 21.07,
|
||||
"p95": 49.72,
|
||||
"p99": 80.57
|
||||
},
|
||||
"mspt_worst_p99": 168.8,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 1013.07,
|
||||
"max": 1179.4
|
||||
},
|
||||
"cpu_percent": 192.47,
|
||||
"chunks_per_second": 178.23,
|
||||
"unique_per_second": 31.85,
|
||||
"forgotten_per_second": 177.93,
|
||||
"waited_ticks_per_second": 60.59,
|
||||
"chunks_total": 73657,
|
||||
"chunks_per_bot": {
|
||||
"walk": 3927.87,
|
||||
"explore": 2474.8,
|
||||
"idle": 473.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 691.73,
|
||||
"explore": 2447.8,
|
||||
"idle": 473.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 5532,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 25,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 58
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 25,
|
||||
"behaviour": "mixed",
|
||||
"start_seconds": 28.57,
|
||||
"mspt": {
|
||||
"average": 31.59,
|
||||
"p50": 25.18,
|
||||
"p95": 70.03,
|
||||
"p99": 222.74
|
||||
},
|
||||
"mspt_worst_p99": 2319.6,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 3742.51,
|
||||
"max": 4055.0
|
||||
},
|
||||
"cpu_percent": 218.34,
|
||||
"chunks_per_second": 30.37,
|
||||
"unique_per_second": 10.84,
|
||||
"forgotten_per_second": 29.44,
|
||||
"waited_ticks_per_second": 50.91,
|
||||
"chunks_total": 14193,
|
||||
"chunks_per_bot": {
|
||||
"walk": 640.2,
|
||||
"explore": 738.0,
|
||||
"idle": 180.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 211.33,
|
||||
"explore": 737.6,
|
||||
"idle": 180.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 4524,
|
||||
"moved_too_quickly": 324,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 25,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 56
|
||||
},
|
||||
{
|
||||
"server": "horizon",
|
||||
"bots": 50,
|
||||
"behaviour": "mixed",
|
||||
"start_seconds": 19.84,
|
||||
"mspt": {
|
||||
"average": 35.2,
|
||||
"p50": 32.12,
|
||||
"p95": 69.04,
|
||||
"p99": 96.56
|
||||
},
|
||||
"mspt_worst_p99": 240.8,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 1013.2,
|
||||
"max": 1249.1
|
||||
},
|
||||
"cpu_percent": 217.68,
|
||||
"chunks_per_second": 273.18,
|
||||
"unique_per_second": 34.58,
|
||||
"forgotten_per_second": 268.19,
|
||||
"waited_ticks_per_second": 138.29,
|
||||
"chunks_total": 115527,
|
||||
"chunks_per_bot": {
|
||||
"walk": 3300.1,
|
||||
"explore": 1192.4,
|
||||
"idle": 460.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 632.67,
|
||||
"explore": 1186.9,
|
||||
"idle": 460.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 8369,
|
||||
"moved_too_quickly": 0,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 50,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 57
|
||||
},
|
||||
{
|
||||
"server": "vanilla",
|
||||
"bots": 50,
|
||||
"behaviour": "mixed",
|
||||
"start_seconds": 20.58,
|
||||
"mspt": {
|
||||
"average": 29.74,
|
||||
"p50": 23.6,
|
||||
"p95": 62.25,
|
||||
"p99": 137.41
|
||||
},
|
||||
"mspt_worst_p99": 489.3,
|
||||
"tps": 20.0,
|
||||
"rss_mb": {
|
||||
"mean": 3995.71,
|
||||
"max": 4597.8
|
||||
},
|
||||
"cpu_percent": 294.31,
|
||||
"chunks_per_second": 94.36,
|
||||
"unique_per_second": 17.76,
|
||||
"forgotten_per_second": 94.69,
|
||||
"waited_ticks_per_second": 101.48,
|
||||
"chunks_total": 41637,
|
||||
"chunks_per_bot": {
|
||||
"walk": 1104.97,
|
||||
"explore": 628.8,
|
||||
"idle": 220.0
|
||||
},
|
||||
"unique_chunks_per_bot": {
|
||||
"walk": 279.4,
|
||||
"explore": 626.4,
|
||||
"idle": 220.0
|
||||
},
|
||||
"resent": 0,
|
||||
"pulled_back": 12034,
|
||||
"moved_too_quickly": 2193,
|
||||
"moved_wrongly": 0,
|
||||
"bots_joined": 50,
|
||||
"bots_failed": [],
|
||||
"tick_queries": 57
|
||||
}
|
||||
],
|
||||
"horizon_build": "v2026.10.18.15 release binaries (horizon-client, the engine, at 6c078b28); horizon-server from #898 (4ea2243e, the supervisor only)"
|
||||
}
|
||||
+120
-12
@@ -9,6 +9,11 @@
|
||||
//! 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`.
|
||||
//! A bot moves only while it holds the chunk it stands in: vanilla's client stops ticking its player in a chunk
|
||||
//! it hasn't received, so a player can't outrun the server's chunk sending, on any server.
|
||||
//! Each bot also keeps the chunks it holds, as a client's chunk cache does, so the report tells chunks that
|
||||
//! entered the view apart from chunks sent again while the bot still held them (`resent`), and counts the
|
||||
//! times the server pulled a bot back (`pulled_back`: a teleport after the first, as for "moved too quickly").
|
||||
|
||||
use horizon_protocol::{
|
||||
PROTOCOL_VERSION,
|
||||
@@ -25,6 +30,7 @@ use horizon_protocol::{
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
@@ -43,8 +49,9 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
|
||||
--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
|
||||
--explore-speed B blocks a second an explore bot flies; default 22 (as fast as chunks come)
|
||||
--name PREFIX bot names: PREFIX0, PREFIX1...; default Bot
|
||||
--report FILE a JSON summary at the end
|
||||
--report FILE a JSON summary at the end (- prints it after the per-second lines)
|
||||
";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -73,6 +80,7 @@ struct Options {
|
||||
seconds: u64,
|
||||
view_distance: i8,
|
||||
seed: u64,
|
||||
explore_speed: f64,
|
||||
name: String,
|
||||
report: Option<String>,
|
||||
}
|
||||
@@ -87,6 +95,7 @@ fn options() -> Result<Options, String> {
|
||||
seconds: 60,
|
||||
view_distance: 10,
|
||||
seed: 12345,
|
||||
explore_speed: 22.0,
|
||||
name: "Bot".into(),
|
||||
report: None,
|
||||
};
|
||||
@@ -110,6 +119,9 @@ fn options() -> Result<Options, String> {
|
||||
"--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"))?,
|
||||
"--explore-speed" => {
|
||||
o.explore_speed = value.parse().map_err(|_| number("blocks a second"))?;
|
||||
}
|
||||
"--name" => o.name = value,
|
||||
"--report" => o.report = Some(value),
|
||||
_ => return Err(format!("unknown option {arg}; --help lists them")),
|
||||
@@ -129,6 +141,17 @@ fn options() -> Result<Options, String> {
|
||||
struct Bot {
|
||||
online: AtomicBool,
|
||||
chunks: AtomicU64,
|
||||
/// Chunks received while the bot already held them (no ForgetLevelChunk in between).
|
||||
resent: AtomicU64,
|
||||
/// Different chunks received over the run.
|
||||
unique: AtomicU64,
|
||||
forgotten: AtomicU64,
|
||||
/// Teleports after the spawn one: the server refused a move.
|
||||
pulled_back: AtomicU64,
|
||||
/// Ticks the bot couldn't move because it didn't hold its chunk yet.
|
||||
waited: AtomicU64,
|
||||
/// The chunks the bot holds (ClientChunkCache).
|
||||
held: Mutex<HashSet<(i32, i32)>>,
|
||||
/// Where the server last put the bot (a teleport), to start from.
|
||||
teleport: Mutex<Option<[f64; 3]>>,
|
||||
error: Mutex<Option<String>>,
|
||||
@@ -268,6 +291,36 @@ fn join(o: &Options, name: &str) -> Result<Connection, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// ChunkBatchSizeCalculator: the chunks per tick a vanilla client asks for, from how long its batches take
|
||||
/// to arrive.
|
||||
struct BatchSize {
|
||||
nanos_per_chunk: f64,
|
||||
old_samples_weight: u32,
|
||||
started: Instant,
|
||||
}
|
||||
|
||||
impl BatchSize {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
nanos_per_chunk: 2_000_000.0,
|
||||
old_samples_weight: 1,
|
||||
started: Instant::now(),
|
||||
}
|
||||
}
|
||||
fn finished(&mut self, size: i32) {
|
||||
if size > 0 {
|
||||
let per_chunk = self.started.elapsed().as_nanos() as f64 / f64::from(size);
|
||||
let clamped = per_chunk.clamp(self.nanos_per_chunk / 3.0, self.nanos_per_chunk * 3.0);
|
||||
let weight = f64::from(self.old_samples_weight);
|
||||
self.nanos_per_chunk = (self.nanos_per_chunk * weight + clamped) / (weight + 1.0);
|
||||
self.old_samples_weight = (self.old_samples_weight + 1).min(49);
|
||||
}
|
||||
}
|
||||
fn desired_chunks_per_tick(&self) -> f32 {
|
||||
(7_000_000.0 / self.nanos_per_chunk) as f32
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the play state: answers what a client must answer, counts chunks, records teleports.
|
||||
fn read(
|
||||
mut reader: horizon_protocol::connection::ConnReader,
|
||||
@@ -277,6 +330,9 @@ fn read(
|
||||
use ids::play::clientbound as cb;
|
||||
use ids::play::serverbound as sb;
|
||||
let send = |body: Vec<u8>| writer.lock().unwrap().send(&body);
|
||||
let mut seen = HashSet::new();
|
||||
let mut batch = BatchSize::new();
|
||||
let mut teleports = 0u64;
|
||||
loop {
|
||||
let (id, body) = match reader.recv() {
|
||||
Ok(packet) => packet,
|
||||
@@ -299,14 +355,38 @@ fn read(
|
||||
},
|
||||
cb::LEVEL_CHUNK_WITH_LIGHT => {
|
||||
bot.chunks.fetch_add(1, Ordering::Relaxed);
|
||||
if let (Ok(x), Ok(z)) = (r.i32(), r.i32()) {
|
||||
if !bot.held.lock().unwrap().insert((x, z)) {
|
||||
bot.resent.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
if seen.insert((x, z)) {
|
||||
bot.unique.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
cb::CHUNK_BATCH_FINISHED => send(
|
||||
play::Serverbound::ChunkBatchReceived(play::ChunkBatchReceived {
|
||||
chunks_per_tick: 25.0,
|
||||
})
|
||||
.encode(),
|
||||
),
|
||||
cb::FORGET_LEVEL_CHUNK => {
|
||||
if let Ok(play::Clientbound::ForgetLevelChunk(p)) =
|
||||
play::Clientbound::decode(id, &body)
|
||||
&& bot.held.lock().unwrap().remove(&(p.pos[0], p.pos[1]))
|
||||
{
|
||||
bot.forgotten.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
cb::CHUNK_BATCH_START => {
|
||||
batch.started = Instant::now();
|
||||
Ok(())
|
||||
}
|
||||
cb::CHUNK_BATCH_FINISHED => {
|
||||
batch.finished(r.varint().unwrap_or(0));
|
||||
send(
|
||||
play::Serverbound::ChunkBatchReceived(play::ChunkBatchReceived {
|
||||
chunks_per_tick: batch.desired_chunks_per_tick(),
|
||||
})
|
||||
.encode(),
|
||||
)
|
||||
}
|
||||
cb::PLAYER_POSITION => match play::Clientbound::decode(id, &body) {
|
||||
Ok(play::Clientbound::PlayerPosition(p)) => {
|
||||
let mut at = bot.teleport.lock().unwrap();
|
||||
@@ -320,6 +400,10 @@ fn read(
|
||||
}
|
||||
});
|
||||
*at = Some(pos);
|
||||
teleports += 1;
|
||||
if teleports > 1 {
|
||||
bot.pulled_back.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
send(
|
||||
play::Serverbound::AcceptTeleportation(play::AcceptTeleportation {
|
||||
id: p.teleport,
|
||||
@@ -416,7 +500,10 @@ fn run_bot(
|
||||
if let Some(at) = bot.teleport.lock().unwrap().take() {
|
||||
pos = at;
|
||||
}
|
||||
if pos[1] < cruise {
|
||||
let here = ((pos[0].floor() as i32) >> 4, (pos[2].floor() as i32) >> 4);
|
||||
if !bot.held.lock().unwrap().contains(&here) {
|
||||
bot.waited.fetch_add(1, Ordering::Relaxed);
|
||||
} else if pos[1] < cruise {
|
||||
pos[1] = (pos[1] + 0.5).min(cruise);
|
||||
} else {
|
||||
match behaviour {
|
||||
@@ -440,9 +527,11 @@ fn run_bot(
|
||||
}
|
||||
}
|
||||
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;
|
||||
// Flying in creative, by default about 22 blocks a second, which keeps new
|
||||
// chunks coming as fast as the server sends them.
|
||||
let step = o.explore_speed / 20.0;
|
||||
pos[0] += heading.cos() * step;
|
||||
pos[2] += heading.sin() * step;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -520,6 +609,9 @@ fn main() {
|
||||
.filter(|b| b.online.load(Ordering::SeqCst))
|
||||
.count();
|
||||
let chunks: u64 = bots.iter().map(|b| b.chunks.load(Ordering::Relaxed)).sum();
|
||||
let total = |f: fn(&Bot) -> &AtomicU64| -> u64 {
|
||||
bots.iter().map(|b| f(b).load(Ordering::Relaxed)).sum()
|
||||
};
|
||||
let failed = bots
|
||||
.iter()
|
||||
.filter(|b| b.error.lock().unwrap().is_some())
|
||||
@@ -536,6 +628,11 @@ fn main() {
|
||||
"failed": failed,
|
||||
"chunks": chunks,
|
||||
"chunks_per_second": chunks - previous_chunks,
|
||||
"unique": total(|b| &b.unique),
|
||||
"forgotten": total(|b| &b.forgotten),
|
||||
"resent": total(|b| &b.resent),
|
||||
"pulled_back": total(|b| &b.pulled_back),
|
||||
"waited_ticks": total(|b| &b.waited),
|
||||
})
|
||||
);
|
||||
previous_chunks = chunks;
|
||||
@@ -554,19 +651,30 @@ fn main() {
|
||||
"bots": o.bots,
|
||||
"behaviour": o.behaviour,
|
||||
"seed": o.seed,
|
||||
"explore_speed": o.explore_speed,
|
||||
"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>(),
|
||||
"resent": bots.iter().map(|b| b.resent.load(Ordering::Relaxed)).sum::<u64>(),
|
||||
"pulled_back": bots.iter().map(|b| b.pulled_back.load(Ordering::Relaxed)).sum::<u64>(),
|
||||
"waited_ticks": bots.iter().map(|b| b.waited.load(Ordering::Relaxed)).sum::<u64>(),
|
||||
"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),
|
||||
"unique_chunks": b.unique.load(Ordering::Relaxed),
|
||||
"resent": b.resent.load(Ordering::Relaxed),
|
||||
"forgotten": b.forgotten.load(Ordering::Relaxed),
|
||||
"pulled_back": b.pulled_back.load(Ordering::Relaxed),
|
||||
"waited_ticks": b.waited.load(Ordering::Relaxed),
|
||||
"error": *b.error.lock().unwrap(),
|
||||
})).collect::<Vec<_>>(),
|
||||
});
|
||||
if let Some(path) = &o.report {
|
||||
if o.report.as_deref() == Some("-") {
|
||||
println!("{summary:#}");
|
||||
} else if let Some(path) = &o.report {
|
||||
if let Err(e) = std::fs::write(path, format!("{summary:#}\n")) {
|
||||
eprintln!("horizon-bots: {path}: {e}");
|
||||
}
|
||||
|
||||
@@ -10,11 +10,17 @@ description = "Horizon native development client"
|
||||
[dependencies]
|
||||
horizon-assets = { path = "../horizon-assets" }
|
||||
horizon-core = { path = "../horizon-core" }
|
||||
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
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" }
|
||||
# The companion (docs/companions.md): its brain, settings, catalogue and setup, then its senses: voice
|
||||
# playback (the radio), push-to-talk and their sidecar.
|
||||
horizon-companion = { path = "../horizon-companion" }
|
||||
horizon-senses = { path = "../horizon-senses" }
|
||||
# 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"] }
|
||||
@@ -31,33 +37,54 @@ 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"
|
||||
# Sound (ALSA on Linux); the `audio` feature.
|
||||
cpal = { version = "0.16", optional = true }
|
||||
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"] }
|
||||
flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] }
|
||||
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"
|
||||
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
|
||||
gilrs = { version = "0.11", optional = true }
|
||||
# The DualSense's rumble, adaptive triggers and lights over HID (gilrs only reads it).
|
||||
horizon-dualsense = { path = "../horizon-dualsense" }
|
||||
horizon-dualsense = { path = "../horizon-dualsense", optional = true }
|
||||
# HTTPS to the Minecraft services (online-mode session join).
|
||||
ureq = { version = "2", default-features = false, features = ["tls"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
# The server thread's CPU time per tick (`HORIZON_SPIKES`, spans.rs).
|
||||
rustix = { version = "1", default-features = false, features = ["std", "time"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
# DirectX 12, where Vulkan has no adapter or device, or by choice (`graphicsApi`, graphics.rs).
|
||||
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or DXC when dxcompiler.dll is
|
||||
# on the PATH.
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
|
||||
|
||||
[target.'cfg(windows)'.build-dependencies]
|
||||
# The icon resource in the .exe (build.rs).
|
||||
winresource = "0.1"
|
||||
|
||||
[features]
|
||||
default = ["audio", "controllers"]
|
||||
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
|
||||
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
|
||||
audio = ["dep:cpal"]
|
||||
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
|
||||
controllers = ["dep:gilrs", "dep:horizon-dualsense"]
|
||||
# Records the call sites of mob random draws (`mob_ai_probe` traces).
|
||||
rng-trace = ["horizon-core/rng-trace"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
|
||||
@@ -58,11 +58,12 @@ fn main() -> Result<()> {
|
||||
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)?;
|
||||
let render = assets.reload(&root)?;
|
||||
let mut scene = World::test_scene(&assets, &render, 1)?;
|
||||
fs::write(
|
||||
dir.join("scene.json"),
|
||||
serde_json::to_vec(
|
||||
&serde_json::json!({"radius":1,"resident_chunks":scene.chunks.len(),"model_gallery":assets.model_gallery}),
|
||||
&serde_json::json!({"radius":1,"resident_chunks":scene.chunks.len(),"model_gallery":render.model_gallery}),
|
||||
)?,
|
||||
)?;
|
||||
if args[2] == "--describe" {
|
||||
|
||||
@@ -31,13 +31,14 @@ fn main() -> Result<()> {
|
||||
};
|
||||
let root = PathBuf::from(root);
|
||||
let assets = Assets::load(&root)?;
|
||||
let mut scene = World::test_scene(&assets, 1)?;
|
||||
let render = assets.reload(&root)?;
|
||||
let mut scene = World::test_scene(&assets, &render, 1)?;
|
||||
let binding = collisions::load(&root, assets.registry)?;
|
||||
let resources = MotionResources::load(&root, &assets)?;
|
||||
fs::write(
|
||||
scene_json,
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"radius": 1, "resident_chunks": scene.chunks.len(), "model_gallery": assets.model_gallery,
|
||||
"radius": 1, "resident_chunks": scene.chunks.len(), "model_gallery": render.model_gallery,
|
||||
}))?,
|
||||
)?;
|
||||
let mut out = BufWriter::new(fs::File::create(output)?);
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
//! The Rust side of tools/test_block_entity_tickers_parity.py: the ops of
|
||||
//! `VanillaBlockEntityTickerOracle` on `BlockEntities`, as `World` drives it
|
||||
//! (a state write calls `state_changed`), printing the tick order.
|
||||
use horizon_client::{
|
||||
block_entities::{BlockEntities, Pos, Ticking},
|
||||
containers::BlockEntity,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// State ids of the fixture: 0 air, 1 a block whose block entity ticks, 2 a
|
||||
/// jukebox without a record, 3 a block entity that never ticks (a chest).
|
||||
fn state(name: &str) -> u32 {
|
||||
match name {
|
||||
"chest" | "barrel" => 3,
|
||||
"jukebox" => 2,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn entity(name: &str) -> BlockEntity {
|
||||
if name.starts_with("jukebox") {
|
||||
// The game makes a jukebox's block entity when a record goes in.
|
||||
return BlockEntity::Jukebox(Default::default());
|
||||
}
|
||||
BlockEntity::for_block(&format!("minecraft:{name}"))
|
||||
.unwrap_or_else(|| panic!("no block entity for {name}"))
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let input = std::fs::read_to_string(&args[1]).expect("input");
|
||||
let mut out = String::new();
|
||||
let mut entities = BlockEntities::default();
|
||||
let mut states: HashMap<Pos, u32> = HashMap::new();
|
||||
let mut kinds: HashMap<Pos, String> = HashMap::new();
|
||||
let table = vec![Ticking::No, Ticking::Yes, Ticking::Idle, Ticking::No];
|
||||
for line in input.lines() {
|
||||
let w: Vec<&str> = line.split_whitespace().collect();
|
||||
let pos = |i: usize| -> Pos {
|
||||
let n = |j: usize| w[i + j].parse::<i32>().expect("coordinate");
|
||||
(n(0), n(1), n(2))
|
||||
};
|
||||
match w[0] {
|
||||
"reset" => {
|
||||
entities = BlockEntities::default();
|
||||
entities.set_states(table.clone());
|
||||
states.clear();
|
||||
kinds.clear();
|
||||
}
|
||||
"load" => {
|
||||
let mut chunk = Vec::new();
|
||||
for i in (3..w.len()).step_by(4) {
|
||||
let p = pos(i + 1);
|
||||
states.insert(p, state(w[i]));
|
||||
kinds.insert(p, w[i].to_owned());
|
||||
chunk.push((p, entity(w[i])));
|
||||
}
|
||||
entities.load_chunk(chunk);
|
||||
}
|
||||
"place" => {
|
||||
let p = pos(2);
|
||||
// World::set, then the game adds the block entity.
|
||||
let old = states.insert(p, state(w[1])).unwrap_or(0);
|
||||
kinds.insert(p, w[1].to_owned());
|
||||
entities.state_changed(p, old, state(w[1]));
|
||||
entities.insert(p, entity(w[1]));
|
||||
}
|
||||
"replace" => {
|
||||
let p = pos(1);
|
||||
entities.insert(p, entity(&kinds[&p]));
|
||||
}
|
||||
"remove" => {
|
||||
let p = pos(1);
|
||||
entities.remove(&p);
|
||||
let old = states.insert(p, 0).unwrap_or(0);
|
||||
entities.state_changed(p, old, 0);
|
||||
}
|
||||
"record" => {
|
||||
let p = pos(1);
|
||||
let s = if w[4] == "1" { 1 } else { 2 };
|
||||
let old = states.insert(p, s).unwrap_or(0);
|
||||
entities.state_changed(p, old, s);
|
||||
}
|
||||
"unload" => {
|
||||
let c = (w[1].parse::<i32>().unwrap(), w[2].parse::<i32>().unwrap());
|
||||
entities.retain(|p, _| (p.0 >> 4, p.2 >> 4) != c);
|
||||
}
|
||||
"tick" => {
|
||||
out.push_str("tick");
|
||||
for (p, _) in entities.tickers(|p| states.get(&p).copied().unwrap_or(0)) {
|
||||
out.push_str(&format!(" {},{},{}", p.0, p.1, p.2));
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
other => panic!("unknown op {other}"),
|
||||
}
|
||||
}
|
||||
std::fs::write(&args[2], out).expect("output");
|
||||
}
|
||||
@@ -12,9 +12,11 @@ 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 render = assets.reload(root)?;
|
||||
let facts = BlockFacts::load(root, assets.registry)?;
|
||||
let tables = MeshTables::new(
|
||||
&assets,
|
||||
&render,
|
||||
&facts,
|
||||
models::load_visibility(root, assets.registry)?,
|
||||
)?;
|
||||
|
||||
@@ -18,9 +18,11 @@ 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 render = assets.reload(root)?;
|
||||
let facts = BlockFacts::load(root, assets.registry)?;
|
||||
let tables = MeshTables::new(
|
||||
&assets,
|
||||
&render,
|
||||
&facts,
|
||||
models::load_visibility(root, assets.registry)?,
|
||||
)?;
|
||||
@@ -33,7 +35,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut vertices = Vec::new();
|
||||
let mut translucent = false;
|
||||
if facts.get(state).render_shape == RenderShape::Model {
|
||||
let models = assets
|
||||
let models = render
|
||||
.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]);
|
||||
|
||||
@@ -31,10 +31,12 @@ fn main() -> Result<()> {
|
||||
};
|
||||
let (seed, size) = (flag("--seed", 12345), flag("--size", 8) as i32);
|
||||
let assets = Assets::load(&root)?;
|
||||
let render = assets.reload(&root)?;
|
||||
let facts = BlockFacts::load(&root, assets.registry)?;
|
||||
let biomes = Biomes::load(&root)?;
|
||||
let tables = MeshTables::new(
|
||||
&assets,
|
||||
&render,
|
||||
&facts,
|
||||
models::load_visibility(&root, assets.registry)?,
|
||||
)?;
|
||||
@@ -132,6 +134,7 @@ fn main() -> Result<()> {
|
||||
let mut mesher = Mesher {
|
||||
nether_shading: false,
|
||||
assets: &assets,
|
||||
render: &render,
|
||||
tables: &tables,
|
||||
biomes: &biomes,
|
||||
visibility: tables.visibility.clone(),
|
||||
|
||||
@@ -233,22 +233,32 @@ fn mouse(c: &Value, out: &mut impl Write) -> Result<()> {
|
||||
fn scroll(c: &Value, out: &mut impl Write) -> Result<()> {
|
||||
let mut handler = MouseLook::default();
|
||||
let mut slot = c["slot"].as_i64().unwrap() as i32;
|
||||
let spectator = c["spectator"].as_bool().unwrap_or(false);
|
||||
let mut speed = 0.05;
|
||||
// OptionInstance.set quantises the wheel sensitivity.
|
||||
let mut file = OptionsFile::default();
|
||||
file.set("mouseWheelSensitivity", V::Double(db(&c["sens"])));
|
||||
let sensitivity = file.double("mouseWheelSensitivity");
|
||||
writeln!(out, "scroll")?;
|
||||
for op in c["ops"].as_array().unwrap() {
|
||||
if let Some((wheel, _)) =
|
||||
if let Some((wheel, (_, y))) =
|
||||
handler.on_scroll(db(&op[0]), db(&op[1]), b(&c["discrete"]), sensitivity)
|
||||
{
|
||||
slot = horizon_client::mouse::ScrollWheelHandler::next_selection(
|
||||
f64::from(wheel),
|
||||
slot,
|
||||
9,
|
||||
);
|
||||
if spectator {
|
||||
speed = horizon_client::mouse::spectator_flying_speed(speed, y);
|
||||
} else {
|
||||
slot = horizon_client::mouse::ScrollWheelHandler::next_selection(
|
||||
f64::from(wheel),
|
||||
slot,
|
||||
9,
|
||||
);
|
||||
}
|
||||
}
|
||||
if spectator {
|
||||
writeln!(out, "{slot} {}", fb(speed))?;
|
||||
} else {
|
||||
writeln!(out, "{slot}")?;
|
||||
}
|
||||
writeln!(out, "{slot}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
use horizon_client::interaction_input::{
|
||||
Button, InteractionInput, InteractionWorld, TargetUseWorld, use_on_target,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::io::{BufRead, Write};
|
||||
|
||||
struct World {
|
||||
using: bool,
|
||||
destroying: bool,
|
||||
entity: bool,
|
||||
dig_on_attack: bool,
|
||||
begin_using: bool,
|
||||
delay: u32,
|
||||
events: Vec<String>,
|
||||
passing: bool,
|
||||
}
|
||||
impl InteractionWorld for World {
|
||||
fn using_item(&self) -> bool {
|
||||
self.using
|
||||
}
|
||||
fn release_using(&mut self) {
|
||||
self.events.push("release".into());
|
||||
self.using = false;
|
||||
}
|
||||
fn start_attack(&mut self) -> bool {
|
||||
self.events.push("attack".into());
|
||||
if !self.entity {
|
||||
self.destroying = self.dig_on_attack;
|
||||
}
|
||||
false
|
||||
}
|
||||
fn start_use(&mut self) {
|
||||
if self.destroying {
|
||||
return;
|
||||
}
|
||||
self.delay = 4;
|
||||
let entity = self.entity;
|
||||
use_on_target(self, entity);
|
||||
}
|
||||
fn pick(&mut self) {
|
||||
self.events.push("pick".into());
|
||||
}
|
||||
fn use_delay(&self) -> u32 {
|
||||
self.delay
|
||||
}
|
||||
fn continue_attack(&mut self, down: bool) {
|
||||
if self.using {
|
||||
return;
|
||||
}
|
||||
let down = down && !self.entity;
|
||||
self.events.push(format!("continue {down}"));
|
||||
if !down {
|
||||
self.destroying = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TargetUseWorld for World {
|
||||
fn use_entity(&mut self) -> bool {
|
||||
self.events
|
||||
.push(if self.passing { "entity" } else { "use" }.into());
|
||||
if self.passing {
|
||||
false
|
||||
} else {
|
||||
self.using = self.begin_using;
|
||||
true
|
||||
}
|
||||
}
|
||||
fn use_block(&mut self) -> bool {
|
||||
self.events
|
||||
.push(if self.passing { "block" } else { "use" }.into());
|
||||
if self.passing {
|
||||
false
|
||||
} else {
|
||||
self.using = self.begin_using;
|
||||
true
|
||||
}
|
||||
}
|
||||
fn use_item(&mut self) {
|
||||
self.events.push("item".into());
|
||||
self.using = self.begin_using;
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<_> = std::env::args().collect();
|
||||
let input = std::io::BufReader::new(std::fs::File::open(&args[1])?);
|
||||
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[2])?);
|
||||
for (i, line) in input.lines().enumerate() {
|
||||
let row: Value = serde_json::from_str(&line?)?;
|
||||
let flag = |key: &str| row[key].as_bool().unwrap();
|
||||
let mut world = World {
|
||||
using: flag("using"),
|
||||
destroying: flag("destroying"),
|
||||
entity: flag("entity"),
|
||||
dig_on_attack: flag("dig_on_attack"),
|
||||
begin_using: flag("begin_using"),
|
||||
delay: row["delay"].as_u64().unwrap() as u32,
|
||||
events: vec![],
|
||||
passing: row["passing"].as_bool().unwrap_or(false),
|
||||
};
|
||||
let mut input = InteractionInput::default();
|
||||
for button in row["clicks"].as_array().unwrap() {
|
||||
input.press(match button.as_u64().unwrap() {
|
||||
0 => Button::Attack,
|
||||
1 => Button::Use,
|
||||
_ => Button::Pick,
|
||||
});
|
||||
}
|
||||
input.tick(&mut world, flag("attack_down"), flag("use_down"));
|
||||
writeln!(out, "== {i}")?;
|
||||
for event in world.events {
|
||||
writeln!(out, "{event}")?;
|
||||
}
|
||||
writeln!(
|
||||
out,
|
||||
"state {} {} {}",
|
||||
world.using, world.destroying, world.delay
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -22,12 +22,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let port: u16 = args.next().ok_or("port")?.parse()?;
|
||||
let assets = Assets::load(&root)?;
|
||||
let facts = BlockFacts::load(&root, assets.registry)?;
|
||||
let state = |name: &str| {
|
||||
assets
|
||||
.registry
|
||||
.default_state(name)
|
||||
.map_or(0, |s| s.0 as u16)
|
||||
};
|
||||
let state = |name: &str| assets.registry.default_state(name).map_or(0, |s| s.0);
|
||||
let sync = Arc::new(SyncData::load(&root)?);
|
||||
let biomes: Vec<String> = sync
|
||||
.dynamic
|
||||
@@ -106,6 +101,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
sprinting: false,
|
||||
equipment: Default::default(),
|
||||
health: 20.0,
|
||||
attributes: Vec::new(),
|
||||
living_flags: 0,
|
||||
};
|
||||
loop {
|
||||
world.game_time += 1;
|
||||
@@ -121,6 +118,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
clock_rate: 1.0,
|
||||
tick_rate: 20.0,
|
||||
tick_frozen: false,
|
||||
collision: None,
|
||||
};
|
||||
for event in hub.tick(&mut ctx) {
|
||||
match event {
|
||||
@@ -132,8 +130,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
eprintln!("[lan] {packet:?}");
|
||||
}
|
||||
}
|
||||
HostEvent::Chat(line) => println!("{line}"),
|
||||
HostEvent::Left(_) => {}
|
||||
HostEvent::Chat { line, .. } | HostEvent::Warn(line) | HostEvent::Error(line) => {
|
||||
println!("{line}")
|
||||
}
|
||||
HostEvent::Left(_) | HostEvent::Said { .. } => {}
|
||||
}
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
|
||||
@@ -206,7 +206,7 @@ fn incremental(
|
||||
sky_light: bool,
|
||||
edits: usize,
|
||||
) {
|
||||
use horizon_client::light::relight;
|
||||
use horizon_client::light::{LightChanges, relight};
|
||||
use std::sync::Arc;
|
||||
let mut world: HashMap<(i32, i32), Arc<Chunk>> = HashMap::new();
|
||||
let inner: Vec<(i32, i32)> = {
|
||||
@@ -232,7 +232,7 @@ fn incremental(
|
||||
c.lit = true;
|
||||
world.insert((x, z), Arc::new(c));
|
||||
}
|
||||
let palette: Vec<u16> = [
|
||||
let palette: Vec<u32> = [
|
||||
"minecraft:air",
|
||||
"minecraft:stone",
|
||||
"minecraft:torch",
|
||||
@@ -242,7 +242,7 @@ fn incremental(
|
||||
"minecraft:glass",
|
||||
]
|
||||
.iter()
|
||||
.filter_map(|n| registry.default_state(n).map(|s| s.0 as u16))
|
||||
.filter_map(|n| registry.default_state(n).map(|s| s.0))
|
||||
.chain(
|
||||
facts
|
||||
.states
|
||||
@@ -257,7 +257,7 @@ fn incremental(
|
||||
]
|
||||
.contains(&&*f.name)
|
||||
})
|
||||
.map(|(i, _)| i as u16)
|
||||
.map(|(i, _)| i as u32)
|
||||
.step_by(3),
|
||||
)
|
||||
.collect();
|
||||
@@ -285,7 +285,14 @@ fn incremental(
|
||||
if old == state || !tables.differs(old, state) {
|
||||
continue;
|
||||
}
|
||||
relight(&mut world, tables, sky_light, &[(x, y, z)], false);
|
||||
relight(
|
||||
&mut world,
|
||||
tables,
|
||||
sky_light,
|
||||
&[(x, y, z)],
|
||||
false,
|
||||
&mut LightChanges::default(),
|
||||
);
|
||||
for dz in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
let (kx, kz) = (cx + dx, cz + dz);
|
||||
|
||||
@@ -19,11 +19,17 @@ fn state(menu: &Menu) -> String {
|
||||
.map(|i| canon_stack(menu.slot(i)))
|
||||
.collect();
|
||||
let data: String = menu.data.iter().map(|d| format!(" {d}")).collect();
|
||||
let equipped: Vec<String> = menu
|
||||
.equipped
|
||||
.iter()
|
||||
.map(|(slot, stack)| format!("{slot}:{}", canon_stack(stack.as_ref())))
|
||||
.collect();
|
||||
format!(
|
||||
"{} | {} |{data} | {}",
|
||||
"{} | {} |{data} | {} | {}",
|
||||
slots.join(" "),
|
||||
canon_stack(menu.carried.as_ref()),
|
||||
menu.drops.join(";")
|
||||
menu.drops.join(";"),
|
||||
equipped.join(";")
|
||||
)
|
||||
}
|
||||
|
||||
@@ -82,6 +88,7 @@ fn main() -> horizon_client::Result<()> {
|
||||
speed: 1.0,
|
||||
});
|
||||
menu.drops.clear();
|
||||
menu.equipped.clear();
|
||||
out += &format!("init {}\n", state(&menu));
|
||||
for op in case["ops"].as_array().ok_or("ops")? {
|
||||
let slot = op[0].as_i64().unwrap_or(0) as i32;
|
||||
|
||||
@@ -999,6 +999,7 @@ fn main() -> horizon_client::Result<()> {
|
||||
hero: false,
|
||||
uuid: PLAYER_UUID,
|
||||
allies: Default::default(),
|
||||
remote: false,
|
||||
}
|
||||
});
|
||||
let mut scratch = LegacyRandom::new(0);
|
||||
@@ -1023,6 +1024,7 @@ fn main() -> horizon_client::Result<()> {
|
||||
collision: &collision,
|
||||
data: &data,
|
||||
player: player.clone(),
|
||||
nearest_player: player.as_ref().filter(|p| !p.spectator).map(|p| p.position),
|
||||
others: $snapshot,
|
||||
others_grid: $grid,
|
||||
daylight: (world.day_time.rem_euclid(24000)) < 12000,
|
||||
|
||||
@@ -84,8 +84,10 @@ fn main() -> Result<()> {
|
||||
horizon_client::collisions::load(&root, registry).map_err(step("collisions"))?;
|
||||
let loaded = began.elapsed().as_secs_f64() - built;
|
||||
let ours = |id: &str| {
|
||||
id.split_once(':')
|
||||
.is_some_and(|(ns, _)| mods.iter().any(|m| m.id == ns))
|
||||
id.split_once(':').is_some_and(|(ns, _)| {
|
||||
mods.iter()
|
||||
.any(|m| m.content.namespaces.iter().any(|n| n == ns))
|
||||
})
|
||||
};
|
||||
let tools: Vec<String> = serde_json::from_value(oracle["tools"].clone())?;
|
||||
|
||||
|
||||
@@ -56,10 +56,11 @@ fn main() -> horizon_client::Result<()> {
|
||||
let initial = LIVE.load(Relaxed);
|
||||
let start = Instant::now();
|
||||
let assets = Assets::load(root)?;
|
||||
let render = assets.reload(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 items = ItemRenderer::load_with_library(root, render.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;
|
||||
@@ -74,7 +75,7 @@ fn main() -> horizon_client::Result<()> {
|
||||
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) {
|
||||
if let Ok(selected) = render.model_catalog.collect(id, pos) {
|
||||
for (variant, model) in selected {
|
||||
format!("{variant:?}").hash(&mut hash);
|
||||
model.ambient_occlusion.hash(&mut hash);
|
||||
|
||||
@@ -268,10 +268,16 @@ impl NaturalLevel for World<'_> {
|
||||
self.biome_info(x, y, z)
|
||||
.map_or(0.1, |b| b.creature_probability)
|
||||
}
|
||||
fn structures_at(&self, x: i32, y: i32, z: i32) -> Vec<StructureAt> {
|
||||
fn structures_at(
|
||||
&self,
|
||||
x: i32,
|
||||
y: i32,
|
||||
z: i32,
|
||||
wanted: &dyn Fn(&str) -> bool,
|
||||
) -> Vec<StructureAt> {
|
||||
let (cx, cz) = (x >> 4, z >> 4);
|
||||
let mut out: Vec<StructureAt> = Vec::new();
|
||||
for start in &self.starts {
|
||||
for start in self.starts.iter().filter(|s| wanted(&s.id)) {
|
||||
let b = start.bounds.0;
|
||||
// Referenced: the start's box reaches the chunk's columns.
|
||||
if b[3] < cx * 16 || b[0] > cx * 16 + 15 || b[5] < cz * 16 || b[2] > cz * 16 + 15 {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
use horizon_client::player_sync::{PositionSender, teleport_velocity};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_protocol::packets::play::{PlayerPosition, Serverbound as S};
|
||||
use serde_json::Value;
|
||||
use std::io::{BufRead, Write};
|
||||
|
||||
fn number(value: &Value) -> f64 {
|
||||
f64::from_bits(u64::from_str_radix(value.as_str().unwrap(), 16).unwrap())
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<_> = std::env::args().collect();
|
||||
let input = std::io::BufReader::new(std::fs::File::open(&args[1])?);
|
||||
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[2])?);
|
||||
for line in input.lines() {
|
||||
let row: Value = serde_json::from_str(&line?)?;
|
||||
if row["kind"] == "movement" {
|
||||
let mut sender = PositionSender::default();
|
||||
for op in row["ops"].as_array().unwrap() {
|
||||
let pos = std::array::from_fn(|i| number(&op[i]));
|
||||
let rot = (number(&op[3]) as f32, number(&op[4]) as f32);
|
||||
if let Some(packet) =
|
||||
sender.update(pos, rot, op[5].as_bool().unwrap(), op[6].as_bool().unwrap())
|
||||
{
|
||||
let name = match packet {
|
||||
S::MovePlayerPos(_) => "pos",
|
||||
S::MovePlayerPosRot(_) => "posrot",
|
||||
S::MovePlayerRot(_) => "rot",
|
||||
S::MovePlayerStatus(_) => "status",
|
||||
_ => unreachable!(),
|
||||
};
|
||||
write!(out, "{name} ")?;
|
||||
for byte in &packet.encode()[1..] {
|
||||
write!(out, "{byte:02x}")?;
|
||||
}
|
||||
writeln!(out)?;
|
||||
} else {
|
||||
writeln!(out, "-")?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let vector = |key: &str| {
|
||||
Vec3::new(
|
||||
number(&row[key][0]),
|
||||
number(&row[key][1]),
|
||||
number(&row[key][2]),
|
||||
)
|
||||
};
|
||||
let rotation = |key: &str| (number(&row[key][0]) as f32, number(&row[key][1]) as f32);
|
||||
let old = rotation("old");
|
||||
let new = rotation("new");
|
||||
let delta = vector("delta");
|
||||
let packet = PlayerPosition {
|
||||
teleport: 0,
|
||||
position: [0.0; 3],
|
||||
delta: [delta.x, delta.y, delta.z],
|
||||
yaw: new.0,
|
||||
pitch: new.1,
|
||||
relatives: row["flags"].as_i64().unwrap() as i32,
|
||||
};
|
||||
let v = teleport_velocity(vector("velocity"), old, new, &packet);
|
||||
writeln!(
|
||||
out,
|
||||
"{:016x} {:016x} {:016x}",
|
||||
v.x.to_bits(),
|
||||
v.y.to_bits(),
|
||||
v.z.to_bits()
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
//! The `entity_portals` section scan only: `may_hold_portal` over synthetic
|
||||
//! entity reaches, with the per-tick palette map against the resident cache.
|
||||
//! Run with prepared client resources:
|
||||
//! cargo run --profile fast -p horizon-client --example portal_scan_bench -- RESOURCES
|
||||
use horizon_client::{
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, MIN_Y, SECTIONS, Section},
|
||||
fast_hash::FxHashMap,
|
||||
portal_sections::PortalSections,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
position::BlockPos,
|
||||
};
|
||||
use horizon_world::{level::Behaviours, registry::BlockStateId};
|
||||
use std::{hint::black_box, path::Path, sync::Arc, time::Instant};
|
||||
|
||||
/// `Game::swept_portal`'s reach (`Entity.checkInsideBlocks`'s swept box).
|
||||
fn reach(from: Vec3, to: Vec3, width: f32, height: f32) -> Aabb {
|
||||
let half = f64::from(width) / 2.0;
|
||||
let bounds = Aabb::new(
|
||||
Vec3::new(to.x - half, to.y, to.z - half),
|
||||
Vec3::new(to.x + half, to.y + f64::from(height), to.z + half),
|
||||
)
|
||||
.deflate(Vec3::new(1.0e-5, 1.0e-5, 1.0e-5));
|
||||
bounds
|
||||
.union_bounds(bounds.move_by(from.subtract(to)))
|
||||
.inflate(Vec3::new(1.0, 1.0, 1.0))
|
||||
}
|
||||
|
||||
fn sections(reach: Aabb) -> (i64, i32, i32, i32, i32, i32, i32) {
|
||||
let (min, max) = (reach.min_position(), reach.max_position());
|
||||
let (min, max) = (BlockPos::containing(min), BlockPos::containing(max));
|
||||
let top = SECTIONS as i32 - 1;
|
||||
let (x0, x1) = (min.x >> 4, max.x >> 4);
|
||||
let (z0, z1) = (min.z >> 4, max.z >> 4);
|
||||
let (y0, y1) = (
|
||||
((min.y - MIN_Y) >> 4).clamp(0, top),
|
||||
((max.y - MIN_Y) >> 4).clamp(0, top),
|
||||
);
|
||||
let count = (i64::from(x1) - i64::from(x0) + 1)
|
||||
* (i64::from(y1) - i64::from(y0) + 1)
|
||||
* (i64::from(z1) - i64::from(z0) + 1);
|
||||
(count, x0, x1, y0, y1, z0, z1)
|
||||
}
|
||||
|
||||
/// The original `Game::may_hold_portal`: per-tick `seen`, palette per section.
|
||||
fn may_hold_portal_baseline(
|
||||
reach: Aabb,
|
||||
world: &FxHashMap<(i32, i32), Arc<Chunk>>,
|
||||
seen: &mut FxHashMap<(i32, i32, i32), bool>,
|
||||
behaviours: &Behaviours,
|
||||
) -> bool {
|
||||
let (count, x0, x1, y0, y1, z0, z1) = sections(reach);
|
||||
if count > 64 {
|
||||
return true;
|
||||
}
|
||||
(x0..=x1).any(|cx| {
|
||||
(z0..=z1).any(|cz| {
|
||||
(y0..=y1).any(|sy| {
|
||||
*seen.entry((cx, sy, cz)).or_insert_with(|| {
|
||||
world
|
||||
.get(&(cx, cz))
|
||||
.and_then(|c| c.sections.get(sy as usize))
|
||||
.is_some_and(|s| {
|
||||
s.blocks
|
||||
.maybe_has(|v| behaviours.portal_axis(BlockStateId(v)).is_some())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The cached `Game::may_hold_portal`: one mask per resident chunk.
|
||||
fn may_hold_portal_cached(
|
||||
reach: Aabb,
|
||||
world: &FxHashMap<(i32, i32), Arc<Chunk>>,
|
||||
cache: &mut PortalSections,
|
||||
behaviours: &Behaviours,
|
||||
) -> bool {
|
||||
let (count, x0, x1, y0, y1, z0, z1) = sections(reach);
|
||||
if count > 64 {
|
||||
return true;
|
||||
}
|
||||
let mut y_mask = 0u32;
|
||||
for sy in y0..=y1 {
|
||||
y_mask |= 1 << sy;
|
||||
}
|
||||
(x0..=x1).any(|cx| {
|
||||
(z0..=z1).any(|cz| {
|
||||
world.get(&(cx, cz)).is_some_and(|chunk| {
|
||||
cache.get((cx, cz), chunk, |v| {
|
||||
behaviours.portal_axis(BlockStateId(v)).is_some()
|
||||
}) & y_mask
|
||||
!= 0
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
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, behaviours) = BlockFacts::load_pair(Path::new(&root), ®istry)?;
|
||||
println!(
|
||||
"resource_load_ms={:.3}",
|
||||
start.elapsed().as_secs_f64() * 1000.0
|
||||
);
|
||||
let len = registry.len() as u32;
|
||||
let portal: Vec<u32> = (0..len)
|
||||
.filter(|&id| behaviours.portal_axis(BlockStateId(id)).is_some())
|
||||
.collect();
|
||||
let inactive_states: Vec<u32> = (0..len)
|
||||
.filter(|&id| behaviours.portal_axis(BlockStateId(id)).is_none())
|
||||
.collect();
|
||||
let _ = &facts;
|
||||
let portal_state = *portal
|
||||
.first()
|
||||
.ok_or("no nether portal block state in the registry")?;
|
||||
println!("portal_states={}", portal.len());
|
||||
|
||||
// 500 entities spread over a 17x17 chunk world. Their reaches touch 1-4
|
||||
// sections, as an item or a mob standing in or stepping into a portal does.
|
||||
let grid = 17;
|
||||
let base = -(grid / 2) * 16;
|
||||
let entities: Vec<(Vec3, Vec3, f32, f32)> = (0..500)
|
||||
.map(|i| {
|
||||
let x = base + (i % grid) * 16 + (i % 5);
|
||||
let z = base + (i / grid % grid) * 16 + (i % 7);
|
||||
let y = 64 + (i % 3) * 16;
|
||||
let from = Vec3::new(f64::from(x), f64::from(y), f64::from(z));
|
||||
let to = from.add_vector(Vec3::new(0.1, 0.0, 0.07));
|
||||
(from, to, 0.25, 0.25)
|
||||
})
|
||||
.collect();
|
||||
let reaches: Vec<Aabb> = entities
|
||||
.iter()
|
||||
.map(|&(from, to, w, h)| reach(from, to, w, h))
|
||||
.collect();
|
||||
|
||||
for (name, palette, portals_every) in [
|
||||
("sparse", 1usize, 0usize),
|
||||
("dense", 128, 0),
|
||||
("global", 300, 0),
|
||||
("dense_portal", 128, 5),
|
||||
] {
|
||||
let start = Instant::now();
|
||||
let mut world: FxHashMap<(i32, i32), Arc<Chunk>> = FxHashMap::default();
|
||||
for czi in 0..grid {
|
||||
for cxi in 0..grid {
|
||||
let (cx, cz) = (-(grid / 2) + cxi, -(grid / 2) + czi);
|
||||
let mut chunk = Chunk::empty(cx, cz);
|
||||
for (i, section) in chunk.sections.iter_mut().enumerate() {
|
||||
let values: [u32; 4096] = std::array::from_fn(|j| {
|
||||
if portals_every > 0 && i % portals_every == 0 && j == 0 {
|
||||
portal_state
|
||||
} else {
|
||||
inactive_states[(j * 7 + i) % palette.min(inactive_states.len())]
|
||||
}
|
||||
});
|
||||
Arc::make_mut(section).blocks = Section::from_blocks(&values);
|
||||
}
|
||||
world.insert((cx, cz), Arc::new(chunk));
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"{name} fixture_ms={:.3}",
|
||||
start.elapsed().as_secs_f64() * 1000.0
|
||||
);
|
||||
let keys: Vec<_> = world.keys().copied().collect();
|
||||
let portal_chunks: Vec<(i32, i32)> = keys
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&(cx, cz)| {
|
||||
world[&(cx, cz)]
|
||||
.sections
|
||||
.iter()
|
||||
.any(|s| s.blocks.maybe_has(|v| v == portal_state))
|
||||
})
|
||||
.collect();
|
||||
println!("{name} portal_chunks={}", portal_chunks.len());
|
||||
// The global palette's maybe_has walks the whole ID range, so rescanning
|
||||
// every chunk each tick (cold/reload/edit_all) is intractable; those rows
|
||||
// are only meaningful for the palettes whose scans are bounded.
|
||||
let workloads: &[(&str, usize)] = if name == "global" {
|
||||
&[("steady", 0), ("edit_1pct", 3)]
|
||||
} else {
|
||||
&[
|
||||
("steady", 0),
|
||||
("cold", 0),
|
||||
("reload", 0),
|
||||
("edit_1pct", 3),
|
||||
("edit_all", 289),
|
||||
]
|
||||
};
|
||||
for &(workload, changed) in workloads {
|
||||
let mut baseline = Vec::new();
|
||||
let mut candidate = Vec::new();
|
||||
let mut cache = PortalSections::default();
|
||||
let (mut base_hits, mut cand_hits) = (0u64, 0u64);
|
||||
for sample in 0..8 {
|
||||
for cached in if sample % 2 == 0 {
|
||||
[false, true]
|
||||
} else {
|
||||
[true, false]
|
||||
} {
|
||||
let start = Instant::now();
|
||||
let mut result = 0u64;
|
||||
for tick in 0..300 {
|
||||
if cached && workload == "cold" {
|
||||
cache = PortalSections::default();
|
||||
}
|
||||
if cached && workload == "reload" {
|
||||
cache.clear();
|
||||
}
|
||||
for key in keys.iter().take(changed) {
|
||||
Arc::make_mut(world.get_mut(key).unwrap()).set(
|
||||
0,
|
||||
MIN_Y,
|
||||
0,
|
||||
inactive_states[tick % inactive_states.len()],
|
||||
);
|
||||
}
|
||||
let mut seen = FxHashMap::default();
|
||||
for reach in &reaches {
|
||||
let hit = if cached {
|
||||
may_hold_portal_cached(*reach, &world, &mut cache, &behaviours)
|
||||
} else {
|
||||
may_hold_portal_baseline(*reach, &world, &mut seen, &behaviours)
|
||||
};
|
||||
result += hit as u64;
|
||||
}
|
||||
}
|
||||
if cached {
|
||||
cache = PortalSections::default();
|
||||
cand_hits += black_box(result);
|
||||
} else {
|
||||
base_hits += black_box(result);
|
||||
}
|
||||
if sample > 0 {
|
||||
let us = start.elapsed().as_secs_f64() * 1e6 / 300.0;
|
||||
if cached {
|
||||
candidate.push(us);
|
||||
} else {
|
||||
baseline.push(us);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
base_hits, cand_hits,
|
||||
"{name} {workload}: cached may_hold_portal diverged"
|
||||
);
|
||||
println!("{name} {workload} baseline_us={baseline:.3?} candidate_us={candidate:.3?}");
|
||||
}
|
||||
}
|
||||
println!("chunk_wrapper_bytes={}", std::mem::size_of::<Chunk>());
|
||||
Ok(())
|
||||
}
|
||||
@@ -56,16 +56,16 @@ fn main() -> horizon_client::Result<()> {
|
||||
"resource_load_ms={:.3}",
|
||||
start.elapsed().as_secs_f64() * 1000.0
|
||||
);
|
||||
let inactive: Vec<u16> = facts
|
||||
let inactive: Vec<u32> = facts
|
||||
.states
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, f)| !f.random_ticks)
|
||||
.take(128)
|
||||
.map(|(i, _)| i as u16)
|
||||
.map(|(i, _)| i as u32)
|
||||
.collect();
|
||||
let active = registry.default_state("minecraft:grass_block").unwrap().0 as u16;
|
||||
assert!(facts.get(BlockStateId(u32::from(active))).random_ticks);
|
||||
let active = registry.default_state("minecraft:grass_block").unwrap().0;
|
||||
assert!(facts.get(BlockStateId(active)).random_ticks);
|
||||
for (name, palette) in [("sparse", 1), ("dense", 128)] {
|
||||
let start = Instant::now();
|
||||
let mut chunks = FxHashMap::default();
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
//! Resident memory of each resource loader, in the game's start order: what a
|
||||
//! larger registry (content mods, docs/modpacks.md) costs, loader by loader.
|
||||
//!
|
||||
//! resource_memory RESOURCES
|
||||
//!
|
||||
//! RESOURCES is a prepared root or a content-mod root (`.horizon-cache/mod-content`).
|
||||
//! Prints, after each step, the live heap (a counting allocator: what the
|
||||
//! step keeps), the heap's peak during the step (what it parsed on the way)
|
||||
//! and the process RSS (what the allocator holds on to). On macOS it ends
|
||||
//! with `footprint`'s physical footprint, the number the OS charges: RSS also
|
||||
//! counts freed pages the allocator marked reusable.
|
||||
use horizon_client::{
|
||||
assets::Assets, biome::Biomes, blocks::BlockFacts, collisions, crafting::Recipes, items::Items,
|
||||
light::LightTables, mesher::MeshTables, models, motion::MotionResources,
|
||||
vanillagen::VanillaTerrain,
|
||||
};
|
||||
use std::{
|
||||
alloc::{GlobalAlloc, Layout, System},
|
||||
sync::atomic::{AtomicUsize, Ordering::Relaxed},
|
||||
};
|
||||
use std::{path::PathBuf, process::Command};
|
||||
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
struct Counting;
|
||||
static LIVE: AtomicUsize = AtomicUsize::new(0);
|
||||
static PEAK: AtomicUsize = AtomicUsize::new(0);
|
||||
// SAFETY: forwards to the system allocator, only counting sizes.
|
||||
unsafe impl GlobalAlloc for Counting {
|
||||
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
|
||||
let p = unsafe { System.alloc(layout) };
|
||||
if !p.is_null() {
|
||||
let now = LIVE.fetch_add(layout.size(), Relaxed) + layout.size();
|
||||
PEAK.fetch_max(now, Relaxed);
|
||||
}
|
||||
p
|
||||
}
|
||||
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
|
||||
unsafe { System.dealloc(ptr, layout) };
|
||||
LIVE.fetch_sub(layout.size(), Relaxed);
|
||||
}
|
||||
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new: usize) -> *mut u8 {
|
||||
let p = unsafe { System.realloc(ptr, layout, new) };
|
||||
if !p.is_null() {
|
||||
if new >= layout.size() {
|
||||
let now = LIVE.fetch_add(new - layout.size(), Relaxed) + new - layout.size();
|
||||
PEAK.fetch_max(now, Relaxed);
|
||||
} else {
|
||||
LIVE.fetch_sub(layout.size() - new, Relaxed);
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
}
|
||||
#[global_allocator]
|
||||
static ALLOC: Counting = Counting;
|
||||
const MB: f64 = 1048576.0;
|
||||
|
||||
fn rss_mb() -> f64 {
|
||||
let out = Command::new("ps")
|
||||
.args(["-o", "rss=", "-p", &std::process::id().to_string()])
|
||||
.output()
|
||||
.ok();
|
||||
out.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.and_then(|s| s.trim().parse::<f64>().ok())
|
||||
.map_or(0.0, |kb| kb / 1024.0)
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let root = PathBuf::from(
|
||||
std::env::args()
|
||||
.nth(1)
|
||||
.ok_or("usage: resource_memory RESOURCES")?,
|
||||
);
|
||||
println!(
|
||||
"{:<24} {:>8} {:>8} {:>10} {:>8}",
|
||||
"step", "live MB", "+live", "step peak", "RSS MB"
|
||||
);
|
||||
let mut last = 0usize;
|
||||
let mut step = |name: &str| {
|
||||
let live = LIVE.load(Relaxed);
|
||||
let peak = PEAK.swap(live, Relaxed);
|
||||
println!(
|
||||
"{name:<24} {:>8.0} {:>+8.0} {:>10.0} {:>8.0}",
|
||||
live as f64 / MB,
|
||||
(live as f64 - last as f64) / MB,
|
||||
peak as f64 / MB,
|
||||
rss_mb()
|
||||
);
|
||||
last = live;
|
||||
};
|
||||
step("start");
|
||||
let registry = horizon_world::level::data::load_registry(&root)?;
|
||||
step("registry alone");
|
||||
drop(registry);
|
||||
step("registry dropped");
|
||||
let assets = Assets::load(&root)?;
|
||||
let render = assets.reload(&root)?;
|
||||
step("assets");
|
||||
println!(
|
||||
" sprites: {} layers of {}px, {:.0} MB of pixels",
|
||||
render.texture_layers,
|
||||
render.sprite_size,
|
||||
render.pixels.len() as f64 / MB
|
||||
);
|
||||
// The game decodes gameplay facts once for both tables (BlockFacts::load_pair).
|
||||
let (facts, behaviours) = BlockFacts::load_pair(&root, assets.registry)?;
|
||||
step("block facts+behaviours");
|
||||
let items = Items::load(&root, assets.registry, &facts)?;
|
||||
step("items");
|
||||
let recipes = Recipes::load(&root, &items)?;
|
||||
step("recipes");
|
||||
let biomes = Biomes::load(&root)?;
|
||||
step("biomes");
|
||||
let light = LightTables::load(&root, assets.registry, &facts)?;
|
||||
step("light tables");
|
||||
let tables = MeshTables::new(
|
||||
&assets,
|
||||
&render,
|
||||
&facts,
|
||||
models::load_visibility(&root, assets.registry)?,
|
||||
)?;
|
||||
step("mesh tables");
|
||||
let collision = collisions::load(&root, assets.registry)?;
|
||||
step("collision");
|
||||
let motion = MotionResources::load_with_facts(&root, &assets, &facts)?;
|
||||
step("motion");
|
||||
let terrain = VanillaTerrain::new(&root.join("worldgen"), assets.registry, &biomes, 1234)?;
|
||||
step("worldgen");
|
||||
println!(
|
||||
"{} block states; live {:.0} MB, RSS {:.0} MB",
|
||||
facts.states.len(),
|
||||
LIVE.load(Relaxed) as f64 / MB,
|
||||
rss_mb()
|
||||
);
|
||||
if let Ok(out) = Command::new("footprint")
|
||||
.arg(std::process::id().to_string())
|
||||
.output()
|
||||
{
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
for line in text
|
||||
.lines()
|
||||
.filter(|l| l.contains("Footprint") || l.contains("phys_footprint"))
|
||||
{
|
||||
println!(" {}", line.trim());
|
||||
}
|
||||
}
|
||||
// Keep everything alive until the end.
|
||||
drop((
|
||||
terrain, behaviours, motion, collision, tables, light, biomes, recipes, items, facts,
|
||||
assets,
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
//! Exact NBT equality is always validated. No GPU, compression or disk I/O timed.
|
||||
use horizon_client::{
|
||||
biome::Biomes,
|
||||
chunk::{BLOCK_STRATEGY, Chunk},
|
||||
chunk::{Chunk, block_strategy},
|
||||
containers::BlockEntity,
|
||||
items::ItemStack,
|
||||
save::{self, ChunkExtras},
|
||||
@@ -64,9 +64,8 @@ fn fixture(per_chunk: usize) -> (Vec<Arc<Chunk>>, Entities, Names) {
|
||||
};
|
||||
let mut template = Chunk::empty(0, 0);
|
||||
for s in &mut template.sections[..8] {
|
||||
Arc::make_mut(s).blocks = PalettedContainer::new(BLOCK_STRATEGY, 1);
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
//! The region writer on real chunks, without a window: how long a world's
|
||||
//! chunks take to be encoded, compressed and written by `SaveDir`, against
|
||||
//! the same encoding and compression done one chunk after another (what the
|
||||
//! writer thread did alone before it had helpers).
|
||||
//!
|
||||
//! save_bench RESOURCES DIMENSION_DIR [--rounds N]
|
||||
//!
|
||||
//! DIMENSION_DIR is a saved dimension folder (`saves/W/dimensions/minecraft/
|
||||
//! overworld`); its chunks are read once, then each round saves them all into
|
||||
//! a scratch folder with `save_later` + `flush`, and times the one-thread
|
||||
//! baseline. The written chunks are checked byte for byte against the
|
||||
//! baseline's compressed records.
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
biome::Biomes,
|
||||
blocks::BlockFacts,
|
||||
save::{SaveDir, SaveTables, encode_with},
|
||||
};
|
||||
use horizon_core::position::ChunkPos;
|
||||
use horizon_world::{
|
||||
compression::Compression,
|
||||
nbt::Limits,
|
||||
region::{ChunkRecord, RegionFile},
|
||||
};
|
||||
use std::{path::PathBuf, sync::Arc, time::Instant};
|
||||
|
||||
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let usage = "usage: save_bench RESOURCES DIMENSION_DIR [--rounds N]";
|
||||
let root = PathBuf::from(args.first().ok_or(usage)?);
|
||||
let dimension = PathBuf::from(args.get(1).ok_or(usage)?);
|
||||
let rounds: usize = args
|
||||
.iter()
|
||||
.position(|a| a == "--rounds")
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(5);
|
||||
|
||||
let assets = Assets::load(&root)?;
|
||||
let facts = BlockFacts::load(&root, assets.registry)?;
|
||||
let biomes = Arc::new(Biomes::load(&root)?);
|
||||
let tables = Arc::new(SaveTables::new(&assets).with_block_entities(&facts));
|
||||
let registry = assets.registry;
|
||||
|
||||
let source = SaveDir::new(dimension.clone());
|
||||
let _ = source.tables.set(tables.clone());
|
||||
let mut chunks = Vec::new();
|
||||
for entry in std::fs::read_dir(dimension.join("region"))? {
|
||||
let path = entry?.path();
|
||||
if path.extension().is_none_or(|e| e != "mca") {
|
||||
continue;
|
||||
}
|
||||
let region = RegionFile::open(&path)?;
|
||||
for (pos, _) in region.chunks() {
|
||||
if let Some(chunk) = source.load(pos.x, pos.z, registry, &biomes) {
|
||||
chunks.push(Arc::new(chunk));
|
||||
}
|
||||
}
|
||||
}
|
||||
chunks.sort_by_key(|c| (c.x, c.z));
|
||||
println!("{} chunks from {}", chunks.len(), dimension.display());
|
||||
|
||||
let scratch = std::env::temp_dir().join(format!("save_bench_{}", std::process::id()));
|
||||
// Encoding and compressing on one thread (what the writer did alone),
|
||||
// then SaveDir's writer with its helpers.
|
||||
let mut times = [Vec::new(), Vec::new(), Vec::new()];
|
||||
let mut written = Vec::new();
|
||||
for round in 0..rounds {
|
||||
let started = Instant::now();
|
||||
let tags: Vec<_> = chunks
|
||||
.iter()
|
||||
.map(|c| encode_with(c, registry, &biomes, Some(&tables), true))
|
||||
.collect();
|
||||
times[0].push(started.elapsed().as_secs_f64());
|
||||
let started = Instant::now();
|
||||
let baseline: Vec<ChunkRecord> = tags
|
||||
.iter()
|
||||
.map(|tag| ChunkRecord::from_nbt(tag, Compression::Zlib, 0, Limits::default()))
|
||||
.collect::<std::io::Result<_>>()?;
|
||||
times[1].push(started.elapsed().as_secs_f64());
|
||||
drop(tags);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&scratch);
|
||||
let save = SaveDir::new(scratch.clone());
|
||||
let _ = save.tables.set(tables.clone());
|
||||
let started = Instant::now();
|
||||
save.save_later(&chunks, registry, &biomes)?;
|
||||
save.flush();
|
||||
times[2].push(started.elapsed().as_secs_f64());
|
||||
drop(save);
|
||||
|
||||
if round == 0 {
|
||||
for (chunk, want) in chunks.iter().zip(&baseline) {
|
||||
let path = scratch.join(format!("region/r.{}.{}.mca", chunk.x >> 5, chunk.z >> 5));
|
||||
let got = RegionFile::open(&path)?
|
||||
.read(ChunkPos::new(chunk.x, chunk.z))?
|
||||
.ok_or("a saved chunk is missing")?;
|
||||
if got.compressed != want.compressed {
|
||||
return Err(format!("chunk {} {} differs", chunk.x, chunk.z).into());
|
||||
}
|
||||
}
|
||||
written = baseline.iter().map(|r| r.compressed.len()).collect();
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&scratch);
|
||||
println!(
|
||||
"{} KiB compressed, identical to the one-thread records",
|
||||
written.iter().sum::<usize>() / 1024
|
||||
);
|
||||
let per_chunk = 1000.0 / chunks.len() as f64;
|
||||
let names = [
|
||||
"one thread: encode",
|
||||
"one thread: compress",
|
||||
"SaveDir (encode, compress, write)",
|
||||
];
|
||||
for (name, t) in names.iter().zip(&mut times) {
|
||||
t.sort_by(f64::total_cmp);
|
||||
println!(
|
||||
"{name}: best {:.0} ms, median {:.0} ms of {rounds} ({:.3} ms per chunk at best)",
|
||||
t[0] * 1000.0,
|
||||
t[t.len() / 2] * 1000.0,
|
||||
t[0] * per_chunk
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -257,9 +257,9 @@ fn main() -> horizon_client::Result<()> {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
fn tables_air(assets: &Assets) -> u16 {
|
||||
fn tables_air(assets: &Assets) -> u32 {
|
||||
assets
|
||||
.registry
|
||||
.default_state("minecraft:air")
|
||||
.map_or(0, |s| s.0 as u16)
|
||||
.map_or(0, |s| s.0)
|
||||
}
|
||||
@@ -40,7 +40,8 @@ fn main() -> Result<()> {
|
||||
return Err("radius must be 1..16".into());
|
||||
}
|
||||
let assets = assets::Assets::load(&resources)?;
|
||||
let world = world::World::test_scene(&assets, radius)?;
|
||||
let render = assets.reload(&resources)?;
|
||||
let world = world::World::test_scene(&assets, &render, radius)?;
|
||||
let binding = collisions::load(&resources, assets.registry)?;
|
||||
let mut scene = collisions::SceneCollisions {
|
||||
world: &world,
|
||||
@@ -50,7 +51,7 @@ fn main() -> Result<()> {
|
||||
fs::write(
|
||||
dir.join("scene.json"),
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"radius":radius,"resident_chunks":world.chunks.len(),"model_gallery":assets.model_gallery,
|
||||
"radius":radius,"resident_chunks":world.chunks.len(),"model_gallery":render.model_gallery,
|
||||
}))?,
|
||||
)?;
|
||||
let mut output = std::io::BufWriter::new(fs::File::create(dir.join("rust.txt"))?);
|
||||
|
||||
@@ -243,13 +243,14 @@ fn main() -> Result<()> {
|
||||
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)?;
|
||||
let render = assets.reload(&root)?;
|
||||
let mut scene = World::test_scene(&assets, &render, 1)?;
|
||||
let binding = collisions::load(&root, assets.registry)?;
|
||||
let resources = MotionResources::load(&root, &assets)?;
|
||||
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}),
|
||||
&serde_json::json!({"radius":1,"resident_chunks":scene.chunks.len(),"model_gallery":render.model_gallery}),
|
||||
)?,
|
||||
)?;
|
||||
if args[2] == "--describe" {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! the oracle's canonical form (vanilla ARGB colours and packed light coords).
|
||||
#![forbid(unsafe_code)]
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
assets::{Assets, RenderAssets},
|
||||
biome::{BiomeInfo, Biomes, GrassModifier},
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, Light, MIN_Y, index},
|
||||
@@ -48,6 +48,7 @@ fn chunk_index(x: i32, z: i32) -> Result<usize> {
|
||||
fn scene(
|
||||
row: &Value,
|
||||
assets: &Assets,
|
||||
render: &RenderAssets,
|
||||
tables: &MeshTables,
|
||||
biomes: &mut Biomes,
|
||||
pristine: &[BiomeInfo],
|
||||
@@ -83,7 +84,7 @@ fn scene(
|
||||
for block in row["blocks"].as_array().ok_or("missing blocks")? {
|
||||
let (x, y, z) = (int(&block[0])?, int(&block[1])?, int(&block[2])?);
|
||||
let i = chunk_index(x, z)?;
|
||||
chunks[i].set(x & 15, y, z & 15, u16::try_from(int(&block[3])?)?);
|
||||
chunks[i].set(x & 15, y, z & 15, u32::try_from(int(&block[3])?)?);
|
||||
}
|
||||
for cell in row["lights"].as_array().ok_or("missing lights")? {
|
||||
let (x, y, z) = (int(&cell[0])?, int(&cell[1])?, int(&cell[2])?);
|
||||
@@ -127,6 +128,7 @@ fn scene(
|
||||
let mut mesher = Mesher {
|
||||
nether_shading: row["nether"].as_bool().unwrap_or(false),
|
||||
assets,
|
||||
render,
|
||||
tables,
|
||||
biomes: &*biomes,
|
||||
visibility: tables.visibility.clone(),
|
||||
@@ -159,15 +161,8 @@ fn scene(
|
||||
let vertices: Vec<Value> = quad
|
||||
.iter()
|
||||
.map(|v| {
|
||||
json!([
|
||||
v.pos[0],
|
||||
v.pos[1],
|
||||
v.pos[2],
|
||||
v.uv[0],
|
||||
v.uv[1],
|
||||
argb(v.color),
|
||||
light_coords(v.light)
|
||||
])
|
||||
let ([x, y, z], [u, w]) = (v.pos, v.uv());
|
||||
json!([x, y, z, u, w, argb(v.color), light_coords(v.light)])
|
||||
})
|
||||
.collect();
|
||||
quads.push(json!({"layer": layer, "sprite": sprite, "vertices": vertices}));
|
||||
@@ -184,11 +179,12 @@ fn main() -> Result<()> {
|
||||
}
|
||||
let root = PathBuf::from(&args[0]);
|
||||
let assets = Assets::load(&root)?;
|
||||
let render = assets.reload(&root)?;
|
||||
let facts = BlockFacts::load(&root, assets.registry)?;
|
||||
let mut biomes = Biomes::load(&root)?;
|
||||
let visibility = models::load_visibility(&root, assets.registry)?;
|
||||
let tables = MeshTables::new(&assets, &facts, visibility)?;
|
||||
let names: BTreeMap<u32, &str> = assets
|
||||
let tables = MeshTables::new(&assets, &render, &facts, visibility)?;
|
||||
let names: BTreeMap<u32, &str> = render
|
||||
.sprite_layers
|
||||
.iter()
|
||||
.map(|(name, layer)| (*layer, name.as_str()))
|
||||
@@ -222,6 +218,7 @@ fn main() -> Result<()> {
|
||||
let result = scene(
|
||||
&row,
|
||||
&assets,
|
||||
&render,
|
||||
&tables,
|
||||
&mut biomes,
|
||||
&pristine,
|
||||
|
||||
@@ -144,10 +144,12 @@ fn main() -> Result<()> {
|
||||
let hz = flag("--hz", 240.0);
|
||||
|
||||
let assets = Arc::new(Assets::load(&root)?);
|
||||
let render = Arc::new(assets.reload(&root)?);
|
||||
let facts = BlockFacts::load(&root, assets.registry)?;
|
||||
let biomes = Arc::new(Biomes::load(&root)?);
|
||||
let tables = Arc::new(MeshTables::new(
|
||||
&assets,
|
||||
&render,
|
||||
&facts,
|
||||
models::load_visibility(&root, assets.registry)?,
|
||||
)?);
|
||||
@@ -160,6 +162,7 @@ fn main() -> Result<()> {
|
||||
)?);
|
||||
let context = Arc::new(WorkerContext {
|
||||
assets,
|
||||
render: render.clone(),
|
||||
tables,
|
||||
biomes,
|
||||
light,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Tooltip display visibility oracle companion for tools/test_tooltip_display_parity.py.
|
||||
use horizon_client::{item_tooltip::TooltipDisplay, items::ItemStack};
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::{error::Error, fmt::Write};
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let mut output = String::new();
|
||||
for line in std::fs::read_to_string(&args[1])?.lines() {
|
||||
let fields: Vec<&str> = line.split('\t').collect();
|
||||
let hide_tooltip: bool = fields[0].parse()?;
|
||||
let creative: bool = fields[1].parse()?;
|
||||
let hide_attributes: bool = fields[2].parse()?;
|
||||
let mut component = Vec::new();
|
||||
if hide_tooltip {
|
||||
component.push(("hide_tooltip", Tag::Byte(1)));
|
||||
}
|
||||
if hide_attributes {
|
||||
component.push((
|
||||
"hidden_components",
|
||||
Tag::List(vec![Tag::String("minecraft:attribute_modifiers".into())]),
|
||||
));
|
||||
}
|
||||
let mut stack = ItemStack::new("minecraft:diamond_sword", 1);
|
||||
if !component.is_empty() {
|
||||
stack
|
||||
.data_mut()
|
||||
.set_other("minecraft:tooltip_display", Some(Tag::compound(component)));
|
||||
}
|
||||
let display = TooltipDisplay::for_stack(&stack);
|
||||
writeln!(
|
||||
output,
|
||||
"{}\t{}",
|
||||
display.name_visible(creative),
|
||||
display.shows("minecraft:attribute_modifiers")
|
||||
)?;
|
||||
}
|
||||
std::fs::write(&args[2], output)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -607,6 +607,29 @@ pub struct Entities<'a> {
|
||||
pub tool: Option<&'a ItemView>,
|
||||
pub block_pos: Option<[i32; 3]>,
|
||||
}
|
||||
/// Conditions shared by item/entity interactions and thrown-item pickups.
|
||||
pub fn item_entity(
|
||||
v: &Value,
|
||||
player: &EntityView,
|
||||
target: &EntityView,
|
||||
stack: &ItemView,
|
||||
w: &dyn World,
|
||||
) -> bool {
|
||||
let pctx = Entities {
|
||||
this: Some(player),
|
||||
origin: player.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
let ectx = Entities {
|
||||
this: Some(target),
|
||||
..pctx
|
||||
};
|
||||
conditions(&v["player"], &pctx, w)
|
||||
&& conditions(&v["entity"], &ectx, w)
|
||||
&& item(&v["item"], stack, w)
|
||||
}
|
||||
|
||||
/// A `ContextAwarePredicate`: a list of loot conditions (or one condition).
|
||||
pub fn conditions(v: &Value, ctx: &Entities, w: &dyn World) -> bool {
|
||||
match v {
|
||||
@@ -996,7 +1019,75 @@ mod tests {
|
||||
false
|
||||
}
|
||||
fn in_tag(&self, _: &str, tag: &str, id: &str) -> bool {
|
||||
tag == "minecraft:stone_tool_materials" && id == "minecraft:cobblestone"
|
||||
(tag == "minecraft:stone_tool_materials" && id == "minecraft:cobblestone")
|
||||
|| (tag == "minecraft:sulfur_cube_archetype/explosive" && id == "minecraft:tnt")
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn uh_oh_accepts_either_tnt_event_only_for_adult_sulfur_cubes() {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(
|
||||
"../../.local/horizon-client/26.3/data/minecraft/advancement/husbandry/uh_oh.json",
|
||||
);
|
||||
if !path.exists() {
|
||||
return;
|
||||
}
|
||||
let json = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
|
||||
let advancement = parse("minecraft:husbandry/uh_oh", &json).unwrap();
|
||||
assert_eq!(advancement.criteria.len(), 2);
|
||||
let player = EntityView {
|
||||
kind: "minecraft:player".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut target = EntityView {
|
||||
kind: "minecraft:sulfur_cube".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut stack = ItemView {
|
||||
id: "minecraft:tnt".into(),
|
||||
count: 1,
|
||||
..Default::default()
|
||||
};
|
||||
for criterion in &advancement.criteria {
|
||||
assert!(item_entity(
|
||||
&criterion.conditions,
|
||||
&player,
|
||||
&target,
|
||||
&stack,
|
||||
&Flat
|
||||
));
|
||||
let mut progress = Progress::default();
|
||||
progress.grant(&advancement.id, &criterion.name, "test");
|
||||
assert!(
|
||||
progress.done(&advancement),
|
||||
"either criterion must award Uh Oh"
|
||||
);
|
||||
target.baby = true;
|
||||
assert!(!item_entity(
|
||||
&criterion.conditions,
|
||||
&player,
|
||||
&target,
|
||||
&stack,
|
||||
&Flat
|
||||
));
|
||||
target.baby = false;
|
||||
target.kind = "minecraft:pig".into();
|
||||
assert!(!item_entity(
|
||||
&criterion.conditions,
|
||||
&player,
|
||||
&target,
|
||||
&stack,
|
||||
&Flat
|
||||
));
|
||||
target.kind = "minecraft:sulfur_cube".into();
|
||||
stack.id = "minecraft:stone".into();
|
||||
assert!(!item_entity(
|
||||
&criterion.conditions,
|
||||
&player,
|
||||
&target,
|
||||
&stack,
|
||||
&Flat
|
||||
));
|
||||
stack.id = "minecraft:tnt".into();
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
|
||||
@@ -2,14 +2,13 @@ use crate::Result;
|
||||
use horizon_world::{
|
||||
DATA_VERSION,
|
||||
heightmap::HeightmapState,
|
||||
nbt::Tag,
|
||||
registry::{BlockRegistry, BlockStateId},
|
||||
worldgen::{tags::TagDefinitions, terrain::TerrainBlockStates},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
fs::{self, File},
|
||||
fs::File,
|
||||
io::{self, BufReader},
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
@@ -43,17 +42,46 @@ impl HeightmapState for StateFacts {
|
||||
}
|
||||
#[derive(Clone)]
|
||||
pub struct Properties(Arc<Vec<StateFacts>>);
|
||||
#[cfg(test)]
|
||||
impl Properties {
|
||||
/// Heightmap facts for tests, per state: (air, has a fluid, blocks
|
||||
/// motion, blocks motion but is not leaves). State 0 is minecraft:air.
|
||||
pub(crate) fn for_tests(states: &[(bool, bool, bool, bool)]) -> Self {
|
||||
Self(Arc::new(
|
||||
states
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &(air, fluid, motion, no_leaves))| StateFacts {
|
||||
air,
|
||||
fluid,
|
||||
air_block: i == 0,
|
||||
motion,
|
||||
no_leaves,
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
}
|
||||
impl TerrainBlockStates for Properties {
|
||||
type State = StateFacts;
|
||||
fn heightmap_state(&self, id: BlockStateId) -> StateFacts {
|
||||
self.0[id.0 as usize]
|
||||
}
|
||||
}
|
||||
/// The block registry and the facts read from `facts.json` and the block tags:
|
||||
/// everything world generation and collisions need, with none of the block
|
||||
/// models or baked sprites.
|
||||
pub struct Assets {
|
||||
/// Leaked once per process: shared by world generation threads.
|
||||
pub registry: &'static BlockRegistry,
|
||||
pub properties: Properties,
|
||||
pub material_states: [BlockStateId; 6],
|
||||
/// Block names in `#minecraft:blocks_fluid_flow` (fluid flow vectors).
|
||||
pub blocks_fluid_flow: BTreeSet<String>,
|
||||
}
|
||||
/// The block models and their baked sprites, read from the pack at `root` over
|
||||
/// an existing block registry: everything the mesher and renderer need.
|
||||
pub struct RenderAssets {
|
||||
pub sprite_size: u32,
|
||||
pub sprite_minimum_size: u32,
|
||||
pub pixels: Vec<u8>,
|
||||
@@ -68,30 +96,10 @@ pub struct Assets {
|
||||
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>,
|
||||
}
|
||||
fn invalid(message: &str) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, message)
|
||||
}
|
||||
fn tag(value: &Value) -> Result<Tag> {
|
||||
Ok(match value {
|
||||
Value::String(value) => Tag::String(value.as_str().into()),
|
||||
Value::Number(value) => Tag::Int(i32::try_from(
|
||||
value
|
||||
.as_i64()
|
||||
.ok_or_else(|| invalid("invalid registry number"))?,
|
||||
)?),
|
||||
Value::Array(values) => Tag::List(values.iter().map(tag).collect::<Result<_>>()?),
|
||||
Value::Object(values) => Tag::Compound(
|
||||
values
|
||||
.iter()
|
||||
.map(|(name, value)| Ok((name.as_str().into(), tag(value)?)))
|
||||
.collect::<Result<_>>()?,
|
||||
),
|
||||
_ => return Err(invalid("unsupported registry value").into()),
|
||||
})
|
||||
}
|
||||
impl Assets {
|
||||
pub fn load(root: &Path) -> Result<Self> {
|
||||
let manifest: Value = serde_json::from_reader(BufReader::new(File::open(
|
||||
@@ -100,23 +108,8 @@ 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_slice(&fs::read(root.join("facts.json"))?)?;
|
||||
let states = facts["states"]
|
||||
.as_array()
|
||||
.ok_or_else(|| invalid("missing registry state facts"))?;
|
||||
let registry = BlockRegistry::from_nbt(&Tag::compound([
|
||||
("DataVersion", Tag::Int(DATA_VERSION)),
|
||||
(
|
||||
"states",
|
||||
Tag::List(
|
||||
states
|
||||
.iter()
|
||||
.map(|state| tag(&state[4]))
|
||||
.collect::<Result<_>>()?,
|
||||
),
|
||||
),
|
||||
("defaults", tag(&facts["defaults"])?),
|
||||
]))?;
|
||||
let (registry, flags) =
|
||||
BlockRegistry::from_facts_json_with_flags(File::open(root.join("facts.json"))?)?;
|
||||
let bundle: Value =
|
||||
serde_json::from_reader(BufReader::new(File::open(root.join("block-tags.json"))?))?;
|
||||
let mut definitions = TagDefinitions::default();
|
||||
@@ -132,7 +125,7 @@ impl Assets {
|
||||
)?;
|
||||
}
|
||||
let built =
|
||||
definitions.build(|name, _| facts["defaults"].get(name).map(|_| name.to_owned()))?;
|
||||
definitions.build(|name, _| registry.default_state(name).map(|_| name.to_owned()))?;
|
||||
if !built.missing.is_empty() {
|
||||
return Err(invalid("unresolved block tags").into());
|
||||
}
|
||||
@@ -158,27 +151,20 @@ impl Assets {
|
||||
.cloned()
|
||||
.collect();
|
||||
let properties = Properties(Arc::new(
|
||||
states
|
||||
flags
|
||||
.iter()
|
||||
.map(|state| {
|
||||
.enumerate()
|
||||
.map(|(i, &(air, fluid))| {
|
||||
let name = ®istry
|
||||
.state(BlockStateId(i as u32))
|
||||
.ok_or_else(|| invalid("missing registry state"))?
|
||||
.name;
|
||||
Ok(StateFacts {
|
||||
air: state[1]
|
||||
.as_bool()
|
||||
.ok_or_else(|| invalid("missing air fact"))?,
|
||||
fluid: state[2]
|
||||
.as_bool()
|
||||
.ok_or_else(|| invalid("missing fluid fact"))?,
|
||||
air_block: state[0].as_str() == Some("minecraft:air"),
|
||||
motion: motion.contains(
|
||||
state[0]
|
||||
.as_str()
|
||||
.ok_or_else(|| invalid("missing state name"))?,
|
||||
),
|
||||
no_leaves: no_leaves.contains(
|
||||
state[0]
|
||||
.as_str()
|
||||
.ok_or_else(|| invalid("missing state name"))?,
|
||||
),
|
||||
air,
|
||||
fluid,
|
||||
air_block: name == "minecraft:air",
|
||||
motion: motion.contains(name.as_str()),
|
||||
no_leaves: no_leaves.contains(name.as_str()),
|
||||
})
|
||||
})
|
||||
.collect::<Result<_>>()?,
|
||||
@@ -189,34 +175,27 @@ impl Assets {
|
||||
.default_state(&format!("minecraft:{name}"))
|
||||
.ok_or_else(|| invalid("missing test-scene material"))?;
|
||||
}
|
||||
// Chunks hold every state of this registry: content mods can take it
|
||||
// past vanilla's 16-bit global palette.
|
||||
crate::chunk::set_block_states(registry.len());
|
||||
let registry: &'static BlockRegistry = Box::leak(Box::new(registry));
|
||||
Self::with_models(
|
||||
root,
|
||||
Ok(Self {
|
||||
registry,
|
||||
properties,
|
||||
material_states,
|
||||
blocks_fluid_flow,
|
||||
)
|
||||
})
|
||||
}
|
||||
/// The same blocks with the models and sprites of another resource root
|
||||
/// The models and sprites of another resource root over these blocks
|
||||
/// (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(),
|
||||
)
|
||||
pub fn reload(&self, root: &Path) -> Result<RenderAssets> {
|
||||
RenderAssets::load(root, self)
|
||||
}
|
||||
fn with_models(
|
||||
root: &Path,
|
||||
registry: &'static BlockRegistry,
|
||||
properties: Properties,
|
||||
material_states: [BlockStateId; 6],
|
||||
blocks_fluid_flow: BTreeSet<String>,
|
||||
) -> Result<Self> {
|
||||
}
|
||||
impl RenderAssets {
|
||||
pub fn load(root: &Path, assets: &Assets) -> Result<Self> {
|
||||
let registry = assets.registry;
|
||||
let mut sprites = crate::sprites::Sprites::default();
|
||||
for name in MATERIALS {
|
||||
sprites.insert(root, &format!("minecraft:block/{name}"))?;
|
||||
@@ -240,9 +219,6 @@ impl Assets {
|
||||
}
|
||||
sprites.finish();
|
||||
Ok(Self {
|
||||
registry,
|
||||
properties,
|
||||
material_states,
|
||||
sprite_size: sprites.size,
|
||||
sprite_minimum_size: sprites.minimum_size,
|
||||
texture_layers: u32::try_from(sprites.count())?,
|
||||
@@ -255,7 +231,6 @@ impl Assets {
|
||||
sprite_layers: sprites.layers,
|
||||
translucent_layers: sprites.translucent,
|
||||
sprite_mips: sprites.mips,
|
||||
blocks_fluid_flow,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,20 @@
|
||||
//! small mixer standing in for OpenAL: one channel per source with its gain,
|
||||
//! pitch, linear distance attenuation (`AL_LINEAR_DISTANCE`, reference 0,
|
||||
//! rolloff 1), looping, relative flag and source state, Ogg/Vorbis decoding
|
||||
//! (streams on a worker thread) and stereo panning.
|
||||
//! (streams on a worker thread) and stereo panning. Companions' voices play
|
||||
//! beside the sources as appendable streams (`voice_start`).
|
||||
use crate::Result;
|
||||
use crate::sound_engine::{
|
||||
Attenuation, Backend, Bound, ChannelId, InstanceId, PlayResult, SoundEngine, SoundInstance,
|
||||
Sounds, TickContext,
|
||||
};
|
||||
use crate::sound_events::{Sound, SoundRegistry};
|
||||
#[cfg(feature = "audio")]
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use horizon_senses::dsp::Radio;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
collections::{HashMap, VecDeque},
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
@@ -45,6 +48,45 @@ struct Mixer {
|
||||
listener: [f64; 3],
|
||||
listener_yaw: f32,
|
||||
limiter_peak: f32,
|
||||
/// Companions' voices; empty (and skipped) unless one is talking.
|
||||
voices: HashMap<VoiceId, Voice>,
|
||||
/// The Voice/Speech slider times the master volume.
|
||||
voice_volume: f32,
|
||||
voices_paused: bool,
|
||||
}
|
||||
/// A companion's voice line in the mixer.
|
||||
pub type VoiceId = u32;
|
||||
/// The companions' sound category (`SoundSource.VOICE`, "Voice/Speech").
|
||||
const VOICE: usize = 9;
|
||||
/// How far a companion is heard directly: as far as a sound at volume 3 (16 blocks per unit of
|
||||
/// volume, linear). Past it, or from another dimension, the voice comes over the radio.
|
||||
pub const VOICE_RANGE: f32 = 48.0;
|
||||
/// The radio fades in over the last blocks of the range, so walking away never switches abruptly.
|
||||
const RADIO_FADE: f32 = 8.0;
|
||||
/// The radio's level against the direct voice at full volume.
|
||||
const RADIO_GAIN: f32 = 0.8;
|
||||
/// Audio held back before a line starts, or resumes after running dry, against generation jitter.
|
||||
const VOICE_PREBUFFER: f32 = 0.12;
|
||||
/// A companion's voice: samples appended while the speech model generates them, played from the
|
||||
/// companion's position (docs/companions.md, "Playback").
|
||||
struct Voice {
|
||||
queue: VecDeque<f32>,
|
||||
rate: u32,
|
||||
/// Between `previous` and the front of the queue, 0..1.
|
||||
phase: f64,
|
||||
previous: f32,
|
||||
/// The tone's gain (a shout is louder, a whisper quieter).
|
||||
gain: f32,
|
||||
at: [f32; 3],
|
||||
/// In another dimension than the listener: only the radio carries it.
|
||||
elsewhere: bool,
|
||||
/// How much of it comes over the radio, eased toward its target so it never clicks.
|
||||
wet: f32,
|
||||
radio: Option<Radio>,
|
||||
/// Buffering before it plays (at the start, or after the generator fell behind).
|
||||
waiting: bool,
|
||||
/// No more samples will come: it ends when the queue runs out.
|
||||
finished: bool,
|
||||
}
|
||||
/// A sound the player could have heard (`SoundEventListener.onPlaySound`):
|
||||
/// what the subtitle overlay needs.
|
||||
@@ -134,8 +176,14 @@ fn attach(mixer: &Mutex<Mixer>, channel: ChannelId, samples: Samples, looping: b
|
||||
c.looping |= looping;
|
||||
}
|
||||
}
|
||||
/// The open output stream. A build without the `audio` feature (the server build, which must not
|
||||
/// need libasound) never has one.
|
||||
#[cfg(feature = "audio")]
|
||||
type Stream = cpal::Stream;
|
||||
#[cfg(not(feature = "audio"))]
|
||||
type Stream = std::convert::Infallible;
|
||||
pub struct Audio {
|
||||
_stream: Option<cpal::Stream>,
|
||||
_stream: Option<Stream>,
|
||||
pub engine: SoundEngine,
|
||||
backend: MixerBackend,
|
||||
/// Record `Heard` sounds (the subtitle overlay is listening).
|
||||
@@ -150,6 +198,11 @@ pub struct Audio {
|
||||
/// Positional sounds played since the last drain, kept while the world
|
||||
/// is open to the network so guests hear them too.
|
||||
pub capture: Option<Vec<(String, [f64; 3], f32, f32)>>,
|
||||
/// The voice volume the mixer has (`Mixer::voice_volume`).
|
||||
voice_volume: f32,
|
||||
next_voice: VoiceId,
|
||||
/// Companions' lines for the subtitle overlay, since the last `take_heard`.
|
||||
voice_heard: Vec<Heard>,
|
||||
}
|
||||
fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
|
||||
use symphonia::core::{
|
||||
@@ -333,6 +386,80 @@ fn distance_gain(linear: Option<f32>, distance: f32) -> f32 {
|
||||
Some(_) => 0.0,
|
||||
}
|
||||
}
|
||||
/// Mix the companions' voices into `data`: direct and panned within `VOICE_RANGE`, then over the
|
||||
/// radio, centred. A voice that runs dry waits for `VOICE_PREBUFFER` more, and one that is
|
||||
/// finished leaves when it has played everything.
|
||||
fn mix_voices(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
let listener = mixer.listener;
|
||||
let yaw = mixer.listener_yaw.to_radians();
|
||||
let volume = mixer.voice_volume;
|
||||
// The radio eases in and out over about 150 ms.
|
||||
let ease = 1.0 - (-1.0 / (0.15 * rate as f32)).exp();
|
||||
mixer.voices.retain(|_, v| {
|
||||
if v.waiting {
|
||||
let ready = v.queue.len() as f32 >= VOICE_PREBUFFER * v.rate as f32;
|
||||
if !ready && !v.finished {
|
||||
return true;
|
||||
}
|
||||
v.waiting = false;
|
||||
}
|
||||
let d = [
|
||||
f64::from(v.at[0]) - listener[0],
|
||||
f64::from(v.at[1]) - listener[1],
|
||||
f64::from(v.at[2]) - listener[2],
|
||||
];
|
||||
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() as f32;
|
||||
let target = if v.elsewhere {
|
||||
1.0
|
||||
} else {
|
||||
((distance - (VOICE_RANGE - RADIO_FADE)) / RADIO_FADE).clamp(0.0, 1.0)
|
||||
};
|
||||
let gain = v.gain.clamp(0.0, 2.0) * volume;
|
||||
let direct = gain * distance_gain(Some(VOICE_RANGE), distance);
|
||||
let side =
|
||||
(-(yaw.cos()) as f64 * d[0] - (yaw.sin()) as f64 * d[2]) as f32 / distance.max(0.001);
|
||||
let pan = side.clamp(-1.0, 1.0);
|
||||
let left = direct * (1.0 - pan.max(0.0) * 0.6);
|
||||
let right = direct * (1.0 + pan.min(0.0) * 0.6);
|
||||
let step = f64::from(v.rate) / f64::from(rate);
|
||||
for frame in data.chunks_mut(channels) {
|
||||
while v.phase >= 1.0 {
|
||||
let Some(x) = v.queue.pop_front() else {
|
||||
break;
|
||||
};
|
||||
v.previous = x;
|
||||
v.phase -= 1.0;
|
||||
}
|
||||
let Some(&next) = v.queue.front().filter(|_| v.phase < 1.0) else {
|
||||
if v.finished {
|
||||
return false;
|
||||
}
|
||||
v.waiting = true;
|
||||
break;
|
||||
};
|
||||
let x = v.previous + (next - v.previous) * v.phase as f32;
|
||||
v.phase += step;
|
||||
v.wet += (target - v.wet) * ease;
|
||||
let dry = 1.0 - v.wet;
|
||||
let (mut l, mut r) = (x * left * dry, x * right * dry);
|
||||
if v.wet > 0.001 {
|
||||
let radio = v.radio.get_or_insert_with(|| Radio::new(rate));
|
||||
let y = radio.process(x) * v.wet * gain * RADIO_GAIN;
|
||||
l += y;
|
||||
r += y;
|
||||
}
|
||||
if channels >= 2 {
|
||||
frame[0] += l;
|
||||
frame[1] += r;
|
||||
} else {
|
||||
frame[0] += (l + r) / 2.0;
|
||||
}
|
||||
}
|
||||
true
|
||||
});
|
||||
}
|
||||
// Only the output stream mixes; a build without one (no `audio` feature) never does.
|
||||
#[cfg_attr(not(feature = "audio"), allow(dead_code))]
|
||||
fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
data.fill(0.0);
|
||||
let listener = mixer.listener;
|
||||
@@ -398,6 +525,9 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
c.position += step;
|
||||
}
|
||||
}
|
||||
if !mixer.voices.is_empty() && !mixer.voices_paused {
|
||||
mix_voices(mixer, data, channels, rate);
|
||||
}
|
||||
// Limit the whole buffer together, preserving waveform and stereo balance
|
||||
// instead of flattening each overloaded sample; release over 50 ms.
|
||||
let peak = data.iter().fold(1.0f32, |peak, s| peak.max(s.abs()));
|
||||
@@ -415,6 +545,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
/// `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).
|
||||
#[cfg(any(feature = "audio", test))]
|
||||
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());
|
||||
@@ -430,6 +561,7 @@ fn output_disabled(var: impl Fn(&str) -> Option<std::ffi::OsString>) -> Option<S
|
||||
}
|
||||
/// An output stream on `device` in its default configuration, whatever its
|
||||
/// sample format: the mixer works in f32 and converts.
|
||||
#[cfg(feature = "audio")]
|
||||
fn open_device(
|
||||
device: &cpal::Device,
|
||||
mixer: Arc<Mutex<Mixer>>,
|
||||
@@ -450,6 +582,7 @@ fn open_device(
|
||||
stream.play()?;
|
||||
Ok((stream, config))
|
||||
}
|
||||
#[cfg(feature = "audio")]
|
||||
fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
|
||||
device: &cpal::Device,
|
||||
config: &cpal::SupportedStreamConfig,
|
||||
@@ -521,9 +654,17 @@ impl Audio {
|
||||
random: LegacyRandom::new(crate::sound_engine::unique_seed()),
|
||||
source_override: None,
|
||||
capture: None,
|
||||
voice_volume: 0.0,
|
||||
next_voice: 0,
|
||||
voice_heard: Vec::new(),
|
||||
}
|
||||
}
|
||||
fn open(mixer: Arc<Mutex<Mixer>>) -> Result<cpal::Stream> {
|
||||
#[cfg(not(feature = "audio"))]
|
||||
fn open(_: Arc<Mutex<Mixer>>) -> Result<Stream> {
|
||||
Err("this build has no sound output (built without the `audio` feature)".into())
|
||||
}
|
||||
#[cfg(feature = "audio")]
|
||||
fn open(mixer: Arc<Mutex<Mixer>>) -> Result<Stream> {
|
||||
if let Some(reason) = output_disabled(|name| std::env::var_os(name)) {
|
||||
return Err(reason.into());
|
||||
}
|
||||
@@ -589,6 +730,13 @@ impl Audio {
|
||||
}
|
||||
}
|
||||
self.engine.listening = self.record_heard;
|
||||
let voice = self.master * volumes[VOICE];
|
||||
if voice != self.voice_volume
|
||||
&& let Ok(mut m) = self.backend.mixer.lock()
|
||||
{
|
||||
m.voice_volume = voice;
|
||||
self.voice_volume = voice;
|
||||
}
|
||||
}
|
||||
/// `SoundManager.tick(paused)`, once per client tick.
|
||||
pub fn tick(&mut self, paused: bool, ctx: &TickContext) {
|
||||
@@ -601,14 +749,24 @@ impl Audio {
|
||||
&[crate::sound_engine::MUSIC, crate::sound_engine::UI],
|
||||
&mut self.backend,
|
||||
);
|
||||
self.pause_voices(true);
|
||||
}
|
||||
/// `SoundManager.resume`.
|
||||
pub fn resume(&mut self) {
|
||||
self.engine.resume(&mut self.backend);
|
||||
self.pause_voices(false);
|
||||
}
|
||||
fn pause_voices(&mut self, paused: bool) {
|
||||
if let Ok(mut m) = self.backend.mixer.lock() {
|
||||
m.voices_paused = paused;
|
||||
}
|
||||
}
|
||||
/// `SoundManager.stop()`: every sound.
|
||||
pub fn stop_all(&mut self) {
|
||||
self.engine.stop_all(&mut self.backend);
|
||||
if let Ok(mut m) = self.backend.mixer.lock() {
|
||||
m.voices.clear();
|
||||
}
|
||||
}
|
||||
/// `SoundManager.reload` (resource packs changed): stop every sound, then
|
||||
/// read sound events and sound files from the new resource root.
|
||||
@@ -749,8 +907,94 @@ impl Audio {
|
||||
looping: n.looping,
|
||||
})
|
||||
})
|
||||
.chain(self.voice_heard.drain(..))
|
||||
.collect()
|
||||
}
|
||||
/// Start a companion's line: samples at `rate` come with `voice_push`, from `at` (in another
|
||||
/// dimension when `elsewhere`), louder or quieter by `gain` (the tone). `None` without an output.
|
||||
pub fn voice_start(
|
||||
&mut self,
|
||||
rate: u32,
|
||||
gain: f32,
|
||||
at: [f64; 3],
|
||||
elsewhere: bool,
|
||||
) -> Option<VoiceId> {
|
||||
if !self.backend.live {
|
||||
return None;
|
||||
}
|
||||
self.sync_volumes();
|
||||
self.next_voice += 1;
|
||||
let id = self.next_voice;
|
||||
self.backend.mixer.lock().ok()?.voices.insert(
|
||||
id,
|
||||
Voice {
|
||||
queue: VecDeque::new(),
|
||||
rate: rate.max(1),
|
||||
phase: 0.0,
|
||||
previous: 0.0,
|
||||
gain,
|
||||
at: at.map(|c| c as f32),
|
||||
elsewhere,
|
||||
wet: if elsewhere { 1.0 } else { 0.0 },
|
||||
radio: None,
|
||||
waiting: true,
|
||||
finished: false,
|
||||
},
|
||||
);
|
||||
Some(id)
|
||||
}
|
||||
fn with_voice(&self, id: VoiceId, f: impl FnOnce(&mut Voice)) {
|
||||
if let Ok(mut m) = self.backend.mixer.lock()
|
||||
&& let Some(v) = m.voices.get_mut(&id)
|
||||
{
|
||||
f(v);
|
||||
}
|
||||
}
|
||||
/// More of a line, as the speech model generates it.
|
||||
pub fn voice_push(&mut self, id: VoiceId, samples: &[f32]) {
|
||||
self.with_voice(id, |v| v.queue.extend(samples));
|
||||
}
|
||||
/// Where the speaker is now (each tick while it talks).
|
||||
pub fn voice_place(&mut self, id: VoiceId, at: [f64; 3], elsewhere: bool) {
|
||||
self.with_voice(id, |v| {
|
||||
v.at = at.map(|c| c as f32);
|
||||
v.elsewhere = elsewhere;
|
||||
});
|
||||
}
|
||||
/// No more samples will come: the line ends when it has played them.
|
||||
pub fn voice_finish(&mut self, id: VoiceId) {
|
||||
self.with_voice(id, |v| v.finished = true);
|
||||
}
|
||||
/// Cut a line off.
|
||||
pub fn voice_stop(&mut self, id: VoiceId) {
|
||||
if let Ok(mut m) = self.backend.mixer.lock() {
|
||||
m.voices.remove(&id);
|
||||
}
|
||||
}
|
||||
/// Whether a line is still buffering or playing.
|
||||
pub fn voice_playing(&self, id: VoiceId) -> bool {
|
||||
self.backend
|
||||
.mixer
|
||||
.lock()
|
||||
.is_ok_and(|m| m.voices.contains_key(&id))
|
||||
}
|
||||
/// Show a companion's line in the subtitles while the overlay listens: at the speaker within
|
||||
/// `VOICE_RANGE`, everywhere over the radio (`at` none).
|
||||
pub fn voice_subtitle(&mut self, text: &str, at: Option<[f64; 3]>) {
|
||||
if self.record_heard {
|
||||
self.voice_heard.push(Heard {
|
||||
// The overlay reads it as a translation key: `%` would start an argument.
|
||||
subtitle: text.replace('%', "%%"),
|
||||
at: Some(at.unwrap_or(self.listener)),
|
||||
range: if at.is_some() {
|
||||
VOICE_RANGE
|
||||
} else {
|
||||
f32::INFINITY
|
||||
},
|
||||
looping: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Sounds for Audio {
|
||||
fn engine(&mut self) -> &mut SoundEngine {
|
||||
@@ -912,6 +1156,128 @@ mod tests {
|
||||
assert_eq!(*recovery.last().unwrap(), 0.25);
|
||||
}
|
||||
|
||||
fn voice(at: [f32; 3], elsewhere: bool) -> Voice {
|
||||
Voice {
|
||||
queue: VecDeque::new(),
|
||||
rate: 24_000,
|
||||
phase: 0.0,
|
||||
previous: 0.0,
|
||||
gain: 1.0,
|
||||
at,
|
||||
elsewhere,
|
||||
wet: if elsewhere { 1.0 } else { 0.0 },
|
||||
radio: None,
|
||||
waiting: true,
|
||||
finished: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn voice_mixer() -> Mixer {
|
||||
Mixer {
|
||||
voice_volume: 1.0,
|
||||
..Mixer::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn sine(freq: f32, rate: u32, seconds: f32) -> Vec<f32> {
|
||||
(0..(rate as f32 * seconds) as usize)
|
||||
.map(|i| (std::f32::consts::TAU * freq * i as f32 / rate as f32).sin() * 0.5)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn rms(data: &[f32]) -> f32 {
|
||||
(data.iter().map(|s| s * s).sum::<f32>() / data.len() as f32).sqrt()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_voice_waits_for_its_prebuffer_plays_appended_samples_and_ends_when_finished() {
|
||||
let mut m = voice_mixer();
|
||||
m.voices.insert(1, voice([0.0; 3], false));
|
||||
let mut out = vec![0.0f32; 2 * 4800];
|
||||
// 50 ms is less than the prebuffer: silence.
|
||||
m.voices.get_mut(&1).unwrap().queue.extend(vec![0.25; 1200]);
|
||||
mix(&mut m, &mut out, 2, 48_000);
|
||||
assert!(out.iter().all(|s| *s == 0.0));
|
||||
// 150 ms more: it plays (at 24 kHz into 48 kHz, 0.1 s of output takes 2 400 samples).
|
||||
m.voices.get_mut(&1).unwrap().queue.extend(vec![0.25; 3600]);
|
||||
mix(&mut m, &mut out, 2, 48_000);
|
||||
assert!((out[2 * 4000] - 0.25).abs() < 1e-4, "{}", out[2 * 4000]);
|
||||
assert!(m.voices.contains_key(&1));
|
||||
// It ran dry while the generator is behind: it waits again, then ends once finished.
|
||||
mix(&mut m, &mut out, 2, 48_000);
|
||||
mix(&mut m, &mut out, 2, 48_000);
|
||||
assert!(m.voices[&1].waiting);
|
||||
m.voices.get_mut(&1).unwrap().finished = true;
|
||||
m.voices.get_mut(&1).unwrap().queue.extend(vec![0.25; 100]);
|
||||
mix(&mut m, &mut out, 2, 48_000);
|
||||
assert!(!m.voices.contains_key(&1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_voice_is_heard_directly_nearby_and_over_the_radio_far_away_or_elsewhere() {
|
||||
let speech = sine(1000.0, 24_000, 0.5);
|
||||
let hum = sine(100.0, 24_000, 0.5);
|
||||
let play = |at: [f32; 3], elsewhere: bool, samples: &[f32]| {
|
||||
let mut m = voice_mixer();
|
||||
let mut v = voice(at, elsewhere);
|
||||
v.queue.extend(samples);
|
||||
v.finished = true;
|
||||
m.voices.insert(1, v);
|
||||
let mut out = vec![0.0f32; 2 * 24_000];
|
||||
mix_voices(&mut m, &mut out, 2, 48_000);
|
||||
out
|
||||
};
|
||||
// On the listener's right (yaw 0 faces +z, so -x is right): louder in the right ear.
|
||||
let near = play([-8.0, 0.0, 0.0], false, &speech);
|
||||
let (left, right): (Vec<f32>, Vec<f32>) =
|
||||
near.as_chunks::<2>().0.iter().map(|f| (f[0], f[1])).unzip();
|
||||
assert!(rms(&right) > rms(&left) * 1.5);
|
||||
// Linear over VOICE_RANGE: at 24 blocks, half as loud as at the listener.
|
||||
let close = play([0.0; 3], false, &speech);
|
||||
let mid = play([0.0, 0.0, 24.0], false, &speech);
|
||||
assert!((rms(&mid) / rms(&close) - 0.5).abs() < 0.02);
|
||||
// Past the range, the radio: the voice band stays, a 100 Hz hum is cut, centred.
|
||||
let far = play([0.0, 0.0, 100.0], false, &speech);
|
||||
assert!(rms(&far) > 0.1, "{}", rms(&far));
|
||||
let far_hum = play([0.0, 0.0, 100.0], false, &hum);
|
||||
assert!(rms(&far_hum) < rms(&far) * 0.3);
|
||||
let (l, r): (Vec<f32>, Vec<f32>) =
|
||||
far.as_chunks::<2>().0.iter().map(|f| (f[0], f[1])).unzip();
|
||||
assert_eq!(l, r);
|
||||
// Another dimension, right beside: the radio too.
|
||||
let elsewhere = play([1.0, 0.0, 0.0], true, &hum);
|
||||
assert!(rms(&elsewhere) < rms(&close) * 0.3);
|
||||
}
|
||||
|
||||
/// The mixer's cost for one voice (run with `--ignored --nocapture`, in release).
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn voice_mixing_cost() {
|
||||
let rounds = 2000;
|
||||
let time = |voices: usize, at: [f32; 3]| {
|
||||
let mut m = voice_mixer();
|
||||
for id in 0..voices as u32 {
|
||||
let mut v = voice(at, false);
|
||||
v.queue.extend(sine(300.0, 24_000, 30.0));
|
||||
v.waiting = false;
|
||||
m.voices.insert(id, v);
|
||||
}
|
||||
let mut out = vec![0.0f32; 2 * 512];
|
||||
let start = std::time::Instant::now();
|
||||
for _ in 0..rounds {
|
||||
mix(&mut m, &mut out, 2, 48_000);
|
||||
}
|
||||
start.elapsed().as_secs_f64() * 1e6 / f64::from(rounds)
|
||||
};
|
||||
let none = time(0, [0.0; 3]);
|
||||
let direct = time(1, [4.0, 0.0, 4.0]);
|
||||
let radio = time(1, [0.0, 0.0, 100.0]);
|
||||
eprintln!(
|
||||
"512-frame stereo callback at 48 kHz (10.7 ms of audio): no voice {none:.2} µs, \
|
||||
a voice nearby {direct:.2} µs, over the radio {radio:.2} µs"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sources_play_attenuated_then_stop_or_loop() {
|
||||
let mut m = Mixer::default();
|
||||
|
||||
@@ -187,6 +187,10 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
&[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!("light", Observe, "light X1 Y1 Z1 X2 Y2 Z2", "the stored sky and block light of a box",
|
||||
"At most 32768 blocks per request. One hex digit per block in `sky` and `block`, x fastest, then z, then y; `.` where no chunk is loaded.",
|
||||
["horizon-ctl light 0 64 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!("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),
|
||||
@@ -321,6 +325,16 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
&[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!("companion", World, "companion spawn [NAME] | dismiss [NAME] | do [NAME] COMMAND | list | summary | reports | cost | watch [NAME|off] | strike [NAME] ID",
|
||||
"spawn, command and inspect AI companions (virtual players in the world)",
|
||||
"do takes follow, stay, come, goto X Y Z | goto there, stop, attack [ID|nearest], defend, holdfire on|off, jump, crouch, eat, \
|
||||
collect N ITEM (#tag, block:ID), give [me] N ITEM, craft N ITEM, make N ITEM (gathers what it lacks), smelt N ITEM, sleep. \
|
||||
list is each body's state, summary what its brain reads, reports what it did, cost the hub's tick time. \
|
||||
watch NAME makes you a spectator camera on it (its portals take your view along; HUD: watch step TEXT, \
|
||||
watch perception LABEL, watch timer reset, watch status), watch off ends it. strike hits an entity id once (dev).",
|
||||
["horizon-ctl companion spawn Ari", "horizon-ctl companion do Ari attack nearest", "horizon-ctl companion list"],
|
||||
&[p("action", Ty::Enum(&["spawn", "dismiss", "do", "list", "status", "summary", "reports", "cost", "watch", "strike"]), "what to do"),
|
||||
opt("rest", Ty::Rest, "a name, then for do the command words")], 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),
|
||||
@@ -339,7 +353,7 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
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).",
|
||||
"The reply's tick_ms and tick_ms_max are the world's smoothed tick time over the flight (the server thread's when single player runs on one). 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),
|
||||
];
|
||||
|
||||
|
||||
@@ -215,7 +215,12 @@ impl Session {
|
||||
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 {
|
||||
// On a server (single player's server thread too), also the
|
||||
// level around the player.
|
||||
if !status["player"].is_null()
|
||||
&& status["pending_chunks"] == 0
|
||||
&& status["server"]["loaded"] != false
|
||||
{
|
||||
return Ok(status);
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
//! The world's block entities and the order they tick in: vanilla's
|
||||
//! `Level.blockEntityTickers`, kept beside the block entity map so a tick
|
||||
//! visits only the block entities that tick, in vanilla's order, instead of
|
||||
//! scanning the whole map once per kind.
|
||||
//!
|
||||
//! The list follows `LevelChunk.updateBlockEntityTicker` and
|
||||
//! `Level.tickBlockEntities`:
|
||||
//! - a block entity whose block state has a ticker gets a slot at the end of
|
||||
//! the list (through `pendingBlockEntityTickers`, so a slot made during a
|
||||
//! tick first ticks on the next one);
|
||||
//! - replacing the block entity at a position (`setBlockEntity`), or changing
|
||||
//! its block state to another state with a ticker, keeps its slot
|
||||
//! (`RebindableTickingBlockEntityWrapper.rebind`);
|
||||
//! - removing it (`removeBlockEntity`), unloading its chunk
|
||||
//! (`clearAllBlockEntities`) or changing its state to one without a ticker
|
||||
//! (a jukebox losing its record) frees the slot; a later ticker there is
|
||||
//! appended again;
|
||||
//! - a loaded chunk's block entities are added in the iteration order of its
|
||||
//! `Object2ObjectOpenHashMap<BlockPos, BlockEntity>`
|
||||
//! (`registerAllBlockEntitiesAfterLevelLoad`), filled in the order the chunk
|
||||
//! listed them.
|
||||
//!
|
||||
//! Which states tick is a table by state id, built by the game from the block
|
||||
//! registry; whether a slot is made is decided when the slot is promoted at
|
||||
//! the start of the next tick, from the block state then.
|
||||
use crate::containers::BlockEntity;
|
||||
use crate::fast_hash::{FxHashMap, FxHashSet};
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub type Pos = (i32, i32, i32);
|
||||
|
||||
/// What a block state's block entity does each tick (`BlockState.getTicker`).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Ticking {
|
||||
/// No ticker, and the block has no block entity that could have one.
|
||||
#[default]
|
||||
No,
|
||||
/// A ticker.
|
||||
Yes,
|
||||
/// The block's block entity, but this state has no ticker (a jukebox
|
||||
/// without a record): a state change to it frees the slot.
|
||||
Idle,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct BlockEntities {
|
||||
map: HashMap<Pos, BlockEntity>,
|
||||
/// `Level.blockEntityTickers`: positions and the slot each was given.
|
||||
tickers: Vec<(Pos, u32)>,
|
||||
/// `LevelChunk.tickersInLevel`: the live slot of each ticking position.
|
||||
live: FxHashMap<Pos, u32>,
|
||||
/// Positions that may need a slot, in the order vanilla would have made
|
||||
/// it (`pendingBlockEntityTickers`), each with a token; decided at the
|
||||
/// next promotion from the block state then.
|
||||
added: Vec<(Pos, u32)>,
|
||||
/// The token of each position's pending entry: an entry whose token is
|
||||
/// not here any more was cancelled.
|
||||
pending: FxHashMap<Pos, u32>,
|
||||
next_slot: u32,
|
||||
/// Whether a slot was freed since the list was last swept: otherwise
|
||||
/// every slot in it is still live, unless its block entity is `out`.
|
||||
freed: bool,
|
||||
/// Positions whose block entity `take` took and `put` has not brought
|
||||
/// back.
|
||||
out: FxHashSet<Pos>,
|
||||
/// `Ticking` by block state id; empty until the game builds it.
|
||||
states: Vec<Ticking>,
|
||||
}
|
||||
|
||||
impl std::ops::Deref for BlockEntities {
|
||||
type Target = HashMap<Pos, BlockEntity>;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.map
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> IntoIterator for &'a BlockEntities {
|
||||
type Item = (&'a Pos, &'a BlockEntity);
|
||||
type IntoIter = std::collections::hash_map::Iter<'a, Pos, BlockEntity>;
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.map.iter()
|
||||
}
|
||||
}
|
||||
|
||||
/// `Vec3i.hashCode`.
|
||||
fn java_hash((x, y, z): Pos) -> i32 {
|
||||
y.wrapping_add(z.wrapping_mul(31))
|
||||
.wrapping_mul(31)
|
||||
.wrapping_add(x)
|
||||
}
|
||||
/// `HashCommon.mix`.
|
||||
fn mix(x: i32) -> usize {
|
||||
let h = (x as u32).wrapping_mul(0x9E37_79B9);
|
||||
(h ^ (h >> 16)) as usize
|
||||
}
|
||||
|
||||
/// The order `values().forEach` visits a `new Object2ObjectOpenHashMap<>()`
|
||||
/// keyed by these positions, put in this order (repeats keep their first
|
||||
/// slot): open addressing with linear probing, a rehash to twice the size
|
||||
/// past three quarters full, and iteration from the last slot down.
|
||||
pub fn chunk_map_order(positions: impl IntoIterator<Item = Pos>) -> Vec<Pos> {
|
||||
// HashCommon.arraySize(16, .75f), and maxFill of each size.
|
||||
let mut slots: Vec<Option<Pos>> = vec![None; 32];
|
||||
let max_fill = |n: usize| (n * 3).div_ceil(4).min(n - 1);
|
||||
let mut size = 0;
|
||||
for p in positions {
|
||||
let mask = slots.len() - 1;
|
||||
let mut at = mix(java_hash(p)) & mask;
|
||||
let mut known = false;
|
||||
while let Some(k) = slots[at] {
|
||||
if k == p {
|
||||
known = true;
|
||||
break;
|
||||
}
|
||||
at = (at + 1) & mask;
|
||||
}
|
||||
if known {
|
||||
continue;
|
||||
}
|
||||
slots[at] = Some(p);
|
||||
// `if (size++ >= maxFill) rehash(arraySize(size + 1, f))`, which
|
||||
// puts the keys back from the last slot down.
|
||||
size += 1;
|
||||
if size > max_fill(slots.len()) {
|
||||
let n = ((size + 1) * 4).div_ceil(3).next_power_of_two();
|
||||
let old = std::mem::replace(&mut slots, vec![None; n]);
|
||||
for k in old.into_iter().rev().flatten() {
|
||||
let mut at = mix(java_hash(k)) & (n - 1);
|
||||
while slots[at].is_some() {
|
||||
at = (at + 1) & (n - 1);
|
||||
}
|
||||
slots[at] = Some(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
slots.into_iter().rev().flatten().collect()
|
||||
}
|
||||
|
||||
impl BlockEntities {
|
||||
/// Whether the ticking table has been built.
|
||||
pub fn has_states(&self) -> bool {
|
||||
!self.states.is_empty()
|
||||
}
|
||||
pub fn set_states(&mut self, states: Vec<Ticking>) {
|
||||
self.states = states;
|
||||
}
|
||||
pub fn take_states(&mut self) -> Vec<Ticking> {
|
||||
std::mem::take(&mut self.states)
|
||||
}
|
||||
pub fn ticking(&self, state: u32) -> Ticking {
|
||||
self.states.get(state as usize).copied().unwrap_or_default()
|
||||
}
|
||||
|
||||
fn next(&mut self) -> u32 {
|
||||
let n = self.next_slot;
|
||||
self.next_slot = n.wrapping_add(1);
|
||||
n
|
||||
}
|
||||
/// A ticker may be made for `pos` now (`updateBlockEntityTicker`).
|
||||
fn add(&mut self, pos: Pos) {
|
||||
let token = self.next();
|
||||
self.pending.insert(pos, token);
|
||||
self.added.push((pos, token));
|
||||
}
|
||||
/// `removeBlockEntityTicker`: no slot, made or pending.
|
||||
fn cancel(&mut self, pos: &Pos) {
|
||||
self.freed |= self.live.remove(pos).is_some();
|
||||
self.pending.remove(pos);
|
||||
}
|
||||
|
||||
pub fn get_mut(&mut self, pos: &Pos) -> Option<&mut BlockEntity> {
|
||||
self.map.get_mut(pos)
|
||||
}
|
||||
pub fn iter_mut(&mut self) -> impl Iterator<Item = (&Pos, &mut BlockEntity)> {
|
||||
self.map.iter_mut()
|
||||
}
|
||||
pub fn values_mut(&mut self) -> impl Iterator<Item = &mut BlockEntity> {
|
||||
self.map.values_mut()
|
||||
}
|
||||
/// `LevelChunk.setBlockEntity` (+ `addAndRegisterBlockEntity`): a new
|
||||
/// block entity may get a slot; one replacing another keeps its slot.
|
||||
pub fn insert(&mut self, pos: Pos, entity: BlockEntity) -> Option<BlockEntity> {
|
||||
let old = self.map.insert(pos, entity);
|
||||
if old.is_none() {
|
||||
self.add(pos);
|
||||
}
|
||||
old
|
||||
}
|
||||
/// The block entity at `pos`, made by `make` when there is none.
|
||||
pub fn get_or_insert_with(
|
||||
&mut self,
|
||||
pos: Pos,
|
||||
make: impl FnOnce() -> BlockEntity,
|
||||
) -> &mut BlockEntity {
|
||||
if !self.map.contains_key(&pos) {
|
||||
self.add(pos);
|
||||
}
|
||||
self.map.entry(pos).or_insert_with(make)
|
||||
}
|
||||
/// `LevelChunk.removeBlockEntity`: the block entity and its slot.
|
||||
pub fn remove(&mut self, pos: &Pos) -> Option<BlockEntity> {
|
||||
self.cancel(pos);
|
||||
self.map.remove(pos)
|
||||
}
|
||||
/// The block entity at `pos`, out of the map for a while: its slot stays
|
||||
/// for `put` to bring it back (a tick working on it).
|
||||
pub fn take(&mut self, pos: &Pos) -> Option<BlockEntity> {
|
||||
let taken = self.map.remove(pos);
|
||||
if taken.is_some() {
|
||||
self.out.insert(*pos);
|
||||
}
|
||||
taken
|
||||
}
|
||||
/// Brings back what `take` took, in its slot.
|
||||
pub fn put(&mut self, pos: Pos, entity: BlockEntity) {
|
||||
self.out.remove(&pos);
|
||||
if self.map.insert(pos, entity).is_none()
|
||||
&& !self.live.contains_key(&pos)
|
||||
&& !self.pending.contains_key(&pos)
|
||||
{
|
||||
self.add(pos);
|
||||
}
|
||||
}
|
||||
/// Removes the block entities `keep` refuses, and their slots
|
||||
/// (`clearAllBlockEntities` of unloaded chunks).
|
||||
pub fn retain(&mut self, mut keep: impl FnMut(&Pos, &mut BlockEntity) -> bool) {
|
||||
let (live, pending, freed) = (&mut self.live, &mut self.pending, &mut self.freed);
|
||||
self.map.retain(|p, e| {
|
||||
let kept = keep(p, e);
|
||||
if !kept {
|
||||
*freed |= live.remove(p).is_some();
|
||||
pending.remove(p);
|
||||
}
|
||||
kept
|
||||
});
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.map.clear();
|
||||
self.tickers.clear();
|
||||
self.live.clear();
|
||||
self.added.clear();
|
||||
self.pending.clear();
|
||||
self.out.clear();
|
||||
self.freed = false;
|
||||
}
|
||||
/// A loaded or generated chunk's block entities, in the order the chunk
|
||||
/// lists them; they tick in its block entity map's order
|
||||
/// (`registerAllBlockEntitiesAfterLevelLoad`).
|
||||
pub fn load_chunk(&mut self, entities: Vec<(Pos, BlockEntity)>) {
|
||||
let order = chunk_map_order(entities.iter().map(|(p, _)| *p));
|
||||
for (p, e) in entities {
|
||||
self.map.insert(p, e);
|
||||
}
|
||||
for p in order {
|
||||
self.add(p);
|
||||
}
|
||||
}
|
||||
/// `LevelChunk.setBlockState` on a block entity's position, from state
|
||||
/// `old` to `new`: a ticker made, kept or dropped.
|
||||
pub fn state_changed(&mut self, pos: Pos, old: u32, new: u32) {
|
||||
if self.states.is_empty() || !self.map.contains_key(&pos) {
|
||||
return;
|
||||
}
|
||||
match self.ticking(new) {
|
||||
// A new ticker, unless one was made or is pending (rebound).
|
||||
Ticking::Yes => {
|
||||
if self.ticking(old) != Ticking::Yes && !self.live.contains_key(&pos) {
|
||||
self.add(pos);
|
||||
}
|
||||
}
|
||||
Ticking::Idle => self.cancel(&pos),
|
||||
// Another block: the game removes or replaces the block entity.
|
||||
Ticking::No => {}
|
||||
}
|
||||
}
|
||||
/// The start of `Level.tickBlockEntities`: pending slots join the list
|
||||
/// and freed ones leave it. `state` reads the block state at a position.
|
||||
/// Returns the slots to tick, in order; check each with `is_live` before
|
||||
/// ticking it, as the ticks before it can free it.
|
||||
pub fn tickers(&mut self, state: impl Fn(Pos) -> u32) -> Vec<(Pos, u32)> {
|
||||
for (pos, token) in std::mem::take(&mut self.added) {
|
||||
if self.pending.get(&pos) != Some(&token) {
|
||||
continue;
|
||||
}
|
||||
self.pending.remove(&pos);
|
||||
if self.live.contains_key(&pos)
|
||||
|| !self.map.contains_key(&pos)
|
||||
|| self.ticking(state(pos)) != Ticking::Yes
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let slot = self.next();
|
||||
self.live.insert(pos, slot);
|
||||
self.tickers.push((pos, slot));
|
||||
}
|
||||
if !std::mem::take(&mut self.freed) && self.out.is_empty() {
|
||||
return self.tickers.clone();
|
||||
}
|
||||
self.out.clear();
|
||||
let (live, map) = (&mut self.live, &self.map);
|
||||
self.tickers.retain(|(p, slot)| {
|
||||
let mine = live.get(p) == Some(slot);
|
||||
if mine && !map.contains_key(p) {
|
||||
live.remove(p);
|
||||
}
|
||||
mine && map.contains_key(p)
|
||||
});
|
||||
self.tickers.clone()
|
||||
}
|
||||
/// Whether a slot from `tickers` still ticks.
|
||||
pub fn is_live(&self, (pos, slot): (Pos, u32)) -> bool {
|
||||
self.live.get(&pos) == Some(&slot) && self.map.contains_key(&pos)
|
||||
}
|
||||
/// Frees the slot at `pos` (the block entity stays).
|
||||
pub fn forget(&mut self, pos: &Pos) {
|
||||
self.cancel(pos);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn hopper() -> BlockEntity {
|
||||
BlockEntity::Chest(vec![None; 5])
|
||||
}
|
||||
/// States: 0 air, 1 ticks, 2 idle.
|
||||
fn level() -> BlockEntities {
|
||||
let mut b = BlockEntities::default();
|
||||
b.set_states(vec![Ticking::No, Ticking::Yes, Ticking::Idle]);
|
||||
b
|
||||
}
|
||||
fn order(b: &mut BlockEntities, states: &HashMap<Pos, u32>) -> Vec<Pos> {
|
||||
b.tickers(|p| states.get(&p).copied().unwrap_or(0))
|
||||
.into_iter()
|
||||
.map(|(p, _)| p)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slots_follow_vanilla_lifecycle() {
|
||||
let mut b = level();
|
||||
let mut s = HashMap::new();
|
||||
for p in [(0, 0, 0), (1, 0, 0), (2, 0, 0)] {
|
||||
s.insert(p, 1);
|
||||
b.insert(p, hopper());
|
||||
}
|
||||
assert_eq!(order(&mut b, &s), [(0, 0, 0), (1, 0, 0), (2, 0, 0)]);
|
||||
// Replacing keeps the slot; removing and placing again appends.
|
||||
b.insert((0, 0, 0), hopper());
|
||||
b.remove(&(1, 0, 0));
|
||||
b.insert((1, 0, 0), hopper());
|
||||
assert_eq!(order(&mut b, &s), [(0, 0, 0), (2, 0, 0), (1, 0, 0)]);
|
||||
// Take and put keep it; an idle state frees it, ticking again appends.
|
||||
let e = b.take(&(0, 0, 0)).unwrap();
|
||||
b.put((0, 0, 0), e);
|
||||
assert_eq!(order(&mut b, &s), [(0, 0, 0), (2, 0, 0), (1, 0, 0)]);
|
||||
s.insert((2, 0, 0), 2);
|
||||
b.state_changed((2, 0, 0), 1, 2);
|
||||
assert_eq!(order(&mut b, &s), [(0, 0, 0), (1, 0, 0)]);
|
||||
s.insert((2, 0, 0), 1);
|
||||
b.state_changed((2, 0, 0), 2, 1);
|
||||
assert_eq!(order(&mut b, &s), [(0, 0, 0), (1, 0, 0), (2, 0, 0)]);
|
||||
// A slot freed during a tick is not live any more.
|
||||
let slots = b.tickers(|p| s.get(&p).copied().unwrap_or(0));
|
||||
b.retain(|p, _| *p != (1, 0, 0));
|
||||
assert!(b.is_live(slots[0]) && !b.is_live(slots[1]));
|
||||
// A block entity taken and never brought back loses its slot.
|
||||
b.take(&(0, 0, 0));
|
||||
assert_eq!(order(&mut b, &s), [(2, 0, 0)]);
|
||||
assert_eq!(order(&mut b, &s), [(2, 0, 0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunk_loads_in_open_hash_map_order() {
|
||||
// A few keys: their mixed hashes over 32 slots, read from the top.
|
||||
let positions = [
|
||||
(0, 64, 0),
|
||||
(1, 64, 0),
|
||||
(0, 64, 1),
|
||||
(15, -64, 15),
|
||||
(3, 70, 9),
|
||||
];
|
||||
let slot = |p: &Pos| mix(java_hash(*p)) & 31;
|
||||
let mut expected = positions.to_vec();
|
||||
expected.sort_by_key(|p| std::cmp::Reverse(slot(p)));
|
||||
let distinct: std::collections::HashSet<usize> = positions.iter().map(slot).collect();
|
||||
assert_eq!(distinct.len(), positions.len());
|
||||
assert_eq!(chunk_map_order(positions), expected);
|
||||
// Past 24 keys the table doubles; every key stays, once.
|
||||
let many: Vec<Pos> = (0..100).map(|i| (i % 16, i / 16, 3)).collect();
|
||||
let order = chunk_map_order(many.iter().copied().chain(many.iter().copied()));
|
||||
assert_eq!(order.len(), 100);
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,11 @@
|
||||
//! (blocks, entities, game time) as closures, so the parity fixture can drive
|
||||
//! them with scripted inputs (`tools/test_block_entity_anim_parity.py`).
|
||||
use crate::block_entity_render::BlockAnim;
|
||||
use crate::fast_hash::FxHashMap;
|
||||
use horizon_core::{
|
||||
geometry::trig::{self, Mth},
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// `Direction` names by `get3DDataValue`.
|
||||
pub const DIRECTIONS: [&str; 6] = ["down", "up", "north", "south", "west", "east"];
|
||||
@@ -832,38 +832,44 @@ pub fn ticker(name: &str, prop: impl Fn(&str) -> Option<String>) -> Option<Ticke
|
||||
/// The animated block entities of a level, by position.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BlockEntityAnims {
|
||||
pub chests: HashMap<[i32; 3], ChestLid>,
|
||||
pub books: HashMap<[i32; 3], EnchantingBook>,
|
||||
pub chests: FxHashMap<[i32; 3], ChestLid>,
|
||||
pub books: FxHashMap<[i32; 3], EnchantingBook>,
|
||||
/// `EnchantingTableBlockEntity.RANDOM`, shared by every table.
|
||||
pub book_random: LegacyRandom,
|
||||
pub shulkers: HashMap<[i32; 3], Shulker>,
|
||||
pub bells: HashMap<[i32; 3], Bell>,
|
||||
pub conduits: HashMap<[i32; 3], Conduit>,
|
||||
pub skulls: HashMap<[i32; 3], Skull>,
|
||||
pub pots: HashMap<[i32; 3], PotWobble>,
|
||||
pub gateways: HashMap<[i32; 3], EndGateway>,
|
||||
pub shulkers: FxHashMap<[i32; 3], Shulker>,
|
||||
pub bells: FxHashMap<[i32; 3], Bell>,
|
||||
pub conduits: FxHashMap<[i32; 3], Conduit>,
|
||||
pub skulls: FxHashMap<[i32; 3], Skull>,
|
||||
pub pots: FxHashMap<[i32; 3], PotWobble>,
|
||||
pub gateways: FxHashMap<[i32; 3], EndGateway>,
|
||||
/// The integrated server's copies of the gateways (`portalTick`).
|
||||
pub gateway_servers: HashMap<[i32; 3], EndGateway>,
|
||||
pub gateway_servers: FxHashMap<[i32; 3], EndGateway>,
|
||||
/// The client level's random (conduit particles).
|
||||
pub random: LegacyRandom,
|
||||
/// [`Ticker`] by block state id (see [`BlockEntityAnims::classify`]).
|
||||
pub kinds: Vec<Option<Ticker>>,
|
||||
/// The drawn block entities with a ticker, sorted by position, and the
|
||||
/// renderer's block entity revision they were read at.
|
||||
pub present: Vec<([i32; 3], Ticker)>,
|
||||
pub present_revision: Option<u64>,
|
||||
}
|
||||
impl Default for BlockEntityAnims {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
chests: HashMap::new(),
|
||||
books: HashMap::new(),
|
||||
chests: FxHashMap::default(),
|
||||
books: FxHashMap::default(),
|
||||
book_random: LegacyRandom::new(0x626f6f6b),
|
||||
shulkers: HashMap::new(),
|
||||
bells: HashMap::new(),
|
||||
conduits: HashMap::new(),
|
||||
skulls: HashMap::new(),
|
||||
pots: HashMap::new(),
|
||||
gateways: HashMap::new(),
|
||||
gateway_servers: HashMap::new(),
|
||||
shulkers: FxHashMap::default(),
|
||||
bells: FxHashMap::default(),
|
||||
conduits: FxHashMap::default(),
|
||||
skulls: FxHashMap::default(),
|
||||
pots: FxHashMap::default(),
|
||||
gateways: FxHashMap::default(),
|
||||
gateway_servers: FxHashMap::default(),
|
||||
random: LegacyRandom::new(0x6c6576656c),
|
||||
kinds: Vec::new(),
|
||||
present: Vec::new(),
|
||||
present_revision: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -877,15 +883,20 @@ impl BlockEntityAnims {
|
||||
.and_then(|s| ticker(&s.name, |k| s.properties.get(k).cloned()))
|
||||
})
|
||||
.collect();
|
||||
self.present_revision = None;
|
||||
}
|
||||
/// The ticker of a block state.
|
||||
pub fn ticker(&self, state: u32) -> Option<Ticker> {
|
||||
self.kinds.get(state as usize).copied().flatten()
|
||||
}
|
||||
/// Drops the state of block entities that are gone or changed kind:
|
||||
/// `present` maps the loaded block entities to their tickers.
|
||||
pub fn retain_present(&mut self, present: &HashMap<[i32; 3], Ticker>) {
|
||||
let is = |p: &[i32; 3], f: fn(&Ticker) -> bool| present.get(p).is_some_and(f);
|
||||
/// `present` holds the loaded block entities' tickers, sorted by position.
|
||||
pub fn retain_present(&mut self, present: &[([i32; 3], Ticker)]) {
|
||||
let is = |p: &[i32; 3], f: fn(&Ticker) -> bool| {
|
||||
present
|
||||
.binary_search_by_key(p, |(q, _)| *q)
|
||||
.is_ok_and(|i| f(&present[i].1))
|
||||
};
|
||||
self.chests.retain(|p, _| is(p, |t| *t == Ticker::Lid));
|
||||
self.books.retain(|p, _| is(p, |t| *t == Ticker::Book));
|
||||
self.shulkers
|
||||
|
||||
@@ -737,6 +737,8 @@ pub struct Scene<'a> {
|
||||
pub struct BlockEntityRenderer {
|
||||
models: HashMap<&'static str, ModelPart>,
|
||||
kinds: Vec<Kind>,
|
||||
// Closed chest geometry: dimension lighting, chest part, horizontal facing.
|
||||
closed_chests: [[[Vec<Vertex>; 4]; 3]; 2],
|
||||
/// Item id to its `PROVIDES_POTTERY_PATTERN` side texture (`Sheets.DECORATED_POT_MAPPER`).
|
||||
pot_patterns: HashMap<String, String>,
|
||||
/// `Block.shouldRenderFace` for End gateway faces (its occlusion pair cache needs a lock).
|
||||
@@ -817,6 +819,43 @@ impl BlockEntityRenderer {
|
||||
Ok(renderer)
|
||||
}
|
||||
fn new(models: HashMap<&'static str, ModelPart>, registry: &BlockRegistry) -> Self {
|
||||
let closed_chests = [false, true].map(|nether| {
|
||||
["chest", "double_chest_left", "double_chest_right"].map(|name| {
|
||||
[0.0f32, 90.0, 180.0, 270.0].map(|y_rot| {
|
||||
let mut vertices = Vec::new();
|
||||
if let Some(model) = models.get(name) {
|
||||
draw_part(
|
||||
model,
|
||||
"root",
|
||||
around(
|
||||
Mat4::from_rotation_y((-y_rot).to_radians()),
|
||||
Vec3::new(0.5, 0.0, 0.5),
|
||||
),
|
||||
&|name, p| {
|
||||
if name == "lid" || name == "lock" {
|
||||
p[3] = crate::block_entity_anim::chest_lid_x_rot(0.0);
|
||||
}
|
||||
},
|
||||
Paint {
|
||||
sprite: Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 16,
|
||||
h: 16,
|
||||
},
|
||||
tint: 0xFFFFFF,
|
||||
light: 0,
|
||||
lights: diffuse_lights(nether),
|
||||
},
|
||||
&mut vertices,
|
||||
);
|
||||
}
|
||||
vertices
|
||||
})
|
||||
})
|
||||
});
|
||||
let kinds = (0..registry.len() as u32)
|
||||
.map(|id| {
|
||||
registry.state(BlockStateId(id)).map_or(Kind::None, |s| {
|
||||
@@ -831,6 +870,7 @@ impl BlockEntityRenderer {
|
||||
Self {
|
||||
models,
|
||||
kinds,
|
||||
closed_chests,
|
||||
pot_patterns: HashMap::new(),
|
||||
visibility: None,
|
||||
}
|
||||
@@ -876,7 +916,7 @@ impl BlockEntityRenderer {
|
||||
.as_vec3();
|
||||
let base = Mat4::from_translation(origin);
|
||||
let packed = scene.world.light(pos[0], pos[1], pos[2]);
|
||||
self.render_one(scene, *pos, kind, base, packed, lights, false, out);
|
||||
self.render_one(scene, *pos, kind, base, packed, lights, false, true, out);
|
||||
}
|
||||
}
|
||||
/// One block entity at `pos` drawn under `base` with the given packed light
|
||||
@@ -892,7 +932,7 @@ impl BlockEntityRenderer {
|
||||
) {
|
||||
let kind = self.kind(state);
|
||||
let lights = diffuse_lights(scene.nether);
|
||||
self.render_one(scene, pos, kind, base, packed, lights, false, out);
|
||||
self.render_one(scene, pos, kind, base, packed, lights, false, false, out);
|
||||
}
|
||||
/// Whether this block state has a block-entity renderer here.
|
||||
pub fn renders(&self, state: u32) -> bool {
|
||||
@@ -915,6 +955,7 @@ impl BlockEntityRenderer {
|
||||
0xff,
|
||||
diffuse_lights(scene.nether),
|
||||
true,
|
||||
false,
|
||||
out,
|
||||
);
|
||||
crate::breaking::entity_geometry(&mut out[start..], origin);
|
||||
@@ -991,7 +1032,17 @@ impl BlockEntityRenderer {
|
||||
let kind = self.kind(state);
|
||||
if !matches!(kind, Kind::None) {
|
||||
let lights = diffuse_lights(scene.nether);
|
||||
self.render_one(scene, [i32::MAX; 3], kind, base, packed, lights, false, out);
|
||||
self.render_one(
|
||||
scene,
|
||||
[i32::MAX; 3],
|
||||
kind,
|
||||
base,
|
||||
packed,
|
||||
lights,
|
||||
false,
|
||||
false,
|
||||
out,
|
||||
);
|
||||
}
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -1004,6 +1055,7 @@ impl BlockEntityRenderer {
|
||||
packed: u8,
|
||||
lights: (Vec3, Vec3),
|
||||
crumbling: bool,
|
||||
world_chests: bool,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let sprite = |name: &str| scene.sprites.get(name).copied();
|
||||
@@ -1107,6 +1159,23 @@ impl BlockEntityRenderer {
|
||||
let l = scene.world.light(other[0], other[1], other[2]);
|
||||
packed = (packed >> 4).max(l >> 4) << 4 | (packed & 15).max(l & 15);
|
||||
}
|
||||
if world_chests && open == 0.0 {
|
||||
if let Some(paint) = paint(texture, 0xFFFFFF, packed) {
|
||||
let vertices = &self.closed_chests[usize::from(scene.nether)]
|
||||
[*part as usize][*y_rot as usize / 90];
|
||||
let origin = base.w_axis.truncate();
|
||||
out.extend(vertices.iter().map(|v| Vertex {
|
||||
pos: (Vec3::from_array(v.pos) + origin).to_array(),
|
||||
uv: [
|
||||
paint.sprite.u0 + (paint.sprite.u1 - paint.sprite.u0) * v.uv[0],
|
||||
paint.sprite.v0 + (paint.sprite.v1 - paint.sprite.v0) * v.uv[1],
|
||||
],
|
||||
light: paint.light,
|
||||
..*v
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let m = base
|
||||
* around(
|
||||
Mat4::from_rotation_y((-y_rot).to_radians()),
|
||||
@@ -1791,16 +1860,18 @@ mod tests {
|
||||
(
|
||||
name.to_owned(),
|
||||
Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
u0: 0.25,
|
||||
v0: 0.125,
|
||||
u1: 0.375,
|
||||
v1: 0.25,
|
||||
w: 16,
|
||||
h: 16,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let cache_open = std::cell::Cell::new(0.0);
|
||||
let cache_lid = |_| cache_open.get();
|
||||
let mut scene = Scene {
|
||||
camera: DVec3::ZERO,
|
||||
orientation: Quat::IDENTITY,
|
||||
@@ -1817,6 +1888,56 @@ mod tests {
|
||||
banner: &|_| Vec::new(),
|
||||
glyph: &|_| (None, 0.0),
|
||||
};
|
||||
// Compare the cached world path with the original model renderer,
|
||||
// including atlas replacement, current light and animated fallback.
|
||||
scene.lid = &cache_lid;
|
||||
for nether in [false, true] {
|
||||
scene.nether = nether;
|
||||
for part in [ChestPart::Single, ChestPart::Left, ChestPart::Right] {
|
||||
for y_rot in [0.0, 90.0, 180.0, 270.0] {
|
||||
let kind = Kind::Chest {
|
||||
block: "chest".into(),
|
||||
texture: "entity/chest/normal",
|
||||
part,
|
||||
y_rot,
|
||||
double_with: None,
|
||||
};
|
||||
for (open, packed) in [(0.0, 0xF0), (0.0, 0x7F), (0.5, 0x7F)] {
|
||||
cache_open.set(open);
|
||||
let base = Mat4::from_translation(Vec3::new(3.25, -8.5, 12.75));
|
||||
let mut cached = Vec::new();
|
||||
let mut original = Vec::new();
|
||||
for (use_cache, out) in [(true, &mut cached), (false, &mut original)] {
|
||||
renderer.render_one(
|
||||
&scene,
|
||||
[0; 3],
|
||||
&kind,
|
||||
base,
|
||||
packed,
|
||||
diffuse_lights(nether),
|
||||
false,
|
||||
use_cache,
|
||||
out,
|
||||
);
|
||||
}
|
||||
assert!(!cached.is_empty());
|
||||
assert_eq!(cached.len(), original.len());
|
||||
for (a, b) in cached.iter().zip(&original) {
|
||||
assert!(
|
||||
(Vec3::from_array(a.pos) - Vec3::from_array(b.pos)).length() < 3e-6
|
||||
);
|
||||
assert_eq!(a.uv, b.uv);
|
||||
assert_eq!(a.color, b.color);
|
||||
assert_eq!(a.light, b.light);
|
||||
assert_eq!(a.normal, b.normal);
|
||||
assert_eq!(a.layer, b.layer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
scene.nether = false;
|
||||
scene.lid = &|_| 0.0;
|
||||
for state in 0..names.len() as u32 {
|
||||
let mut base = Vec::new();
|
||||
renderer.render_block(&scene, [0; 3], state, Mat4::IDENTITY, 0xff, &mut base);
|
||||
|
||||
@@ -386,6 +386,12 @@ pub struct ChatScreen {
|
||||
parse: Option<ParseResults>,
|
||||
/// The completions at the cursor (`pendingSuggestions`): range start (bytes) and list.
|
||||
pending: Option<(usize, Vec<Suggestion>)>,
|
||||
/// Completions waiting for the server's (an `ask_server` node at the
|
||||
/// cursor): `pendingSuggestions` is not done until they come.
|
||||
awaiting: Option<Awaiting>,
|
||||
/// The request to send (`ServerboundCommandSuggestionPacket`): id and
|
||||
/// the command up to the cursor.
|
||||
request: Option<(i32, String)>,
|
||||
suggestions: Option<SuggestionList>,
|
||||
usage: Vec<Text>,
|
||||
usage_position: i32,
|
||||
@@ -394,6 +400,16 @@ pub struct ChatScreen {
|
||||
keep_suggestions: bool,
|
||||
pub auto_suggestions: bool,
|
||||
}
|
||||
/// Local completions waiting for the server's reply to request `id`.
|
||||
struct Awaiting {
|
||||
id: i32,
|
||||
/// The command up to the cursor, as sent.
|
||||
truncated: String,
|
||||
local: Vec<Suggestion>,
|
||||
}
|
||||
/// `ClientSuggestionProvider.pendingSuggestionsId`: one counter for the
|
||||
/// connection, so a reply to an older request never matches.
|
||||
static SUGGESTION_ID: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
|
||||
/// `CommandSuggestions`' fill colour.
|
||||
const FILL: i32 = -805306368;
|
||||
fn byte_at(s: &str, chars: usize) -> usize {
|
||||
@@ -442,6 +458,8 @@ impl ChatScreen {
|
||||
history_pos: history_len,
|
||||
parse: None,
|
||||
pending: None,
|
||||
awaiting: None,
|
||||
request: None,
|
||||
suggestions: None,
|
||||
usage: Vec::new(),
|
||||
usage_position: 0,
|
||||
@@ -518,24 +536,84 @@ impl ChatScreen {
|
||||
}
|
||||
if cursor >= 1 && (self.suggestions.is_none() || !self.keep_suggestions) {
|
||||
let parse = self.parse.as_ref().expect("parsed above");
|
||||
let (range, list) = c
|
||||
.dispatcher
|
||||
.client_suggestions(parse, cursor_b, c.registries);
|
||||
self.pending = Some((range.0, list));
|
||||
self.update_usage_info(m, c);
|
||||
let (range, list, ask) =
|
||||
c.dispatcher
|
||||
.client_suggestions_asking(parse, cursor_b, c.registries);
|
||||
if ask {
|
||||
// ClientSuggestionProvider.customSuggestion: the
|
||||
// suggestions are done when the server answers.
|
||||
let id = SUGGESTION_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
||||
let truncated = command[..cursor_b].to_owned();
|
||||
self.request = Some((id, truncated.clone()));
|
||||
self.awaiting = Some(Awaiting {
|
||||
id,
|
||||
truncated,
|
||||
local: list,
|
||||
});
|
||||
self.pending = None;
|
||||
} else {
|
||||
self.awaiting = None;
|
||||
self.pending = Some((range.0, list));
|
||||
self.update_usage_info(m, c);
|
||||
}
|
||||
}
|
||||
} else if !command.chars().all(char::is_whitespace) {
|
||||
let partial = &command[..cursor_b];
|
||||
let mut builder = SuggestionsBuilder::new(partial, last_word_index(partial));
|
||||
builder.suggest_matching(c.registries.players.iter().map(String::as_str));
|
||||
let (range, list) = merge_suggestions(partial, builder.out);
|
||||
self.awaiting = None;
|
||||
self.pending = Some((range.0, list));
|
||||
self.usage_width = 0;
|
||||
self.usage_position = 0;
|
||||
} else {
|
||||
self.awaiting = None;
|
||||
self.pending = None;
|
||||
}
|
||||
}
|
||||
/// The completion request to send to the server, if one is waiting.
|
||||
pub fn take_request(&mut self) -> Option<(i32, String)> {
|
||||
self.request.take()
|
||||
}
|
||||
/// `ClientSuggestionProvider.completeCustomSuggestions`: the server's
|
||||
/// completions for request `id`, with `range` in UTF-16 units of the
|
||||
/// command sent. They join the local ones (`Suggestions.merge`) and the
|
||||
/// pending suggestions are done.
|
||||
pub fn server_suggestions(
|
||||
&mut self,
|
||||
m: &Measure,
|
||||
c: &Commands,
|
||||
id: i32,
|
||||
range: (usize, usize),
|
||||
list: Vec<(String, Option<String>)>,
|
||||
) {
|
||||
let Some(awaiting) = self.awaiting.take_if(|a| a.id == id) else {
|
||||
return;
|
||||
};
|
||||
let input = &awaiting.truncated;
|
||||
let byte = |units: usize| {
|
||||
let mut n = 0;
|
||||
input
|
||||
.char_indices()
|
||||
.find(|(_, ch)| {
|
||||
let past = n >= units;
|
||||
n += ch.len_utf16();
|
||||
past
|
||||
})
|
||||
.map_or(input.len(), |(i, _)| i)
|
||||
};
|
||||
let range = (byte(range.0), byte(range.0 + range.1));
|
||||
let mut all = awaiting.local;
|
||||
all.extend(list.into_iter().map(|(text, tooltip)| Suggestion {
|
||||
range,
|
||||
text,
|
||||
tooltip,
|
||||
int_value: None,
|
||||
}));
|
||||
let (range, list) = merge_suggestions(input, all);
|
||||
self.pending = Some((range.0, list));
|
||||
self.update_usage_info(m, c);
|
||||
}
|
||||
/// `CommandSuggestions.updateUsageInfo`.
|
||||
fn update_usage_info(&mut self, m: &Measure, c: &Commands) {
|
||||
let Some(parse) = &self.parse else { return };
|
||||
|
||||
@@ -230,3 +230,58 @@ fn mod_commands_are_coloured_and_complete_like_vanilla_ones() {
|
||||
screen.key(&m, &c, &[], winit::keyboard::KeyCode::Tab, false, false);
|
||||
assert_eq!(screen.value(), "/coords");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ask_server_nodes_wait_for_the_servers_completions() {
|
||||
// ClientSuggestionProvider.customSuggestion: an `ask_server` node at the
|
||||
// cursor sends the command so far, and the list is done with the reply.
|
||||
let font = font();
|
||||
let m = Measure(&font);
|
||||
let registries = Registries {
|
||||
permission: 4,
|
||||
..Registries::default()
|
||||
};
|
||||
let mut d = crate::command::vanilla::game_dispatcher().clone();
|
||||
let child = |d: &crate::command::Dispatcher, n: usize, name: &str| {
|
||||
d.nodes[n]
|
||||
.children
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|&c| d.nodes[c].name() == name)
|
||||
.expect(name)
|
||||
};
|
||||
let id = child(&d, child(&d, child(&d, 0, "bossbar"), "get"), "id");
|
||||
d.nodes[id].suggests = Some(crate::command::Suggests::AskServer);
|
||||
let c = Commands {
|
||||
dispatcher: &d,
|
||||
registries: ®istries,
|
||||
};
|
||||
let tab = |screen: &mut ChatScreen| {
|
||||
screen.key(&m, &c, &[], winit::keyboard::KeyCode::Tab, false, false);
|
||||
};
|
||||
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360);
|
||||
screen.type_text(&m, &c, "/bossbar get pt");
|
||||
let (request, sent) = screen.take_request().expect("the server is asked");
|
||||
assert_eq!(sent, "/bossbar get pt");
|
||||
assert_eq!(screen.take_request(), None);
|
||||
// Nothing to complete until the reply.
|
||||
tab(&mut screen);
|
||||
assert_eq!(screen.value(), "/bossbar get pt");
|
||||
// A reply to an older request is dropped; the range is in UTF-16 units.
|
||||
let reply = |text: &str| vec![(text.to_owned(), None)];
|
||||
screen.server_suggestions(&m, &c, request - 1, (13, 2), reply("pt6:old"));
|
||||
tab(&mut screen);
|
||||
assert_eq!(screen.value(), "/bossbar get pt");
|
||||
screen.server_suggestions(&m, &c, request, (13, 2), reply("pt6:check"));
|
||||
tab(&mut screen);
|
||||
assert_eq!(screen.value(), "/bossbar get pt6:check");
|
||||
// Without the server's provider the local list is done at once.
|
||||
let local = crate::command::vanilla::game_dispatcher();
|
||||
let c = Commands {
|
||||
dispatcher: local,
|
||||
registries: ®istries,
|
||||
};
|
||||
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360);
|
||||
screen.type_text(&m, &c, "/bossbar get pt");
|
||||
assert_eq!(screen.take_request(), None);
|
||||
}
|
||||
@@ -3,17 +3,32 @@
|
||||
//! (copy-on-write through `Arc::make_mut`).
|
||||
use horizon_core::paletted::{PalettedContainer, Strategy};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
pub const MIN_Y: i32 = -64;
|
||||
pub const HEIGHT: i32 = 384;
|
||||
pub const SECTIONS: usize = (HEIGHT / 16) as usize;
|
||||
|
||||
/// Block states are `PalettedContainer`s over the pinned registry (35,723
|
||||
/// states: a 16-bit global palette); biomes are 4x4x4 containers over the
|
||||
/// biome table (67 entries: a 7-bit global palette).
|
||||
pub const BLOCK_STRATEGY: Strategy = Strategy::blocks(16);
|
||||
/// Biomes are 4x4x4 containers over the biome table (67 entries: a 7-bit
|
||||
/// global palette).
|
||||
pub const BIOME_STRATEGY: Strategy = Strategy::biomes(7);
|
||||
|
||||
/// Bits of the block states' global palette: 16 for vanilla's 35,723 states
|
||||
/// (what vanilla servers and clients send), more when content mods add states
|
||||
/// past 65,536 (docs/modpacks.md). Set once, before the first chunk.
|
||||
static BLOCK_BITS: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(16);
|
||||
|
||||
/// Block states are `PalettedContainer`s over the registry: vanilla's 16-bit
|
||||
/// global palette, wider with enough content mods.
|
||||
pub fn block_strategy() -> Strategy {
|
||||
Strategy::blocks(BLOCK_BITS.load(std::sync::atomic::Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// Sizes the block states' global palette for a registry of `states`.
|
||||
pub fn set_block_states(states: usize) {
|
||||
let bits = horizon_core::paletted::ceil_log2(states).max(16);
|
||||
BLOCK_BITS.store(bits, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
/// Packed light per block: sky light in the high nibble, block light in the low one.
|
||||
#[derive(Clone)]
|
||||
pub enum Light {
|
||||
@@ -30,6 +45,10 @@ impl Light {
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
fn column(x: i32, z: i32) -> usize {
|
||||
((z & 15) << 4 | (x & 15)) as usize
|
||||
}
|
||||
#[inline]
|
||||
pub fn index(x: i32, y: i32, z: i32) -> usize {
|
||||
((y & 15) << 8 | (z & 15) << 4 | (x & 15)) as usize
|
||||
}
|
||||
@@ -43,7 +62,7 @@ pub struct Section {
|
||||
impl Default for Section {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
blocks: PalettedContainer::new(BLOCK_STRATEGY, 0),
|
||||
blocks: PalettedContainer::new(block_strategy(), 0),
|
||||
biomes: PalettedContainer::new(BIOME_STRATEGY, 0),
|
||||
light: Light::Uniform(0xF0),
|
||||
}
|
||||
@@ -51,27 +70,26 @@ impl Default for Section {
|
||||
}
|
||||
impl Section {
|
||||
#[inline]
|
||||
pub fn get(&self, i: usize) -> u16 {
|
||||
self.blocks.get(i) as u16
|
||||
pub fn get(&self, i: usize) -> u32 {
|
||||
self.blocks.get(i)
|
||||
}
|
||||
pub fn set(&mut self, i: usize, v: u16) -> u16 {
|
||||
self.blocks.set(i, u32::from(v)) as u16
|
||||
pub fn set(&mut self, i: usize, v: u32) -> u32 {
|
||||
self.blocks.set(i, v)
|
||||
}
|
||||
/// All 4,096 block states in index order.
|
||||
pub fn unpack(&self, out: &mut [u16; 4096]) {
|
||||
self.blocks.unpack_u16(out);
|
||||
pub fn unpack(&self, out: &mut [u32; 4096]) {
|
||||
self.blocks.unpack(out);
|
||||
}
|
||||
/// The block states of a whole section at once.
|
||||
pub fn from_blocks(values: &[u16; 4096]) -> PalettedContainer {
|
||||
let wide: Vec<u32> = values.iter().map(|&v| u32::from(v)).collect();
|
||||
PalettedContainer::from_values(BLOCK_STRATEGY, &wide)
|
||||
pub fn from_blocks(values: &[u32; 4096]) -> PalettedContainer {
|
||||
PalettedContainer::from_values(block_strategy(), values)
|
||||
}
|
||||
#[inline]
|
||||
pub fn light(&self, i: usize) -> u8 {
|
||||
self.light.get(i)
|
||||
}
|
||||
pub fn is_uniform(&self, v: u16) -> bool {
|
||||
self.blocks.is_uniform(u32::from(v))
|
||||
pub fn is_uniform(&self, v: u32) -> bool {
|
||||
self.blocks.is_uniform(v)
|
||||
}
|
||||
/// Heap bytes held by the block, biome and light storage (benchmark accounting).
|
||||
pub fn heap_bytes(&self) -> usize {
|
||||
@@ -104,8 +122,10 @@ pub struct Chunk {
|
||||
pub x: i32,
|
||||
pub z: i32,
|
||||
pub sections: Vec<Arc<Section>>,
|
||||
/// Highest non-air, light-blocking-or-fluid Y per column (MOTION_BLOCKING-like), for rain/sky.
|
||||
pub heights: Arc<[i16; 256]>,
|
||||
/// `Heightmap.Types.WORLD_SURFACE` per column (`z << 4 | x`): one above
|
||||
/// the highest non-air block, or `MIN_Y`. Built on the first read; a
|
||||
/// `set` that can move a column's surface drops it.
|
||||
surface: OnceLock<Box<[i16; 256]>>,
|
||||
pub lit: bool,
|
||||
/// Light read from a save with `isLightOn`: no light job is needed.
|
||||
pub light_loaded: bool,
|
||||
@@ -134,7 +154,7 @@ impl Chunk {
|
||||
sections: (0..SECTIONS)
|
||||
.map(|_| Arc::new(Section::default()))
|
||||
.collect(),
|
||||
heights: Arc::new([MIN_Y as i16; 256]),
|
||||
surface: OnceLock::new(),
|
||||
lit: false,
|
||||
light_loaded: false,
|
||||
fresh: false,
|
||||
@@ -147,7 +167,7 @@ impl Chunk {
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
pub fn get(&self, x: i32, y: i32, z: i32) -> u16 {
|
||||
pub fn get(&self, x: i32, y: i32, z: i32) -> u32 {
|
||||
let s = (y - MIN_Y) >> 4;
|
||||
if !(0..SECTIONS as i32).contains(&s) {
|
||||
return 0;
|
||||
@@ -156,11 +176,11 @@ impl Chunk {
|
||||
}
|
||||
#[inline]
|
||||
pub fn block(&self, x: i32, y: i32, z: i32) -> BlockStateId {
|
||||
BlockStateId(u32::from(self.get(x, y, z)))
|
||||
BlockStateId(self.get(x, y, z))
|
||||
}
|
||||
/// The highest y where `matches`, scanning down. Sections holding only
|
||||
/// `skip` are passed over whole: `matches` must be false for it.
|
||||
pub fn highest(&self, skip: u16, mut matches: impl FnMut(i32) -> bool) -> Option<i32> {
|
||||
pub fn highest(&self, skip: u32, mut matches: impl FnMut(i32) -> bool) -> Option<i32> {
|
||||
self.sections
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -171,12 +191,57 @@ impl Chunk {
|
||||
(base..base + 16).rev().find(|&y| matches(y))
|
||||
})
|
||||
}
|
||||
pub fn set(&mut self, x: i32, y: i32, z: i32, v: u16) -> u16 {
|
||||
pub fn set(&mut self, x: i32, y: i32, z: i32, v: u32) -> u32 {
|
||||
let s = (y - MIN_Y) >> 4;
|
||||
if !(0..SECTIONS as i32).contains(&s) {
|
||||
return v;
|
||||
}
|
||||
Arc::make_mut(&mut self.sections[s as usize]).set(index(x, y, z), v)
|
||||
let previous = Arc::make_mut(&mut self.sections[s as usize]).set(index(x, y, z), v);
|
||||
// `Heightmap.update`: a write two or more below the surface can't
|
||||
// move it. Any other change rebuilds the map on the next read.
|
||||
if previous != v
|
||||
&& let Some(surface) = self.surface.get()
|
||||
&& y >= i32::from(surface[column(x, z)]) - 1
|
||||
{
|
||||
self.surface = OnceLock::new();
|
||||
}
|
||||
previous
|
||||
}
|
||||
/// `getHeight(Heightmap.Types.WORLD_SURFACE, x, z)` (`x`, `z` within the
|
||||
/// chunk): one above the highest block that isn't `air`, or `MIN_Y`.
|
||||
/// Sections must change only through `set` once this has been read.
|
||||
pub fn world_surface(&self, x: i32, z: i32, air: impl Fn(u32) -> bool) -> i32 {
|
||||
let surface = self.surface.get_or_init(|| self.prime_surface(air));
|
||||
i32::from(surface[column(x, z)])
|
||||
}
|
||||
/// `Heightmap.primeHeightmaps` for WORLD_SURFACE: each section from the
|
||||
/// top, skipping those of one air state, until every column is found.
|
||||
fn prime_surface(&self, air: impl Fn(u32) -> bool) -> Box<[i16; 256]> {
|
||||
let mut surface = Box::new([MIN_Y as i16; 256]);
|
||||
let mut found = [false; 256];
|
||||
let mut left = 256;
|
||||
let mut values = [0u32; 4096];
|
||||
for (s, section) in self.sections.iter().enumerate().rev() {
|
||||
if left == 0 {
|
||||
break;
|
||||
}
|
||||
if section.blocks.uniform().is_some_and(&air) {
|
||||
continue;
|
||||
}
|
||||
section.unpack(&mut values);
|
||||
let base = MIN_Y + s as i32 * 16;
|
||||
for i in 0..256 {
|
||||
if found[i] {
|
||||
continue;
|
||||
}
|
||||
if let Some(dy) = (0..16).rev().find(|&dy| !air(values[dy << 8 | i])) {
|
||||
surface[i] = (base + dy as i32 + 1) as i16;
|
||||
found[i] = true;
|
||||
left -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
surface
|
||||
}
|
||||
#[inline]
|
||||
pub fn light(&self, x: i32, y: i32, z: i32) -> u8 {
|
||||
@@ -228,15 +293,15 @@ mod tests {
|
||||
Arc::make_mut(&mut chunk.sections[5]).set(i, cave_air);
|
||||
}
|
||||
chunk.set(3, 150, 4, stone);
|
||||
let full = |c: &Chunk, matches: &dyn Fn(u16) -> bool| {
|
||||
let full = |c: &Chunk, matches: &dyn Fn(u32) -> bool| {
|
||||
(MIN_Y..MIN_Y + HEIGHT)
|
||||
.rev()
|
||||
.find(|&y| matches(c.get(3, y, 4)))
|
||||
};
|
||||
for matches in [
|
||||
&(|v: u16| v != air) as &dyn Fn(u16) -> bool,
|
||||
&|v: u16| v == stone,
|
||||
&|v: u16| v == cave_air,
|
||||
&(|v: u32| v != air) as &dyn Fn(u32) -> bool,
|
||||
&|v: u32| v == stone,
|
||||
&|v: u32| v == cave_air,
|
||||
] {
|
||||
let fast = chunk.highest(air, |y| matches(chunk.get(3, y, 4)));
|
||||
assert_eq!(fast, full(&chunk, matches));
|
||||
@@ -247,4 +312,50 @@ mod tests {
|
||||
);
|
||||
assert_eq!(Chunk::empty(0, 0).highest(air, |_| unreachable!()), None);
|
||||
}
|
||||
/// The kept WORLD_SURFACE heights match a scan of every column after
|
||||
/// each of a few thousand writes (air, cave air, stone, whole sections).
|
||||
#[test]
|
||||
fn world_surface_follows_writes() {
|
||||
let (air, cave_air, stone) = (0, 7, 1);
|
||||
let is_air = |v: u32| v == air || v == cave_air;
|
||||
let scan = |c: &Chunk, x: i32, z: i32| {
|
||||
(MIN_Y..MIN_Y + HEIGHT)
|
||||
.rev()
|
||||
.find(|&y| !is_air(c.get(x, y, z)))
|
||||
.map_or(MIN_Y, |y| y + 1)
|
||||
};
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
assert_eq!(chunk.world_surface(5, 5, is_air), MIN_Y);
|
||||
for y in MIN_Y..MIN_Y + 70 {
|
||||
for i in 0..256 {
|
||||
chunk.set(i & 15, y, i >> 4, stone);
|
||||
}
|
||||
}
|
||||
for i in 0..4096 {
|
||||
Arc::make_mut(&mut chunk.sections[8]).set(i, cave_air);
|
||||
}
|
||||
let mut random = horizon_core::random::LegacyRandom::new(9);
|
||||
use horizon_core::random::RandomSource;
|
||||
for step in 0..4000 {
|
||||
let x = random.next_int_bound(16).unwrap();
|
||||
let z = random.next_int_bound(16).unwrap();
|
||||
let y = MIN_Y + 40 + random.next_int_bound(120).unwrap();
|
||||
let v = [air, cave_air, stone][random.next_int_bound(3).unwrap() as usize];
|
||||
chunk.set(x, y, z, v);
|
||||
if step % 7 == 0 {
|
||||
for i in 0..256 {
|
||||
assert_eq!(
|
||||
chunk.world_surface(i & 15, i >> 4, is_air),
|
||||
scan(&chunk, i & 15, i >> 4)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Out-of-range writes and unchanged states keep the map.
|
||||
let before = chunk.world_surface(3, 3, is_air);
|
||||
chunk.set(3, MIN_Y + HEIGHT, 3, stone);
|
||||
chunk.set(3, before - 1, 3, chunk.get(3, before - 1, 3));
|
||||
assert!(chunk.surface.get().is_some());
|
||||
assert_eq!(chunk.world_surface(3, 3, |_| unreachable!()), before);
|
||||
}
|
||||
}
|
||||
@@ -76,7 +76,7 @@ impl<'a> IntoIterator for &'a ChunkMap {
|
||||
}
|
||||
}
|
||||
impl crate::light::LightWorld for ChunkMap {
|
||||
fn state(&self, x: i32, y: i32, z: i32) -> Option<u16> {
|
||||
fn state(&self, x: i32, y: i32, z: i32) -> Option<u32> {
|
||||
self.columns.state(x, y, z)
|
||||
}
|
||||
fn light(&self, x: i32, y: i32, z: i32) -> Option<u8> {
|
||||
|
||||
@@ -15,24 +15,18 @@ use horizon_world::{
|
||||
entity_obstruction::EntityIdentity,
|
||||
fluid::{FluidKind, FluidState},
|
||||
liquid_collision::LiquidCollisionWorld,
|
||||
nbt::{self, Limits},
|
||||
powder_snow_collision::{PowderSnowEntity, PowderSnowWorld},
|
||||
registry::BlockRegistry,
|
||||
registry::{BlockRegistry, BlockTableFile},
|
||||
world_movement::{
|
||||
CollisionCheckRequest, EntityMovementWorld, MovementRequest, no_world_collision,
|
||||
resolve_world_movement,
|
||||
},
|
||||
};
|
||||
use std::{fs, io, path::Path, sync::Arc};
|
||||
use std::{io, path::Path, sync::Arc};
|
||||
|
||||
pub fn load<'r>(root: &Path, registry: &'r BlockRegistry) -> Result<BlockCollisionRegistry<'r>> {
|
||||
let data = nbt::read_named(
|
||||
&fs::read(root.join("cached-collision.nbt"))?,
|
||||
Limits::tables(),
|
||||
)?
|
||||
.0
|
||||
.tag;
|
||||
let cached = CachedCollisionRegistry::from_nbt(&data, registry)?;
|
||||
let file = BlockTableFile::open(&root.join("cached-collision.nbt"), registry)?;
|
||||
let cached = CachedCollisionRegistry::from_table(file.table("collision")?)?;
|
||||
if !cached.has_canonical_identities() {
|
||||
return Err("scene collisions require canonical shape identities".into());
|
||||
}
|
||||
@@ -137,12 +131,25 @@ impl SceneCollisions<'_, '_> {
|
||||
movement: Vec3,
|
||||
max_step: f32,
|
||||
on_ground: bool,
|
||||
) -> Result<Vec3> {
|
||||
self.resolve_with_context(bounds, movement, max_step, on_ground, self.context(bounds))
|
||||
}
|
||||
/// Resolve movement with the entity's captured collision context. Most
|
||||
/// callers only need the position context; entities such as falling blocks
|
||||
/// also affect contextual block shapes (for example powder snow).
|
||||
pub fn resolve_with_context(
|
||||
&mut self,
|
||||
bounds: Aabb,
|
||||
movement: Vec3,
|
||||
max_step: f32,
|
||||
on_ground: bool,
|
||||
context: CollisionContext<'_>,
|
||||
) -> Result<Vec3> {
|
||||
Ok(resolve_world_movement(
|
||||
self,
|
||||
MovementRequest {
|
||||
entity: EntityIdentity(0),
|
||||
context: self.context(bounds),
|
||||
context,
|
||||
bounds,
|
||||
movement,
|
||||
max_step,
|
||||
|
||||
@@ -312,6 +312,9 @@ pub enum Suggests {
|
||||
/// ScoreboardCommand.suggestTriggers: trigger objectives some of the
|
||||
/// parsed `targets` have no score in, or a locked one.
|
||||
EnableTriggers,
|
||||
/// SuggestionProviders.ASK_SERVER: the server completes the node
|
||||
/// (ClientSuggestionProvider.customSuggestion); nothing locally.
|
||||
AskServer,
|
||||
}
|
||||
impl Node {
|
||||
pub fn name(&self) -> &str {
|
||||
@@ -691,7 +694,7 @@ impl Suggests {
|
||||
Suggests::Triggers => {
|
||||
builder.suggest_matching(reg.trigger_objectives.iter().map(String::as_str))
|
||||
}
|
||||
Suggests::Nothing => {}
|
||||
Suggests::Nothing | Suggests::AskServer => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -955,7 +958,8 @@ impl Dispatcher {
|
||||
cursor: usize,
|
||||
reg: &Registries,
|
||||
) -> (Range, Vec<Suggestion>) {
|
||||
self.suggestions_within(parse, cursor, reg, u8::MAX)
|
||||
let (range, list, _) = self.suggestions_within(parse, cursor, reg, u8::MAX);
|
||||
(range, list)
|
||||
}
|
||||
/// The suggestions the player's chat shows: the client's tree only holds
|
||||
/// the nodes the player may use (Commands.sendCommands prunes by canUse).
|
||||
@@ -965,6 +969,18 @@ impl Dispatcher {
|
||||
cursor: usize,
|
||||
reg: &Registries,
|
||||
) -> (Range, Vec<Suggestion>) {
|
||||
let (range, list, _) = self.suggestions_within(parse, cursor, reg, reg.permission);
|
||||
(range, list)
|
||||
}
|
||||
/// [`Self::client_suggestions`], and whether a node at the cursor asks
|
||||
/// the server (`ask_server`): its completions come with the server's
|
||||
/// reply, to be merged with these.
|
||||
pub fn client_suggestions_asking(
|
||||
&self,
|
||||
parse: &ParseResults,
|
||||
cursor: usize,
|
||||
reg: &Registries,
|
||||
) -> (Range, Vec<Suggestion>, bool) {
|
||||
self.suggestions_within(parse, cursor, reg, reg.permission)
|
||||
}
|
||||
fn suggestions_within(
|
||||
@@ -973,15 +989,17 @@ impl Dispatcher {
|
||||
cursor: usize,
|
||||
reg: &Registries,
|
||||
permission: u8,
|
||||
) -> (Range, Vec<Suggestion>) {
|
||||
) -> (Range, Vec<Suggestion>, bool) {
|
||||
let (parent, start, context) = parse.context.suggestion_context(cursor);
|
||||
let start = start.min(cursor);
|
||||
let truncated = &parse.input[..cursor.min(parse.input.len())];
|
||||
let mut all = Vec::new();
|
||||
let mut ask = false;
|
||||
for &child in &self.nodes[parent].children {
|
||||
if self.nodes[child].permission > permission {
|
||||
continue;
|
||||
}
|
||||
ask |= matches!(self.nodes[child].suggests, Some(Suggests::AskServer));
|
||||
let mut builder = SuggestionsBuilder::new(truncated, start);
|
||||
match &self.nodes[child].kind {
|
||||
NodeKind::Literal(l) => {
|
||||
@@ -997,7 +1015,8 @@ impl Dispatcher {
|
||||
}
|
||||
all.extend(builder.out);
|
||||
}
|
||||
merge_suggestions(truncated, all)
|
||||
let (range, list) = merge_suggestions(truncated, all);
|
||||
(range, list, ask)
|
||||
}
|
||||
fn smart_usage_of(&self, node: usize, optional: bool, deep: bool, level: u8) -> Option<String> {
|
||||
let n = &self.nodes[node];
|
||||
|
||||
@@ -83,10 +83,9 @@ pub fn from_packet(packet: &Commands, local: &Dispatcher) -> Dispatcher {
|
||||
}
|
||||
_ => NodeKind::Root,
|
||||
};
|
||||
let suggests = match local_node {
|
||||
Some(l) => l.suggests.clone(),
|
||||
None => node.suggestions.as_deref().and_then(provider),
|
||||
};
|
||||
// The server's providers, as the client knows them
|
||||
// (ArgumentNodeStub.build): its own custom ones arrive as `ask_server`.
|
||||
let suggests = node.suggestions.as_deref().and_then(provider);
|
||||
nodes[index(i)] = Some(Node {
|
||||
kind: if i == root { NodeKind::Root } else { kind },
|
||||
children: node.children.iter().filter_map(valid).map(index).collect(),
|
||||
@@ -104,10 +103,10 @@ pub fn from_packet(packet: &Commands, local: &Dispatcher) -> Dispatcher {
|
||||
d
|
||||
}
|
||||
|
||||
/// SuggestionProviders by id (`ask_server` asks the server; the client's
|
||||
/// own argument suggestions stand in for it).
|
||||
/// SuggestionProviders by id (`SuggestionProviders.getProvider`).
|
||||
fn provider(id: &str) -> Option<Suggests> {
|
||||
match id {
|
||||
"minecraft:ask_server" => Some(Suggests::AskServer),
|
||||
"minecraft:summonable_entities" => Some(Suggests::SummonableEntities),
|
||||
_ => None,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
//! `companions/<uuid>.json` in the world folder: who a companion is. Its body
|
||||
//! (inventory, position, health) is saved like a guest's player data,
|
||||
//! `players/data/<uuid>.dat` (lan/playerdata.rs).
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The file's format version.
|
||||
pub const VERSION: u32 = 1;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct CompanionFile {
|
||||
pub version: u32,
|
||||
/// The body's profile UUID (`8-4-4-4-12` hex).
|
||||
pub uuid: String,
|
||||
pub name: String,
|
||||
/// The personality preset (docs/companions.md: veteran, coach, expert,
|
||||
/// joker, quiet, custom).
|
||||
pub personality: String,
|
||||
/// Its role (`prompt::Role::id`: assistant, teammate, speedrunner, bodyguard, coach), empty for
|
||||
/// the Companion settings' role; files from before roles say `level` (helper, super_strong,
|
||||
/// partner), read the same way.
|
||||
#[serde(alias = "level")]
|
||||
pub role: String,
|
||||
/// How much of the world it may know (`prompt::Perception::id`: fair, sharp, scout,
|
||||
/// all_knowing), empty for the settings'.
|
||||
pub perception: String,
|
||||
/// The owner's profile UUID.
|
||||
pub owner: String,
|
||||
/// The brain's rolling memory (slice 3 fills it; "forget everything" clears it).
|
||||
pub notes: Vec<String>,
|
||||
/// It was in the world when the world was saved: it comes back with it.
|
||||
pub active: bool,
|
||||
/// What it was doing (follow, stay, defend or idle), where it stood
|
||||
/// guard, and whether it held its fire: a reloaded world resumes them.
|
||||
pub skill: String,
|
||||
pub post: Option<[f64; 3]>,
|
||||
pub hold_fire: bool,
|
||||
/// The work it had on (collect, give, craft, smelt), as command words
|
||||
/// with what is left to do: a reloaded world takes it up again.
|
||||
pub task: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for CompanionFile {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version: VERSION,
|
||||
uuid: String::new(),
|
||||
name: String::new(),
|
||||
personality: "coach".into(),
|
||||
role: String::new(),
|
||||
perception: String::new(),
|
||||
owner: String::new(),
|
||||
notes: Vec::new(),
|
||||
active: true,
|
||||
skill: "follow".into(),
|
||||
post: None,
|
||||
hold_fire: false,
|
||||
task: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The companions folder of a world.
|
||||
pub fn dir(world: &Path) -> PathBuf {
|
||||
world.join("companions")
|
||||
}
|
||||
|
||||
impl CompanionFile {
|
||||
pub fn path(&self, world: &Path) -> PathBuf {
|
||||
dir(world).join(format!("{}.json", self.uuid))
|
||||
}
|
||||
/// The body's UUID as a number, if the file's is well formed.
|
||||
pub fn uuid(&self) -> Option<u128> {
|
||||
parse_uuid(&self.uuid)
|
||||
}
|
||||
pub fn save(&self, world: &Path) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(dir(world))?;
|
||||
let text = serde_json::to_string_pretty(self).map_err(std::io::Error::other)?;
|
||||
// Written beside, then renamed: a crash never leaves half a file.
|
||||
let path = self.path(world);
|
||||
let partial = path.with_extension("json.tmp");
|
||||
std::fs::write(&partial, text)?;
|
||||
std::fs::rename(partial, path)
|
||||
}
|
||||
/// Every companion of a world, by name.
|
||||
pub fn load_all(world: &Path) -> Vec<Self> {
|
||||
let Ok(entries) = std::fs::read_dir(dir(world)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut all: Vec<Self> = entries
|
||||
.flatten()
|
||||
.filter(|e| e.path().extension().is_some_and(|x| x == "json"))
|
||||
.filter_map(|e| std::fs::read_to_string(e.path()).ok())
|
||||
.filter_map(|t| serde_json::from_str::<Self>(&t).ok())
|
||||
.filter(|f| f.uuid().is_some())
|
||||
.collect();
|
||||
all.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
all
|
||||
}
|
||||
}
|
||||
|
||||
/// A UUID in its `8-4-4-4-12` hex form.
|
||||
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()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn files_round_trip_in_the_world_folder() {
|
||||
let world = std::env::temp_dir().join(format!("horizon-companion-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&world);
|
||||
let uuid = 0x1234_5678_9abc_4def_8123_4567_89ab_cdefu128;
|
||||
let file = CompanionFile {
|
||||
uuid: horizon_protocol::crypto::uuid_string(uuid),
|
||||
name: "Ari".into(),
|
||||
owner: horizon_protocol::crypto::uuid_string(7),
|
||||
notes: vec!["likes oak".into()],
|
||||
task: Some("collect 3 oak_log".into()),
|
||||
..Default::default()
|
||||
};
|
||||
file.save(&world).unwrap();
|
||||
assert!(
|
||||
world
|
||||
.join("companions")
|
||||
.join(format!("{}.json", file.uuid))
|
||||
.is_file()
|
||||
);
|
||||
let all = CompanionFile::load_all(&world);
|
||||
assert_eq!(all, vec![file.clone()]);
|
||||
assert_eq!(all[0].uuid(), Some(uuid));
|
||||
// Fields a later version adds or drops don't break loading.
|
||||
std::fs::write(
|
||||
file.path(&world),
|
||||
r#"{"uuid":"00000000-0000-4000-8000-000000000001","name":"Zed","mood":1}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let all = CompanionFile::load_all(&world);
|
||||
assert_eq!(all[0].name, "Zed");
|
||||
assert_eq!(all[0].role, "");
|
||||
std::fs::write(file.path(&world), r#"{"uuid":"00000000-0000-4000-8000-000000000001","name":"Zed","level":"super_strong"}"#).unwrap();
|
||||
assert_eq!(CompanionFile::load_all(&world)[0].role, "super_strong");
|
||||
assert!(all[0].active);
|
||||
let _ = std::fs::remove_dir_all(&world);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
//! What a companion picks from its inventory: the best weapon, armour piece,
|
||||
//! tool for a block and food, scored from the items' own data components.
|
||||
use crate::items::{ItemStack, Items};
|
||||
|
||||
/// Foods a player would rather not eat: they poison, nauseate or teleport.
|
||||
const BAD_FOOD: [&str; 6] = [
|
||||
"minecraft:rotten_flesh",
|
||||
"minecraft:spider_eye",
|
||||
"minecraft:poisonous_potato",
|
||||
"minecraft:pufferfish",
|
||||
"minecraft:chorus_fruit",
|
||||
"minecraft:suspicious_stew",
|
||||
];
|
||||
|
||||
/// A melee weapon's worth: its attack damage, then its attack speed (the
|
||||
/// modifiers' amounts; a bare hand is 0). None for what isn't a weapon.
|
||||
pub fn weapon_score(items: &Items, stack: &ItemStack) -> Option<f32> {
|
||||
let info = items.get(&stack.item)?;
|
||||
(info.attack_damage > 0.0).then_some(info.attack_damage * 10.0 + info.attack_speed)
|
||||
}
|
||||
|
||||
/// An armour piece's worth and its slot (0 head .. 3 feet): its armour, then
|
||||
/// its toughness and knockback resistance.
|
||||
pub fn armor_score(items: &Items, stack: &ItemStack) -> Option<(usize, f64)> {
|
||||
let info = items.get(&stack.item)?;
|
||||
let slot = match info.equip_slot.as_deref()? {
|
||||
"head" => 0,
|
||||
"chest" => 1,
|
||||
"legs" => 2,
|
||||
"feet" => 3,
|
||||
_ => return None,
|
||||
};
|
||||
// An elytra is worn for gliding, not chosen over a chestplate.
|
||||
if info.glider {
|
||||
return None;
|
||||
}
|
||||
let amount = |attribute: &str| {
|
||||
info.modifiers
|
||||
.iter()
|
||||
.filter(|m| m.0 == attribute)
|
||||
.map(|m| m.2)
|
||||
.sum::<f64>()
|
||||
};
|
||||
let score = amount("minecraft:armor") * 100.0
|
||||
+ amount("minecraft:armor_toughness") * 10.0
|
||||
+ amount("minecraft:knockback_resistance");
|
||||
// Curse of binding sticks: never put it on by choice.
|
||||
(score > 0.0 && stack.enchantment_level("minecraft:binding_curse") == 0)
|
||||
.then_some((slot, score))
|
||||
}
|
||||
|
||||
/// A food's worth when hungry: nutrition then saturation; None for what isn't
|
||||
/// food or is a food players avoid.
|
||||
pub fn food_score(items: &Items, stack: &ItemStack) -> Option<f32> {
|
||||
if BAD_FOOD.contains(&&*stack.item) {
|
||||
return None;
|
||||
}
|
||||
let (nutrition, saturation) = items.get(&stack.item)?.food?;
|
||||
Some(nutrition as f32 * 10.0 + saturation)
|
||||
}
|
||||
|
||||
/// The best of `stacks` by `score`, as an index.
|
||||
pub fn best<T: PartialOrd + Copy>(
|
||||
stacks: &[Option<ItemStack>],
|
||||
mut score: impl FnMut(&ItemStack) -> Option<T>,
|
||||
) -> Option<(usize, T)> {
|
||||
let mut best: Option<(usize, T)> = None;
|
||||
for (i, s) in stacks.iter().enumerate() {
|
||||
let Some(v) = s.as_ref().and_then(&mut score) else {
|
||||
continue;
|
||||
};
|
||||
if best.is_none_or(|(_, b)| v > b) {
|
||||
best = Some((i, v));
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// The tools a companion knows to ask for, cheapest first within each kind.
|
||||
pub const TOOLS: [&str; 25] = [
|
||||
"minecraft:wooden_pickaxe",
|
||||
"minecraft:stone_pickaxe",
|
||||
"minecraft:copper_pickaxe",
|
||||
"minecraft:iron_pickaxe",
|
||||
"minecraft:diamond_pickaxe",
|
||||
"minecraft:netherite_pickaxe",
|
||||
"minecraft:wooden_shovel",
|
||||
"minecraft:stone_shovel",
|
||||
"minecraft:copper_shovel",
|
||||
"minecraft:iron_shovel",
|
||||
"minecraft:diamond_shovel",
|
||||
"minecraft:netherite_shovel",
|
||||
"minecraft:wooden_axe",
|
||||
"minecraft:stone_axe",
|
||||
"minecraft:copper_axe",
|
||||
"minecraft:iron_axe",
|
||||
"minecraft:diamond_axe",
|
||||
"minecraft:netherite_axe",
|
||||
"minecraft:wooden_hoe",
|
||||
"minecraft:stone_hoe",
|
||||
"minecraft:copper_hoe",
|
||||
"minecraft:iron_hoe",
|
||||
"minecraft:diamond_hoe",
|
||||
"minecraft:netherite_hoe",
|
||||
"minecraft:shears",
|
||||
];
|
||||
|
||||
/// How well a stack digs `block` (`Tool.getMiningSpeed`), and whether the
|
||||
/// block drops with it (`Tool.isCorrectForDrops`, or the block needs no tool).
|
||||
pub fn dig_score(
|
||||
items: &Items,
|
||||
stack: Option<&ItemStack>,
|
||||
block: &str,
|
||||
requires_tool: bool,
|
||||
) -> (bool, f32) {
|
||||
let speed = items.mining_speed(stack, block);
|
||||
let drops = !requires_tool || items.correct_for_drops(stack, block);
|
||||
(drops, speed)
|
||||
}
|
||||
|
||||
/// The best stack of `stacks` to dig `block` with: one it drops with first,
|
||||
/// then the fastest; a slot with no tool (or an empty hand) when nothing
|
||||
/// beats the hand, so that a sword or a pickaxe isn't worn for nothing. None
|
||||
/// when the hand is as good and no slot is free of tools.
|
||||
pub fn best_tool(
|
||||
items: &Items,
|
||||
stacks: &[Option<ItemStack>],
|
||||
block: &str,
|
||||
requires_tool: bool,
|
||||
) -> Option<usize> {
|
||||
let hand = dig_score(items, None, block, requires_tool);
|
||||
let mut best: Option<(usize, (bool, f32))> = None;
|
||||
for (i, s) in stacks.iter().enumerate() {
|
||||
let Some(s) = s else { continue };
|
||||
let score = dig_score(items, Some(s), block, requires_tool);
|
||||
let better = |a: (bool, f32), b: (bool, f32)| a.0 && !b.0 || a.0 == b.0 && a.1 > b.1;
|
||||
if better(score, hand) && best.is_none_or(|(_, b)| better(score, b)) {
|
||||
best = Some((i, score));
|
||||
}
|
||||
}
|
||||
best.map(|(i, _)| i).or_else(|| {
|
||||
// Bare hands: an empty slot, else something that takes no wear.
|
||||
stacks.iter().take(9).position(Option::is_none).or_else(|| {
|
||||
stacks.iter().take(9).position(|s| {
|
||||
s.as_ref()
|
||||
.is_some_and(|s| items.get(&s.item).is_none_or(|i| i.max_damage == 0))
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The cheapest tool that makes `block` drop (`needs a stone pickaxe`).
|
||||
pub fn tool_needed(items: &Items, block: &str) -> Option<&'static str> {
|
||||
TOOLS
|
||||
.into_iter()
|
||||
.find(|t| items.correct_for_drops(Some(&ItemStack::new(t, 1)), block))
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
//! Companions (docs/companions.md): an AI teammate with a player's body. This
|
||||
//! is the part without the game: what the brain and the reflexes can tell the
|
||||
//! body to do ([`CompanionCommand`]), what the body tells them back
|
||||
//! ([`CompanionReport`]), the rules its skills decide by, its file in the
|
||||
//! world folder and the compact state text the brain reads
|
||||
//! ([`summary::state_summary`]). The body itself, a LAN guest with no
|
||||
//! connection, lives in the game (game/companion.rs).
|
||||
pub mod file;
|
||||
pub mod gear;
|
||||
pub mod summary;
|
||||
pub mod work;
|
||||
|
||||
use crate::text::{self, Text};
|
||||
|
||||
/// What the brain or the reflexes ask of the companion. Commands queue and
|
||||
/// apply at the start of its next server tick, in order.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum CompanionCommand {
|
||||
/// Keep 3 to 6 blocks from the owner; sprint to catch up, teleport past 48.
|
||||
Follow,
|
||||
/// Hold this spot and fight hostiles that come within 12 blocks.
|
||||
Stay,
|
||||
/// Come to the owner, then wait there.
|
||||
Come,
|
||||
/// Walk to a block, or to what the owner looks at.
|
||||
GoTo(GoTarget),
|
||||
/// Drop the current skill and stand still (no fighting until told).
|
||||
Stop,
|
||||
/// Fight an entity by id, or the nearest hostile.
|
||||
Attack(AttackTarget),
|
||||
/// Follow and fight whatever threatens the owner, even before it strikes.
|
||||
Defend,
|
||||
/// Hold fire (true) blocks every attack, self-defence included, until
|
||||
/// "fire" (false).
|
||||
HoldFire(bool),
|
||||
Jump,
|
||||
Crouch(bool),
|
||||
/// Eat the best food it carries, whatever its hunger.
|
||||
Eat,
|
||||
/// Get `count` more of an item, an item tag or a block: mined in the
|
||||
/// loaded chunks within 48 blocks, picked up, or taken from mobs.
|
||||
Collect {
|
||||
what: Want,
|
||||
count: u32,
|
||||
},
|
||||
/// Throw `count` of an item to the owner.
|
||||
Give {
|
||||
what: Want,
|
||||
count: u32,
|
||||
},
|
||||
/// Craft `count` of an item, in its 2x2 grid or at a crafting table;
|
||||
/// `gather` ("make") collects the basic materials it lacks first.
|
||||
Craft {
|
||||
item: String,
|
||||
count: u32,
|
||||
gather: bool,
|
||||
},
|
||||
/// Smelt `count` of an item in a furnace (the input, or the result wanted).
|
||||
Smelt {
|
||||
item: String,
|
||||
count: u32,
|
||||
},
|
||||
/// Sleep in a bed within 16 blocks (it also does when its owner sleeps).
|
||||
Sleep,
|
||||
}
|
||||
|
||||
/// What [`CompanionCommand::Collect`] and [`CompanionCommand::Give`] are
|
||||
/// about: an item (`minecraft:oak_log`), an item tag (`minecraft:logs`) or a
|
||||
/// block mined for its own sake, whatever it drops.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Want {
|
||||
Item(String),
|
||||
Tag(String),
|
||||
Block(String),
|
||||
}
|
||||
|
||||
impl Want {
|
||||
/// `oak_log`, `#logs`, `block:stone` as an item, tag or block id.
|
||||
pub fn parse(word: &str) -> Self {
|
||||
if let Some(tag) = word.strip_prefix('#') {
|
||||
Want::Tag(namespaced(tag))
|
||||
} else if let Some(block) = word.strip_prefix("block:") {
|
||||
Want::Block(namespaced(block))
|
||||
} else {
|
||||
Want::Item(namespaced(word))
|
||||
}
|
||||
}
|
||||
/// The id without `minecraft:` (tags with `#`, blocks with `block:`).
|
||||
pub fn words(&self) -> String {
|
||||
match self {
|
||||
Want::Item(i) => short_id(i).to_owned(),
|
||||
Want::Tag(t) => format!("#{}", short_id(t)),
|
||||
Want::Block(b) => format!("block:{}", short_id(b)),
|
||||
}
|
||||
}
|
||||
pub fn id(&self) -> &str {
|
||||
match self {
|
||||
Want::Item(i) | Want::Tag(i) | Want::Block(i) => i,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `oak_log` as `minecraft:oak_log`.
|
||||
pub fn namespaced(id: &str) -> String {
|
||||
let id = id.to_ascii_lowercase();
|
||||
if id.contains(':') {
|
||||
id
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
}
|
||||
}
|
||||
|
||||
impl CompanionCommand {
|
||||
/// The words [`parse_command`] reads back: a work skill is saved this way
|
||||
/// with the world (its count is what is left to do).
|
||||
pub fn words(&self) -> Option<String> {
|
||||
Some(match self {
|
||||
Self::Collect { what, count } => format!("collect {count} {}", what.words()),
|
||||
Self::Give { what, count } => format!("give {count} {}", what.words()),
|
||||
Self::Craft {
|
||||
item,
|
||||
count,
|
||||
gather,
|
||||
} => {
|
||||
format!(
|
||||
"{} {count} {}",
|
||||
if *gather { "make" } else { "craft" },
|
||||
short_id(item)
|
||||
)
|
||||
}
|
||||
Self::Smelt { item, count } => format!("smelt {count} {}", short_id(item)),
|
||||
Self::Sleep => "sleep".into(),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Where [`CompanionCommand::GoTo`] goes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum GoTarget {
|
||||
Block([i32; 3]),
|
||||
/// The block the owner's crosshair hits, up to [`CROSSHAIR_RANGE`] away.
|
||||
Crosshair,
|
||||
}
|
||||
|
||||
/// Whom [`CompanionCommand::Attack`] fights.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum AttackTarget {
|
||||
Entity(u32),
|
||||
NearestHostile,
|
||||
}
|
||||
|
||||
/// The skill the body runs: one at a time, each a small state machine ticked
|
||||
/// on the server (game/companion_body.rs).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Skill {
|
||||
Idle,
|
||||
Follow,
|
||||
Stay,
|
||||
Come,
|
||||
GoTo,
|
||||
Attack,
|
||||
Defend,
|
||||
Retreat,
|
||||
Collect,
|
||||
Give,
|
||||
Craft,
|
||||
Smelt,
|
||||
Sleep,
|
||||
}
|
||||
|
||||
impl Skill {
|
||||
pub fn from_name(name: &str) -> Option<Self> {
|
||||
[
|
||||
Skill::Idle,
|
||||
Skill::Follow,
|
||||
Skill::Stay,
|
||||
Skill::Come,
|
||||
Skill::GoTo,
|
||||
Skill::Attack,
|
||||
Skill::Defend,
|
||||
Skill::Retreat,
|
||||
Skill::Collect,
|
||||
Skill::Give,
|
||||
Skill::Craft,
|
||||
Skill::Smelt,
|
||||
Skill::Sleep,
|
||||
]
|
||||
.into_iter()
|
||||
.find(|s| s.name() == name)
|
||||
}
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Skill::Idle => "idle",
|
||||
Skill::Follow => "follow",
|
||||
Skill::Stay => "stay",
|
||||
Skill::Come => "come",
|
||||
Skill::GoTo => "go to",
|
||||
Skill::Attack => "attack",
|
||||
Skill::Defend => "defend",
|
||||
Skill::Retreat => "retreat",
|
||||
Skill::Collect => "collect",
|
||||
Skill::Give => "give",
|
||||
Skill::Craft => "craft",
|
||||
Skill::Smelt => "smelt",
|
||||
Skill::Sleep => "sleep",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the body tells the brain, in the order it happened.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum CompanionReport {
|
||||
SkillStarted(Skill),
|
||||
SkillFinished(Skill),
|
||||
SkillFailed {
|
||||
skill: Skill,
|
||||
reason: String,
|
||||
},
|
||||
/// It lost health; `by` names the entity type that hit it, when one did.
|
||||
Hurt {
|
||||
amount: f32,
|
||||
by: Option<String>,
|
||||
},
|
||||
Died {
|
||||
cause: String,
|
||||
},
|
||||
Respawned,
|
||||
/// A hostile came into view: its type, the clock bearing from the
|
||||
/// owner's facing (12 ahead, 3 right) and its distance from the owner.
|
||||
SawHostile {
|
||||
kind: String,
|
||||
bearing: u8,
|
||||
distance: f32,
|
||||
},
|
||||
/// It was too far behind and teleported beside the owner.
|
||||
Teleported,
|
||||
Ate {
|
||||
item: String,
|
||||
},
|
||||
Equipped {
|
||||
item: String,
|
||||
},
|
||||
PickedUp {
|
||||
item: String,
|
||||
count: u32,
|
||||
},
|
||||
/// It came along when the owner changed dimension, or stayed behind.
|
||||
ChangedDimension {
|
||||
dimension: String,
|
||||
},
|
||||
StayedBehind {
|
||||
dimension: String,
|
||||
},
|
||||
/// A work skill moved on: `done` of `total` items collected, given,
|
||||
/// crafted or smelted.
|
||||
Progress {
|
||||
skill: Skill,
|
||||
item: String,
|
||||
done: u32,
|
||||
total: u32,
|
||||
},
|
||||
}
|
||||
|
||||
impl CompanionReport {
|
||||
/// One short line, for the state summary's recent events and the logs.
|
||||
pub fn text(&self) -> String {
|
||||
match self {
|
||||
Self::SkillStarted(s) => format!("started {}", s.name()),
|
||||
Self::SkillFinished(s) => format!("finished {}", s.name()),
|
||||
Self::SkillFailed { skill, reason } => format!("{} failed: {reason}", skill.name()),
|
||||
Self::Hurt { amount, by } => match by {
|
||||
Some(by) => format!("hurt {amount:.0} by {}", short_id(by)),
|
||||
None => format!("hurt {amount:.0}"),
|
||||
},
|
||||
Self::Died { cause } => format!("died ({cause})"),
|
||||
Self::Respawned => "respawned".into(),
|
||||
Self::SawHostile {
|
||||
kind,
|
||||
bearing,
|
||||
distance,
|
||||
} => format!("{} at {bearing} o'clock {distance:.0}m", short_id(kind)),
|
||||
Self::Teleported => "teleported to owner".into(),
|
||||
Self::Ate { item } => format!("ate {}", short_id(item)),
|
||||
Self::Equipped { item } => format!("equipped {}", short_id(item)),
|
||||
Self::PickedUp { item, count } => format!("picked up {count} {}", short_id(item)),
|
||||
Self::ChangedDimension { dimension } => {
|
||||
format!("came along to {}", short_id(dimension))
|
||||
}
|
||||
Self::StayedBehind { dimension } => format!("stayed in {}", short_id(dimension)),
|
||||
Self::Progress {
|
||||
skill,
|
||||
item,
|
||||
done,
|
||||
total,
|
||||
} => format!("{} {done}/{total} {}", skill.name(), short_id(item)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A command in words, as the dev command and the control socket take it:
|
||||
/// `follow`, `stay` (`guard`), `come`, `goto <x> <y> <z>`, `goto there`
|
||||
/// (the owner's crosshair), `stop`, `attack [<entity id>|nearest]`,
|
||||
/// `defend`, `hold` / `fire` (`holdfire on|off`), `jump`, `crouch [on|off]`,
|
||||
/// `eat`; the work skills `collect|get|mine <n> <item>` (`#tag`,
|
||||
/// `block:<id>`), `give [me] <n> <item>`, `craft <n> <item>`, `make <n>
|
||||
/// <item>` (gathering what it lacks), `smelt <n> <item>` and `sleep`. Item
|
||||
/// names may be words (`oak log`) and the count may come after them.
|
||||
pub fn parse_command(words: &[&str]) -> Result<CompanionCommand, String> {
|
||||
let on_off = |w: Option<&&str>, default: bool| match w.copied() {
|
||||
None => Ok(default),
|
||||
Some("on" | "true" | "1") => Ok(true),
|
||||
Some("off" | "false" | "0") => Ok(false),
|
||||
Some(other) => Err(format!("on or off, not {other}")),
|
||||
};
|
||||
let Some(first) = words.first() else {
|
||||
return Err("which command?".into());
|
||||
};
|
||||
Ok(match first.to_ascii_lowercase().as_str() {
|
||||
"follow" => CompanionCommand::Follow,
|
||||
"stay" | "guard" | "wait" => CompanionCommand::Stay,
|
||||
"come" => CompanionCommand::Come,
|
||||
"goto" | "go" => match &words[1..] {
|
||||
[] | ["there"] | ["crosshair"] => CompanionCommand::GoTo(GoTarget::Crosshair),
|
||||
[x, y, z] => {
|
||||
let n = |v: &str| {
|
||||
v.parse::<i32>()
|
||||
.map_err(|_| format!("not a block coordinate: {v}"))
|
||||
};
|
||||
CompanionCommand::GoTo(GoTarget::Block([n(x)?, n(y)?, n(z)?]))
|
||||
}
|
||||
_ => return Err("goto <x> <y> <z>, or goto there".into()),
|
||||
},
|
||||
"stop" => CompanionCommand::Stop,
|
||||
"attack" | "fight" => match words.get(1).copied() {
|
||||
None | Some("nearest") => CompanionCommand::Attack(AttackTarget::NearestHostile),
|
||||
Some(id) => CompanionCommand::Attack(AttackTarget::Entity(
|
||||
id.parse().map_err(|_| format!("not an entity id: {id}"))?,
|
||||
)),
|
||||
},
|
||||
"defend" | "protect" => CompanionCommand::Defend,
|
||||
"hold" => CompanionCommand::HoldFire(true),
|
||||
"fire" => CompanionCommand::HoldFire(false),
|
||||
"holdfire" => CompanionCommand::HoldFire(on_off(words.get(1), true)?),
|
||||
"jump" => CompanionCommand::Jump,
|
||||
"crouch" | "sneak" => CompanionCommand::Crouch(on_off(words.get(1), true)?),
|
||||
"uncrouch" | "stand" => CompanionCommand::Crouch(false),
|
||||
"eat" => CompanionCommand::Eat,
|
||||
"collect" | "get" | "mine" | "gather" | "chop" => {
|
||||
let (count, item) = count_and_item(&words[1..])?;
|
||||
CompanionCommand::Collect {
|
||||
what: Want::parse(&item),
|
||||
count,
|
||||
}
|
||||
}
|
||||
"give" | "toss" | "throw" => {
|
||||
let rest = match words.get(1) {
|
||||
Some(w) if w.eq_ignore_ascii_case("me") => &words[2..],
|
||||
_ => &words[1..],
|
||||
};
|
||||
let (count, item) = count_and_item(rest)?;
|
||||
CompanionCommand::Give {
|
||||
what: Want::parse(&item),
|
||||
count,
|
||||
}
|
||||
}
|
||||
verb @ ("craft" | "make") => {
|
||||
let (count, item) = count_and_item(&words[1..])?;
|
||||
CompanionCommand::Craft {
|
||||
item: namespaced(&item),
|
||||
count,
|
||||
gather: verb == "make",
|
||||
}
|
||||
}
|
||||
"smelt" | "cook" => {
|
||||
let (count, item) = count_and_item(&words[1..])?;
|
||||
CompanionCommand::Smelt {
|
||||
item: namespaced(&item),
|
||||
count,
|
||||
}
|
||||
}
|
||||
"sleep" | "bed" => CompanionCommand::Sleep,
|
||||
other => return Err(format!("unknown companion command: {other}")),
|
||||
})
|
||||
}
|
||||
|
||||
/// A count (1 when none is given; `a` and `an` are 1) and an item named by
|
||||
/// the other words, joined with `_`: `3 oak log`, `oak_log 3`, `a wooden pickaxe`.
|
||||
fn count_and_item(words: &[&str]) -> Result<(u32, String), String> {
|
||||
let mut count = None;
|
||||
let mut name: Vec<String> = Vec::new();
|
||||
for w in words {
|
||||
match w.parse::<u32>() {
|
||||
Ok(n) if count.is_none() => count = Some(n),
|
||||
_ if name.is_empty()
|
||||
&& matches!(w.to_ascii_lowercase().as_str(), "a" | "an" | "some" | "the") => {}
|
||||
_ => name.push(w.to_ascii_lowercase()),
|
||||
}
|
||||
}
|
||||
let count = count.unwrap_or(1);
|
||||
if name.is_empty() {
|
||||
return Err("which item?".into());
|
||||
}
|
||||
if count == 0 || count > 64 * 36 {
|
||||
return Err(format!("a count from 1 to {}, not {count}", 64 * 36));
|
||||
}
|
||||
Ok((count, name.join("_")))
|
||||
}
|
||||
|
||||
/// `minecraft:zombie` as `zombie`.
|
||||
pub fn short_id(id: &str) -> &str {
|
||||
id.strip_prefix("minecraft:").unwrap_or(id)
|
||||
}
|
||||
|
||||
// ---- the rules the skills follow ------------------------------------------
|
||||
|
||||
/// Follow keeps between these distances from the owner.
|
||||
pub const FOLLOW_NEAR: f64 = 3.0;
|
||||
pub const FOLLOW_FAR: f64 = 6.0;
|
||||
/// Past this it sprints to catch up.
|
||||
pub const FOLLOW_SPRINT: f64 = 10.0;
|
||||
/// Past this it teleports beside the owner (tamed animals do it at 12; the
|
||||
/// companion walks much further first, as a player would).
|
||||
pub const FOLLOW_TELEPORT: f64 = 48.0;
|
||||
/// Stay / guard fights hostiles within this radius of its post.
|
||||
pub const GUARD_RADIUS: f64 = 12.0;
|
||||
/// The owner's crosshair ray for "go there".
|
||||
pub const CROSSHAIR_RANGE: f64 = 128.0;
|
||||
/// Hostiles this close to the owner or the companion are noticed.
|
||||
pub const NOTICE_RADIUS: f64 = 16.0;
|
||||
/// It eats below this food level (players can't sprint at 6 and stop healing below 18).
|
||||
pub const EAT_BELOW: i32 = 14;
|
||||
/// It backs off below this health, and is brave again above `BRAVE_AGAIN`.
|
||||
pub const RETREAT_BELOW: f32 = 6.0;
|
||||
pub const BRAVE_AGAIN: f32 = 12.0;
|
||||
/// Its body respawns this many ticks after it died (10 s).
|
||||
pub const RESPAWN_TICKS: u32 = 200;
|
||||
/// It comes along through a dimension change within this distance of the owner.
|
||||
pub const COME_ALONG: f64 = 16.0;
|
||||
|
||||
/// What Follow does at a distance from the owner.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FollowStep {
|
||||
/// Close enough: stand (or wander to pick up items).
|
||||
Hold,
|
||||
Walk,
|
||||
Sprint,
|
||||
Teleport,
|
||||
}
|
||||
|
||||
/// Follow's choice at `distance` blocks, `moving` when it is already on its
|
||||
/// way: it starts walking past [`FOLLOW_FAR`] and keeps going until within
|
||||
/// [`FOLLOW_NEAR`], so it doesn't stop and start at one distance.
|
||||
pub fn follow_step(distance: f64, moving: bool) -> FollowStep {
|
||||
if distance > FOLLOW_TELEPORT {
|
||||
FollowStep::Teleport
|
||||
} else if distance > FOLLOW_SPRINT {
|
||||
FollowStep::Sprint
|
||||
} else if distance > FOLLOW_FAR || moving && distance > FOLLOW_NEAR {
|
||||
FollowStep::Walk
|
||||
} else {
|
||||
FollowStep::Hold
|
||||
}
|
||||
}
|
||||
|
||||
/// Mth.wrapDegrees.
|
||||
pub fn wrap_degrees(degrees: f64) -> f64 {
|
||||
let d = degrees % 360.0;
|
||||
if d >= 180.0 {
|
||||
d - 360.0
|
||||
} else if d < -180.0 {
|
||||
d + 360.0
|
||||
} else {
|
||||
d
|
||||
}
|
||||
}
|
||||
|
||||
/// The yaw (Minecraft's: 0 south, 90 west) from `from` toward `to`.
|
||||
pub fn yaw_toward(from: [f64; 3], to: [f64; 3]) -> f64 {
|
||||
(-(to[0] - from[0])).atan2(to[2] - from[2]).to_degrees()
|
||||
}
|
||||
|
||||
/// The clock bearing of `to` seen from `from` facing `yaw`: 12 straight
|
||||
/// ahead, 3 to the right, 6 behind, 9 to the left.
|
||||
pub fn clock_bearing(from: [f64; 3], yaw: f32, to: [f64; 3]) -> u8 {
|
||||
let relative = wrap_degrees(yaw_toward(from, to) - f64::from(yaw));
|
||||
// Turning right raises the yaw, so a positive angle is to the right.
|
||||
let hour = (relative / 30.0).round().rem_euclid(12.0) as u8;
|
||||
if hour == 0 { 12 } else { hour }
|
||||
}
|
||||
|
||||
/// The horizontal distance between two points.
|
||||
pub fn horizontal(a: [f64; 3], b: [f64; 3]) -> f64 {
|
||||
((a[0] - b[0]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
|
||||
}
|
||||
|
||||
/// The distance between two points.
|
||||
pub fn distance(a: [f64; 3], b: [f64; 3]) -> f64 {
|
||||
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
|
||||
}
|
||||
|
||||
// ---- looks ----------------------------------------------------------------
|
||||
|
||||
/// The name tag and player list name: the name, then a small aqua AI marker.
|
||||
pub fn name_text(name: &str) -> Text {
|
||||
let mut t = Text::literal(name);
|
||||
t.push(marker());
|
||||
t
|
||||
}
|
||||
|
||||
/// The marker after a companion's name: ` [AI]`, the brackets in aqua.
|
||||
pub fn marker() -> Text {
|
||||
let mut t = Text::literal(" ");
|
||||
t.push(Text::literal("[AI]").with_color(text::AQUA));
|
||||
t
|
||||
}
|
||||
|
||||
/// [`name_text`] as the player list's display name for the guests.
|
||||
pub fn listed_name(name: &str) -> horizon_world::nbt::Tag {
|
||||
crate::lan::data::component_nbt(&name_text(name).component())
|
||||
}
|
||||
|
||||
/// The default skins a companion can wear, as vanilla picks them for a
|
||||
/// profile without a skin (`DefaultPlayerSkin.get(UUID)`): wearing the one its
|
||||
/// UUID gives, it looks the same to vanilla clients.
|
||||
pub const SKINS: [&str; 9] = [
|
||||
"alex", "ari", "efe", "kai", "makena", "noor", "steve", "sunny", "zuri",
|
||||
];
|
||||
|
||||
/// A random UUID (version 4) that vanilla's default skin choice maps to
|
||||
/// `skin` (`slim/alex`, `wide/steve`...): its low bits are walked until the
|
||||
/// skin matches, so no skin needs to be sent.
|
||||
pub fn uuid_for_skin(seed: u64, skin: Option<&str>) -> u128 {
|
||||
let mut state = seed | 1;
|
||||
let mut next = || {
|
||||
// SplitMix64.
|
||||
state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut z = state;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
};
|
||||
loop {
|
||||
let raw = u128::from(next()) << 64 | u128::from(next());
|
||||
// Version 4, IETF variant.
|
||||
let uuid = raw & !(0xF << 76) | (0x4 << 76);
|
||||
let uuid = uuid & !(0x3 << 62) | (0x2 << 62);
|
||||
let wanted = skin.is_none_or(|s| {
|
||||
let got = crate::tab_list::default_skin(uuid);
|
||||
got.ends_with(&format!("/{s}")) || got == format!("entity/player/{s}")
|
||||
});
|
||||
if wanted {
|
||||
return uuid;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A profile name for a new companion: 3 to 16 letters, digits or `_`
|
||||
/// (`StringUtil.isValidPlayerName`), from `wanted` or a list of its own.
|
||||
pub fn valid_name(wanted: &str) -> Option<String> {
|
||||
let ok = (3..=16).contains(&wanted.len())
|
||||
&& wanted
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_');
|
||||
ok.then(|| wanted.to_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn clock_bearings_follow_the_facing() {
|
||||
let o = [0.0, 64.0, 0.0];
|
||||
// Facing south (+Z): ahead is 12, west (-X) is on the right.
|
||||
assert_eq!(clock_bearing(o, 0.0, [0.0, 64.0, 10.0]), 12);
|
||||
assert_eq!(clock_bearing(o, 0.0, [-10.0, 64.0, 0.0]), 3);
|
||||
assert_eq!(clock_bearing(o, 0.0, [0.0, 64.0, -10.0]), 6);
|
||||
assert_eq!(clock_bearing(o, 0.0, [10.0, 64.0, 0.0]), 9);
|
||||
// Facing west (yaw 90): north (-Z) is on the right.
|
||||
assert_eq!(clock_bearing(o, 90.0, [0.0, 64.0, -10.0]), 3);
|
||||
assert_eq!(clock_bearing(o, 90.0, [-10.0, 64.0, 10.0]), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_keeps_its_distance_without_dithering() {
|
||||
assert_eq!(follow_step(2.0, false), FollowStep::Hold);
|
||||
assert_eq!(follow_step(5.0, false), FollowStep::Hold);
|
||||
assert_eq!(follow_step(5.0, true), FollowStep::Walk);
|
||||
assert_eq!(follow_step(2.9, true), FollowStep::Hold);
|
||||
assert_eq!(follow_step(7.0, false), FollowStep::Walk);
|
||||
assert_eq!(follow_step(20.0, false), FollowStep::Sprint);
|
||||
assert_eq!(follow_step(49.0, true), FollowStep::Teleport);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_skin_is_chosen_through_the_uuid() {
|
||||
for skin in ["slim/alex", "wide/steve", "wide/zuri"] {
|
||||
let uuid = uuid_for_skin(7, Some(skin));
|
||||
assert!(crate::tab_list::default_skin(uuid).ends_with(skin));
|
||||
assert_eq!((uuid >> 76) & 0xF, 4, "version 4");
|
||||
}
|
||||
assert_ne!(uuid_for_skin(1, None), uuid_for_skin(2, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_parse_from_words() {
|
||||
let p = |line: &str| parse_command(&line.split(' ').collect::<Vec<_>>());
|
||||
assert_eq!(p("follow"), Ok(CompanionCommand::Follow));
|
||||
assert_eq!(p("guard"), Ok(CompanionCommand::Stay));
|
||||
assert_eq!(
|
||||
p("goto 1 64 -3"),
|
||||
Ok(CompanionCommand::GoTo(GoTarget::Block([1, 64, -3])))
|
||||
);
|
||||
assert_eq!(
|
||||
p("goto there"),
|
||||
Ok(CompanionCommand::GoTo(GoTarget::Crosshair))
|
||||
);
|
||||
assert_eq!(
|
||||
p("attack 42"),
|
||||
Ok(CompanionCommand::Attack(AttackTarget::Entity(42)))
|
||||
);
|
||||
assert_eq!(
|
||||
p("attack"),
|
||||
Ok(CompanionCommand::Attack(AttackTarget::NearestHostile))
|
||||
);
|
||||
assert_eq!(p("holdfire off"), Ok(CompanionCommand::HoldFire(false)));
|
||||
assert_eq!(p("crouch"), Ok(CompanionCommand::Crouch(true)));
|
||||
assert_eq!(
|
||||
p("collect 8 oak log"),
|
||||
Ok(CompanionCommand::Collect {
|
||||
what: Want::Item("minecraft:oak_log".into()),
|
||||
count: 8
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
p("get #logs 3"),
|
||||
Ok(CompanionCommand::Collect {
|
||||
what: Want::Tag("minecraft:logs".into()),
|
||||
count: 3
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
p("give me 4 oak_planks"),
|
||||
Ok(CompanionCommand::Give {
|
||||
what: Want::Item("minecraft:oak_planks".into()),
|
||||
count: 4
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
p("make a wooden pickaxe"),
|
||||
Ok(CompanionCommand::Craft {
|
||||
item: "minecraft:wooden_pickaxe".into(),
|
||||
count: 1,
|
||||
gather: true
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
p("smelt 3 raw_iron"),
|
||||
Ok(CompanionCommand::Smelt {
|
||||
item: "minecraft:raw_iron".into(),
|
||||
count: 3
|
||||
})
|
||||
);
|
||||
assert_eq!(p("sleep"), Ok(CompanionCommand::Sleep));
|
||||
assert!(p("collect").is_err());
|
||||
assert!(p("collect 0 dirt").is_err());
|
||||
// A work skill saved as words reads back the same.
|
||||
for line in [
|
||||
"collect 5 block:stone",
|
||||
"give 2 #planks",
|
||||
"make 1 torch",
|
||||
"smelt 3 raw_iron",
|
||||
"sleep",
|
||||
] {
|
||||
let command = p(line).unwrap();
|
||||
assert_eq!(command.words().as_deref(), Some(line));
|
||||
}
|
||||
assert!(p("dance").is_err());
|
||||
assert!(p("goto 1 2").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_are_profile_names() {
|
||||
assert_eq!(valid_name("Kira_2").as_deref(), Some("Kira_2"));
|
||||
assert_eq!(valid_name("ab"), None);
|
||||
assert_eq!(valid_name("no spaces"), None);
|
||||
assert_eq!(valid_name("waytoolongforaprofile"), None);
|
||||
assert_eq!(name_text("Ari").plain(), "Ari [AI]");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
//! The state the brain reads each time it is called: about 250 tokens of
|
||||
//! plain text, the same layout every time so the model learns where to look.
|
||||
//! Bearings are clock hours from the owner's facing ("zombie 2 o'clock 6m"),
|
||||
//! the way a teammate would call them out.
|
||||
use super::short_id;
|
||||
|
||||
/// What the companion knows of the world this tick (game/companion_body.rs
|
||||
/// gathers it).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Perception {
|
||||
pub name: String,
|
||||
pub position: [f64; 3],
|
||||
pub dimension: String,
|
||||
pub biome: String,
|
||||
/// The day time (`World.day_time`).
|
||||
pub day_time: i64,
|
||||
pub raining: bool,
|
||||
pub thundering: bool,
|
||||
pub health: f32,
|
||||
pub food: i32,
|
||||
pub armor: f32,
|
||||
pub held: Option<String>,
|
||||
/// The inventory, summed by item, most first.
|
||||
pub inventory: Vec<(String, u32)>,
|
||||
pub owner: Option<OwnerView>,
|
||||
/// Entities near the owner, nearest first.
|
||||
pub entities: Vec<Nearby>,
|
||||
/// Notable blocks near the owner (ores, chests, water, lava, beds),
|
||||
/// nearest of each kind first.
|
||||
pub blocks: Vec<Nearby>,
|
||||
pub skill: String,
|
||||
pub hold_fire: bool,
|
||||
/// The last few reports, oldest first.
|
||||
pub events: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct OwnerView {
|
||||
pub name: String,
|
||||
pub health: f32,
|
||||
pub food: i32,
|
||||
pub distance: f64,
|
||||
/// The companion's bearing from the owner.
|
||||
pub bearing: u8,
|
||||
/// What the owner's crosshair rests on: a block or an entity, and how far.
|
||||
pub looking_at: Option<(String, f64)>,
|
||||
}
|
||||
|
||||
/// An entity or block, as the owner sees it.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Nearby {
|
||||
pub id: String,
|
||||
pub hostile: bool,
|
||||
pub bearing: u8,
|
||||
pub distance: f64,
|
||||
}
|
||||
|
||||
/// At most this many of each list go in the text.
|
||||
const ENTITIES: usize = 6;
|
||||
const BLOCKS: usize = 5;
|
||||
const ITEMS: usize = 8;
|
||||
const EVENTS: usize = 5;
|
||||
|
||||
/// `ticks` of the day as a clock time and a word (`06:00 morning`).
|
||||
fn time_of_day(day_time: i64) -> String {
|
||||
// Day time 0 is 06:00.
|
||||
let t = day_time.rem_euclid(24_000);
|
||||
let minutes = (t * 60 / 1000 + 6 * 60) % (24 * 60);
|
||||
let word = match t {
|
||||
0..=999 => "morning",
|
||||
1000..=5999 => "day",
|
||||
6000..=11_999 => "afternoon",
|
||||
12_000..=13_799 => "dusk",
|
||||
13_800..=22_199 => "night",
|
||||
_ => "dawn",
|
||||
};
|
||||
format!("{:02}:{:02} {word}", minutes / 60, minutes % 60)
|
||||
}
|
||||
|
||||
fn nearby(list: &[Nearby], max: usize) -> String {
|
||||
let shown: Vec<String> = list
|
||||
.iter()
|
||||
.take(max)
|
||||
.map(|n| {
|
||||
let tag = if n.hostile { "!" } else { "" };
|
||||
format!("{tag}{} {}h {:.0}m", short_id(&n.id), n.bearing, n.distance)
|
||||
})
|
||||
.collect();
|
||||
let more = list.len().saturating_sub(max);
|
||||
match (shown.is_empty(), more) {
|
||||
(true, _) => "none".into(),
|
||||
(false, 0) => shown.join(", "),
|
||||
(false, n) => format!("{} (+{n})", shown.join(", ")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The compact state text (docs/companions.md "What it is given", 3).
|
||||
pub fn state_summary(p: &Perception) -> String {
|
||||
let mut out = String::with_capacity(1024);
|
||||
let [x, y, z] = p.position.map(|v| v.floor() as i64);
|
||||
let weather = if p.thundering {
|
||||
"thunder"
|
||||
} else if p.raining {
|
||||
"rain"
|
||||
} else {
|
||||
"clear"
|
||||
};
|
||||
out.push_str(&format!(
|
||||
"You: {} at {x} {y} {z}, {}, {}, {}, {weather}.\n",
|
||||
p.name,
|
||||
short_id(&p.dimension),
|
||||
short_id(&p.biome),
|
||||
time_of_day(p.day_time)
|
||||
));
|
||||
out.push_str(&format!(
|
||||
"Health {:.0}/20, food {}/20, armour {:.0}, holding {}.\n",
|
||||
p.health,
|
||||
p.food,
|
||||
p.armor,
|
||||
p.held.as_deref().map_or("nothing", short_id)
|
||||
));
|
||||
match &p.owner {
|
||||
Some(o) => {
|
||||
out.push_str(&format!(
|
||||
"Owner {}: health {:.0}/20, food {}/20, you are at {}h {:.0}m.",
|
||||
o.name, o.health, o.food, o.bearing, o.distance
|
||||
));
|
||||
if let Some((what, d)) = &o.looking_at {
|
||||
out.push_str(&format!(" Looking at {} {d:.0}m.", short_id(what)));
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
None => out.push_str("Owner: not here.\n"),
|
||||
}
|
||||
let items: Vec<String> = p
|
||||
.inventory
|
||||
.iter()
|
||||
.take(ITEMS)
|
||||
.map(|(id, n)| format!("{n} {}", short_id(id)))
|
||||
.collect();
|
||||
let more = p.inventory.len().saturating_sub(ITEMS);
|
||||
out.push_str("Inventory: ");
|
||||
if items.is_empty() {
|
||||
out.push_str("empty");
|
||||
} else {
|
||||
out.push_str(&items.join(", "));
|
||||
if more > 0 {
|
||||
out.push_str(&format!(" (+{more} kinds)"));
|
||||
}
|
||||
}
|
||||
out.push_str(".\n");
|
||||
out.push_str(&format!(
|
||||
"Near (from owner's view, ! hostile): {}.\n",
|
||||
nearby(&p.entities, ENTITIES)
|
||||
));
|
||||
out.push_str(&format!("Blocks: {}.\n", nearby(&p.blocks, BLOCKS)));
|
||||
out.push_str(&format!(
|
||||
"Doing: {}{}.\n",
|
||||
p.skill,
|
||||
if p.hold_fire { ", holding fire" } else { "" }
|
||||
));
|
||||
let start = p.events.len().saturating_sub(EVENTS);
|
||||
if p.events.len() > start {
|
||||
out.push_str(&format!("Recent: {}.\n", p.events[start..].join("; ")));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn busy() -> Perception {
|
||||
let near = |id: &str, hostile, bearing, distance| Nearby {
|
||||
id: id.into(),
|
||||
hostile,
|
||||
bearing,
|
||||
distance,
|
||||
};
|
||||
Perception {
|
||||
name: "Ari".into(),
|
||||
position: [12.7, 64.0, -3.2],
|
||||
dimension: "minecraft:overworld".into(),
|
||||
biome: "minecraft:old_growth_birch_forest".into(),
|
||||
day_time: 13_000,
|
||||
raining: true,
|
||||
thundering: false,
|
||||
health: 14.0,
|
||||
food: 17,
|
||||
armor: 7.0,
|
||||
held: Some("minecraft:iron_sword".into()),
|
||||
inventory: (0..12)
|
||||
.map(|i| (format!("minecraft:item_number_{i}"), 64 - i))
|
||||
.collect(),
|
||||
owner: Some(OwnerView {
|
||||
name: "Ludovic111".into(),
|
||||
health: 9.0,
|
||||
food: 12,
|
||||
distance: 4.2,
|
||||
bearing: 5,
|
||||
looking_at: Some(("minecraft:oak_log".into(), 7.9)),
|
||||
}),
|
||||
entities: (0..9)
|
||||
.map(|i| {
|
||||
near(
|
||||
"minecraft:zombie_villager",
|
||||
i % 2 == 0,
|
||||
(i % 12 + 1) as u8,
|
||||
3.0 + f64::from(i),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
blocks: (0..8)
|
||||
.map(|i| {
|
||||
near(
|
||||
"minecraft:deepslate_diamond_ore",
|
||||
false,
|
||||
2,
|
||||
8.0 + f64::from(i),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
skill: "follow".into(),
|
||||
hold_fire: true,
|
||||
events: (0..9).map(|i| format!("hurt {i} by skeleton")).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_summary_stays_compact() {
|
||||
let text = state_summary(&busy());
|
||||
// About 250 tokens: four characters a token is generous for this text.
|
||||
assert!(text.len() < 1000, "{} chars:\n{text}", text.len());
|
||||
assert!(text.contains(
|
||||
"You: Ari at 12 64 -4, overworld, old_growth_birch_forest, 19:00 dusk, rain."
|
||||
));
|
||||
assert!(text.contains(
|
||||
"Owner Ludovic111: health 9/20, food 12/20, you are at 5h 4m. Looking at oak_log 8m."
|
||||
));
|
||||
assert!(text.contains("!zombie_villager 1h 3m"));
|
||||
assert!(text.contains("(+3)"));
|
||||
assert!(text.contains("(+4 kinds)"));
|
||||
assert!(text.contains("Doing: follow, holding fire."));
|
||||
assert!(text.contains("Recent: hurt 4 by skeleton;"));
|
||||
assert!(!text.contains("hurt 3 by"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_state_still_reads() {
|
||||
let text = state_summary(&Perception {
|
||||
name: "Kai".into(),
|
||||
skill: "idle".into(),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(text.contains("Owner: not here."));
|
||||
assert!(text.contains("Inventory: empty."));
|
||||
assert!(text.contains("Near (from owner's view, ! hostile): none."));
|
||||
assert!(!text.contains("Recent"));
|
||||
assert_eq!(time_of_day(0), "06:00 morning");
|
||||
assert_eq!(time_of_day(18_000), "00:00 night");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,576 @@
|
||||
//! The work skills' rules that need no world (docs/companions.md, "Skills"):
|
||||
//! what a loot table can drop without a special tool, the crafting plan from
|
||||
//! what it carries (logs → planks → sticks → tools), how high to aim a bow,
|
||||
//! and how much fuel a smelt takes. The skills themselves run in the game
|
||||
//! (game/companion_work.rs, game/companion_craft.rs).
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Collect searches the loaded chunks this far around the companion.
|
||||
pub const SEARCH_RADIUS: i32 = 48;
|
||||
/// A crafting table, furnace or bed this close is used; past it one is placed.
|
||||
pub const STATION_RADIUS: i32 = 16;
|
||||
/// A player's block interaction range (`Player.blockInteractionRange`).
|
||||
pub const BLOCK_REACH: f64 = 4.5;
|
||||
|
||||
// ---- loot -------------------------------------------------------------------
|
||||
|
||||
/// The items a loot table (`minecraft:blocks/stone`) can drop when broken by
|
||||
/// hand or with an ordinary tool: entries and pools that need a tool match
|
||||
/// (silk touch, shears) are left out, so stone gives cobblestone and grass
|
||||
/// blocks dirt.
|
||||
pub fn loot_items(table: &Value) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for pool in table["pools"].as_array().into_iter().flatten() {
|
||||
if needs_tool(pool) {
|
||||
continue;
|
||||
}
|
||||
for entry in pool["entries"].as_array().into_iter().flatten() {
|
||||
entry_items(entry, &mut out);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn entry_items(entry: &Value, out: &mut Vec<String>) {
|
||||
if needs_tool(entry) {
|
||||
return;
|
||||
}
|
||||
match entry["type"].as_str().unwrap_or_default() {
|
||||
"minecraft:item" | "item" => {
|
||||
if let Some(name) = entry["name"].as_str()
|
||||
&& !out.iter().any(|o| o == name)
|
||||
{
|
||||
out.push(name.to_owned());
|
||||
}
|
||||
}
|
||||
"minecraft:alternatives"
|
||||
| "minecraft:group"
|
||||
| "minecraft:sequence"
|
||||
| "alternatives"
|
||||
| "group"
|
||||
| "sequence" => {
|
||||
for child in entry["children"].as_array().into_iter().flatten() {
|
||||
entry_items(child, out);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an entry's or pool's condition asks for a tool: `match_tool`, a
|
||||
/// `minecraft:tool/...` predicate reference (26.3's `can_silk_touch`,
|
||||
/// `can_shear`), alone or among `any_of` / `all_of`; an inverted one asks
|
||||
/// for its absence. Reads 26.3's single `condition` and the older
|
||||
/// `conditions` list, whose terms name their kind in `condition`.
|
||||
fn needs_tool(v: &Value) -> bool {
|
||||
fn condition(c: &Value) -> bool {
|
||||
if let Some(reference) = c.as_str() {
|
||||
return reference
|
||||
.trim_start_matches("minecraft:")
|
||||
.starts_with("tool/");
|
||||
}
|
||||
let kind = c["type"]
|
||||
.as_str()
|
||||
.or_else(|| c["condition"].as_str())
|
||||
.unwrap_or_default();
|
||||
match kind.trim_start_matches("minecraft:") {
|
||||
"match_tool" => true,
|
||||
"any_of" | "all_of" => c["terms"]
|
||||
.as_array()
|
||||
.is_some_and(|t| t.iter().any(condition)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
v["conditions"]
|
||||
.as_array()
|
||||
.is_some_and(|c| c.iter().any(condition))
|
||||
|| !v["condition"].is_null() && condition(&v["condition"])
|
||||
}
|
||||
|
||||
// ---- crafting plans -----------------------------------------------------------
|
||||
|
||||
/// A crafting recipe as the planner sees it: the accepted items per cell.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CraftRecipe {
|
||||
/// `minecraft:oak_planks`.
|
||||
pub id: String,
|
||||
pub result: String,
|
||||
/// Shaped: `width` x `height` cells, row by row; shapeless: one per ingredient.
|
||||
pub cells: Vec<Option<Vec<String>>>,
|
||||
/// 0 for a shapeless recipe.
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
/// Items one craft makes.
|
||||
pub count: u32,
|
||||
}
|
||||
|
||||
impl CraftRecipe {
|
||||
pub fn shaped(&self) -> bool {
|
||||
self.width > 0
|
||||
}
|
||||
/// Whether it needs a crafting table's 3x3 grid.
|
||||
pub fn needs_table(&self) -> bool {
|
||||
if self.shaped() {
|
||||
self.width > 2 || self.height > 2
|
||||
} else {
|
||||
self.cells.len() > 4
|
||||
}
|
||||
}
|
||||
/// The grid slot (0-based, row by row in a `size` x `size` grid) of cell `i`.
|
||||
pub fn grid_slot(&self, i: usize, size: usize) -> usize {
|
||||
if self.shaped() {
|
||||
i / self.width * size + i % self.width
|
||||
} else {
|
||||
i
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The crafting recipes that make an item.
|
||||
pub trait RecipeBook {
|
||||
fn recipes_for(&self, item: &str) -> &[Arc<CraftRecipe>];
|
||||
}
|
||||
|
||||
/// One craft of a plan: the recipe, how many times, and the item put in
|
||||
/// each cell (None for an empty cell).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Step {
|
||||
pub recipe: Arc<CraftRecipe>,
|
||||
pub times: u32,
|
||||
pub picks: Vec<Option<String>>,
|
||||
}
|
||||
|
||||
/// What it takes to craft something from a stock of items.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Plan {
|
||||
/// The crafts in order, what each needs made before it.
|
||||
pub steps: Vec<Step>,
|
||||
/// What it lacks and can't craft: the items accepted and how many.
|
||||
pub missing: Vec<(Vec<String>, u32)>,
|
||||
}
|
||||
|
||||
impl Plan {
|
||||
pub fn needs_table(&self) -> bool {
|
||||
self.steps.iter().any(|s| s.recipe.needs_table())
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursion this deep at most: log → planks → sticks → torch is 3.
|
||||
const DEPTH: u32 = 5;
|
||||
/// Recipes tried at most per plan, so that a recipe web can't stall a tick.
|
||||
const BUDGET: u32 = 4000;
|
||||
|
||||
/// Plans crafts from a stock: each cell takes one kind of item, from the
|
||||
/// stock, or crafted first (a recipe that needs nothing missing is preferred).
|
||||
pub struct Planner<'a, B: RecipeBook> {
|
||||
book: &'a B,
|
||||
pub stock: HashMap<String, u32>,
|
||||
plan: Plan,
|
||||
budget: u32,
|
||||
/// Items it already failed to craft so many of from what there is: the
|
||||
/// stock only shrinks while planning, so as many or more fail again.
|
||||
dead: HashMap<String, u32>,
|
||||
}
|
||||
|
||||
impl<'a, B: RecipeBook> Planner<'a, B> {
|
||||
pub fn new(book: &'a B, stock: HashMap<String, u32>) -> Self {
|
||||
Self {
|
||||
book,
|
||||
stock,
|
||||
plan: Plan::default(),
|
||||
budget: BUDGET,
|
||||
dead: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Plan `n` more of `item` on top of what is planned; true when nothing
|
||||
/// is missing for it.
|
||||
pub fn make(&mut self, item: &str, n: u32) -> bool {
|
||||
self.craft(item, n, 0)
|
||||
}
|
||||
|
||||
/// Keep `n` of `item` aside (a crafting table it will stand at).
|
||||
pub fn reserve(&mut self, item: &str, n: u32) -> bool {
|
||||
let have = self.have(item);
|
||||
if have >= n {
|
||||
*self.stock.entry(item.to_owned()).or_default() -= n;
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn finish(self) -> Plan {
|
||||
let mut plan = self.plan;
|
||||
// The same craft twice in a row is one craft done more times.
|
||||
let mut steps: Vec<Step> = Vec::new();
|
||||
for s in plan.steps {
|
||||
match steps.last_mut() {
|
||||
Some(last) if last.recipe == s.recipe && last.picks == s.picks => {
|
||||
last.times += s.times
|
||||
}
|
||||
_ => steps.push(s),
|
||||
}
|
||||
}
|
||||
plan.steps = steps;
|
||||
plan
|
||||
}
|
||||
|
||||
fn have(&self, item: &str) -> u32 {
|
||||
self.stock.get(item).copied().unwrap_or(0)
|
||||
}
|
||||
|
||||
fn craft(&mut self, item: &str, n: u32, depth: u32) -> bool {
|
||||
if n == 0 {
|
||||
return true;
|
||||
}
|
||||
let mut recipes: Vec<&Arc<CraftRecipe>> = self.book.recipes_for(item).iter().collect();
|
||||
// The recipe named after the item first (oak_planks before the
|
||||
// bamboo or log variants).
|
||||
recipes.sort_by_key(|r| r.id != item);
|
||||
for r in &recipes {
|
||||
let saved = (self.stock.clone(), self.plan.clone());
|
||||
if self.craft_with(r, n, depth) {
|
||||
return true;
|
||||
}
|
||||
(self.stock, self.plan) = saved;
|
||||
}
|
||||
match recipes.first() {
|
||||
Some(r) => {
|
||||
self.craft_with(r, n, depth);
|
||||
false
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn craft_with(&mut self, r: &Arc<CraftRecipe>, n: u32, depth: u32) -> bool {
|
||||
if self.budget == 0 {
|
||||
return false;
|
||||
}
|
||||
self.budget -= 1;
|
||||
let times = n.div_ceil(r.count.max(1));
|
||||
let mut clean = true;
|
||||
let mut picks = Vec::with_capacity(r.cells.len());
|
||||
for cell in &r.cells {
|
||||
picks.push(match cell {
|
||||
Some(options) => {
|
||||
let (pick, ok) = self.obtain(options, times, depth);
|
||||
clean &= ok;
|
||||
pick
|
||||
}
|
||||
None => None,
|
||||
});
|
||||
}
|
||||
self.plan.steps.push(Step {
|
||||
recipe: Arc::clone(r),
|
||||
times,
|
||||
picks,
|
||||
});
|
||||
*self.stock.entry(r.result.clone()).or_default() += times * r.count;
|
||||
clean
|
||||
}
|
||||
|
||||
/// `n` of one of `options` for a cell: from the stock, else crafted.
|
||||
fn obtain(&mut self, options: &[String], n: u32, depth: u32) -> (Option<String>, bool) {
|
||||
let mut order: Vec<&String> = options.iter().collect();
|
||||
order.sort_by_key(|o| std::cmp::Reverse(self.have(o)));
|
||||
if let Some(o) = order.first().filter(|o| self.have(o) >= n) {
|
||||
let o = (*o).clone();
|
||||
*self.stock.entry(o.clone()).or_default() -= n;
|
||||
return (Some(o), true);
|
||||
}
|
||||
if depth < DEPTH {
|
||||
for o in &order {
|
||||
let need = n - self.have(o);
|
||||
if self.book.recipes_for(o).is_empty()
|
||||
|| self.dead.get(*o).is_some_and(|d| need >= *d)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let saved = (self.stock.clone(), self.plan.clone());
|
||||
if self.craft(o, need, depth + 1) {
|
||||
*self.stock.entry((*o).clone()).or_default() -= n;
|
||||
return (Some((*o).clone()), true);
|
||||
}
|
||||
(self.stock, self.plan) = saved;
|
||||
let d = self.dead.entry((*o).clone()).or_insert(need);
|
||||
*d = (*d).min(need);
|
||||
}
|
||||
// Nothing works out: a raw material is what's missing (logs,
|
||||
// not the oak wood they would make); else the first craftable.
|
||||
let raw = order.iter().any(|o| self.book.recipes_for(o).is_empty());
|
||||
if !raw && let Some(o) = order.first() {
|
||||
let need = n - self.have(o);
|
||||
self.craft(o, need, depth + 1);
|
||||
let left = self.have(o).min(n);
|
||||
*self.stock.entry((*o).clone()).or_default() -= left;
|
||||
return (Some((*o).clone()), false);
|
||||
}
|
||||
}
|
||||
// Nothing to craft it from: take what there is, the rest is missing.
|
||||
let best = order.first().map(|o| (*o).clone());
|
||||
let have = best.as_deref().map_or(0, |o| self.have(o));
|
||||
if let Some(o) = &best {
|
||||
*self.stock.entry(o.clone()).or_default() -= have;
|
||||
}
|
||||
self.plan.missing.push((options.to_vec(), n - have));
|
||||
(best, false)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- bows ---------------------------------------------------------------------------
|
||||
|
||||
/// Arrow gravity and drag per tick (`AbstractArrow`).
|
||||
const ARROW_GRAVITY: f64 = 0.05;
|
||||
const ARROW_DRAG: f64 = 0.99;
|
||||
|
||||
/// The pitch (degrees; negative looks up) that sends an arrow leaving at
|
||||
/// `speed` blocks a tick to a point `distance` blocks away horizontally and
|
||||
/// `rise` blocks higher, on the flattest path; None when it can't reach.
|
||||
pub fn bow_pitch(distance: f64, rise: f64, speed: f64) -> Option<f32> {
|
||||
let height_at = |angle: f64| -> Option<f64> {
|
||||
let (mut x, mut y) = (0.0, 0.0);
|
||||
let (mut vx, mut vy) = (speed * angle.cos(), speed * angle.sin());
|
||||
for _ in 0..200 {
|
||||
if x + vx >= distance {
|
||||
return Some(y + vy * (distance - x) / vx);
|
||||
}
|
||||
x += vx;
|
||||
y += vy;
|
||||
vx *= ARROW_DRAG;
|
||||
vy = vy * ARROW_DRAG - ARROW_GRAVITY;
|
||||
if vx < 1e-3 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
None
|
||||
};
|
||||
let (mut lo, mut hi) = (-std::f64::consts::FRAC_PI_4, std::f64::consts::FRAC_PI_4);
|
||||
if height_at(hi).is_none_or(|h| h < rise) {
|
||||
return None;
|
||||
}
|
||||
for _ in 0..40 {
|
||||
let mid = (lo + hi) / 2.0;
|
||||
if height_at(mid).is_some_and(|h| h >= rise) {
|
||||
hi = mid;
|
||||
} else {
|
||||
lo = mid;
|
||||
}
|
||||
}
|
||||
Some(-hi.to_degrees() as f32)
|
||||
}
|
||||
|
||||
/// The ticks a full draw takes (`BowItem.getPowerForTime` reaches 1 at 20).
|
||||
pub const FULL_DRAW: u32 = 20;
|
||||
|
||||
// ---- furnaces -------------------------------------------------------------------------
|
||||
|
||||
/// Fuels it burns, best first, and how many items one smelts (a furnace
|
||||
/// cooks one item in 200 ticks; coal burns 1600).
|
||||
pub fn fuel_value(item: &str) -> Option<f32> {
|
||||
let name = item.trim_start_matches("minecraft:");
|
||||
Some(match name {
|
||||
"coal" | "charcoal" => 8.0,
|
||||
"coal_block" => 80.0,
|
||||
"blaze_rod" => 12.0,
|
||||
"dried_kelp_block" => 20.0,
|
||||
"stick" => 0.5,
|
||||
_ if name.starts_with("crimson_") || name.starts_with("warped_") => return None,
|
||||
_ if name.ends_with("_planks") || name.ends_with("_log") || name.ends_with("_wood") => 1.5,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn loot_without_a_tool_match() {
|
||||
// blocks/stone: silk touch gives stone, else cobblestone.
|
||||
let stone = json!({"pools": [{"entries": [{"type": "minecraft:alternatives", "children": [
|
||||
{"type": "minecraft:item", "name": "minecraft:stone", "conditions": [
|
||||
{"condition": "minecraft:match_tool", "predicate": {}}]},
|
||||
{"type": "minecraft:item", "name": "minecraft:cobblestone"}]}]}]});
|
||||
assert_eq!(loot_items(&stone), vec!["minecraft:cobblestone"]);
|
||||
// Glass drops only with silk touch.
|
||||
let glass = json!({"pools": [{"conditions": [{"condition": "minecraft:match_tool"}],
|
||||
"entries": [{"type": "minecraft:item", "name": "minecraft:glass"}]}]});
|
||||
assert!(loot_items(&glass).is_empty());
|
||||
// Leaves: shears or silk touch for the block, sticks otherwise.
|
||||
let leaves = json!({"pools": [
|
||||
{"entries": [{"type": "minecraft:alternatives", "children": [
|
||||
{"type": "minecraft:item", "name": "minecraft:oak_leaves", "conditions": [{"condition": "minecraft:any_of",
|
||||
"terms": [{"condition": "minecraft:match_tool"}, {"condition": "minecraft:match_tool"}]}]},
|
||||
{"type": "minecraft:item", "name": "minecraft:oak_sapling"}]}]},
|
||||
{"conditions": [{"condition": "minecraft:inverted", "term": {"condition": "minecraft:match_tool"}}],
|
||||
"entries": [{"type": "minecraft:item", "name": "minecraft:stick"}]}]});
|
||||
assert_eq!(
|
||||
loot_items(&leaves),
|
||||
vec!["minecraft:oak_sapling", "minecraft:stick"]
|
||||
);
|
||||
// 26.3's shape: one `condition`, a predicate reference or a typed term.
|
||||
let ore = json!({"pools": [{"entries": [{"type": "minecraft:alternatives", "children": [
|
||||
{"type": "minecraft:item", "condition": "minecraft:tool/can_silk_touch", "name": "minecraft:iron_ore"},
|
||||
{"type": "minecraft:item", "name": "minecraft:raw_iron"}]}]}]});
|
||||
assert_eq!(loot_items(&ore), vec!["minecraft:raw_iron"]);
|
||||
let glass = json!({"pools": [{"condition": {"type": "minecraft:any_of",
|
||||
"terms": ["minecraft:tool/can_silk_touch", {"type": "minecraft:match_tool"}]},
|
||||
"entries": [{"type": "minecraft:item", "name": "minecraft:glass_pane"}]}]});
|
||||
assert!(loot_items(&glass).is_empty());
|
||||
let leaves = json!({"pools": [{"condition": {"type": "minecraft:inverted", "term": "minecraft:tool/can_shear"},
|
||||
"entries": [{"type": "minecraft:item", "name": "minecraft:stick"}]}]});
|
||||
assert_eq!(loot_items(&leaves), vec!["minecraft:stick"]);
|
||||
}
|
||||
|
||||
struct Book(HashMap<String, Vec<Arc<CraftRecipe>>>);
|
||||
impl RecipeBook for Book {
|
||||
fn recipes_for(&self, item: &str) -> &[Arc<CraftRecipe>] {
|
||||
self.0.get(item).map_or(&[], Vec::as_slice)
|
||||
}
|
||||
}
|
||||
impl Book {
|
||||
fn new(recipes: Vec<CraftRecipe>) -> Self {
|
||||
let mut by: HashMap<String, Vec<Arc<CraftRecipe>>> = HashMap::new();
|
||||
for r in recipes {
|
||||
by.entry(r.result.clone()).or_default().push(Arc::new(r));
|
||||
}
|
||||
Self(by)
|
||||
}
|
||||
}
|
||||
fn s(v: &[&str]) -> Vec<String> {
|
||||
v.iter().map(|x| format!("minecraft:{x}")).collect()
|
||||
}
|
||||
fn book() -> Book {
|
||||
let planks = s(&["oak_planks", "birch_planks"]);
|
||||
let recipe =
|
||||
|id: &str, cells: Vec<Option<Vec<String>>>, width, height, count| CraftRecipe {
|
||||
id: format!("minecraft:{id}"),
|
||||
result: format!("minecraft:{id}"),
|
||||
cells,
|
||||
width,
|
||||
height,
|
||||
count,
|
||||
};
|
||||
Book::new(vec![
|
||||
recipe(
|
||||
"oak_planks",
|
||||
vec![Some(s(&["oak_log", "oak_wood"]))],
|
||||
0,
|
||||
0,
|
||||
4,
|
||||
),
|
||||
recipe("birch_planks", vec![Some(s(&["birch_log"]))], 0, 0, 4),
|
||||
recipe("oak_wood", vec![Some(s(&["oak_log"])); 4], 2, 2, 3),
|
||||
recipe(
|
||||
"stick",
|
||||
vec![Some(planks.clone()), Some(planks.clone())],
|
||||
1,
|
||||
2,
|
||||
4,
|
||||
),
|
||||
recipe("crafting_table", vec![Some(planks.clone()); 4], 2, 2, 1),
|
||||
recipe(
|
||||
"wooden_pickaxe",
|
||||
vec![
|
||||
Some(planks.clone()),
|
||||
Some(planks.clone()),
|
||||
Some(planks.clone()),
|
||||
None,
|
||||
Some(s(&["stick"])),
|
||||
None,
|
||||
None,
|
||||
Some(s(&["stick"])),
|
||||
None,
|
||||
],
|
||||
3,
|
||||
3,
|
||||
1,
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pickaxe_from_logs() {
|
||||
let book = book();
|
||||
let stock = HashMap::from([("minecraft:oak_log".to_owned(), 3)]);
|
||||
let mut p = Planner::new(&book, stock);
|
||||
assert!(p.make("minecraft:crafting_table", 1));
|
||||
assert!(p.make("minecraft:wooden_pickaxe", 1));
|
||||
let plan = p.finish();
|
||||
assert!(plan.missing.is_empty(), "{plan:?}");
|
||||
assert!(plan.needs_table());
|
||||
let crafts: Vec<(&str, u32)> = plan
|
||||
.steps
|
||||
.iter()
|
||||
.map(|s| (&s.recipe.id[10..], s.times))
|
||||
.collect();
|
||||
// 4 planks for the table, 3 for the head, 2 for the sticks: 3 logs.
|
||||
assert_eq!(
|
||||
crafts,
|
||||
vec![
|
||||
("oak_planks", 1),
|
||||
("crafting_table", 1),
|
||||
("oak_planks", 2),
|
||||
("stick", 1),
|
||||
("wooden_pickaxe", 1)
|
||||
]
|
||||
);
|
||||
assert_eq!(plan.steps[4].picks[4].as_deref(), Some("minecraft:stick"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_is_missing_from_nothing() {
|
||||
let book = book();
|
||||
let mut p = Planner::new(&book, HashMap::new());
|
||||
assert!(!p.make("minecraft:wooden_pickaxe", 1));
|
||||
let plan = p.finish();
|
||||
let logs: u32 = plan
|
||||
.missing
|
||||
.iter()
|
||||
.filter(|(o, _)| o.contains(&"minecraft:oak_log".to_owned()))
|
||||
.map(|(_, n)| n)
|
||||
.sum();
|
||||
// Three planks cells and a stick: the planks come 4 a log.
|
||||
assert!((1..=4).contains(&logs), "{plan:?}");
|
||||
// Birch logs do: the planner picks the planks they make.
|
||||
let stock = HashMap::from([("minecraft:birch_log".to_owned(), 2)]);
|
||||
let mut p = Planner::new(&book, stock);
|
||||
assert!(p.make("minecraft:wooden_pickaxe", 1));
|
||||
let plan = p.finish();
|
||||
assert_eq!(plan.steps[0].recipe.id, "minecraft:birch_planks");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_slots_of_shaped_and_shapeless() {
|
||||
let book = book();
|
||||
let pick = &book.recipes_for("minecraft:wooden_pickaxe")[0];
|
||||
assert_eq!(pick.grid_slot(4, 3), 4);
|
||||
let stick = &book.recipes_for("minecraft:stick")[0];
|
||||
assert!(!stick.needs_table());
|
||||
assert_eq!(stick.grid_slot(1, 2), 2);
|
||||
assert_eq!(stick.grid_slot(1, 3), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bows_aim_above_far_targets() {
|
||||
let near = bow_pitch(5.0, 0.0, 3.0).unwrap();
|
||||
let far = bow_pitch(30.0, 0.0, 3.0).unwrap();
|
||||
assert!(near < 0.0 && near > -2.0, "{near}");
|
||||
assert!(far < near, "{far} {near}");
|
||||
// Down a cliff it aims below the line; a weak shot can't reach far.
|
||||
assert!(bow_pitch(10.0, -6.0, 3.0).unwrap() > 20.0);
|
||||
assert!(bow_pitch(200.0, 0.0, 0.5).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuels() {
|
||||
assert_eq!(fuel_value("minecraft:coal"), Some(8.0));
|
||||
assert_eq!(fuel_value("minecraft:oak_planks"), Some(1.5));
|
||||
assert_eq!(fuel_value("minecraft:crimson_planks"), None);
|
||||
assert_eq!(fuel_value("minecraft:stone"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,624 @@
|
||||
//! The Companion section of the options (docs/companions.md, "Settings"): the rows both menu
|
||||
//! styles show, their values, and what a click does. The game fills [`CompanionView`] (the PC, the
|
||||
//! plan, the setup's progress, who is in the world) and carries out the [`Action`]s; the settings
|
||||
//! themselves are the launcher's `<home>/companion/settings.json`, so the two stay in sync.
|
||||
use horizon_companion::{
|
||||
plan::Part,
|
||||
prompt::{Perception, Personality, Role},
|
||||
settings::{BrainChoice, Chattiness, CompanionSettings, PartChoice},
|
||||
};
|
||||
|
||||
/// What the section shows, filled by the game while the options are open.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct CompanionView {
|
||||
/// Filled: the section exists (not in the first-run menu, not under No AI).
|
||||
pub ready: bool,
|
||||
pub settings: CompanionSettings,
|
||||
/// A world is open where companions can play, and the companion in it, if any.
|
||||
pub in_world: bool,
|
||||
pub here: Option<String>,
|
||||
/// "16 GB of memory, 8 cores, Apple M4"; empty while it is being checked.
|
||||
pub machine: String,
|
||||
/// One line per part of the plan.
|
||||
pub plan: Vec<String>,
|
||||
/// Still to download, in bytes.
|
||||
pub download: u64,
|
||||
/// Setting up: the step and its progress.
|
||||
pub progress: Option<(String, Option<f32>)>,
|
||||
/// The last setup's error.
|
||||
pub error: Option<String>,
|
||||
/// What runs now ("Brain ready", "Missing: ...").
|
||||
pub status: Vec<String>,
|
||||
pub advanced: bool,
|
||||
/// Advanced's choices per part: (option id, label).
|
||||
pub choices: Vec<(Part, Vec<(String, String)>)>,
|
||||
/// Models and runtimes on disk, in bytes.
|
||||
pub disk: u64,
|
||||
/// Salty was picked and waits for the 18+ confirmation.
|
||||
pub confirm_salty: bool,
|
||||
/// The text-to-speech model that would speak, and its voices: (id, label, description).
|
||||
pub tts_model: Option<String>,
|
||||
pub voices: Vec<(String, String, String)>,
|
||||
/// The personality's default voice on that model.
|
||||
pub default_voice: Option<String>,
|
||||
/// Advanced: the brain tier's uncensored variant for Salty mode: (name, licence), or `None`
|
||||
/// while none is chosen.
|
||||
pub salty_brain: Option<(String, String)>,
|
||||
/// The owner is watching the companion here (Watch mode, `game/companion_watch.rs`).
|
||||
pub watching: bool,
|
||||
}
|
||||
|
||||
/// One row of the section.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Row {
|
||||
Header(&'static str),
|
||||
Pc,
|
||||
Plan(usize),
|
||||
SetUp,
|
||||
Status(usize),
|
||||
Name,
|
||||
Personality,
|
||||
Role,
|
||||
Perception,
|
||||
Language,
|
||||
/// The 18+ confirmation Salty needs.
|
||||
SaltyConfirm,
|
||||
Chattiness,
|
||||
Voice,
|
||||
/// Which voice it speaks with, and a sample of it.
|
||||
VoiceCharacter,
|
||||
VoiceSample,
|
||||
TalkKey,
|
||||
Spawn,
|
||||
/// Watch mode: the owner becomes a camera on the companion.
|
||||
Watch,
|
||||
Advanced,
|
||||
Part(Part),
|
||||
/// Salty mode's uncensored brain.
|
||||
SaltyBrain,
|
||||
Delete,
|
||||
}
|
||||
|
||||
/// What the game does after a click.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
/// The settings changed: save them (and restart the parts that changed).
|
||||
Changed,
|
||||
SetUp,
|
||||
Cancel,
|
||||
Spawn,
|
||||
Dismiss,
|
||||
/// Delete the downloaded models and runtimes.
|
||||
Delete,
|
||||
/// Bind the push-to-talk key (the next key pressed).
|
||||
BindTalk,
|
||||
/// The name box took the focus.
|
||||
EditName,
|
||||
/// Say a short line with the chosen voice.
|
||||
PlaySample,
|
||||
/// Start or stop watching the companion here.
|
||||
Watch,
|
||||
}
|
||||
|
||||
pub fn rows(view: &CompanionView) -> Vec<Row> {
|
||||
let mut rows = vec![Row::Header("Your PC"), Row::Pc];
|
||||
rows.extend((0..view.plan.len()).map(Row::Plan));
|
||||
rows.push(Row::SetUp);
|
||||
rows.extend((0..view.status.len()).map(Row::Status));
|
||||
rows.extend([
|
||||
Row::Header("Companion"),
|
||||
Row::Name,
|
||||
Row::Personality,
|
||||
Row::Role,
|
||||
Row::Perception,
|
||||
Row::Language,
|
||||
]);
|
||||
if view.confirm_salty {
|
||||
rows.push(Row::SaltyConfirm);
|
||||
}
|
||||
rows.extend([Row::Chattiness, Row::Voice]);
|
||||
if view.settings.voice {
|
||||
if !view.voices.is_empty() {
|
||||
rows.extend([Row::VoiceCharacter, Row::VoiceSample]);
|
||||
}
|
||||
rows.push(Row::TalkKey);
|
||||
}
|
||||
rows.push(Row::Spawn);
|
||||
if view.in_world && view.here.is_some() {
|
||||
rows.push(Row::Watch);
|
||||
}
|
||||
rows.push(Row::Advanced);
|
||||
if view.advanced {
|
||||
rows.push(Row::Header("Advanced"));
|
||||
rows.extend([Part::Brain, Part::Classifier].map(Row::Part));
|
||||
if view.settings.voice {
|
||||
rows.extend([Part::SpeechToText, Part::TextToSpeech].map(Row::Part));
|
||||
}
|
||||
if view.settings.persona.salty_on() {
|
||||
rows.push(Row::SaltyBrain);
|
||||
}
|
||||
rows.push(Row::Delete);
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
/// How a row's control looks.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Control {
|
||||
None,
|
||||
Switch(bool),
|
||||
Button(String),
|
||||
/// A button that can't be pressed now.
|
||||
Inactive(String),
|
||||
TextBox(String, bool),
|
||||
}
|
||||
|
||||
/// The caption, the description and the control of a row.
|
||||
/// `editing`: the name being typed while the name box has the focus.
|
||||
pub fn row_parts(
|
||||
row: Row,
|
||||
view: &CompanionView,
|
||||
talk_key: &str,
|
||||
editing: Option<&str>,
|
||||
) -> (String, String, Control) {
|
||||
let s = &view.settings;
|
||||
let p = &s.persona;
|
||||
match row {
|
||||
Row::Header(h) => (h.into(), String::new(), Control::None),
|
||||
Row::Pc => (
|
||||
"This PC".into(),
|
||||
if view.machine.is_empty() { "Checking…".into() } else { view.machine.clone() },
|
||||
Control::None,
|
||||
),
|
||||
Row::Plan(i) => (String::new(), view.plan.get(i).cloned().unwrap_or_default(), Control::None),
|
||||
Row::SetUp => {
|
||||
let (text, control) = match (&view.progress, &view.error) {
|
||||
(Some((step, Some(f))), _) => (format!("{step} ({:.0} %)", f * 100.0), Control::Button("Cancel".into())),
|
||||
(Some((step, None)), _) => (step.clone(), Control::Button("Cancel".into())),
|
||||
(None, Some(e)) => (format!("Setup failed: {e}"), Control::Button("Try again".into())),
|
||||
(None, None) if s.set_up && view.download == 0 => (
|
||||
"Set up: everything is on this PC.".into(),
|
||||
Control::Button("Check again".into()),
|
||||
),
|
||||
(None, None) => (
|
||||
if view.download > 0 {
|
||||
format!("Downloads {} and runs on this PC, offline.", gigabytes(view.download))
|
||||
} else {
|
||||
"Runs on this PC, offline.".into()
|
||||
},
|
||||
Control::Button("Set up companion".into()),
|
||||
),
|
||||
};
|
||||
("Set up".into(), text, control)
|
||||
}
|
||||
Row::Status(i) => (String::new(), view.status.get(i).cloned().unwrap_or_default(), Control::None),
|
||||
Row::Name => {
|
||||
let value = editing.map_or_else(|| p.name.clone(), str::to_owned);
|
||||
("Name".into(), "3 to 16 letters, digits or _.".into(), Control::TextBox(value, editing.is_some()))
|
||||
}
|
||||
Row::Personality => (
|
||||
"Personality".into(),
|
||||
if p.personality == Personality::Custom {
|
||||
"Your own description, from the launcher.".into()
|
||||
} else {
|
||||
p.personality.blurb().into()
|
||||
},
|
||||
Control::Button(p.personality.label().into()),
|
||||
),
|
||||
Row::Role => ("Role".into(), p.role.blurb().into(), Control::Button(p.role.label().into())),
|
||||
Row::Perception => (
|
||||
"Perception".into(),
|
||||
p.perception.blurb().into(),
|
||||
Control::Button(p.perception.label().into()),
|
||||
),
|
||||
Row::Language => (
|
||||
"Language".into(),
|
||||
if p.kid_friendly {
|
||||
"Gentle humour, no swearing, nothing scary."
|
||||
} else if p.salty_on() {
|
||||
"Real swearing and attitude. Still refuses anything harmful."
|
||||
} else {
|
||||
"No swearing. Salty (18+) swears for real; Kid-friendly is gentler."
|
||||
}
|
||||
.into(),
|
||||
Control::Button(language_label(s).into()),
|
||||
),
|
||||
Row::SaltyConfirm => (
|
||||
"Salty is for adults".into(),
|
||||
"Real swearing and attitude. Confirm that you are 18 or older.".into(),
|
||||
Control::Button("I'm 18 or older".into()),
|
||||
),
|
||||
Row::Chattiness => (
|
||||
"Chattiness".into(),
|
||||
match s.chattiness {
|
||||
Chattiness::Quiet => "Only warns of danger.",
|
||||
Chattiness::Normal => "Warns of danger and remarks now and then.",
|
||||
Chattiness::Chatty => "Talks a lot.",
|
||||
}
|
||||
.into(),
|
||||
Control::Button(s.chattiness.label().into()),
|
||||
),
|
||||
Row::Voice => (
|
||||
"Voice".into(),
|
||||
"Hear it speak, and talk to it with push-to-talk. The microphone opens only while the key is held.".into(),
|
||||
Control::Switch(s.voice),
|
||||
),
|
||||
Row::VoiceCharacter => {
|
||||
let chosen = voice_choice(view);
|
||||
let shown = chosen.clone().or_else(|| view.default_voice.clone());
|
||||
let (label, description) = view
|
||||
.voices
|
||||
.iter()
|
||||
.find(|(id, ..)| Some(id) == shown.as_ref())
|
||||
.map(|(_, l, d)| (l.clone(), d.clone()))
|
||||
.unwrap_or_default();
|
||||
let label = if chosen.is_none() { format!("{label} (default)") } else { label };
|
||||
("Sounds like".into(), description, Control::Button(label))
|
||||
}
|
||||
Row::VoiceSample => (
|
||||
String::new(),
|
||||
"A short line in this voice.".into(),
|
||||
Control::Button("Play sample".into()),
|
||||
),
|
||||
Row::TalkKey => ("Push-to-talk key".into(), String::new(), Control::Button(talk_key.into())),
|
||||
Row::Spawn => {
|
||||
let control = match (&view.here, view.in_world) {
|
||||
(Some(_), _) => Control::Button("Dismiss".into()),
|
||||
(None, true) => Control::Button("Spawn".into()),
|
||||
(None, false) => Control::Inactive("Spawn".into()),
|
||||
};
|
||||
let text = match &view.here {
|
||||
Some(name) => format!("{name} is in this world."),
|
||||
None if view.in_world => "Brings your companion into this world.".into(),
|
||||
None => "Open a world to bring your companion in.".into(),
|
||||
};
|
||||
("Companion".into(), text, control)
|
||||
}
|
||||
Row::Watch => (
|
||||
"Watch your companion".into(),
|
||||
"Follow it as a camera, through portals, with its timer, step and perception.".into(),
|
||||
Control::Button(if view.watching { "Stop watching" } else { "Watch" }.into()),
|
||||
),
|
||||
Row::Advanced => (
|
||||
"Show advanced".into(),
|
||||
"Pick each model yourself.".into(),
|
||||
Control::Switch(view.advanced),
|
||||
),
|
||||
Row::Part(part) => {
|
||||
let caption = match part {
|
||||
Part::Brain => "Brain",
|
||||
Part::Classifier => "Command recognition",
|
||||
Part::SpeechToText => "Speech recognition",
|
||||
Part::TextToSpeech => "Voice model",
|
||||
};
|
||||
(caption.into(), String::new(), Control::Button(part_label(view, part)))
|
||||
}
|
||||
Row::SaltyBrain => (
|
||||
"Salty brain".into(),
|
||||
match &view.salty_brain {
|
||||
Some((name, licence)) => format!("Uncensored: {name} ({licence})."),
|
||||
None => "No uncensored model is chosen yet: the normal brain runs with the Salty prompt.".into(),
|
||||
},
|
||||
Control::Switch(s.salty_uncensored),
|
||||
),
|
||||
Row::Delete => (
|
||||
"Downloaded models".into(),
|
||||
format!("{} on disk.", gigabytes(view.disk)),
|
||||
if view.disk > 0 { Control::Button("Delete".into()) } else { Control::Inactive("Delete".into()) },
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// A click on a row (`back`: the right button, cycling backwards).
|
||||
pub fn click(row: Row, view: &mut CompanionView, back: bool) -> Option<Action> {
|
||||
let s = &mut view.settings;
|
||||
match row {
|
||||
Row::SetUp if view.progress.is_some() => Some(Action::Cancel),
|
||||
Row::SetUp => Some(Action::SetUp),
|
||||
Row::Name => Some(Action::EditName),
|
||||
Row::Personality => {
|
||||
s.persona.personality = cycle(&Personality::ALL, s.persona.personality, back);
|
||||
Some(Action::Changed)
|
||||
}
|
||||
Row::Role => {
|
||||
s.persona.role = cycle(&Role::ALL, s.persona.role, back);
|
||||
Some(Action::Changed)
|
||||
}
|
||||
Row::Perception => {
|
||||
s.persona.perception = cycle(&Perception::ALL, s.persona.perception, back);
|
||||
Some(Action::Changed)
|
||||
}
|
||||
Row::Language => {
|
||||
let next = cycle(&[0, 1, 2], language_index(view), back);
|
||||
let s = &mut view.settings;
|
||||
view.confirm_salty = false;
|
||||
match next {
|
||||
// Salty waits for the 18+ confirmation the first time.
|
||||
1 if !s.persona.adult_confirmed => {
|
||||
view.confirm_salty = true;
|
||||
s.persona.kid_friendly = false;
|
||||
s.persona.salty = false;
|
||||
}
|
||||
_ => {
|
||||
s.persona.salty = next == 1;
|
||||
s.persona.kid_friendly = next == 2;
|
||||
}
|
||||
}
|
||||
Some(Action::Changed)
|
||||
}
|
||||
Row::SaltyConfirm => {
|
||||
view.confirm_salty = false;
|
||||
s.persona.adult_confirmed = true;
|
||||
s.persona.salty = true;
|
||||
s.persona.kid_friendly = false;
|
||||
Some(Action::Changed)
|
||||
}
|
||||
Row::Chattiness => {
|
||||
s.chattiness = cycle(&Chattiness::ALL, s.chattiness, back);
|
||||
Some(Action::Changed)
|
||||
}
|
||||
Row::Voice => {
|
||||
s.voice = !s.voice;
|
||||
Some(Action::Changed)
|
||||
}
|
||||
Row::VoiceCharacter => {
|
||||
let ids: Vec<Option<String>> = std::iter::once(None)
|
||||
.chain(view.voices.iter().map(|(id, ..)| Some(id.clone())))
|
||||
.collect();
|
||||
let next = cycle(&ids, voice_choice(view), back);
|
||||
if let Some(model) = view.tts_model.clone() {
|
||||
let s = &mut view.settings;
|
||||
match next {
|
||||
Some(id) => s.voices.insert(model, id),
|
||||
None => s.voices.remove(&model),
|
||||
};
|
||||
}
|
||||
Some(Action::Changed)
|
||||
}
|
||||
Row::VoiceSample => Some(Action::PlaySample),
|
||||
Row::SaltyBrain => {
|
||||
s.salty_uncensored = !s.salty_uncensored;
|
||||
Some(Action::Changed)
|
||||
}
|
||||
Row::TalkKey => Some(Action::BindTalk),
|
||||
Row::Spawn if view.here.is_some() => Some(Action::Dismiss),
|
||||
Row::Spawn if view.in_world => Some(Action::Spawn),
|
||||
Row::Watch if view.here.is_some() => Some(Action::Watch),
|
||||
Row::Advanced => {
|
||||
view.advanced = !view.advanced;
|
||||
None
|
||||
}
|
||||
Row::Part(part) => {
|
||||
let ids: Vec<Option<String>> = std::iter::once(None)
|
||||
.chain(choices(view, part).iter().map(|(id, _)| Some(id.clone())))
|
||||
.chain((part != Part::Brain).then(|| Some("off".to_owned())))
|
||||
.collect();
|
||||
let now = current_id(&view.settings, part);
|
||||
let next = cycle(&ids, now, back);
|
||||
let s = &mut view.settings;
|
||||
match (part, next) {
|
||||
(Part::Brain, None) => s.brain = BrainChoice::Auto,
|
||||
(Part::Brain, Some(id)) => s.brain = BrainChoice::Option { id },
|
||||
(_, choice) => {
|
||||
let choice = match choice.as_deref() {
|
||||
None => PartChoice::Auto,
|
||||
Some("off") => PartChoice::Off,
|
||||
Some(id) => PartChoice::Option { id: id.to_owned() },
|
||||
};
|
||||
match part {
|
||||
Part::Classifier => s.classifier = choice,
|
||||
Part::SpeechToText => s.speech_to_text = choice,
|
||||
_ => s.text_to_speech = choice,
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Action::Changed)
|
||||
}
|
||||
Row::Delete if view.disk > 0 => Some(Action::Delete),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn choices(view: &CompanionView, part: Part) -> &[(String, String)] {
|
||||
view.choices
|
||||
.iter()
|
||||
.find(|(p, _)| *p == part)
|
||||
.map_or(&[], |(_, c)| c.as_slice())
|
||||
}
|
||||
|
||||
fn current_id(s: &CompanionSettings, part: Part) -> Option<String> {
|
||||
let choice = match part {
|
||||
Part::Brain => match &s.brain {
|
||||
BrainChoice::Option { id } => return Some(id.clone()),
|
||||
_ => return None,
|
||||
},
|
||||
Part::Classifier => &s.classifier,
|
||||
Part::SpeechToText => &s.speech_to_text,
|
||||
Part::TextToSpeech => &s.text_to_speech,
|
||||
};
|
||||
match choice {
|
||||
PartChoice::Auto => None,
|
||||
PartChoice::Off => Some("off".into()),
|
||||
PartChoice::Option { id } => Some(id.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn part_label(view: &CompanionView, part: Part) -> String {
|
||||
let s = &view.settings;
|
||||
if part == Part::Brain {
|
||||
match &s.brain {
|
||||
BrainChoice::Gguf { .. } => return "Your own model".into(),
|
||||
BrainChoice::Server { model, .. } => return model.clone(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
match current_id(s, part) {
|
||||
None => "Automatic".into(),
|
||||
Some(id) if id == "off" => "Off".into(),
|
||||
Some(id) => choices(view, part)
|
||||
.iter()
|
||||
.find(|(i, _)| *i == id)
|
||||
.map_or(id.clone(), |(_, label)| label.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Clean, Salty (also while it waits for the confirmation) or Kid-friendly.
|
||||
fn language_index(view: &CompanionView) -> usize {
|
||||
let p = &view.settings.persona;
|
||||
if p.kid_friendly {
|
||||
2
|
||||
} else if p.salty_on() || view.confirm_salty {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
fn language_label(s: &CompanionSettings) -> &'static str {
|
||||
if s.persona.kid_friendly {
|
||||
"Kid-friendly"
|
||||
} else if s.persona.salty_on() {
|
||||
"Salty (18+)"
|
||||
} else {
|
||||
"Clean"
|
||||
}
|
||||
}
|
||||
|
||||
/// The voice the settings name for the speaking model, if they name one it has.
|
||||
fn voice_choice(view: &CompanionView) -> Option<String> {
|
||||
let model = view.tts_model.as_ref()?;
|
||||
view.settings
|
||||
.voices
|
||||
.get(model)
|
||||
.filter(|id| view.voices.iter().any(|(v, ..)| v == *id))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn cycle<T: Clone + PartialEq>(all: &[T], now: T, back: bool) -> T {
|
||||
let i = all.iter().position(|v| *v == now).unwrap_or(0);
|
||||
let n = all.len();
|
||||
all[if back { (i + n - 1) % n } else { (i + 1) % n }].clone()
|
||||
}
|
||||
|
||||
/// "1.4 GB", "350 MB".
|
||||
pub fn gigabytes(bytes: u64) -> String {
|
||||
horizon_companion::setup::gigabytes(bytes)
|
||||
}
|
||||
|
||||
/// A name the companion can have: letters, digits and `_`, 16 at most.
|
||||
pub fn name_chars(typed: &str) -> String {
|
||||
typed
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric() || *c == '_')
|
||||
.take(16)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rows_follow_voice_and_advanced() {
|
||||
let mut view = CompanionView::default();
|
||||
assert!(!rows(&view).contains(&Row::TalkKey));
|
||||
view.settings.voice = true;
|
||||
view.advanced = true;
|
||||
let r = rows(&view);
|
||||
assert!(r.contains(&Row::TalkKey) && r.contains(&Row::Part(Part::TextToSpeech)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicks_cycle_and_report() {
|
||||
let mut view = CompanionView::default();
|
||||
assert_eq!(click(Row::Role, &mut view, false), Some(Action::Changed));
|
||||
assert_eq!(view.settings.persona.role, Role::Teammate);
|
||||
click(Row::Perception, &mut view, true);
|
||||
assert_eq!(view.settings.persona.perception, Perception::AllKnowing);
|
||||
// Salty asks for 18+ first, and stays off until it is confirmed.
|
||||
click(Row::Language, &mut view, false);
|
||||
assert!(view.confirm_salty && !view.settings.persona.salty_on());
|
||||
assert!(rows(&view).contains(&Row::SaltyConfirm));
|
||||
click(Row::SaltyConfirm, &mut view, false);
|
||||
assert!(view.settings.persona.salty_on() && !rows(&view).contains(&Row::SaltyConfirm));
|
||||
assert_eq!(
|
||||
row_parts(Row::Language, &view, "", None).2,
|
||||
Control::Button("Salty (18+)".into())
|
||||
);
|
||||
// Kid-friendly turns it off; coming back needs no second confirmation.
|
||||
click(Row::Language, &mut view, false);
|
||||
assert!(view.settings.persona.kid_friendly && !view.settings.persona.salty_on());
|
||||
click(Row::Language, &mut view, false);
|
||||
click(Row::Language, &mut view, false);
|
||||
assert!(view.settings.persona.salty_on() && !view.confirm_salty);
|
||||
view.choices = vec![(Part::Classifier, vec![("e5@cpu".into(), "Accurate".into())])];
|
||||
click(Row::Part(Part::Classifier), &mut view, false);
|
||||
assert_eq!(
|
||||
view.settings.classifier,
|
||||
PartChoice::Option {
|
||||
id: "e5@cpu".into()
|
||||
}
|
||||
);
|
||||
click(Row::Part(Part::Classifier), &mut view, false);
|
||||
assert_eq!(view.settings.classifier, PartChoice::Off);
|
||||
click(Row::Part(Part::Classifier), &mut view, false);
|
||||
assert_eq!(view.settings.classifier, PartChoice::Auto);
|
||||
assert_eq!(click(Row::Spawn, &mut view, false), None);
|
||||
view.in_world = true;
|
||||
assert_eq!(click(Row::Spawn, &mut view, false), Some(Action::Spawn));
|
||||
assert_eq!(name_chars("Kev the 2nd!"), "Kevthe2nd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watch_shows_once_a_companion_is_here() {
|
||||
let mut view = CompanionView {
|
||||
ready: true,
|
||||
in_world: true,
|
||||
..CompanionView::default()
|
||||
};
|
||||
assert!(!rows(&view).contains(&Row::Watch));
|
||||
view.here = Some("Kev".into());
|
||||
assert!(rows(&view).contains(&Row::Watch));
|
||||
assert_eq!(click(Row::Watch, &mut view, false), Some(Action::Watch));
|
||||
assert_eq!(
|
||||
row_parts(Row::Watch, &view, "", None).2,
|
||||
Control::Button("Watch".into())
|
||||
);
|
||||
view.watching = true;
|
||||
assert_eq!(
|
||||
row_parts(Row::Watch, &view, "", None).2,
|
||||
Control::Button("Stop watching".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voices_default_to_the_personality_and_can_be_picked() {
|
||||
let mut view = CompanionView::default();
|
||||
view.settings.voice = true;
|
||||
assert!(!rows(&view).contains(&Row::VoiceCharacter));
|
||||
view.tts_model = Some("tts".into());
|
||||
view.voices = vec![
|
||||
("ryan".into(), "Energetic, bright".into(), "Man".into()),
|
||||
("uncle_fu".into(), "Gruff, deep".into(), "Man, deep".into()),
|
||||
];
|
||||
view.default_voice = Some("uncle_fu".into());
|
||||
assert!(rows(&view).contains(&Row::VoiceSample));
|
||||
let label = |v: &CompanionView| row_parts(Row::VoiceCharacter, v, "", None).2;
|
||||
assert_eq!(
|
||||
label(&view),
|
||||
Control::Button("Gruff, deep (default)".into())
|
||||
);
|
||||
click(Row::VoiceCharacter, &mut view, false);
|
||||
assert_eq!(
|
||||
view.settings.voices.get("tts").map(String::as_str),
|
||||
Some("ryan")
|
||||
);
|
||||
assert_eq!(label(&view), Control::Button("Energetic, bright".into()));
|
||||
click(Row::VoiceCharacter, &mut view, true);
|
||||
assert!(view.settings.voices.is_empty());
|
||||
assert_eq!(
|
||||
click(Row::VoiceSample, &mut view, false),
|
||||
Some(Action::PlaySample)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,613 @@
|
||||
//! The companion's voice in the game (docs/companions.md, "Voice"): push-to-talk in (the microphone,
|
||||
//! opened only while the key is held and resampled to 16 kHz, then speech to text on a worker thread)
|
||||
//! and its lines out (speech streamed into the mixer from where the companion stands). Nothing here
|
||||
//! exists until voice is turned on: the game holds none of it otherwise.
|
||||
use crate::audio::{Audio, VoiceId};
|
||||
use horizon_companion::senses::SenseModel;
|
||||
use horizon_senses::{
|
||||
dsp::To16k,
|
||||
voice::{RecognitionClient, Speak, SpeechClient, SpeechEvent, Transcript},
|
||||
};
|
||||
use std::{
|
||||
io,
|
||||
sync::{Arc, Mutex, mpsc},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
/// The longest clip: held longer, push-to-talk lets go by itself. Parakeet's memory grows with the
|
||||
/// clip (2.6 GB resident after 3.5 s, 3.3 GB after 14 s on the Mac mini).
|
||||
pub const MAX_CLIP: Duration = Duration::from_secs(15);
|
||||
/// Shorter than this is a tap of the key, not speech: nothing is sent.
|
||||
pub const MIN_CLIP: Duration = Duration::from_millis(300);
|
||||
|
||||
/// What push-to-talk understood.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Heard {
|
||||
/// The words, and their language when the model says (ISO 639-1).
|
||||
Said {
|
||||
text: String,
|
||||
language: Option<String>,
|
||||
},
|
||||
/// Too short, or nothing recognisable in it.
|
||||
Nothing,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// What the microphone callback fills.
|
||||
#[derive(Default)]
|
||||
struct Capture {
|
||||
resampler: Option<To16k>,
|
||||
samples: Vec<f32>,
|
||||
/// The loudest sample of the last callback, for the HUD's level.
|
||||
level: f32,
|
||||
}
|
||||
|
||||
impl Capture {
|
||||
#[cfg_attr(not(feature = "audio"), allow(dead_code))]
|
||||
fn push(&mut self, interleaved: &[f32]) {
|
||||
self.level = interleaved.iter().fold(0.0f32, |m, s| m.max(s.abs()));
|
||||
let max = (MAX_CLIP.as_secs_f32() * To16k::RATE as f32) as usize;
|
||||
if self.samples.len() < max
|
||||
&& let Some(r) = self.resampler.as_mut()
|
||||
{
|
||||
r.push(interleaved, &mut self.samples);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The open microphone: the default input device in its own format, mixed to mono 16 kHz.
|
||||
pub struct Mic {
|
||||
#[cfg(feature = "audio")]
|
||||
_stream: cpal::Stream,
|
||||
capture: Arc<Mutex<Capture>>,
|
||||
}
|
||||
|
||||
impl Mic {
|
||||
/// Open the default input. On macOS the first time asks the player for permission (the app's
|
||||
/// `NSMicrophoneUsageDescription`, packaging/macos/Info.plist).
|
||||
#[cfg(feature = "audio")]
|
||||
pub fn open() -> Result<Mic, String> {
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
let device = cpal::default_host()
|
||||
.default_input_device()
|
||||
.ok_or("no microphone")?;
|
||||
let config = device.default_input_config().map_err(|e| e.to_string())?;
|
||||
let capture = Arc::new(Mutex::new(Capture {
|
||||
resampler: Some(To16k::new(config.sample_rate().0, config.channels().into())),
|
||||
..Capture::default()
|
||||
}));
|
||||
let stream = build_input(&device, &config, capture.clone())?;
|
||||
stream.play().map_err(|e| e.to_string())?;
|
||||
Ok(Mic {
|
||||
_stream: stream,
|
||||
capture,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "audio"))]
|
||||
pub fn open() -> Result<Mic, String> {
|
||||
Err("this build has no sound (built without the `audio` feature)".into())
|
||||
}
|
||||
|
||||
/// How loud the player is, 0..1.
|
||||
pub fn level(&self) -> f32 {
|
||||
self.capture.lock().map_or(0.0, |c| c.level)
|
||||
}
|
||||
|
||||
/// Close the device and take the clip (16 kHz mono).
|
||||
pub fn close(self) -> Vec<f32> {
|
||||
let capture = self.capture.clone();
|
||||
drop(self);
|
||||
capture
|
||||
.lock()
|
||||
.map(|mut c| std::mem::take(&mut c.samples))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "audio")]
|
||||
fn build_input(
|
||||
device: &cpal::Device,
|
||||
config: &cpal::SupportedStreamConfig,
|
||||
capture: Arc<Mutex<Capture>>,
|
||||
) -> Result<cpal::Stream, String> {
|
||||
use cpal::SampleFormat as F;
|
||||
fn stream<T: cpal::SizedSample>(
|
||||
device: &cpal::Device,
|
||||
config: &cpal::SupportedStreamConfig,
|
||||
capture: Arc<Mutex<Capture>>,
|
||||
) -> Result<cpal::Stream, String>
|
||||
where
|
||||
f32: cpal::FromSample<T>,
|
||||
{
|
||||
use cpal::traits::DeviceTrait;
|
||||
let mut scratch = Vec::new();
|
||||
device
|
||||
.build_input_stream(
|
||||
&config.config(),
|
||||
move |data: &[T], _| {
|
||||
scratch.clear();
|
||||
scratch.extend(data.iter().map(|s| s.to_sample::<f32>()));
|
||||
if let Ok(mut c) = capture.lock() {
|
||||
c.push(&scratch);
|
||||
}
|
||||
},
|
||||
|e| log::warn!("Horizon: microphone error: {e}"),
|
||||
None,
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
match config.sample_format() {
|
||||
F::F32 => stream::<f32>(device, config, capture),
|
||||
F::I16 => stream::<i16>(device, config, capture),
|
||||
F::I32 => stream::<i32>(device, config, capture),
|
||||
F::U16 => stream::<u16>(device, config, capture),
|
||||
F::U8 => stream::<u8>(device, config, capture),
|
||||
F::I8 => stream::<i8>(device, config, capture),
|
||||
F::F64 => stream::<f64>(device, config, capture),
|
||||
other => Err(format!("unsupported microphone format {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Push-to-talk: `press` opens the microphone, `release` closes it and sends the clip to speech to
|
||||
/// text on a worker thread, `poll` (each tick) brings the result.
|
||||
pub struct PushToTalk {
|
||||
recognition: RecognitionClient,
|
||||
/// The game's language (ISO 639-1): a hint for the models that need one.
|
||||
pub language: Option<String>,
|
||||
mic: Option<(Mic, Instant)>,
|
||||
pending: Option<mpsc::Receiver<io::Result<Transcript>>>,
|
||||
}
|
||||
|
||||
impl PushToTalk {
|
||||
pub fn new(recognition: RecognitionClient, language: Option<String>) -> PushToTalk {
|
||||
PushToTalk {
|
||||
recognition,
|
||||
language,
|
||||
mic: None,
|
||||
pending: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn model(&self) -> &'static SenseModel {
|
||||
self.recognition.model()
|
||||
}
|
||||
|
||||
/// The key went down: open the microphone.
|
||||
pub fn press(&mut self) -> Result<(), String> {
|
||||
if self.mic.is_none() {
|
||||
self.mic = Some((Mic::open()?, Instant::now()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The key came up (or the clip is full): close the microphone and transcribe what it heard.
|
||||
/// `None` when the clip was too short to send.
|
||||
pub fn release(&mut self) -> Option<Heard> {
|
||||
let (mic, opened) = self.mic.take()?;
|
||||
let held = opened.elapsed();
|
||||
let clip = mic.close();
|
||||
if held < MIN_CLIP || clip.len() < (MIN_CLIP.as_secs_f32() * To16k::RATE as f32) as usize {
|
||||
return Some(Heard::Nothing);
|
||||
}
|
||||
self.transcribe(clip);
|
||||
None
|
||||
}
|
||||
|
||||
/// Send a 16 kHz clip to speech to text on a worker thread.
|
||||
pub fn transcribe(&mut self, clip: Vec<f32>) {
|
||||
let (send, receive) = mpsc::channel();
|
||||
let recognition = self.recognition.clone();
|
||||
let language = self.language.clone();
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("companion ears".into())
|
||||
.spawn(move || {
|
||||
let _ = send.send(recognition.transcribe(&clip, language.as_deref()));
|
||||
});
|
||||
if spawned.is_ok() {
|
||||
self.pending = Some(receive);
|
||||
}
|
||||
}
|
||||
|
||||
/// Each tick: lets go of a clip that reached `MAX_CLIP`, and brings what speech to text heard.
|
||||
pub fn poll(&mut self) -> Option<Heard> {
|
||||
if self
|
||||
.mic
|
||||
.as_ref()
|
||||
.is_some_and(|(_, opened)| opened.elapsed() >= MAX_CLIP)
|
||||
&& let Some(heard) = self.release()
|
||||
{
|
||||
return Some(heard);
|
||||
}
|
||||
let result = match self.pending.as_ref()?.try_recv() {
|
||||
Ok(result) => result,
|
||||
Err(mpsc::TryRecvError::Empty) => return None,
|
||||
Err(mpsc::TryRecvError::Disconnected) => {
|
||||
Err(io::Error::other("speech to text stopped"))
|
||||
}
|
||||
};
|
||||
self.pending = None;
|
||||
Some(match result {
|
||||
Ok(t) if t.text.chars().any(char::is_alphanumeric) => Heard::Said {
|
||||
text: t.text,
|
||||
language: t.language,
|
||||
},
|
||||
Ok(_) => Heard::Nothing,
|
||||
Err(e) => Heard::Failed(e.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
/// The microphone is open.
|
||||
pub fn listening(&self) -> bool {
|
||||
self.mic.is_some()
|
||||
}
|
||||
|
||||
/// The microphone's level while it is open.
|
||||
pub fn level(&self) -> f32 {
|
||||
self.mic.as_ref().map_or(0.0, |(m, _)| m.level())
|
||||
}
|
||||
|
||||
/// A clip is being transcribed.
|
||||
pub fn busy(&self) -> bool {
|
||||
self.pending.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// A line waiting for its turn or being generated.
|
||||
struct Line {
|
||||
speak: Speak,
|
||||
events: Option<mpsc::Receiver<SpeechEvent>>,
|
||||
gain: f32,
|
||||
/// Its subtitle went up (with its first sound).
|
||||
shown: bool,
|
||||
/// What it sounded like, kept for the WAV tap.
|
||||
recorded: Vec<f32>,
|
||||
}
|
||||
|
||||
/// One companion talking: its lines are generated one after the other into one stream, so the
|
||||
/// sentences of a reply follow without gaps.
|
||||
struct Talker {
|
||||
name: String,
|
||||
lines: std::collections::VecDeque<Line>,
|
||||
voice: Option<VoiceId>,
|
||||
at: [f64; 3],
|
||||
elsewhere: bool,
|
||||
}
|
||||
|
||||
/// The companions' voices: `say` queues a line for a speaker, `tick` moves what the speech model
|
||||
/// generated into the mixer and keeps each voice where its speaker is.
|
||||
pub struct Speakers {
|
||||
client: SpeechClient,
|
||||
talkers: Vec<Talker>,
|
||||
/// `HORIZON_COMPANION_VOICE_WAV=<folder>` (tests and demos): every line is also written there
|
||||
/// as `<n>-<speaker>.wav`, so a voice can be checked without playing it out loud.
|
||||
tap: Option<(std::path::PathBuf, u32)>,
|
||||
}
|
||||
|
||||
/// Lines a speaker may have waiting for their turn.
|
||||
const MAX_WAITING: usize = 4;
|
||||
|
||||
/// The longest subtitle; the whole line is in the chat.
|
||||
const SUBTITLE_CHARS: usize = 48;
|
||||
|
||||
impl Speakers {
|
||||
pub fn new(client: SpeechClient) -> Speakers {
|
||||
// Numbered after the session's start, so a second run doesn't overwrite the first.
|
||||
let start = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs() % 100_000);
|
||||
let tap = std::env::var_os("HORIZON_COMPANION_VOICE_WAV").map(|dir| (dir.into(), start as u32 * 1000));
|
||||
Speakers {
|
||||
client,
|
||||
talkers: Vec::new(),
|
||||
tap,
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue a line for `speaker` (a sentence: the brain's reply comes sentence by sentence).
|
||||
pub fn say(&mut self, speaker: &str, speak: Speak) {
|
||||
let gain = self.client.request(&speak).2;
|
||||
let line = Line {
|
||||
speak,
|
||||
events: None,
|
||||
gain,
|
||||
shown: false,
|
||||
recorded: Vec::new(),
|
||||
};
|
||||
match self.talkers.iter_mut().find(|t| t.name == speaker) {
|
||||
Some(t) => {
|
||||
// A line waiting behind too many others is stale by the time its turn comes (a run of
|
||||
// quick orders): the oldest not yet started goes; the chat still has it.
|
||||
let waiting = t.lines.iter().filter(|l| l.events.is_none()).count();
|
||||
if waiting >= MAX_WAITING
|
||||
&& let Some(i) = t.lines.iter().position(|l| l.events.is_none())
|
||||
{
|
||||
t.lines.remove(i);
|
||||
}
|
||||
t.lines.push_back(line);
|
||||
}
|
||||
None => self.talkers.push(Talker {
|
||||
name: speaker.to_owned(),
|
||||
lines: [line].into(),
|
||||
voice: None,
|
||||
at: [0.0; 3],
|
||||
elsewhere: true,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a speaker is: its position, and whether it is in another dimension than the player.
|
||||
pub fn place(&mut self, speaker: &str, at: [f64; 3], elsewhere: bool) {
|
||||
if let Some(t) = self.talkers.iter_mut().find(|t| t.name == speaker) {
|
||||
t.at = at;
|
||||
t.elsewhere = elsewhere;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `speaker` has something left to say or play.
|
||||
pub fn talking(&self, speaker: &str, audio: &Audio) -> bool {
|
||||
self.talkers.iter().any(|t| {
|
||||
t.name == speaker
|
||||
&& (!t.lines.is_empty() || t.voice.is_some_and(|v| audio.voice_playing(v)))
|
||||
})
|
||||
}
|
||||
|
||||
/// Silence a speaker (its lines still generating are dropped).
|
||||
pub fn hush(&mut self, speaker: &str, audio: &mut Audio) {
|
||||
self.talkers.retain(|t| {
|
||||
if t.name != speaker {
|
||||
return true;
|
||||
}
|
||||
if let Some(v) = t.voice {
|
||||
audio.voice_stop(v);
|
||||
}
|
||||
false
|
||||
});
|
||||
}
|
||||
|
||||
/// Each tick: start the next line of each speaker, move generated samples into the mixer, place
|
||||
/// the voices, and forget speakers that are done. Returns the errors of failed lines.
|
||||
pub fn tick(&mut self, audio: &mut Audio) -> Vec<String> {
|
||||
let mut failed = Vec::new();
|
||||
let client = &self.client;
|
||||
let tap = &mut self.tap;
|
||||
self.talkers.retain_mut(|t| {
|
||||
if let Some(v) = t.voice {
|
||||
audio.voice_place(v, t.at, t.elsewhere);
|
||||
}
|
||||
while let Some(line) = t.lines.front_mut() {
|
||||
let events = line
|
||||
.events
|
||||
.get_or_insert_with(|| client.speak(line.speak.clone()));
|
||||
let mut finished = false;
|
||||
for event in events.try_iter() {
|
||||
match event {
|
||||
SpeechEvent::Audio(mut samples) => {
|
||||
let rate = client.model_for(&line.speak.language).sample_rate;
|
||||
let voice = match t.voice.filter(|v| audio.voice_playing(*v)) {
|
||||
Some(v) => Some(v),
|
||||
None => audio.voice_start(rate, 1.0, t.at, t.elsewhere),
|
||||
};
|
||||
t.voice = voice;
|
||||
if !line.shown {
|
||||
line.shown = true;
|
||||
let subtitle = subtitle(&t.name, &line.speak.text);
|
||||
audio.voice_subtitle(&subtitle, (!t.elsewhere).then_some(t.at));
|
||||
}
|
||||
samples.iter_mut().for_each(|s| *s *= line.gain);
|
||||
if tap.is_some() {
|
||||
line.recorded.extend_from_slice(&samples);
|
||||
}
|
||||
if let Some(v) = voice {
|
||||
audio.voice_push(v, &samples);
|
||||
}
|
||||
}
|
||||
SpeechEvent::Done(_) => {
|
||||
finished = true;
|
||||
if let Some((dir, n)) = tap.as_mut() {
|
||||
*n += 1;
|
||||
let rate = client.model_for(&line.speak.language).sample_rate;
|
||||
let path = dir.join(format!("{n:08}-{}.wav", t.name));
|
||||
let wav = horizon_senses::dsp::wav_pcm16(&line.recorded, rate);
|
||||
if let Err(e) = std::fs::create_dir_all(dir).and_then(|()| std::fs::write(&path, wav)) {
|
||||
failed.push(format!("{}: {e}", path.display()));
|
||||
}
|
||||
}
|
||||
}
|
||||
SpeechEvent::Failed(why) => {
|
||||
failed.push(why);
|
||||
finished = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !finished {
|
||||
break;
|
||||
}
|
||||
t.lines.pop_front();
|
||||
}
|
||||
if t.lines.is_empty() {
|
||||
match t.voice {
|
||||
Some(v) if audio.voice_playing(v) => {
|
||||
audio.voice_finish(v);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
failed
|
||||
}
|
||||
}
|
||||
|
||||
/// "Name: the line", cut to `SUBTITLE_CHARS`.
|
||||
fn subtitle(name: &str, text: &str) -> String {
|
||||
let line = format!("{name}: {}", text.trim());
|
||||
if line.chars().count() <= SUBTITLE_CHARS {
|
||||
return line;
|
||||
}
|
||||
let cut: String = line.chars().take(SUBTITLE_CHARS - 1).collect();
|
||||
format!("{}…", cut.trim_end())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_companion::senses::model;
|
||||
use std::io::{Read, Write};
|
||||
|
||||
/// A stand-in server answering one request with `reply`.
|
||||
fn one_shot(reply: String) -> String {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
std::thread::spawn(move || {
|
||||
let (mut s, _) = listener.accept().unwrap();
|
||||
let mut buf = vec![0; 1 << 20];
|
||||
let mut got = Vec::new();
|
||||
// Read the headers and the body (Content-Length), then answer.
|
||||
loop {
|
||||
let n = s.read(&mut buf).unwrap();
|
||||
got.extend_from_slice(&buf[..n]);
|
||||
let text = String::from_utf8_lossy(&got);
|
||||
if let Some(end) = text.find("\r\n\r\n") {
|
||||
let length = text[..end]
|
||||
.lines()
|
||||
.find_map(|l| {
|
||||
l.to_lowercase()
|
||||
.strip_prefix("content-length:")
|
||||
.map(|v| v.trim().parse::<usize>().unwrap())
|
||||
})
|
||||
.unwrap_or(0);
|
||||
if got.len() >= end + 4 + length {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
s.write_all(reply.as_bytes()).unwrap();
|
||||
});
|
||||
base
|
||||
}
|
||||
|
||||
fn json_reply(body: &str) -> String {
|
||||
format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
)
|
||||
}
|
||||
|
||||
fn wait(ptt: &mut PushToTalk) -> Heard {
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
if let Some(h) = ptt.poll() {
|
||||
return h;
|
||||
}
|
||||
assert!(start.elapsed() < Duration::from_secs(10));
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clip_comes_back_as_words_or_nothing() {
|
||||
let parakeet = model("parakeet-tdt-0.6b-v3-q8_0").unwrap();
|
||||
let base = one_shot(json_reply(r#"{"text":"suis-moi","timing":{}}"#));
|
||||
let mut ptt = PushToTalk::new(RecognitionClient::new(&base, parakeet), Some("fr".into()));
|
||||
assert!(!ptt.listening() && !ptt.busy());
|
||||
ptt.transcribe(vec![0.1; 16000]);
|
||||
assert!(ptt.busy());
|
||||
assert_eq!(
|
||||
wait(&mut ptt),
|
||||
Heard::Said {
|
||||
text: "suis-moi".into(),
|
||||
language: None
|
||||
}
|
||||
);
|
||||
assert!(!ptt.busy());
|
||||
// Canary returns nothing for some clips: not a line.
|
||||
let base = one_shot(json_reply(r#"{"text":" . ","language":"es"}"#));
|
||||
let mut ptt = PushToTalk::new(RecognitionClient::new(&base, parakeet), None);
|
||||
ptt.transcribe(vec![0.1; 16000]);
|
||||
assert_eq!(wait(&mut ptt), Heard::Nothing);
|
||||
// A server that is gone.
|
||||
let mut ptt = PushToTalk::new(RecognitionClient::new("http://127.0.0.1:1", parakeet), None);
|
||||
ptt.transcribe(vec![0.1; 16000]);
|
||||
assert!(matches!(wait(&mut ptt), Heard::Failed(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_capture_resamples_to_16_khz_and_stops_at_the_longest_clip() {
|
||||
let mut c = Capture {
|
||||
resampler: Some(To16k::new(48000, 2)),
|
||||
..Capture::default()
|
||||
};
|
||||
c.push(&vec![0.5; 48000 * 2]);
|
||||
assert!(
|
||||
(c.samples.len() as i32 - 16000).abs() < 10,
|
||||
"{}",
|
||||
c.samples.len()
|
||||
);
|
||||
assert_eq!(c.level, 0.5);
|
||||
for _ in 0..20 {
|
||||
c.push(&vec![0.0; 48000 * 2]);
|
||||
}
|
||||
let max = (MAX_CLIP.as_secs_f32() * 16000.0) as usize;
|
||||
assert!(c.samples.len() <= max + 16000, "{}", c.samples.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subtitles_name_the_speaker_and_stay_short() {
|
||||
assert_eq!(subtitle("Kira", " Creeper! "), "Kira: Creeper!");
|
||||
let long = subtitle("Kira", &"word ".repeat(30));
|
||||
assert_eq!(long.chars().count(), SUBTITLE_CHARS);
|
||||
assert!(long.ends_with('…'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_speakers_lines_play_one_after_the_other_in_one_stream() {
|
||||
// Two lines of 0.25 s at 24 kHz from a stand-in speech server (one connection each).
|
||||
// 12 000 zero bytes in base64.
|
||||
let chunk = "A".repeat(16000);
|
||||
let sse = format!(
|
||||
"data: {{\"type\":\"speech.audio.delta\",\"audio\":\"{chunk}\"}}\n\ndata: {{\"type\":\"speech.audio.done\"}}\n\ndata: [DONE]\n\n"
|
||||
);
|
||||
let reply = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{sse}",
|
||||
sse.len()
|
||||
);
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
let served = std::thread::spawn(move || {
|
||||
for _ in 0..2 {
|
||||
let (mut s, _) = listener.accept().unwrap();
|
||||
let mut buf = [0; 4096];
|
||||
let _ = s.read(&mut buf).unwrap();
|
||||
s.write_all(reply.as_bytes()).unwrap();
|
||||
}
|
||||
});
|
||||
let pocket = model("pocket-tts-en-q8_0").unwrap();
|
||||
let mut speakers = Speakers::new(SpeechClient::new(&base, vec![pocket]));
|
||||
let line = |text: &str| Speak {
|
||||
text: text.into(),
|
||||
voice: None,
|
||||
tone: horizon_senses::voice::Tone::Shout,
|
||||
language: "en".into(),
|
||||
};
|
||||
speakers.say("Kira", line("One."));
|
||||
speakers.say("Kira", line("Two."));
|
||||
// A silent Audio has no output: the lines are generated, nothing is mixed.
|
||||
let mut audio = Audio::silent(std::path::Path::new("/nonexistent/horizon-test-assets"));
|
||||
let start = Instant::now();
|
||||
while speakers.talking("Kira", &audio) {
|
||||
assert!(speakers.tick(&mut audio).is_empty());
|
||||
assert!(start.elapsed() < Duration::from_secs(10));
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
served.join().unwrap();
|
||||
speakers.say("Kira", line("Three."));
|
||||
speakers.hush("Kira", &mut audio);
|
||||
assert!(!speakers.talking("Kira", &audio));
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,9 @@ pub struct Furnace {
|
||||
pub cooking_total: i32,
|
||||
pub speed: f32,
|
||||
}
|
||||
/// A hopper's slots: the only plain container with five.
|
||||
pub const HOPPER_SLOTS: usize = 5;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum BlockEntity {
|
||||
Chest(Vec<Option<ItemStack>>),
|
||||
@@ -242,7 +245,7 @@ impl BlockEntity {
|
||||
let size = match name {
|
||||
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => 27,
|
||||
n if n.ends_with("copper_chest") => 27,
|
||||
"minecraft:hopper" => 5,
|
||||
"minecraft:hopper" => HOPPER_SLOTS,
|
||||
"minecraft:dispenser" | "minecraft:dropper" => 9,
|
||||
n if n.ends_with("shulker_box") => 27,
|
||||
_ => return None,
|
||||
@@ -270,7 +273,7 @@ impl BlockEntity {
|
||||
pub fn id(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Chest(items) => match items.len() {
|
||||
5 => "minecraft:hopper",
|
||||
HOPPER_SLOTS => "minecraft:hopper",
|
||||
9 => "minecraft:dispenser",
|
||||
_ => "minecraft:chest",
|
||||
},
|
||||
|
||||
@@ -1209,7 +1209,15 @@ impl Recipes {
|
||||
for (prefix, path) in files {
|
||||
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
|
||||
let name = format!("{prefix}{stem}");
|
||||
let v: Value = serde_json::from_slice(&fs::read(&path)?)?;
|
||||
// Vanilla's RecipeManager logs a file that does not parse and
|
||||
// goes on ("Couldn't parse data file").
|
||||
let v: Value = match serde_json::from_slice(&fs::read(&path)?) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: recipe {name} not loaded: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let text = |k: &str| v[k].as_str().unwrap_or("").to_owned();
|
||||
meta.insert(name.clone(), (text("category"), text("group")));
|
||||
let ing = |k: &str| Ingredient::parse(&v[k], items);
|
||||
|
||||
@@ -336,67 +336,8 @@ impl PlayerEffects {
|
||||
pub fn modifiers(&self) -> Vec<(&'static str, &'static str, f64, &'static str)> {
|
||||
let mut out = Vec::new();
|
||||
for e in &self.list {
|
||||
let level = f64::from(e.amplifier + 1);
|
||||
let m = match e.id {
|
||||
"speed" => Some((
|
||||
"minecraft:movement_speed",
|
||||
"minecraft:effect.speed",
|
||||
0.2,
|
||||
"add_multiplied_total",
|
||||
)),
|
||||
"slowness" => Some((
|
||||
"minecraft:movement_speed",
|
||||
"minecraft:effect.slowness",
|
||||
-0.15,
|
||||
"add_multiplied_total",
|
||||
)),
|
||||
"haste" => Some((
|
||||
"minecraft:attack_speed",
|
||||
"minecraft:effect.haste",
|
||||
0.1,
|
||||
"add_multiplied_total",
|
||||
)),
|
||||
"mining_fatigue" => Some((
|
||||
"minecraft:attack_speed",
|
||||
"minecraft:effect.mining_fatigue",
|
||||
-0.1,
|
||||
"add_multiplied_total",
|
||||
)),
|
||||
"strength" => Some((
|
||||
"minecraft:attack_damage",
|
||||
"minecraft:effect.strength",
|
||||
3.0,
|
||||
"add_value",
|
||||
)),
|
||||
"weakness" => Some((
|
||||
"minecraft:attack_damage",
|
||||
"minecraft:effect.weakness",
|
||||
-4.0,
|
||||
"add_value",
|
||||
)),
|
||||
"jump_boost" => Some((
|
||||
"minecraft:safe_fall_distance",
|
||||
"minecraft:effect.jump_boost",
|
||||
1.0,
|
||||
"add_value",
|
||||
)),
|
||||
"health_boost" => Some((
|
||||
"minecraft:max_health",
|
||||
"minecraft:effect.health_boost",
|
||||
4.0,
|
||||
"add_value",
|
||||
)),
|
||||
"luck" => Some(("minecraft:luck", "minecraft:effect.luck", 1.0, "add_value")),
|
||||
"unluck" => Some((
|
||||
"minecraft:luck",
|
||||
"minecraft:effect.unluck",
|
||||
-1.0,
|
||||
"add_value",
|
||||
)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some((a, id, amount, op)) = m {
|
||||
out.push((a, id, amount * level, op));
|
||||
if let Some((a, id, amount, op)) = effect_modifier(e.id) {
|
||||
out.push((a, id, amount * f64::from(e.amplifier + 1), op));
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -439,6 +380,76 @@ impl StatusFlags {
|
||||
}
|
||||
}
|
||||
|
||||
/// The attribute modifier an effect adds at amplifier 0
|
||||
/// (`MobEffect.addAttributeModifier`): (attribute, id, amount, operation).
|
||||
/// Each level multiplies the amount (`amplifier + 1`).
|
||||
pub fn effect_modifier(effect: &str) -> Option<(&'static str, &'static str, f64, &'static str)> {
|
||||
match effect {
|
||||
"speed" => Some((
|
||||
"minecraft:movement_speed",
|
||||
"minecraft:effect.speed",
|
||||
0.2,
|
||||
"add_multiplied_total",
|
||||
)),
|
||||
"slowness" => Some((
|
||||
"minecraft:movement_speed",
|
||||
"minecraft:effect.slowness",
|
||||
-0.15,
|
||||
"add_multiplied_total",
|
||||
)),
|
||||
"haste" => Some((
|
||||
"minecraft:attack_speed",
|
||||
"minecraft:effect.haste",
|
||||
0.1,
|
||||
"add_multiplied_total",
|
||||
)),
|
||||
"mining_fatigue" => Some((
|
||||
"minecraft:attack_speed",
|
||||
"minecraft:effect.mining_fatigue",
|
||||
-0.1,
|
||||
"add_multiplied_total",
|
||||
)),
|
||||
"strength" => Some((
|
||||
"minecraft:attack_damage",
|
||||
"minecraft:effect.strength",
|
||||
3.0,
|
||||
"add_value",
|
||||
)),
|
||||
"weakness" => Some((
|
||||
"minecraft:attack_damage",
|
||||
"minecraft:effect.weakness",
|
||||
-4.0,
|
||||
"add_value",
|
||||
)),
|
||||
"jump_boost" => Some((
|
||||
"minecraft:safe_fall_distance",
|
||||
"minecraft:effect.jump_boost",
|
||||
1.0,
|
||||
"add_value",
|
||||
)),
|
||||
"health_boost" => Some((
|
||||
"minecraft:max_health",
|
||||
"minecraft:effect.health_boost",
|
||||
4.0,
|
||||
"add_value",
|
||||
)),
|
||||
"absorption" => Some((
|
||||
"minecraft:max_absorption",
|
||||
"minecraft:effect.absorption",
|
||||
4.0,
|
||||
"add_value",
|
||||
)),
|
||||
"luck" => Some(("minecraft:luck", "minecraft:effect.luck", 1.0, "add_value")),
|
||||
"unluck" => Some((
|
||||
"minecraft:luck",
|
||||
"minecraft:effect.unluck",
|
||||
-1.0,
|
||||
"add_value",
|
||||
)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -538,4 +549,29 @@ mod tests {
|
||||
assert!((fx.mining_speed_factor() - 1.4).abs() < 1e-6);
|
||||
assert_eq!(fx.modifiers().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absorption_modifier_scales_with_amplifier() {
|
||||
let mut fx = PlayerEffects::default();
|
||||
fx.add(EffectInstance::new("absorption", 2400, 0));
|
||||
assert_eq!(
|
||||
fx.modifiers(),
|
||||
vec![(
|
||||
"minecraft:max_absorption",
|
||||
"minecraft:effect.absorption",
|
||||
4.0,
|
||||
"add_value",
|
||||
)]
|
||||
);
|
||||
fx.add(EffectInstance::new("absorption", 2400, 3));
|
||||
assert_eq!(
|
||||
fx.modifiers(),
|
||||
vec![(
|
||||
"minecraft:max_absorption",
|
||||
"minecraft:effect.absorption",
|
||||
16.0,
|
||||
"add_value",
|
||||
)]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ pub struct ItemEntity {
|
||||
pub fire_ticks: i32,
|
||||
pub id: u32,
|
||||
pub stack: ItemStack,
|
||||
pub thrower: Option<[i32; 4]>,
|
||||
pub position: Vec3,
|
||||
pub previous: Vec3,
|
||||
pub velocity: Vec3,
|
||||
@@ -178,6 +179,8 @@ impl ItemEntity {
|
||||
/// ExperienceOrb: gravity 0.03, homing towards the player within 8 blocks.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ExperienceOrb {
|
||||
/// The entity id servers track it by.
|
||||
pub id: u32,
|
||||
pub position: Vec3,
|
||||
pub previous: Vec3,
|
||||
pub velocity: Vec3,
|
||||
@@ -532,15 +535,16 @@ impl ArmorStand {
|
||||
Vec3::new(p.x + w, p.y + h, p.z + w),
|
||||
)
|
||||
}
|
||||
/// LivingEntity.travel with gravity 0.08 and 0.98 drag, falling until it
|
||||
/// lands (armor stands have no other movement).
|
||||
/// LivingEntity.travelInAir (armor stands have no other movement): move
|
||||
/// by the velocity (a landing stops the fall,
|
||||
/// `Block.updateEntityMovementAfterFallOn`), then gravity 0.08 and the
|
||||
/// 0.98 drag, so a stand at rest on the ground keeps -0.0784 as vanilla's.
|
||||
pub fn tick(&mut self, scene: &mut crate::collisions::SceneCollisions) {
|
||||
if self.marker {
|
||||
return;
|
||||
}
|
||||
let mut v = self.velocity;
|
||||
v.y -= 0.08;
|
||||
let wanted = v;
|
||||
let wanted = self.velocity;
|
||||
let mut y = wanted.y;
|
||||
if let Ok(moved) = scene.resolve(self.bounds(), wanted, 0.0, self.on_ground) {
|
||||
self.position = Vec3::new(
|
||||
self.position.x + moved.x,
|
||||
@@ -548,9 +552,11 @@ impl ArmorStand {
|
||||
self.position.z + moved.z,
|
||||
);
|
||||
self.on_ground = wanted.y < 0.0 && moved.y > wanted.y;
|
||||
v = Vec3::new(0.0, if moved.y != wanted.y { 0.0 } else { v.y * 0.98 }, 0.0);
|
||||
if moved.y != wanted.y {
|
||||
y = 0.0;
|
||||
}
|
||||
}
|
||||
self.velocity = v;
|
||||
self.velocity = Vec3::new(0.0, (y - 0.08) * f64::from(0.98f32), 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -596,9 +602,10 @@ mod fire_environment_tests {
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let items = crate::items::Items::load(&root, assets.registry, &facts).unwrap();
|
||||
let mut world = World::test_scene(&assets, 3).unwrap();
|
||||
let mut world = World::test_scene(&assets, &render, 3).unwrap();
|
||||
let at = Vec3::new(20.5, 4.0, 20.5);
|
||||
world
|
||||
.set(
|
||||
@@ -610,6 +617,7 @@ mod fire_environment_tests {
|
||||
.unwrap();
|
||||
let make = |id: &str| ItemEntity {
|
||||
id: 1,
|
||||
thrower: None,
|
||||
stack: ItemStack {
|
||||
item: std::sync::Arc::from(id),
|
||||
count: 1,
|
||||
|
||||
@@ -840,9 +840,12 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.species(),
|
||||
crate::mobs::Species::Slime | crate::mobs::Species::MagmaCube
|
||||
) {
|
||||
let health = m.health;
|
||||
// AbstractCubeMob.readAdditionalSaveData: the size's attributes, then
|
||||
// the saved health, the max health when there is none.
|
||||
crate::mobs::cube::set_size(&mut m, number(tag.get("Size")).unwrap_or(0) as i32 + 1);
|
||||
m.health = health;
|
||||
if let Some(h) = float(tag.get("Health")) {
|
||||
m.health = h as f32;
|
||||
}
|
||||
m.extra.flag2 = flag(tag, "wasOnGround");
|
||||
}
|
||||
// Zombie, Piglin and Zoglin.readAdditionalSaveData: IsBaby (the baby
|
||||
@@ -863,7 +866,7 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// `ItemEntity`: Health, Age, PickupDelay, Item.
|
||||
/// `ItemEntity`: Health, Age, PickupDelay, Item, Thrower.
|
||||
pub fn item_entity_to_nbt(e: &ItemEntity, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
let base = Base {
|
||||
id: "minecraft:item",
|
||||
@@ -887,6 +890,11 @@ pub fn item_entity_to_nbt(e: &ItemEntity, uuid: [i32; 4], raw: Option<&Tag>) ->
|
||||
] {
|
||||
put(&mut tag, key, value);
|
||||
}
|
||||
if let Some(thrower) = e.thrower {
|
||||
put(&mut tag, "Thrower", Tag::IntArray(thrower.to_vec()));
|
||||
} else if let Tag::Compound(map) = &mut tag {
|
||||
map.remove(&"Thrower".into());
|
||||
}
|
||||
tag
|
||||
}
|
||||
pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemEntity> {
|
||||
@@ -897,6 +905,7 @@ pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemE
|
||||
fire_ticks: number(tag.get("Fire")).unwrap_or(0) as i32,
|
||||
id,
|
||||
stack,
|
||||
thrower: uuid_from_tag(tag.get("Thrower")),
|
||||
position,
|
||||
previous: position,
|
||||
velocity: motion(tag),
|
||||
@@ -937,6 +946,7 @@ pub fn orbs_from_nbt(tag: &Tag) -> Vec<ExperienceOrb> {
|
||||
};
|
||||
let count = number(tag.get("Count")).unwrap_or(1).clamp(1, 64) as usize;
|
||||
let orb = ExperienceOrb {
|
||||
id: 0,
|
||||
position,
|
||||
previous: position,
|
||||
velocity: motion(tag),
|
||||
@@ -1529,6 +1539,7 @@ mod tests {
|
||||
let e = ItemEntity {
|
||||
health: 2,
|
||||
fire_ticks: 73,
|
||||
thrower: Some([5, 6, 7, 8]),
|
||||
id: 7,
|
||||
stack: ItemStack {
|
||||
item: Arc::from("minecraft:diamond_pickaxe"),
|
||||
@@ -1555,6 +1566,11 @@ mod tests {
|
||||
(120, 10, true)
|
||||
);
|
||||
assert_eq!(back.position.x, 100.5);
|
||||
assert_eq!(back.thrower, e.thrower);
|
||||
let mut cleared = back;
|
||||
cleared.thrower = None;
|
||||
let cleared = item_entity_to_nbt(&cleared, [1, 2, 3, 4], Some(&tag));
|
||||
assert!(cleared.get("Thrower").is_none());
|
||||
}
|
||||
#[test]
|
||||
fn unknown_fields_survive() {
|
||||
@@ -1575,6 +1591,7 @@ mod tests {
|
||||
#[test]
|
||||
fn orbs_expand_their_count() {
|
||||
let o = ExperienceOrb {
|
||||
id: 0,
|
||||
position: Vec3::new(1.0, 2.0, 3.0),
|
||||
previous: Vec3::ZERO,
|
||||
velocity: Vec3::ZERO,
|
||||
|
||||
@@ -105,6 +105,9 @@ pub struct Extras {
|
||||
pub sort: bool,
|
||||
/// A vanilla key name: sorts the part under the cursor.
|
||||
pub sort_key: String,
|
||||
/// No AI: every companion feature hidden (docs/companions.md). The launcher writes it too
|
||||
/// (`horizon_companion::settings::sync_extras`).
|
||||
pub no_ai: bool,
|
||||
}
|
||||
impl Default for Extras {
|
||||
fn default() -> Self {
|
||||
@@ -132,6 +135,7 @@ impl Default for Extras {
|
||||
mouse_tweaks_wheel: true,
|
||||
sort: false,
|
||||
sort_key: "key.keyboard.unknown".into(),
|
||||
no_ai: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -194,6 +198,7 @@ impl Extras {
|
||||
"mouseTweaksWheel" => e.mouse_tweaks_wheel = flag(e.mouse_tweaks_wheel),
|
||||
"sort" => e.sort = flag(e.sort),
|
||||
"sortKey" if value.starts_with("key.") => e.sort_key = value.to_owned(),
|
||||
"noAi" => e.no_ai = flag(e.no_ai),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -201,7 +206,7 @@ impl Extras {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"fullbright:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n",
|
||||
"fullbright:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nnoAi:{}\n",
|
||||
bool_text(self.fullbright),
|
||||
bool_text(self.zoom),
|
||||
self.zoom_key,
|
||||
@@ -229,6 +234,7 @@ impl Extras {
|
||||
bool_text(self.mouse_tweaks_wheel),
|
||||
bool_text(self.sort),
|
||||
self.sort_key,
|
||||
bool_text(self.no_ai),
|
||||
)
|
||||
}
|
||||
/// Binds the extra key `name` (`ZOOM_KEY`, `SORT_KEY`); false for
|
||||
@@ -445,6 +451,7 @@ pub enum Item {
|
||||
MouseTweaksWheel,
|
||||
Sort,
|
||||
SortKey,
|
||||
NoAi,
|
||||
}
|
||||
/// The Extras page: headers and items.
|
||||
pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
@@ -480,6 +487,8 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
Ok(SortKey),
|
||||
Err("Multiplayer"),
|
||||
Ok(AutoReconnect),
|
||||
Err("AI"),
|
||||
Ok(NoAi),
|
||||
]
|
||||
}
|
||||
/// "1 block", "2 blocks", "½ block".
|
||||
@@ -521,6 +530,7 @@ impl Item {
|
||||
Item::FpsLows => "Minimum and 1% low",
|
||||
Item::Keystrokes => "Keystrokes",
|
||||
Item::KeystrokesCps => "Clicks per second",
|
||||
Item::NoAi => "No AI",
|
||||
}
|
||||
}
|
||||
/// What the row says under its caption.
|
||||
@@ -559,6 +569,9 @@ impl Item {
|
||||
}
|
||||
Item::Keystrokes => "Movement keys, jump and mouse buttons as you press them.",
|
||||
Item::KeystrokesCps => "Clicks in the last second under each mouse button.",
|
||||
Item::NoAi => {
|
||||
"Hides the AI companion everywhere, here and in the launcher: nothing is downloaded or started."
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The frame-time cost the main switch of an extra shows.
|
||||
@@ -588,6 +601,7 @@ impl Item {
|
||||
Item::MouseTweaks => e.mouse_tweaks,
|
||||
Item::MouseTweaksWheel => e.mouse_tweaks_wheel,
|
||||
Item::Sort => e.sort,
|
||||
Item::NoAi => e.no_ai,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -635,6 +649,7 @@ impl Item {
|
||||
Item::MouseTweaks => e.mouse_tweaks = !e.mouse_tweaks,
|
||||
Item::MouseTweaksWheel => e.mouse_tweaks_wheel = !e.mouse_tweaks_wheel,
|
||||
Item::Sort => e.sort = !e.sort,
|
||||
Item::NoAi => e.no_ai = !e.no_ai,
|
||||
Item::MinimapCorner => e.minimap_corner = next(&Corner::ALL, e.minimap_corner, back),
|
||||
Item::MinimapSize => {
|
||||
let sizes: Vec<u8> = MINIMAP_SIZES.iter().map(|s| s.0).collect();
|
||||
|
||||
@@ -4,8 +4,9 @@ use horizon_world::{nbt::Tag, registry::BlockRegistry};
|
||||
|
||||
/// The layers of a flat generator, bottom up from the world's minimum Y.
|
||||
pub struct FlatTerrain {
|
||||
layers: Vec<u16>,
|
||||
layers: Vec<u32>,
|
||||
biome: u16,
|
||||
surface: Option<crate::lod::Surface>,
|
||||
}
|
||||
impl FlatTerrain {
|
||||
/// The overworld generator of `world_gen_settings` (`data.dimensions`), if it
|
||||
@@ -30,13 +31,44 @@ impl FlatTerrain {
|
||||
for layer in list {
|
||||
let block = crate::save::text(layer.get("block"))?;
|
||||
let height = crate::save::number(layer.get("height")).unwrap_or(0).max(0);
|
||||
let state = registry.default_state(&block)?.0 as u16;
|
||||
let state = registry.default_state(&block)?.0;
|
||||
layers.extend(std::iter::repeat_n(state, height as usize));
|
||||
}
|
||||
}
|
||||
let biome = biomes.id(&biome);
|
||||
let mut water = None;
|
||||
let mut surface = None;
|
||||
for (i, &id) in layers
|
||||
.iter()
|
||||
.take(crate::chunk::HEIGHT as usize)
|
||||
.enumerate()
|
||||
.rev()
|
||||
{
|
||||
let state = horizon_world::registry::BlockStateId(id);
|
||||
let name = registry.state(state)?.name.as_str();
|
||||
if matches!(
|
||||
name,
|
||||
"minecraft:air" | "minecraft:cave_air" | "minecraft:void_air"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let height = crate::chunk::MIN_Y + i as i32 + 1;
|
||||
if matches!(name, "minecraft:water" | "minecraft:lava") {
|
||||
water.get_or_insert((height, state));
|
||||
} else {
|
||||
surface = Some(crate::lod::Surface {
|
||||
height,
|
||||
state,
|
||||
water,
|
||||
biome,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(Self {
|
||||
layers,
|
||||
biome: biomes.id(&biome),
|
||||
biome,
|
||||
surface,
|
||||
})
|
||||
}
|
||||
/// FlatLevelSource.getSpawnHeight: the top of the layers.
|
||||
@@ -44,9 +76,119 @@ impl FlatTerrain {
|
||||
crate::chunk::MIN_Y + self.layers.len().min(crate::chunk::HEIGHT as usize) as i32
|
||||
}
|
||||
}
|
||||
/// DimensionType.Y_SIZE: the most layers FlatLevelGeneratorSettings takes.
|
||||
const Y_SIZE: i64 = 4064;
|
||||
|
||||
/// FlatLevelGeneratorSettings.CODEC reading a dedicated server's
|
||||
/// `generator-settings` (DedicatedServerProperties.WorldDimensionData for
|
||||
/// the `minecraft:flat` preset): the settings as `world_gen_settings` stores
|
||||
/// them, and the errors vanilla logs, in order (the constructor's, then
|
||||
/// `resultOrPartial`'s). No settings means the preset's own layers; a layer
|
||||
/// that doesn't parse is left out, as the codec's partial result leaves it.
|
||||
/// `is_block` says whether a block id is registered, `is_biome` a biome id.
|
||||
pub fn settings_from_json(
|
||||
json: &crate::server::json::Json,
|
||||
is_block: impl Fn(&str) -> bool,
|
||||
is_biome: impl Fn(&str) -> bool,
|
||||
) -> (Option<Tag>, Vec<String>) {
|
||||
use crate::server::json::Json;
|
||||
let id = |s: &str| {
|
||||
if s.contains(':') {
|
||||
s.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{s}")
|
||||
}
|
||||
};
|
||||
let Some(Json::Array(list)) = json.get("layers") else {
|
||||
let error = match json.get("layers") {
|
||||
None => format!("No key layers in MapLike[{}]", json.compact()),
|
||||
Some(v) => format!("Not a list: {}", v.compact()),
|
||||
};
|
||||
return (None, vec![error]);
|
||||
};
|
||||
let mut errors = Vec::new();
|
||||
let mut layers = Vec::new();
|
||||
let mut total = 0;
|
||||
for layer in list {
|
||||
let height = layer.get("height").and_then(Json::as_string);
|
||||
let height = height.as_deref().and_then(|h| h.parse::<i64>().ok());
|
||||
let block = layer.get("block").and_then(Json::as_string).map(|b| id(&b));
|
||||
match (height, block) {
|
||||
(Some(h), _) if !(0..=Y_SIZE).contains(&h) => {
|
||||
errors.push(format!("Value {h} outside of range [0:{Y_SIZE}]"));
|
||||
}
|
||||
(Some(h), Some(b)) if is_block(&b) => {
|
||||
total += h;
|
||||
layers.push(Tag::compound([
|
||||
("height", Tag::Int(h as i32)),
|
||||
("block", Tag::String(b.as_str().into())),
|
||||
]));
|
||||
}
|
||||
(Some(_), Some(b)) => errors.push(format!(
|
||||
"Unknown registry key in ResourceKey[minecraft:root / minecraft:block]: {b}"
|
||||
)),
|
||||
(None, _) => errors.push(format!("No key height in MapLike[{}]", layer.compact())),
|
||||
(_, None) => errors.push(format!("No key block in MapLike[{}]", layer.compact())),
|
||||
}
|
||||
}
|
||||
// The constructor: Biome.CODEC is lenient, and a biome that is missing
|
||||
// or unknown is Plains.
|
||||
let biome = json
|
||||
.get("biome")
|
||||
.and_then(Json::as_string)
|
||||
.map(|b| id(&b))
|
||||
.filter(|b| is_biome(b));
|
||||
let mut logged = Vec::new();
|
||||
if biome.is_none() {
|
||||
logged.push("Unknown biome, defaulting to plains".to_owned());
|
||||
}
|
||||
let biome = biome.unwrap_or_else(|| "minecraft:plains".to_owned());
|
||||
// validateHeight.
|
||||
if total > Y_SIZE {
|
||||
errors.push(format!("Sum of layer heights is > {Y_SIZE}"));
|
||||
logged.push(errors.join("; "));
|
||||
return (None, logged);
|
||||
}
|
||||
let flag = |key: &str| match json.get(key) {
|
||||
Some(Json::Bool(b)) => *b,
|
||||
Some(Json::Number(n)) => n.parse::<f64>().is_ok_and(|v| v != 0.0),
|
||||
_ => false,
|
||||
};
|
||||
let mut settings = Vec::new();
|
||||
// RegistryCodecs.holderSet, lenient too: a tag or a list of ids.
|
||||
match json.get("structure_overrides") {
|
||||
Some(Json::String(s)) => {
|
||||
settings.push(("structure_overrides", Tag::String(s.as_str().into())))
|
||||
}
|
||||
Some(Json::Array(ids)) if ids.iter().all(|i| matches!(i, Json::String(_))) => settings
|
||||
.push((
|
||||
"structure_overrides",
|
||||
Tag::List(
|
||||
ids.iter()
|
||||
.filter_map(Json::as_string)
|
||||
.map(|i| Tag::String(id(&i).as_str().into()))
|
||||
.collect(),
|
||||
),
|
||||
)),
|
||||
_ => {}
|
||||
}
|
||||
settings.extend([
|
||||
("layers", Tag::List(layers)),
|
||||
("lakes", Tag::Byte(i8::from(flag("lakes")))),
|
||||
("features", Tag::Byte(i8::from(flag("features")))),
|
||||
("biome", Tag::String(biome.as_str().into())),
|
||||
]);
|
||||
if !errors.is_empty() {
|
||||
logged.push(errors.join("; "));
|
||||
}
|
||||
(Some(Tag::compound(settings)), logged)
|
||||
}
|
||||
impl ChunkGenerator for FlatTerrain {
|
||||
type Worker = ();
|
||||
fn worker(&self) {}
|
||||
fn lod_surface(&self, _: &mut (), _: i32, _: i32) -> Option<crate::lod::Surface> {
|
||||
self.surface
|
||||
}
|
||||
fn proto(&self, _: &mut (), cx: i32, cz: i32) -> crate::streaming::Proto {
|
||||
let mut chunk = Chunk::empty(cx, cz);
|
||||
for s in &mut chunk.sections {
|
||||
@@ -75,3 +217,71 @@ impl ChunkGenerator for FlatTerrain {
|
||||
*proto.downcast::<Chunk>().expect("flat terrain proto")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::save::{number, text};
|
||||
use crate::server::json::{self, Json};
|
||||
|
||||
fn read(text: &str) -> (Option<Tag>, Vec<String>) {
|
||||
let json: Json = json::parse(text, false).unwrap();
|
||||
settings_from_json(
|
||||
&json,
|
||||
|b| ["minecraft:stone", "minecraft:bedrock"].contains(&b),
|
||||
|b| ["minecraft:plains", "minecraft:desert"].contains(&b),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generator_settings_read_as_flat_level_generator_settings() {
|
||||
// server.properties' default: the preset's own layers, and the codec's error.
|
||||
assert_eq!(
|
||||
read("{}"),
|
||||
(None, vec!["No key layers in MapLike[{}]".into()])
|
||||
);
|
||||
let (settings, errors) = read(
|
||||
r#"{"layers":[{"block":"bedrock","height":1},{"block":"minecraft:stone","height":3}],"biome":"desert","features":true}"#,
|
||||
);
|
||||
assert!(errors.is_empty());
|
||||
let settings = settings.unwrap();
|
||||
let Some(Tag::List(layers)) = settings.get("layers") else {
|
||||
panic!("{settings:?}")
|
||||
};
|
||||
assert_eq!(layers.len(), 2);
|
||||
assert_eq!(
|
||||
text(layers[0].get("block")).as_deref(),
|
||||
Some("minecraft:bedrock")
|
||||
);
|
||||
assert_eq!(number(layers[1].get("height")), Some(3));
|
||||
assert_eq!(
|
||||
text(settings.get("biome")).as_deref(),
|
||||
Some("minecraft:desert")
|
||||
);
|
||||
assert_eq!(number(settings.get("features")), Some(1));
|
||||
assert_eq!(number(settings.get("lakes")), Some(0));
|
||||
// A layer that doesn't parse is left out; an unknown biome is Plains, and logged first.
|
||||
let (settings, errors) = read(
|
||||
r#"{"layers":[{"block":"nope","height":2},{"block":"stone","height":2}],"biome":"x"}"#,
|
||||
);
|
||||
assert_eq!(errors.len(), 2);
|
||||
assert_eq!(errors[0], "Unknown biome, defaulting to plains");
|
||||
assert!(errors[1].contains("minecraft:nope"));
|
||||
let settings = settings.unwrap();
|
||||
let Some(Tag::List(layers)) = settings.get("layers") else {
|
||||
panic!("{settings:?}")
|
||||
};
|
||||
assert_eq!(layers.len(), 1);
|
||||
assert_eq!(
|
||||
text(settings.get("biome")).as_deref(),
|
||||
Some("minecraft:plains")
|
||||
);
|
||||
// validateHeight.
|
||||
assert_eq!(
|
||||
read(
|
||||
r#"{"layers":[{"block":"stone","height":4000},{"block":"stone","height":100}],"biome":"plains"}"#
|
||||
),
|
||||
(None, vec!["Sum of layer heights is > 4064".into()])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ pub struct FogData {
|
||||
}
|
||||
|
||||
/// Per-biome water fog end multipliers (`visual/water_fog_end_distance`).
|
||||
#[derive(Default)]
|
||||
pub struct FogTables {
|
||||
water_fog_end: Vec<f32>,
|
||||
}
|
||||
@@ -180,6 +181,8 @@ pub struct FogInputs<'a> {
|
||||
pub water_fog_color: [f32; 3],
|
||||
pub biome: u16,
|
||||
pub render_distance_chunks: i32,
|
||||
/// Extend ordinary Overworld atmosphere for terrain beyond vanilla's range.
|
||||
pub distant_terrain: bool,
|
||||
/// The Cloud Distance option, in chunks.
|
||||
pub cloud_range_chunks: i32,
|
||||
pub camera_y: f64,
|
||||
@@ -336,6 +339,18 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
|
||||
let span = (rd / 10.0).clamp(4.0, 64.0);
|
||||
fog.render_distance_start = rd - span;
|
||||
fog.render_distance_end = rd;
|
||||
if input.distant_terrain
|
||||
&& input.skybox
|
||||
&& input.fog_type == FogType::Atmospheric
|
||||
&& input.blindness.is_none()
|
||||
&& input.darkness.is_none()
|
||||
&& !input.boss_world_fog
|
||||
&& sky.fog_end >= 1024.0
|
||||
{
|
||||
let scale = (rd / 1024.0).max(1.0);
|
||||
fog.environmental_start *= scale;
|
||||
fog.environmental_end *= scale;
|
||||
}
|
||||
fog
|
||||
}
|
||||
/// `AtmosphericFogEnvironment.updateRainFogState`.
|
||||
@@ -368,6 +383,7 @@ mod tests {
|
||||
water_fog_color: [0.0, 0.0, 0.2],
|
||||
biome: 0,
|
||||
render_distance_chunks: 12,
|
||||
distant_terrain: false,
|
||||
cloud_range_chunks: 128,
|
||||
camera_y: 70.0,
|
||||
min_y: -64,
|
||||
@@ -446,6 +462,38 @@ mod tests {
|
||||
assert_eq!(fog.sky_end, 192.0);
|
||||
}
|
||||
#[test]
|
||||
fn distant_atmosphere_extends_without_extending_environmental_overrides() {
|
||||
let sky = Sky {
|
||||
fog_end: 1024.,
|
||||
..Sky::default()
|
||||
};
|
||||
let tables = FogTables {
|
||||
water_fog_end: vec![1.],
|
||||
};
|
||||
let mut input = inputs(&sky);
|
||||
input.render_distance_chunks = 1024;
|
||||
input.distant_terrain = true;
|
||||
let fog = setup_fog(&tables, &FogState::default(), &input);
|
||||
assert_eq!(fog.environmental_end, 16384.);
|
||||
input.fog_type = FogType::Water;
|
||||
assert_eq!(
|
||||
setup_fog(&tables, &FogState::default(), &input).environmental_end,
|
||||
24.
|
||||
);
|
||||
input.fog_type = FogType::Atmospheric;
|
||||
input.darkness = Some(1.);
|
||||
assert_eq!(
|
||||
setup_fog(&tables, &FogState::default(), &input).environmental_end,
|
||||
15.
|
||||
);
|
||||
input.darkness = None;
|
||||
input.boss_world_fog = true;
|
||||
assert_eq!(
|
||||
setup_fog(&tables, &FogState::default(), &input).environmental_end,
|
||||
96.
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn cloud_fog_ends_at_the_cloud_distance() {
|
||||
let sky = Sky {
|
||||
fog_end: 1024.0,
|
||||
|
||||
@@ -13,6 +13,7 @@ use crate::{
|
||||
self as t, BODY, CONTROL, CONTROL_LARGE, Face, Kind, SIDEBAR, SMALL, Ui, color, id,
|
||||
},
|
||||
};
|
||||
use horizon_companion::prompt::Role;
|
||||
use std::time::Instant;
|
||||
|
||||
const NAV: u64 = id("title.nav", 0);
|
||||
@@ -30,6 +31,8 @@ const NAME: u64 = id("create.name", 0);
|
||||
const SEED: u64 = id("create.seed", 0);
|
||||
const MODE: u64 = id("create.mode", 0);
|
||||
const MAKE: u64 = id("create.make", 0);
|
||||
const COMPANION: u64 = id("create.companion", 0);
|
||||
const ROLE: u64 = id("create.role", 0);
|
||||
const SERVER: u64 = id("mp.row", 0);
|
||||
const JOIN: u64 = id("mp.join", 0);
|
||||
const DIRECT: u64 = id("mp.direct", 0);
|
||||
@@ -154,10 +157,11 @@ impl Title {
|
||||
seed,
|
||||
creative,
|
||||
focus,
|
||||
..
|
||||
options,
|
||||
} => {
|
||||
let caret = (self.started.elapsed().as_millis() / 500).is_multiple_of(2);
|
||||
create_page(ui, x, w, name, seed, *creative, *focus, caret);
|
||||
let companion = options.companion.map(|on| (on, options.companion_role));
|
||||
create_page(ui, x, w, name, seed, *creative, *focus, caret, companion);
|
||||
}
|
||||
Mode::Delete { world } => delete_page(ui, x, w, world),
|
||||
Mode::Error { message, .. } => error_page(ui, x, w, message),
|
||||
@@ -399,12 +403,21 @@ impl Title {
|
||||
_ => {}
|
||||
},
|
||||
Mode::Create {
|
||||
creative, focus, ..
|
||||
creative,
|
||||
focus,
|
||||
options,
|
||||
..
|
||||
} => match hit {
|
||||
BACK => self.mode = select(&saves),
|
||||
NAME => *focus = 0,
|
||||
SEED => *focus = 1,
|
||||
h if h == MODE || h == MODE + 1 => *creative = h == MODE + 1,
|
||||
h if (h == COMPANION || h == COMPANION + 1) && options.companion.is_some() => {
|
||||
options.companion = Some(h == COMPANION + 1);
|
||||
}
|
||||
h if (ROLE..ROLE + Role::ALL.len() as u64).contains(&h) => {
|
||||
options.companion_role = Role::ALL[(h - ROLE) as usize];
|
||||
}
|
||||
MAKE => return self.create_action(),
|
||||
_ => {}
|
||||
},
|
||||
@@ -763,6 +776,7 @@ fn create_page(
|
||||
creative: bool,
|
||||
focus: u8,
|
||||
caret: bool,
|
||||
companion: Option<(bool, Role)>,
|
||||
) {
|
||||
let mut y = header(
|
||||
ui,
|
||||
@@ -802,6 +816,31 @@ fn create_page(
|
||||
y += rh;
|
||||
ui.divider(x, y, w);
|
||||
}
|
||||
// The AI companion, offered once it is set up (create_world.rs's companion_offer).
|
||||
let Some((on, role)) = companion else {
|
||||
return;
|
||||
};
|
||||
let rh = 12.0 + CONTROL + 12.0;
|
||||
ui.text_line(Face::Regular, BODY, rh, x, y, color::MUTED, "Companion");
|
||||
ui.segmented(COMPANION, fx, y + 12.0, &["Off", "On"], usize::from(on));
|
||||
y += rh;
|
||||
ui.divider(x, y, w);
|
||||
if on {
|
||||
ui.text_line(
|
||||
Face::Regular,
|
||||
BODY,
|
||||
rh,
|
||||
x,
|
||||
y,
|
||||
color::MUTED,
|
||||
"Companion role",
|
||||
);
|
||||
let labels: Vec<&str> = Role::ALL.iter().map(|r| r.label()).collect();
|
||||
let selected = Role::ALL.iter().position(|r| *r == role).unwrap_or(0);
|
||||
ui.segmented(ROLE, fx, y + 12.0, &labels, selected);
|
||||
y += rh;
|
||||
ui.divider(x, y, w);
|
||||
}
|
||||
}
|
||||
|
||||
fn multiplayer_page(ui: &mut Ui, x: f32, w: f32, screen: &Screen, caret: bool) {
|
||||
|
||||
+2426
-603
File diff suppressed because it is too large.
Load diff
@@ -88,6 +88,9 @@ pub(super) struct AdvState {
|
||||
pub(super) changed: BTreeSet<usize>,
|
||||
pub(super) unlocked: Vec<String>,
|
||||
pub(super) announced: Vec<Text>,
|
||||
/// The tree being read on another thread since the world opened
|
||||
/// (`prefetch_advancement_tree`), taken by the first `advancement_tree`.
|
||||
pub(super) pending_tree: Option<std::thread::JoinHandle<Tree>>,
|
||||
}
|
||||
/// The game's world for predicates.
|
||||
struct View<'a, 'g>(&'a Game<'g>);
|
||||
@@ -103,7 +106,7 @@ impl adv::World for View<'_, '_> {
|
||||
.map(|c| {
|
||||
self.0
|
||||
.res
|
||||
.biomes
|
||||
.biomes()
|
||||
.get(c.biome(p[0], p[1], p[2]))
|
||||
.name
|
||||
.clone()
|
||||
@@ -115,7 +118,7 @@ impl adv::World for View<'_, '_> {
|
||||
}
|
||||
fn block(&self, p: [i32; 3]) -> (String, BTreeMap<String, String>) {
|
||||
let state = self.0.world.block(p[0], p[1], p[2]);
|
||||
let name = self.0.res.facts.get(state).name.to_string();
|
||||
let name = self.0.res.facts().get(state).name.to_string();
|
||||
let props = self
|
||||
.0
|
||||
.pack
|
||||
@@ -135,7 +138,7 @@ impl adv::World for View<'_, '_> {
|
||||
match self
|
||||
.0
|
||||
.res
|
||||
.facts
|
||||
.facts()
|
||||
.get(self.0.world.block(p[0], p[1], p[2]))
|
||||
.fluid
|
||||
{
|
||||
@@ -149,7 +152,7 @@ impl adv::World for View<'_, '_> {
|
||||
!self
|
||||
.0
|
||||
.res
|
||||
.facts
|
||||
.facts()
|
||||
.get(self.0.world.block(p[0], y, p[2]))
|
||||
.full_collision
|
||||
})
|
||||
@@ -162,19 +165,19 @@ impl adv::World for View<'_, '_> {
|
||||
(1..=5).any(|d| {
|
||||
self.0
|
||||
.res
|
||||
.facts
|
||||
.facts()
|
||||
.get(self.0.world.block(p[0], p[1] - d, p[2]))
|
||||
.name
|
||||
.ends_with("campfire")
|
||||
})
|
||||
}
|
||||
fn in_tag(&self, registry: &str, tag: &str, id: &str) -> bool {
|
||||
let items = &self.0.res.items;
|
||||
let items = &self.0.res.items();
|
||||
match registry {
|
||||
"item" => items.item_tags.get(tag).is_some_and(|t| t.contains(id)),
|
||||
"block" => items.block_tags.get(tag).is_some_and(|t| t.contains(id)),
|
||||
"entity_type" => self.0.res.mob_data.entity_has(tag, id),
|
||||
"worldgen/biome" => self.0.res.mob_data.biome_has(tag, id),
|
||||
"entity_type" => self.0.res.mob_data().entity_has(tag, id),
|
||||
"worldgen/biome" => self.0.res.mob_data().biome_has(tag, id),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -221,10 +224,27 @@ impl Game<'_> {
|
||||
/// The advancement tree, loaded once.
|
||||
pub(super) fn advancement_tree(&mut self) -> Arc<Tree> {
|
||||
if self.adv.tree.list.is_empty() {
|
||||
self.adv.tree = Arc::new(Tree::load(&self.options.resources.join("data")));
|
||||
let tree = match self.adv.pending_tree.take().map(|h| h.join()) {
|
||||
Some(Ok(tree)) => tree,
|
||||
_ => Tree::load(&self.options.resources.join("data")),
|
||||
};
|
||||
self.adv.tree = Arc::new(tree);
|
||||
}
|
||||
self.adv.tree.clone()
|
||||
}
|
||||
/// Start reading the advancement files on another thread while the world
|
||||
/// loads. The first tick's first criterion trigger used to read and parse
|
||||
/// all of them on the game thread (about 60 ms).
|
||||
pub(super) fn prefetch_advancement_tree(&mut self) {
|
||||
if !self.adv.tree.list.is_empty() || self.adv.pending_tree.is_some() {
|
||||
return;
|
||||
}
|
||||
let data = self.options.resources.join("data");
|
||||
self.adv.pending_tree = std::thread::Builder::new()
|
||||
.name("horizon-advancements".into())
|
||||
.spawn(move || Tree::load(&data))
|
||||
.ok();
|
||||
}
|
||||
pub(super) fn load_advancements(&mut self) {
|
||||
if self.adv.loaded || self.remote.is_some() || self.no_player_here() {
|
||||
return;
|
||||
@@ -388,6 +408,9 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if hits.is_empty() {
|
||||
return;
|
||||
}
|
||||
let date = now_text();
|
||||
for (ai, ci) in hits {
|
||||
let a = &tree.list[ai];
|
||||
@@ -434,17 +457,17 @@ impl Game<'_> {
|
||||
.rewards
|
||||
.recipes
|
||||
.iter()
|
||||
.filter(|r| self.res.stations.recipe_index.contains(r))
|
||||
.filter(|r| self.res.stations().recipe_index.contains(r))
|
||||
.cloned()
|
||||
.collect();
|
||||
let (_, unlocked) = self
|
||||
.level
|
||||
.recipe_book
|
||||
.add_recipes(&self.res.stations.recipe_index, &rewards);
|
||||
.add_recipes(&self.res.stations().recipe_index, &rewards);
|
||||
for table in &a.rewards.loot {
|
||||
let items = self
|
||||
.res
|
||||
.items
|
||||
.items()
|
||||
.loot_table(table, false, None, &mut self.world.random);
|
||||
for s in items {
|
||||
if let Some(rest) = self.give(s)
|
||||
@@ -456,6 +479,10 @@ impl Game<'_> {
|
||||
}
|
||||
if let Some(d) = &a.display {
|
||||
// A network player's client shows its own toast.
|
||||
if self.lan_acting.is_none() && !self.adv.quiet {
|
||||
let title = text_of(&d.title).plain();
|
||||
self.companion_owner_advancement(&title);
|
||||
}
|
||||
if d.show_toast && !self.adv.quiet && self.lan_acting.is_none() {
|
||||
self.adv.toasts.push_back(Toast {
|
||||
icon: d.icon.clone(),
|
||||
@@ -477,10 +504,15 @@ impl Game<'_> {
|
||||
// PlayerList.broadcastSystemMessage: everyone hears of a
|
||||
// network player's advancement.
|
||||
match self.lan_acting_name.clone() {
|
||||
Some(player) => self
|
||||
.adv
|
||||
.announced
|
||||
.push(Text::translate(key, &[Text::literal(&player), name])),
|
||||
// A companion's are its own business.
|
||||
Some(_) if self.companions.acting => {}
|
||||
// Player.getDisplayName.
|
||||
Some(_) => {
|
||||
let player = self.target_name(super::commands::Target::Player);
|
||||
self.adv
|
||||
.announced
|
||||
.push(Text::translate(key, &[player, name]));
|
||||
}
|
||||
None => self.system_message(Text::translate(
|
||||
key,
|
||||
&[Text::literal(super::chat::PLAYER_NAME), name],
|
||||
@@ -529,7 +561,7 @@ impl Game<'_> {
|
||||
.filter_map(|s| s.as_ref())
|
||||
.filter(|s| {
|
||||
self.res
|
||||
.items
|
||||
.items()
|
||||
.get(&s.item)
|
||||
.is_some_and(|i| s.count >= i.max_stack)
|
||||
})
|
||||
@@ -622,6 +654,19 @@ impl Game<'_> {
|
||||
adv::conditions(&c["player"], &pctx, w) && adv::item(&c["item"], &it, w)
|
||||
});
|
||||
}
|
||||
pub(super) fn trigger_item_entity(
|
||||
&mut self,
|
||||
name: &str,
|
||||
stack: &ItemStack,
|
||||
target: &EntityView,
|
||||
) {
|
||||
if self.lan_acting.is_some() {
|
||||
return;
|
||||
}
|
||||
let me = self.player_entity();
|
||||
let it = item_view(stack);
|
||||
self.trigger(name, |c, w| adv::item_entity(c, &me, target, &it, w));
|
||||
}
|
||||
/// `EnterBlockTrigger`: the block, its state properties and the player.
|
||||
pub(super) fn trigger_enter_block(&mut self, state: horizon_world::registry::BlockStateId) {
|
||||
let Some(definition) = self.pack.assets.registry.state(state).cloned() else {
|
||||
@@ -757,11 +802,11 @@ impl Game<'_> {
|
||||
if let Some((station, item)) = recipe {
|
||||
// RecipeToast: black description under a purple title, the
|
||||
// station at 0.6 scale in the corner over the cycling result.
|
||||
let atlas = &self.atlas;
|
||||
let atlas = &self.gui.atlas;
|
||||
batch.sprite(atlas, "gui/sprites/toast/recipe", x, 0.0);
|
||||
let title = self.translate("recipe.toast.title", "New Recipe(s) Unlocked!");
|
||||
let description = self.translate("recipe.toast.description", "Check your recipe book");
|
||||
let atlas = &self.atlas;
|
||||
let atlas = &self.gui.atlas;
|
||||
batch.text(atlas, &title, x + 30.0, 7.0, 0xFF50_0050, false);
|
||||
batch.text(atlas, &description, x + 30.0, 18.0, 0xFF00_0000, false);
|
||||
let start = batch.vertices.len();
|
||||
@@ -770,7 +815,7 @@ impl Game<'_> {
|
||||
self.draw_item_model(batch, &item, x + 8.0, 8.0);
|
||||
return;
|
||||
}
|
||||
let atlas = &self.atlas;
|
||||
let atlas = &self.gui.atlas;
|
||||
batch.sprite(atlas, "gui/sprites/toast/advancement", x, 0.0);
|
||||
let (key, color) = match frame {
|
||||
Frame::Task => ("advancements.toast.task", 0xFF00_FFFF),
|
||||
@@ -799,18 +844,25 @@ pub(super) fn adv_name(a: &adv::Advancement) -> Text {
|
||||
Frame::Challenge => text::DARK_PURPLE,
|
||||
_ => text::GREEN,
|
||||
};
|
||||
let tooltip = text_of(&d.title)
|
||||
.with_color(color)
|
||||
// Advancement.decorateName: the title in the frame's colour (styles
|
||||
// merged into it), a new line and the description.
|
||||
let coloured = text_of(&d.title).with_color(color);
|
||||
let mut component = coloured.component();
|
||||
if let Some(o) = component.as_object_mut() {
|
||||
o.insert(
|
||||
"extra".into(),
|
||||
serde_json::json!(["\n", text_of(&d.description).component()]),
|
||||
);
|
||||
}
|
||||
let tooltip = coloured
|
||||
.append("\n")
|
||||
.append(text_of(&d.description));
|
||||
.append(text_of(&d.description))
|
||||
.with_component(component);
|
||||
let title = text_of(&d.title).with_style(&Style {
|
||||
hover: Some(Box::new(tooltip)),
|
||||
..Style::default()
|
||||
});
|
||||
Text::literal("[")
|
||||
.append(title)
|
||||
.append("]")
|
||||
.with_color(color)
|
||||
Text::translate("chat.square_brackets", &[title]).with_color(color)
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `AdvancementCommands.Action.GRANT` on one advancement (or one of
|
||||
|
||||
@@ -269,7 +269,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// `AdvancementWidget`'s title lines, description lines and width.
|
||||
fn adv_widget_text(&self, i: usize) -> (Vec<Text>, Vec<Text>, f32) {
|
||||
let font = &self.atlas.font;
|
||||
let font = &self.gui.atlas.font;
|
||||
let a = &self.adv.tree.list[i];
|
||||
let Some(d) = &a.display else {
|
||||
return (Vec::new(), Vec::new(), 0.0);
|
||||
@@ -332,7 +332,7 @@ impl Game<'_> {
|
||||
let (sx, sy) = self.adv_ui.scroll.unwrap_or_default();
|
||||
let (sx, sy) = (sx.floor() as i32, sy.floor() as i32);
|
||||
self.adv_tick_hover(&view, sx, sy, left, top);
|
||||
let atlas = &self.atlas;
|
||||
let atlas = &self.gui.atlas;
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
|
||||
let title = Text::translate("gui.advancements", &[]);
|
||||
let tw = atlas.font.width_text(&title);
|
||||
@@ -444,7 +444,7 @@ impl Game<'_> {
|
||||
}
|
||||
let (x, y) = pos(i);
|
||||
let (x, y) = ((ox + x) as f32, (oy + y) as f32);
|
||||
let atlas = &self.atlas;
|
||||
let atlas = &self.gui.atlas;
|
||||
batch.sprite_scaled(
|
||||
atlas,
|
||||
frame_sprite(d.frame, done),
|
||||
@@ -461,7 +461,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
// The window, tabs and title.
|
||||
let atlas = &self.atlas;
|
||||
let atlas = &self.gui.atlas;
|
||||
batch.sprite_region(
|
||||
atlas,
|
||||
"gui/advancements/window",
|
||||
@@ -475,7 +475,7 @@ impl Game<'_> {
|
||||
let (x, y) = tab_pos(ty, i);
|
||||
let sprite = tab_sprite(ty, i, ty.3, Some(t) == tab);
|
||||
batch.sprite_scaled(
|
||||
&self.atlas,
|
||||
&self.gui.atlas,
|
||||
&sprite,
|
||||
left + x,
|
||||
top + y,
|
||||
@@ -494,7 +494,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let atlas = &self.atlas;
|
||||
let atlas = &self.gui.atlas;
|
||||
let title = tab.and_then(|t| tree.list[t].display.as_ref()).map_or_else(
|
||||
|| Text::translate("gui.advancements", &[]),
|
||||
|d| text_of(&d.title),
|
||||
@@ -509,7 +509,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
// Done.
|
||||
let atlas = &self.atlas;
|
||||
let atlas = &self.gui.atlas;
|
||||
let (bx, by) = ((w / 2.0 - 100.0).floor(), h - FOOTER + 6.0);
|
||||
let hover = ui::hovered(self.cursor, bx, by, 200.0, 20.0);
|
||||
ui::button(
|
||||
@@ -532,7 +532,7 @@ impl Game<'_> {
|
||||
let (cx, cy) = self.cursor;
|
||||
if cx > x && cx < x + ty.1 && cy > y && cy < y + ty.2 {
|
||||
if let Some(d) = &tree.list[t].display {
|
||||
ui::tooltip(batch, &self.atlas, &[text_of(&d.title)], self.cursor);
|
||||
ui::tooltip(batch, &self.gui.atlas, &[text_of(&d.title)], self.cursor);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -616,7 +616,7 @@ impl Game<'_> {
|
||||
],
|
||||
)
|
||||
});
|
||||
let atlas = &self.atlas;
|
||||
let atlas = &self.gui.atlas;
|
||||
let progress_w = progress.as_ref().map_or(0.0, |t| atlas.font.width_text(t)) as i32;
|
||||
let top_side = title_bottom + desc_h >= INSIDE_H;
|
||||
let amount = if a.criteria.is_empty() || total == 0 {
|
||||
|
||||
Loaded 100 of 770 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user