mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Compare commits
576
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2818c89f24 | ||
|
|
9821bb7ef5 | ||
|
|
b9f3821f24 | ||
|
|
4ff23b686d | ||
|
|
30f0c715b2 | ||
|
|
a4feb65161 | ||
|
|
5f12705a29 | ||
|
|
e4741b7793 | ||
|
|
805d5532e5 | ||
|
|
002f14ce53 | ||
|
|
732e77a7ad | ||
|
|
851d34f762 | ||
|
|
b2784363a1 | ||
|
|
a67a323959 | ||
|
|
799ec775e1 | ||
|
|
995bd56cdf | ||
|
|
3e31144362 | ||
|
|
6d40915b63 | ||
|
|
45cbb4f7d6 | ||
|
|
78e3206133 | ||
|
|
6026893e88 | ||
|
|
a1c20290ae | ||
|
|
980ce75d63 | ||
|
|
7fb54b8150 | ||
|
|
fa2536c120 | ||
|
|
3135e1e3d1 | ||
|
|
87e500fa8b | ||
|
|
ed3ad72588 | ||
|
|
4b76566863 | ||
|
|
15b26795e6 | ||
|
|
c3e5a20488 | ||
|
|
98dc7b17b7 | ||
|
|
cf00d588c2 | ||
|
|
b859b22111 | ||
|
|
c2a82d4618 | ||
|
|
af0cfa9d3f | ||
|
|
22bb18d67a | ||
|
|
814b70b8c2 | ||
|
|
1f1d5a5aa4 | ||
|
|
3d21289df7 | ||
|
|
394bc68702 | ||
|
|
516661592f | ||
|
|
1af78463eb | ||
|
|
d5837931aa | ||
|
|
fda706d17f | ||
|
|
51db0894bf | ||
|
|
5b922172ac | ||
|
|
46c7daad92 | ||
|
|
74860c0312 | ||
|
|
171fbeb8ac | ||
|
|
b47f565849 | ||
|
|
69a0d13eda | ||
|
|
62f96e9d65 | ||
|
|
e87e5f0cd2 | ||
|
|
5d027877ec | ||
|
|
09c039c423 | ||
|
|
bc69b88c5d | ||
|
|
03de0e8e13 | ||
|
|
f58a9bdf29 | ||
|
|
ee0f382bdf | ||
|
|
aea0dc4c93 | ||
|
|
310047c225 | ||
|
|
b771c0b48e | ||
|
|
cc8249d2ce | ||
|
|
c7ad9b861f | ||
|
|
4410c3ba44 | ||
|
|
d85a16daf8 | ||
|
|
94209a32fc | ||
|
|
123987c459 | ||
|
|
4d4e5ebe75 | ||
|
|
1e7517dc3d | ||
|
|
48cee5f6d4 | ||
|
|
ef3cd64475 | ||
|
|
0a69d6b7bc | ||
|
|
f0295c1671 | ||
|
|
23db75c429 | ||
|
|
9665a7c5b0 | ||
|
|
edffb857e1 | ||
|
|
6c6b58dd55 | ||
|
|
7ee9b0c81d | ||
|
|
67633fc789 | ||
|
|
56a4989560 | ||
|
|
206b91e8aa | ||
|
|
ee104603dd | ||
|
|
6154eb95c3 | ||
|
|
4a8355047e | ||
|
|
aeaccb0413 | ||
|
|
cfcf69f0b9 | ||
|
|
99d5ebea1a | ||
|
|
b9c6f318ac | ||
|
|
03ff05c317 | ||
|
|
6a19e40092 | ||
|
|
b4481e9aad | ||
|
|
16721b0ab5 | ||
|
|
543d04ba44 | ||
|
|
2aba405281 | ||
|
|
ed09e757ba | ||
|
|
cceda07476 | ||
|
|
1efc7f83d1 | ||
|
|
51703fdf3b | ||
|
|
75ee9a3299 | ||
|
|
1a68d8ddf0 | ||
|
|
591e6f9161 | ||
|
|
36ea3a4140 | ||
|
|
8f4eda4d12 | ||
|
|
5725ee0efa | ||
|
|
bf851bfea1 | ||
|
|
ead46669a5 | ||
|
|
02dce1093b | ||
|
|
455967bd47 | ||
|
|
1e514f8539 | ||
|
|
b0e31eb14f | ||
|
|
303c8d4452 | ||
|
|
03ca95e8af | ||
|
|
1e597f356e | ||
|
|
2822d4e1b7 | ||
|
|
5f9dba6011 | ||
|
|
103d44f72f | ||
|
|
5118d824a6 | ||
|
|
928f31e428 | ||
|
|
7575719d11 | ||
|
|
6c5b04694e | ||
|
|
65e5640350 | ||
|
|
b0a82da895 | ||
|
|
f3dd2c27e5 | ||
|
|
6eba3f034e | ||
|
|
e0d6e56281 | ||
|
|
5515cbab33 | ||
|
|
107671b1e8 | ||
|
|
2e98f6a18a | ||
|
|
e7f7236c34 | ||
|
|
40970589be | ||
|
|
8ff9247edb | ||
|
|
0da61e660b | ||
|
|
d9dc5de701 | ||
|
|
892b8ca743 | ||
|
|
0789bf0719 | ||
|
|
6d8b174216 | ||
|
|
1e45fd3697 | ||
|
|
d6d97e087f | ||
|
|
5ad2d80908 | ||
|
|
c2f2e15643 | ||
|
|
1d79cf69e1 | ||
|
|
604d83ddcd | ||
|
|
2ea6689f46 | ||
|
|
c2c7d7f4bb | ||
|
|
cf7ed868b9 | ||
|
|
d98ee9a760 | ||
|
|
cde0703645 | ||
|
|
8fa5c38663 | ||
|
|
803aee8beb | ||
|
|
bbed107a6a | ||
|
|
fab2a7cb06 | ||
|
|
2a593ea64f | ||
|
|
ef633b252f | ||
|
|
b174299f25 | ||
|
|
b2f0af559b | ||
|
|
ac87384749 | ||
|
|
7b7c4eb083 | ||
|
|
73ac737ccf | ||
|
|
24ac4e149b | ||
|
|
256ecc4daa | ||
|
|
66d187ed06 | ||
|
|
943dd210d7 | ||
|
|
cd61e11b56 | ||
|
|
1d3759bb77 | ||
|
|
ad5f05a8c7 | ||
|
|
3230b6b88d | ||
|
|
cf837118e9 | ||
|
|
5d614f1cf6 | ||
|
|
78791b79ce | ||
|
|
a01d47f28f | ||
|
|
1fc3f6724e | ||
|
|
e422bd138a | ||
|
|
f03990f663 | ||
|
|
2b1cd3e96e | ||
|
|
a024409a1a | ||
|
|
34d1ef6510 | ||
|
|
7e430564a6 | ||
|
|
64a4c72ebd | ||
|
|
69d03e546a | ||
|
|
0f3a9e1508 | ||
|
|
ac2cc43aa6 | ||
|
|
dc42425c01 | ||
|
|
ae22d5dd96 | ||
|
|
4f2674275d | ||
|
|
e7003e7d49 | ||
|
|
d652b25ead | ||
|
|
7384db307d | ||
|
|
7f4636fcb5 | ||
|
|
43d5ed8246 | ||
|
|
e6068b1246 | ||
|
|
cf4f95f3d8 | ||
|
|
f535d1b48c | ||
|
|
ee3a160cbd | ||
|
|
652e547b53 | ||
|
|
f3f2301dce | ||
|
|
f04562c640 | ||
|
|
000fafa9bd | ||
|
|
7d57523c63 | ||
|
|
3c28f55ce3 | ||
|
|
2e2645ba9d | ||
|
|
b324ee7e3a | ||
|
|
e5fcdaaec6 | ||
|
|
361c994d98 | ||
|
|
a71d148ca5 | ||
|
|
30d11d6a2e | ||
|
|
bd0dd979a0 | ||
|
|
19aac92bb7 | ||
|
|
44c3ebb0d8 | ||
|
|
4e9495175c | ||
|
|
9726af184b | ||
|
|
0560bbf876 | ||
|
|
32f5f01e22 | ||
|
|
886def4360 | ||
|
|
ddc115e969 | ||
|
|
5dac64f770 | ||
|
|
89740d39e0 | ||
|
|
53955abf99 | ||
|
|
167339c03c | ||
|
|
86ccd5d299 | ||
|
|
9b2825df34 | ||
|
|
7a27781ab5 | ||
|
|
af15fb1a98 | ||
|
|
4ff55f7a99 | ||
|
|
6b86fa9bef | ||
|
|
b9d4ef3194 | ||
|
|
949751656a | ||
|
|
500241cbc6 | ||
|
|
2483f90779 | ||
|
|
07f7e3aeab | ||
|
|
a1b0d95db1 | ||
|
|
9c03535011 | ||
|
|
820936b975 | ||
|
|
7f77772e15 | ||
|
|
36b3fef0ed | ||
|
|
e21919c114 | ||
|
|
cff7a66881 | ||
|
|
0c532fc1b0 | ||
|
|
cbb8c6a239 | ||
|
|
db45b6f87a | ||
|
|
56dc4352d2 | ||
|
|
c2f4ecc188 | ||
|
|
c57c409f40 | ||
|
|
a08add231a | ||
|
|
c7ee8baa25 | ||
|
|
47182eccbf | ||
|
|
8f86098a02 | ||
|
|
05d1b92d56 | ||
|
|
a2cce7486d | ||
|
|
3db9a250be | ||
|
|
cce44ad97d | ||
|
|
4218a496b2 | ||
|
|
24bb3f97aa | ||
|
|
bfb6a0458d | ||
|
|
ed6af00ff8 | ||
|
|
3510764cc6 | ||
|
|
e8832190d1 | ||
|
|
9d21209ae8 | ||
|
|
b4ad65386c | ||
|
|
9d75d839aa | ||
|
|
38640fc845 | ||
|
|
586acadd81 | ||
|
|
72ec93a089 | ||
|
|
020e01746f | ||
|
|
be6a9d2cb5 | ||
|
|
6754c52e82 | ||
|
|
673ac84724 | ||
|
|
85858610bf | ||
|
|
ac7b7f98c0 | ||
|
|
002cfcd859 | ||
|
|
746c888cd3 | ||
|
|
c7073db8b5 | ||
|
|
71635fbab7 | ||
|
|
dd7282c9da | ||
|
|
4c3b2b789f | ||
|
|
0981e0abcf | ||
|
|
0cdcbd17ba | ||
|
|
c47dc83eab | ||
|
|
1ee64ded55 | ||
|
|
a06367edf1 | ||
|
|
5bc981e577 | ||
|
|
bd8dff2076 | ||
|
|
69b3eb1864 | ||
|
|
ece9ab2b79 | ||
|
|
b9d6d1c7d9 | ||
|
|
b27e4b9323 | ||
|
|
65f9819686 | ||
|
|
e86b05333e | ||
|
|
18dc7e7d8a | ||
|
|
8c5efc54d8 | ||
|
|
f7d042c6d0 | ||
|
|
55945ee975 | ||
|
|
bc4d0cc806 | ||
|
|
8a48f3b383 | ||
|
|
54acb709e4 | ||
|
|
4f1735170e | ||
|
|
dbe28edc9b | ||
|
|
baaa5e7374 | ||
|
|
e4b336fe6d | ||
|
|
1e1c3b79e5 | ||
|
|
9e73a8417f | ||
|
|
65e0ec9f97 | ||
|
|
93ce080d71 | ||
|
|
81243f7897 | ||
|
|
5c7fa1c575 | ||
|
|
8953448987 | ||
|
|
45397e4b30 | ||
|
|
b8f1011cb1 | ||
|
|
9c87672a0c | ||
|
|
b523567d1e | ||
|
|
330c3ef07e | ||
|
|
b1a3adff81 | ||
|
|
018c5e43e3 | ||
|
|
7cfc19305a | ||
|
|
6ff898ec29 | ||
|
|
56fec974ee | ||
|
|
f5c91eded1 | ||
|
|
8da9c8c4c6 | ||
|
|
04bdf82ac9 | ||
|
|
e8757cd75c | ||
|
|
9bbe5c4fd3 | ||
|
|
a950a046ae | ||
|
|
e27e556ab2 | ||
|
|
ac4ed86925 | ||
|
|
91360e2f29 | ||
|
|
ee088c0022 | ||
|
|
4224687e54 | ||
|
|
a8b6eea474 | ||
|
|
bd42496af0 | ||
|
|
4410064c58 | ||
|
|
7e1e5eb7a0 | ||
|
|
3e6052ac92 | ||
|
|
bc0cd9cc84 | ||
|
|
a8579b4df8 | ||
|
|
2a33f2c500 | ||
|
|
984a06e582 | ||
|
|
487d76c0ae | ||
|
|
74fafdd787 | ||
|
|
11130e6ef7 | ||
|
|
3f7fba836e | ||
|
|
ce49480b3e | ||
|
|
fc59063ada | ||
|
|
23a54d4a22 | ||
|
|
257b5e9f2d | ||
|
|
64f7ecd367 | ||
|
|
36d7f02802 | ||
|
|
2e1212346f | ||
|
|
e289980c1e | ||
|
|
90644915c5 | ||
|
|
7b53b06aca | ||
|
|
53fcf14850 | ||
|
|
84999e0762 | ||
|
|
3c81a89d1e | ||
|
|
d993c36e02 | ||
|
|
9311cd5cd3 | ||
|
|
600bae5703 | ||
|
|
327aa5af6e | ||
|
|
dac18e8350 | ||
|
|
e27efc16ba | ||
|
|
924698e506 | ||
|
|
705fab9bed | ||
|
|
7e8dd577ac | ||
|
|
a75c3a315f | ||
|
|
932c7bfc17 | ||
|
|
d0fa05b3e7 | ||
|
|
592ac6966b | ||
|
|
3e19e0d4dd | ||
|
|
6a0d4284d0 | ||
|
|
d9deabd54e | ||
|
|
3cfb7b8053 | ||
|
|
f188dfeb45 | ||
|
|
45404c4507 | ||
|
|
d7c7c56e05 | ||
|
|
d1c753e085 | ||
|
|
9126df5ac5 | ||
|
|
d3b9441833 | ||
|
|
e6a2d72467 | ||
|
|
3c57aba6c9 | ||
|
|
9b9b23bf3a | ||
|
|
953cdc947f | ||
|
|
4de37ac0e4 | ||
|
|
1bf51d786f | ||
|
|
64a82acdab | ||
|
|
f2bbd6e3dc | ||
|
|
63453086b6 | ||
|
|
c4f915c9d1 | ||
|
|
ed5427197f | ||
|
|
3ac3efe749 | ||
|
|
2c07b5e621 | ||
|
|
2567a4a41a | ||
|
|
d5fbe2b7d6 | ||
|
|
9044ae30c9 | ||
|
|
c72a5643a4 | ||
|
|
3315138536 | ||
|
|
f8c22f4e72 | ||
|
|
128111ff27 | ||
|
|
09e01f066f | ||
|
|
fb5a307903 | ||
|
|
7cad2d7284 | ||
|
|
c163fbff83 | ||
|
|
608507699c | ||
|
|
03fe857ec2 | ||
|
|
5373998ee0 | ||
|
|
93ea338bfd | ||
|
|
98321f5d8d | ||
|
|
9f7c1c3ce5 | ||
|
|
a8e0dead35 | ||
|
|
8ca9cda5f6 | ||
|
|
9cec0dfb45 | ||
|
|
d333780478 | ||
|
|
964280aa00 | ||
|
|
457a5d7ce2 | ||
|
|
310a3fb73b | ||
|
|
d56c6dfd23 | ||
|
|
d30df8ce33 | ||
|
|
e0b5e25c1a | ||
|
|
b2e9fafbe0 | ||
|
|
71fc3482ba | ||
|
|
561efb340e | ||
|
|
2862f17e86 | ||
|
|
23cadd8b65 | ||
|
|
6aea14c8a0 | ||
|
|
504a70c062 | ||
|
|
5350f3465b | ||
|
|
99cd0ec0ad | ||
|
|
a42db752d7 | ||
|
|
de53b7c14e | ||
|
|
e2fa721c7a | ||
|
|
1c91fad962 | ||
|
|
824fad45cf | ||
|
|
5f45cf3830 | ||
|
|
08c4805e10 | ||
|
|
fc97b78931 | ||
|
|
37f6481e14 | ||
|
|
e1d9ae1dd7 | ||
|
|
38a9f6543f | ||
|
|
98ebdcad7b | ||
|
|
b22583d057 | ||
|
|
d28521f765 | ||
|
|
7d6936e000 | ||
|
|
5419ef0ed4 | ||
|
|
457fb77ab0 | ||
|
|
850788d46c | ||
|
|
77127ccfed | ||
|
|
13c4230431 | ||
|
|
99dfeabfa5 | ||
|
|
4a0c639f63 | ||
|
|
18b5310b0f | ||
|
|
5a0ad89207 | ||
|
|
d1495076e6 | ||
|
|
b66eea6506 | ||
|
|
b5a7138489 | ||
|
|
725146c0ca | ||
|
|
d675918db9 | ||
|
|
0c103a4a97 | ||
|
|
9b4d5d5613 | ||
|
|
292873e11a | ||
|
|
c6aefdb82b | ||
|
|
66dc8ce3a4 | ||
|
|
6bf89f912c | ||
|
|
17feed1620 | ||
|
|
32f7356eda | ||
|
|
2f23a776f8 | ||
|
|
bf4fcc3aa9 | ||
|
|
8e6a129f6b | ||
|
|
dd856ec5f6 | ||
|
|
fd1acd5ed7 | ||
|
|
227edf7517 | ||
|
|
9948ff42aa | ||
|
|
8d6b8b3f03 | ||
|
|
f601630f05 | ||
|
|
4718fef2ab | ||
|
|
9e2d46d54e | ||
|
|
adef2bdc24 | ||
|
|
3761f2a5fb | ||
|
|
05a1e10bbe | ||
|
|
3e0793af21 | ||
|
|
5efb5ea335 | ||
|
|
a061c40fde | ||
|
|
b9e926588b | ||
|
|
aca5f3a8ba | ||
|
|
f8bcfcfec6 | ||
|
|
be57f82a07 | ||
|
|
6663af199e | ||
|
|
6086758f94 | ||
|
|
df3244d335 | ||
|
|
0681064767 | ||
|
|
a316768bfc | ||
|
|
36f61d68f1 | ||
|
|
d53a366718 | ||
|
|
713ff1d4a1 | ||
|
|
4566d3175b | ||
|
|
1264cb6556 | ||
|
|
1ce266f66c | ||
|
|
ac955934b5 | ||
|
|
466e260514 | ||
|
|
9cb9f8beeb | ||
|
|
1a04810baf | ||
|
|
c3b32fdab5 | ||
|
|
b8ed3a113a | ||
|
|
68cbad54a3 | ||
|
|
24a702181a | ||
|
|
f6ceb1da22 | ||
|
|
e6588a13f3 | ||
|
|
c8d5d13813 | ||
|
|
49fc287254 | ||
|
|
92d1338e24 | ||
|
|
43134d5ebf | ||
|
|
6b26f926df | ||
|
|
293c589e35 | ||
|
|
58af88f809 | ||
|
|
7e9bca982b | ||
|
|
b4cb428ca9 | ||
|
|
a6047e59c0 | ||
|
|
1b468100aa | ||
|
|
e0ecbf1db1 | ||
|
|
3137c7b977 | ||
|
|
7f631bb36f | ||
|
|
047bf45af1 | ||
|
|
35f78ae27c | ||
|
|
9739740379 | ||
|
|
eb466fe2d2 | ||
|
|
6d4aa954bb | ||
|
|
b1c365fca1 | ||
|
|
0892f944c0 | ||
|
|
41f82e8db0 | ||
|
|
1a067b3d9e | ||
|
|
304ea50a5f | ||
|
|
1573e4e553 | ||
|
|
337ee470b5 | ||
|
|
1286523047 | ||
|
|
b0abb62883 | ||
|
|
a2b62913ba | ||
|
|
8dc2a0e90a | ||
|
|
969ff7e4d9 | ||
|
|
7b8189d19c | ||
|
|
8c5d1ed86b | ||
|
|
90edb028bf | ||
|
|
0248df4d90 | ||
|
|
b3906d803d | ||
|
|
670e055f4a | ||
|
|
c6eb50f10b | ||
|
|
62b704ceaf | ||
|
|
59d0148057 | ||
|
|
64f480ce65 | ||
|
|
7c0d3ceea6 | ||
|
|
93bb113e0d | ||
|
|
58ad81674b | ||
|
|
720e91fb8c | ||
|
|
81c3e58a1c | ||
|
|
a3cf73d9be | ||
|
|
44e21e6601 | ||
|
|
ce6bc9c5c7 | ||
|
|
96268647d3 | ||
|
|
d4c16084d7 | ||
|
|
fd3d456de8 | ||
|
|
23ac335b53 | ||
|
|
204e4fdccb | ||
|
|
e00ce7c89e | ||
|
|
40d5b43e43 | ||
|
|
d5e7dc76e6 | ||
|
|
ed0febf163 | ||
|
|
ef92004ede | ||
|
|
bea9d4b765 | ||
|
|
ce4d520f55 | ||
|
|
e162f9cdb7 | ||
|
|
64739d2b8b | ||
|
|
5e2935747b | ||
|
|
ffd0297dc4 | ||
|
|
e2473aafd8 | ||
|
|
72ff742273 | ||
|
|
fac4944cfa | ||
|
|
0c161a0336 | ||
|
|
61a127a2a2 | ||
|
|
adb19abd4c |
No files matched your search
@@ -27,6 +27,13 @@ jobs:
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Does this pull request need Rust checks?
|
||||
if: github.event_name == 'pull_request'
|
||||
id: rust
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: python3 tools/ci/rust_changes.py "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
|
||||
- name: Keep game artifacts out of Git
|
||||
run: |
|
||||
python tools/check_public_tree.py
|
||||
@@ -34,19 +41,23 @@ jobs:
|
||||
- name: Test offline research tooling
|
||||
run: python -m unittest discover -s tests -v
|
||||
- name: Install Rust toolchain and the ALSA and udev headers the client links against
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
|
||||
rustup toolchain install stable --profile minimal --component rustfmt --component clippy --target wasm32-unknown-unknown
|
||||
# Dependencies and the workspace's own build, saved from main's runs (pull requests read main's cache).
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
with:
|
||||
shared-key: verify
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Check formatting and lint
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: |
|
||||
cargo +stable fmt --all -- --check
|
||||
cargo +stable clippy --workspace --all-targets -- -D warnings
|
||||
- name: Test Rust core and voxel kernel correctness
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: cargo +stable test --workspace
|
||||
parity:
|
||||
if: >-
|
||||
@@ -72,7 +83,7 @@ jobs:
|
||||
- name: The harnesses this change can affect
|
||||
env:
|
||||
PARITY_HOSTS: ''
|
||||
run: python3 tools/parity.py --changed ${{ github.event.pull_request.base.sha }} --timeout 1500
|
||||
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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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),
|
||||
# push to main that changes the launcher or what it builds in (crates/horizon-prepare, crates/horizon-theme,
|
||||
# crates/horizon-telemetry),
|
||||
# every 10 minutes (a release waiting for merges to settle goes out) and by hand, tools/release_train.py decides as
|
||||
# for the game.
|
||||
# A release is launcher-vX.Y.Z: the patch after the last launcher tag, or the next minor when a merged pull
|
||||
@@ -16,6 +17,7 @@ on:
|
||||
- 'launcher/**'
|
||||
- 'crates/horizon-prepare/**'
|
||||
- 'crates/horizon-theme/**'
|
||||
- 'crates/horizon-telemetry/**'
|
||||
- '.github/workflows/launcher.yml'
|
||||
schedule:
|
||||
- cron: '7-59/10 * * * *'
|
||||
|
||||
@@ -109,18 +109,18 @@ jobs:
|
||||
- name: ALSA and udev headers
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
- name: Build the client, horizon-ctl and horizon-prepare
|
||||
run: cargo build --release --locked -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare
|
||||
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
|
||||
run: cargo build --release --locked -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
|
||||
- name: Every program the release folder takes was built
|
||||
run: |
|
||||
for binary in horizon-client horizon-ctl horizon-prepare; do
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
test -f "target/release/$binary${{ matrix.exe }}" || { echo "::error::target/release/$binary${{ matrix.exe }} is missing"; exit 1; }
|
||||
done
|
||||
- name: Only system libraries
|
||||
# Windows: no non-system DLL (a "DLL not found" dialog on some players' machines). Linux: the standard
|
||||
# loader and libraries every desktop distribution has. macOS: only the system's own libraries.
|
||||
run: |
|
||||
for binary in horizon-client horizon-ctl horizon-prepare; do
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
file="target/release/$binary${{ matrix.exe }}"
|
||||
if [[ "$RUNNER_OS" == macOS ]]; then
|
||||
others=$(otool -L "$file" | tail -n +2 | awk '{print $1}' | grep -v -E '^(/usr/lib/|/System/Library/)' || true)
|
||||
@@ -137,6 +137,7 @@ jobs:
|
||||
"$bin/horizon-ctl${{ matrix.exe }}" --help > /dev/null
|
||||
"$bin/horizon-prepare${{ matrix.exe }}" --version
|
||||
"$bin/horizon-prepare${{ matrix.exe }}" --help | grep -q -- '--minecraft-dir'
|
||||
"$bin/horizon-server${{ matrix.exe }}" --version
|
||||
- name: Tools from the runner
|
||||
if: runner.environment == 'self-hosted'
|
||||
run: bash tools/ci/runner-env.sh
|
||||
@@ -154,7 +155,7 @@ jobs:
|
||||
client="$PWD/target/release/horizon-client"
|
||||
resources="$PWD/.local/horizon-client/$version"
|
||||
(cd "$home" && HORIZON_HOME="$home" HORIZON_NO_AUDIO=1 "$client" --resources "$resources" --offscreen \
|
||||
--world smoke --seed 1 --render-distance 4 --frames 1500 --timeout-seconds 180 \
|
||||
--world smoke --seed 1 --render-distance 4 --world-frames 1500 --timeout-seconds 180 \
|
||||
--smoke-report "$home/smoke.json" > "$home/client.log" 2>&1) || { tail -40 "$home/client.log"; exit 1; }
|
||||
python3 - "$home/smoke.json" <<'EOF'
|
||||
import json, sys
|
||||
|
||||
@@ -24,7 +24,10 @@
|
||||
/horizon.txt
|
||||
/horizon-graphics.txt
|
||||
/horizon-controller.txt
|
||||
/horizon-extras.txt
|
||||
/horizon-player.txt
|
||||
/servers.dat
|
||||
/screenshots/
|
||||
/.horizon-cache/
|
||||
/crash-reports/
|
||||
/debug/
|
||||
@@ -4,6 +4,9 @@ Thanks for helping. Horizon is Minecraft: Java Edition 26.3 rewritten in Rust, a
|
||||
everything is that **it plays exactly like vanilla**. This page is the short version of how we work; questions are
|
||||
welcome on [Discord](https://discord.gg/HcTeMZxe5E).
|
||||
|
||||
This repository holds the game, the launcher and the website, plus the tools, tests and docs that build, check and
|
||||
release them. Anything else (videos, Discord and other community material) lives elsewhere.
|
||||
|
||||
## Build it
|
||||
|
||||
You need Rust (stable), Python 3.10+, JDK 25, and on Linux the ALSA and udev headers (`libasound2-dev libudev-dev`).
|
||||
|
||||
Generated
+1434
-10
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
exclude = ["launcher", "crates/horizon-telemetry-server"]
|
||||
members = ["crates/horizon-core", "crates/horizon-world", "crates/voxel-kernels", "crates/horizon-client", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "xtask"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-world", "crates/voxel-kernels", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-shaderpack", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -199,6 +199,8 @@ It runs rustfmt, clippy with warnings denied, the tests of the crates you touche
|
||||
|
||||
[MIT](LICENSE), for the project's original work only. Dependencies keep their own licences. Minecraft is a trademark of Mojang Synergies AB; Minecraft code and assets belong to Mojang and Microsoft and are covered by the [Minecraft EULA](https://www.minecraft.net/en-us/eula). Decompiled game source and prepared resources stay on your machine and are never published.
|
||||
|
||||
Third-party credits and licenses: [Third-party notices](THIRD_PARTY_NOTICES.md).
|
||||
|
||||
## Author
|
||||
|
||||
Made by [Ludovic Marie](https://lu4ovic.com) ([@ludovic111](https://github.com/ludovic111)).
|
||||
@@ -0,0 +1,31 @@
|
||||
# Third-party notices
|
||||
|
||||
## CullLeaves
|
||||
|
||||
The optional leaf and mangrove-root face-culling behavior is adapted from
|
||||
[CullLeaves](https://github.com/TeamMidnightDust/CullLeaves) by MidnightDust.
|
||||
Its MIT license is reproduced below.
|
||||
|
||||
```text
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 MidnightDust
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
@@ -0,0 +1,379 @@
|
||||
{
|
||||
"scope": "Assets + BlockFacts + Behaviours loader only; no full startup or first frame",
|
||||
"resources": "/srv/bob-data/horizon/.local/horizon-client/26.3",
|
||||
"repeats": 10,
|
||||
"ordering": [
|
||||
[
|
||||
"released",
|
||||
"retained"
|
||||
],
|
||||
[
|
||||
"retained",
|
||||
"released"
|
||||
],
|
||||
[
|
||||
"released",
|
||||
"retained"
|
||||
],
|
||||
[
|
||||
"retained",
|
||||
"released"
|
||||
],
|
||||
[
|
||||
"released",
|
||||
"retained"
|
||||
],
|
||||
[
|
||||
"retained",
|
||||
"released"
|
||||
],
|
||||
[
|
||||
"released",
|
||||
"retained"
|
||||
],
|
||||
[
|
||||
"retained",
|
||||
"released"
|
||||
],
|
||||
[
|
||||
"released",
|
||||
"retained"
|
||||
],
|
||||
[
|
||||
"retained",
|
||||
"released"
|
||||
]
|
||||
],
|
||||
"load_average_before": [
|
||||
8.2421875,
|
||||
6.56396484375,
|
||||
6.39208984375
|
||||
],
|
||||
"load_average_after": [
|
||||
7.98095703125,
|
||||
6.6455078125,
|
||||
6.4248046875
|
||||
],
|
||||
"executables": {
|
||||
"released": {
|
||||
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-integrated",
|
||||
"mode": "deferred",
|
||||
"sha256": "a52d6a7efc7b3198ef0dd8150325f56a3fe5d0c5883fd6d14c4b0c281866ce7c"
|
||||
},
|
||||
"retained": {
|
||||
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-integrated",
|
||||
"mode": "deferred-held",
|
||||
"sha256": "a52d6a7efc7b3198ef0dd8150325f56a3fe5d0c5883fd6d14c4b0c281866ce7c"
|
||||
}
|
||||
},
|
||||
"metrics": {
|
||||
"released": {
|
||||
"phase_ms": {
|
||||
"median": 482.591,
|
||||
"p25": 444.276,
|
||||
"p75": 586.92,
|
||||
"min": 430.128,
|
||||
"max": 736.543,
|
||||
"samples": [
|
||||
529.244,
|
||||
606.146,
|
||||
449.23,
|
||||
442.624,
|
||||
669.108,
|
||||
511.519,
|
||||
736.543,
|
||||
432.616,
|
||||
430.128,
|
||||
453.663
|
||||
]
|
||||
},
|
||||
"total_ms": {
|
||||
"median": 987.004,
|
||||
"p25": 932.885,
|
||||
"p75": 1134.876,
|
||||
"min": 914.242,
|
||||
"max": 1201.759,
|
||||
"samples": [
|
||||
1055.119,
|
||||
1161.462,
|
||||
968.629,
|
||||
914.242,
|
||||
1201.759,
|
||||
1005.379,
|
||||
1200.01,
|
||||
928.249,
|
||||
935.775,
|
||||
931.921
|
||||
]
|
||||
},
|
||||
"cpu_ms": {
|
||||
"median": 1004.675,
|
||||
"p25": 936.465,
|
||||
"p75": 1116.791,
|
||||
"min": 908.327,
|
||||
"max": 1241.168,
|
||||
"samples": [
|
||||
1074.425,
|
||||
1130.913,
|
||||
984.832,
|
||||
931.205,
|
||||
1186.169,
|
||||
1024.519,
|
||||
1241.168,
|
||||
908.327,
|
||||
952.245,
|
||||
927.15
|
||||
]
|
||||
},
|
||||
"peak_rss_kib": {
|
||||
"median": 268124.0,
|
||||
"p25": 267753.0,
|
||||
"p75": 268455.0,
|
||||
"min": 267708,
|
||||
"max": 268700,
|
||||
"samples": [
|
||||
268268,
|
||||
268500,
|
||||
267744,
|
||||
267724,
|
||||
267708,
|
||||
267780,
|
||||
268700,
|
||||
268496,
|
||||
268332,
|
||||
267980
|
||||
]
|
||||
},
|
||||
"behaviour_json_ms": {
|
||||
"median": 24.371,
|
||||
"p25": 22.469,
|
||||
"p75": 28.486,
|
||||
"min": 20.662,
|
||||
"max": 35.297,
|
||||
"samples": [
|
||||
33.387,
|
||||
35.297,
|
||||
24.732,
|
||||
24.011,
|
||||
27.699,
|
||||
28.748,
|
||||
23.623,
|
||||
22.084,
|
||||
20.662,
|
||||
21.266
|
||||
]
|
||||
},
|
||||
"facts_ms": {
|
||||
"median": 292.77,
|
||||
"p25": 270.649,
|
||||
"p75": 383.193,
|
||||
"min": 264.691,
|
||||
"max": 443.291,
|
||||
"samples": [
|
||||
346.613,
|
||||
398.468,
|
||||
274.673,
|
||||
264.691,
|
||||
443.291,
|
||||
292.926,
|
||||
395.387,
|
||||
267.232,
|
||||
269.308,
|
||||
292.615
|
||||
]
|
||||
},
|
||||
"behaviours_ms": {
|
||||
"median": 148.635,
|
||||
"p25": 141.329,
|
||||
"p75": 181.741,
|
||||
"min": 136.361,
|
||||
"max": 307.004,
|
||||
"samples": [
|
||||
145.009,
|
||||
168.982,
|
||||
146.535,
|
||||
150.736,
|
||||
194.604,
|
||||
185.994,
|
||||
307.004,
|
||||
140.102,
|
||||
136.785,
|
||||
136.361
|
||||
]
|
||||
},
|
||||
"post_rss_kib": {
|
||||
"median": 268668.0,
|
||||
"p25": 268246.0,
|
||||
"p75": 268922.0,
|
||||
"min": 268048.0,
|
||||
"max": 269012.0,
|
||||
"samples": [
|
||||
268668.0,
|
||||
268936.0,
|
||||
268108.0,
|
||||
268088.0,
|
||||
268048.0,
|
||||
268668.0,
|
||||
268880.0,
|
||||
269012.0,
|
||||
268960.0,
|
||||
268660.0
|
||||
]
|
||||
}
|
||||
},
|
||||
"retained": {
|
||||
"phase_ms": {
|
||||
"median": 487.954,
|
||||
"p25": 471.456,
|
||||
"p75": 531.586,
|
||||
"min": 401.516,
|
||||
"max": 834.154,
|
||||
"samples": [
|
||||
486.974,
|
||||
613.755,
|
||||
540.535,
|
||||
470.333,
|
||||
488.934,
|
||||
474.825,
|
||||
834.154,
|
||||
504.74,
|
||||
401.516,
|
||||
440.381
|
||||
]
|
||||
},
|
||||
"total_ms": {
|
||||
"median": 1038.875,
|
||||
"p25": 989.724,
|
||||
"p75": 1079.197,
|
||||
"min": 880.933,
|
||||
"max": 1746.386,
|
||||
"samples": [
|
||||
996.407,
|
||||
1067.467,
|
||||
1082.69,
|
||||
1103.131,
|
||||
1010.283,
|
||||
1068.719,
|
||||
1746.386,
|
||||
987.496,
|
||||
890.46,
|
||||
880.933
|
||||
]
|
||||
},
|
||||
"cpu_ms": {
|
||||
"median": 1035.537,
|
||||
"p25": 982.276,
|
||||
"p75": 1082.698,
|
||||
"min": 908.774,
|
||||
"max": 1660.068,
|
||||
"samples": [
|
||||
1011.712,
|
||||
1087.688,
|
||||
1076.523,
|
||||
1084.757,
|
||||
998.654,
|
||||
1059.363,
|
||||
1660.068,
|
||||
976.817,
|
||||
912.849,
|
||||
908.774
|
||||
]
|
||||
},
|
||||
"peak_rss_kib": {
|
||||
"median": 268150.0,
|
||||
"p25": 267537.0,
|
||||
"p75": 268614.0,
|
||||
"min": 267268,
|
||||
"max": 268848,
|
||||
"samples": [
|
||||
268748,
|
||||
267968,
|
||||
267268,
|
||||
268332,
|
||||
267540,
|
||||
268560,
|
||||
267492,
|
||||
268632,
|
||||
267536,
|
||||
268848
|
||||
]
|
||||
},
|
||||
"behaviour_json_ms": {
|
||||
"median": 23.208,
|
||||
"p25": 22.305,
|
||||
"p75": 26.544,
|
||||
"min": 20.059,
|
||||
"max": 32.669,
|
||||
"samples": [
|
||||
32.669,
|
||||
22.62,
|
||||
22.2,
|
||||
27.331,
|
||||
25.088,
|
||||
27.029,
|
||||
23.488,
|
||||
20.977,
|
||||
22.928,
|
||||
20.059
|
||||
]
|
||||
},
|
||||
"facts_ms": {
|
||||
"median": 304.44,
|
||||
"p25": 291.562,
|
||||
"p75": 330.991,
|
||||
"min": 244.758,
|
||||
"max": 384.634,
|
||||
"samples": [
|
||||
307.936,
|
||||
374.145,
|
||||
336.931,
|
||||
300.944,
|
||||
290.81,
|
||||
293.817,
|
||||
384.634,
|
||||
313.171,
|
||||
244.758,
|
||||
279.303
|
||||
]
|
||||
},
|
||||
"behaviours_ms": {
|
||||
"median": 162.285,
|
||||
"p25": 143.135,
|
||||
"p75": 179.31,
|
||||
"min": 133.83,
|
||||
"max": 426.031,
|
||||
"samples": [
|
||||
146.367,
|
||||
216.988,
|
||||
181.402,
|
||||
142.058,
|
||||
173.036,
|
||||
153.978,
|
||||
426.031,
|
||||
170.592,
|
||||
133.83,
|
||||
141.017
|
||||
]
|
||||
},
|
||||
"post_rss_kib": {
|
||||
"median": 268694.0,
|
||||
"p25": 268116.0,
|
||||
"p75": 269056.0,
|
||||
"min": 267988.0,
|
||||
"max": 269212.0,
|
||||
"samples": [
|
||||
269212.0,
|
||||
268564.0,
|
||||
268096.0,
|
||||
268824.0,
|
||||
268176.0,
|
||||
269032.0,
|
||||
267988.0,
|
||||
269064.0,
|
||||
268044.0,
|
||||
269120.0
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
{
|
||||
"scope": "Isolated gameplay-facts.json validation/typed decoding; includes JSON file read",
|
||||
"resources": "/srv/bob-data/horizon/.local/horizon-client/26.3",
|
||||
"repeats": 10,
|
||||
"load_averages_before_after": [
|
||||
[
|
||||
14.7451171875,
|
||||
8.8994140625,
|
||||
6.89599609375
|
||||
],
|
||||
[
|
||||
14.7451171875,
|
||||
8.8994140625,
|
||||
6.89599609375
|
||||
]
|
||||
],
|
||||
"binary_sha256": "c2be9140c792720499d70c5806b8def0472734264ad72486d01365447e0cc2d7",
|
||||
"metrics": {
|
||||
"validate": {
|
||||
"phase_ms": {
|
||||
"median": 117.417,
|
||||
"p25": 114.101,
|
||||
"p75": 158.855,
|
||||
"min": 109.199,
|
||||
"max": 173.408,
|
||||
"samples": [
|
||||
109.199,
|
||||
113.958,
|
||||
110.195,
|
||||
118.987,
|
||||
171.275,
|
||||
124.701,
|
||||
170.24,
|
||||
173.408,
|
||||
115.848,
|
||||
114.531
|
||||
]
|
||||
},
|
||||
"total_ms": {
|
||||
"median": 139.933,
|
||||
"p25": 133.863,
|
||||
"p75": 193.565,
|
||||
"min": 129.75,
|
||||
"max": 276.311,
|
||||
"samples": [
|
||||
276.311,
|
||||
135.448,
|
||||
129.75,
|
||||
138.452,
|
||||
195.127,
|
||||
141.413,
|
||||
188.88,
|
||||
199.802,
|
||||
133.335,
|
||||
132.094
|
||||
]
|
||||
},
|
||||
"cpu_ms": {
|
||||
"median": 138.849,
|
||||
"p25": 134.285,
|
||||
"p75": 168.881,
|
||||
"min": 129.089,
|
||||
"max": 190.82,
|
||||
"samples": [
|
||||
140.962,
|
||||
136.06,
|
||||
129.089,
|
||||
139.059,
|
||||
189.478,
|
||||
138.639,
|
||||
178.187,
|
||||
190.82,
|
||||
133.693,
|
||||
132.716
|
||||
]
|
||||
},
|
||||
"peak_rss_kib": {
|
||||
"median": 56654.0,
|
||||
"p25": 56642.0,
|
||||
"p75": 56704.0,
|
||||
"min": 56616,
|
||||
"max": 56724,
|
||||
"samples": [
|
||||
56620,
|
||||
56656,
|
||||
56640,
|
||||
56720,
|
||||
56616,
|
||||
56724,
|
||||
56652,
|
||||
56720,
|
||||
56648,
|
||||
56656
|
||||
]
|
||||
}
|
||||
},
|
||||
"typed": {
|
||||
"phase_ms": {
|
||||
"median": 115.873,
|
||||
"p25": 109.422,
|
||||
"p75": 129.67,
|
||||
"min": 100.872,
|
||||
"max": 183.923,
|
||||
"samples": [
|
||||
116.965,
|
||||
132.071,
|
||||
100.872,
|
||||
105.098,
|
||||
122.468,
|
||||
170.777,
|
||||
107.997,
|
||||
183.923,
|
||||
113.699,
|
||||
114.781
|
||||
]
|
||||
},
|
||||
"total_ms": {
|
||||
"median": 133.883,
|
||||
"p25": 126.939,
|
||||
"p75": 155.913,
|
||||
"min": 117.54,
|
||||
"max": 210.164,
|
||||
"samples": [
|
||||
135.04,
|
||||
159.38,
|
||||
117.54,
|
||||
122.391,
|
||||
145.512,
|
||||
189.132,
|
||||
125.846,
|
||||
210.164,
|
||||
130.217,
|
||||
132.727
|
||||
]
|
||||
},
|
||||
"cpu_ms": {
|
||||
"median": 134.375,
|
||||
"p25": 126.231,
|
||||
"p75": 154.675,
|
||||
"min": 118.132,
|
||||
"max": 204.242,
|
||||
"samples": [
|
||||
135.633,
|
||||
157.961,
|
||||
118.132,
|
||||
122.77,
|
||||
144.816,
|
||||
178.293,
|
||||
124.755,
|
||||
204.242,
|
||||
130.661,
|
||||
133.117
|
||||
]
|
||||
},
|
||||
"peak_rss_kib": {
|
||||
"median": 83974.0,
|
||||
"p25": 82744.0,
|
||||
"p75": 84490.0,
|
||||
"min": 82332,
|
||||
"max": 84596,
|
||||
"samples": [
|
||||
83596,
|
||||
84596,
|
||||
82332,
|
||||
82424,
|
||||
84288,
|
||||
82460,
|
||||
84532,
|
||||
83660,
|
||||
84364,
|
||||
84536
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
{
|
||||
"scope": "Assets + BlockFacts + Behaviours loader only; no full startup or first frame",
|
||||
"resources": "/srv/bob-data/horizon/.local/horizon-client/26.3",
|
||||
"repeats": 8,
|
||||
"ordering": [
|
||||
[
|
||||
"baseline",
|
||||
"a01d47f2",
|
||||
"cf837118",
|
||||
"cd61e11b",
|
||||
"current"
|
||||
],
|
||||
[
|
||||
"current",
|
||||
"cd61e11b",
|
||||
"cf837118",
|
||||
"a01d47f2",
|
||||
"baseline"
|
||||
],
|
||||
[
|
||||
"baseline",
|
||||
"a01d47f2",
|
||||
"cf837118",
|
||||
"cd61e11b",
|
||||
"current"
|
||||
],
|
||||
[
|
||||
"current",
|
||||
"cd61e11b",
|
||||
"cf837118",
|
||||
"a01d47f2",
|
||||
"baseline"
|
||||
],
|
||||
[
|
||||
"baseline",
|
||||
"a01d47f2",
|
||||
"cf837118",
|
||||
"cd61e11b",
|
||||
"current"
|
||||
],
|
||||
[
|
||||
"current",
|
||||
"cd61e11b",
|
||||
"cf837118",
|
||||
"a01d47f2",
|
||||
"baseline"
|
||||
],
|
||||
[
|
||||
"baseline",
|
||||
"a01d47f2",
|
||||
"cf837118",
|
||||
"cd61e11b",
|
||||
"current"
|
||||
],
|
||||
[
|
||||
"current",
|
||||
"cd61e11b",
|
||||
"cf837118",
|
||||
"a01d47f2",
|
||||
"baseline"
|
||||
]
|
||||
],
|
||||
"load_average_before": [
|
||||
10.57958984375,
|
||||
8.45947265625,
|
||||
6.8134765625
|
||||
],
|
||||
"load_average_after": [
|
||||
7.158203125,
|
||||
7.7998046875,
|
||||
6.66455078125
|
||||
],
|
||||
"executables": {
|
||||
"baseline": {
|
||||
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-e422bd13",
|
||||
"mode": "baseline",
|
||||
"sha256": "bf61f2e6c92e001be3c3bc93cf8072cc6af6fd1d7bcfa7273045960948ec89e7"
|
||||
},
|
||||
"a01d47f2": {
|
||||
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-a01d47f2",
|
||||
"mode": "shared",
|
||||
"sha256": "88be56f8dbd28d0b4abd6c672486ba758df96ba18f68da45b94db14231dd3eea"
|
||||
},
|
||||
"cf837118": {
|
||||
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-cf837118",
|
||||
"mode": "deferred",
|
||||
"sha256": "f1b044cd9ac2f67c644fb40bd7ecbe38128ec739a8112d57fc36f05fe9036a52"
|
||||
},
|
||||
"cd61e11b": {
|
||||
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-cd61e11b",
|
||||
"mode": "deferred",
|
||||
"sha256": "fb2d3725b09ee38f85b49901f7053a6fe339b980a2b2dac6930a8ded22c139e8"
|
||||
},
|
||||
"current": {
|
||||
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-new",
|
||||
"mode": "deferred",
|
||||
"sha256": "84eb1baae8e138f52e300b0b44f1339fca38e70db6e06126e78db776ea3e5cd0"
|
||||
}
|
||||
},
|
||||
"metrics": {
|
||||
"baseline": {
|
||||
"phase_ms": {
|
||||
"median": 1277.781,
|
||||
"p25": 1271.23,
|
||||
"p75": 1296.157,
|
||||
"min": 1223.341,
|
||||
"max": 1338.745,
|
||||
"samples": [
|
||||
1265.934,
|
||||
1223.341,
|
||||
1274.931,
|
||||
1289.177,
|
||||
1338.745,
|
||||
1317.096,
|
||||
1272.995,
|
||||
1280.631
|
||||
]
|
||||
},
|
||||
"total_ms": {
|
||||
"median": 1711.802,
|
||||
"p25": 1695.745,
|
||||
"p75": 1754.276,
|
||||
"min": 1645.69,
|
||||
"max": 2887.078,
|
||||
"samples": [
|
||||
2887.078,
|
||||
1645.69,
|
||||
1690.413,
|
||||
1711.459,
|
||||
1761.859,
|
||||
1751.748,
|
||||
1697.522,
|
||||
1712.144
|
||||
]
|
||||
},
|
||||
"cpu_ms": {
|
||||
"median": 1769.996,
|
||||
"p25": 1748.925,
|
||||
"p75": 1807.254,
|
||||
"min": 1705.167,
|
||||
"max": 1950.463,
|
||||
"samples": [
|
||||
1950.463,
|
||||
1705.167,
|
||||
1749.023,
|
||||
1768.05,
|
||||
1804.543,
|
||||
1815.386,
|
||||
1771.942,
|
||||
1748.632
|
||||
]
|
||||
},
|
||||
"peak_rss_kib": {
|
||||
"median": 820180.0,
|
||||
"p25": 820030.0,
|
||||
"p75": 820502.0,
|
||||
"min": 819472,
|
||||
"max": 820884,
|
||||
"samples": [
|
||||
819472,
|
||||
820416,
|
||||
820060,
|
||||
820096,
|
||||
820264,
|
||||
820884,
|
||||
819940,
|
||||
820760
|
||||
]
|
||||
}
|
||||
},
|
||||
"a01d47f2": {
|
||||
"phase_ms": {
|
||||
"median": 430.783,
|
||||
"p25": 427.542,
|
||||
"p75": 451.763,
|
||||
"min": 423.035,
|
||||
"max": 550.345,
|
||||
"samples": [
|
||||
491.599,
|
||||
423.035,
|
||||
428.85,
|
||||
427.694,
|
||||
432.715,
|
||||
427.084,
|
||||
438.484,
|
||||
550.345
|
||||
]
|
||||
},
|
||||
"total_ms": {
|
||||
"median": 868.457,
|
||||
"p25": 863.709,
|
||||
"p75": 932.066,
|
||||
"min": 837.75,
|
||||
"max": 997.779,
|
||||
"samples": [
|
||||
911.935,
|
||||
837.75,
|
||||
865.419,
|
||||
868.626,
|
||||
858.578,
|
||||
997.779,
|
||||
868.288,
|
||||
992.46
|
||||
]
|
||||
},
|
||||
"cpu_ms": {
|
||||
"median": 897.639,
|
||||
"p25": 887.661,
|
||||
"p75": 944.363,
|
||||
"min": 865.497,
|
||||
"max": 1001.635,
|
||||
"samples": [
|
||||
925.321,
|
||||
865.497,
|
||||
899.505,
|
||||
895.774,
|
||||
876.929,
|
||||
1001.635,
|
||||
891.239,
|
||||
1001.488
|
||||
]
|
||||
},
|
||||
"peak_rss_kib": {
|
||||
"median": 351906.0,
|
||||
"p25": 351505.0,
|
||||
"p75": 352181.0,
|
||||
"min": 351176,
|
||||
"max": 352440,
|
||||
"samples": [
|
||||
352008,
|
||||
352120,
|
||||
351352,
|
||||
351556,
|
||||
351804,
|
||||
351176,
|
||||
352440,
|
||||
352364
|
||||
]
|
||||
}
|
||||
},
|
||||
"cf837118": {
|
||||
"phase_ms": {
|
||||
"median": 408.459,
|
||||
"p25": 398.987,
|
||||
"p75": 428.585,
|
||||
"min": 387.999,
|
||||
"max": 524.425,
|
||||
"samples": [
|
||||
433.521,
|
||||
387.999,
|
||||
412.195,
|
||||
397.366,
|
||||
404.724,
|
||||
399.527,
|
||||
426.939,
|
||||
524.425
|
||||
]
|
||||
},
|
||||
"total_ms": {
|
||||
"median": 851.294,
|
||||
"p25": 823.291,
|
||||
"p75": 890.55,
|
||||
"min": 801.356,
|
||||
"max": 1030.737,
|
||||
"samples": [
|
||||
892.054,
|
||||
801.356,
|
||||
840.381,
|
||||
818.285,
|
||||
862.208,
|
||||
824.96,
|
||||
890.049,
|
||||
1030.737
|
||||
]
|
||||
},
|
||||
"cpu_ms": {
|
||||
"median": 870.765,
|
||||
"p25": 850.849,
|
||||
"p75": 907.922,
|
||||
"min": 825.718,
|
||||
"max": 1041.996,
|
||||
"samples": [
|
||||
906.657,
|
||||
825.718,
|
||||
866.472,
|
||||
843.591,
|
||||
875.058,
|
||||
853.268,
|
||||
911.715,
|
||||
1041.996
|
||||
]
|
||||
},
|
||||
"peak_rss_kib": {
|
||||
"median": 274172.0,
|
||||
"p25": 273909.0,
|
||||
"p75": 274438.0,
|
||||
"min": 273668,
|
||||
"max": 274964,
|
||||
"samples": [
|
||||
274372,
|
||||
274964,
|
||||
273668,
|
||||
273972,
|
||||
274552,
|
||||
274400,
|
||||
273840,
|
||||
273932
|
||||
]
|
||||
}
|
||||
},
|
||||
"cd61e11b": {
|
||||
"phase_ms": {
|
||||
"median": 419.114,
|
||||
"p25": 404.191,
|
||||
"p75": 460.634,
|
||||
"min": 388.557,
|
||||
"max": 735.687,
|
||||
"samples": [
|
||||
457.971,
|
||||
418.303,
|
||||
388.557,
|
||||
405.041,
|
||||
419.925,
|
||||
401.64,
|
||||
468.624,
|
||||
735.687
|
||||
]
|
||||
},
|
||||
"total_ms": {
|
||||
"median": 840.723,
|
||||
"p25": 835.398,
|
||||
"p75": 917.995,
|
||||
"min": 811.43,
|
||||
"max": 1565.471,
|
||||
"samples": [
|
||||
906.328,
|
||||
837.706,
|
||||
811.43,
|
||||
835.727,
|
||||
843.74,
|
||||
834.412,
|
||||
952.996,
|
||||
1565.471
|
||||
]
|
||||
},
|
||||
"cpu_ms": {
|
||||
"median": 863.404,
|
||||
"p25": 858.916,
|
||||
"p75": 934.2,
|
||||
"min": 833.955,
|
||||
"max": 1550.132,
|
||||
"samples": [
|
||||
916.017,
|
||||
864.82,
|
||||
833.955,
|
||||
859.057,
|
||||
861.989,
|
||||
858.494,
|
||||
988.748,
|
||||
1550.132
|
||||
]
|
||||
},
|
||||
"peak_rss_kib": {
|
||||
"median": 267508.0,
|
||||
"p25": 267310.0,
|
||||
"p75": 267890.0,
|
||||
"min": 267172,
|
||||
"max": 268412,
|
||||
"samples": [
|
||||
267304,
|
||||
268292,
|
||||
267312,
|
||||
267756,
|
||||
267340,
|
||||
267676,
|
||||
267172,
|
||||
268412
|
||||
]
|
||||
}
|
||||
},
|
||||
"current": {
|
||||
"phase_ms": {
|
||||
"median": 406.779,
|
||||
"p25": 397.839,
|
||||
"p75": 413.957,
|
||||
"min": 385.028,
|
||||
"max": 552.051,
|
||||
"samples": [
|
||||
387.136,
|
||||
385.028,
|
||||
409.317,
|
||||
404.242,
|
||||
401.407,
|
||||
411.451,
|
||||
421.476,
|
||||
552.051
|
||||
]
|
||||
},
|
||||
"total_ms": {
|
||||
"median": 819.787,
|
||||
"p25": 814.211,
|
||||
"p75": 855.69,
|
||||
"min": 800.867,
|
||||
"max": 1128.453,
|
||||
"samples": [
|
||||
808.685,
|
||||
800.867,
|
||||
816.053,
|
||||
817.907,
|
||||
821.667,
|
||||
843.26,
|
||||
892.982,
|
||||
1128.453
|
||||
]
|
||||
},
|
||||
"cpu_ms": {
|
||||
"median": 847.078,
|
||||
"p25": 836.598,
|
||||
"p75": 877.524,
|
||||
"min": 826.002,
|
||||
"max": 1134.369,
|
||||
"samples": [
|
||||
834.665,
|
||||
826.002,
|
||||
837.243,
|
||||
842.86,
|
||||
851.296,
|
||||
864.892,
|
||||
915.419,
|
||||
1134.369
|
||||
]
|
||||
},
|
||||
"peak_rss_kib": {
|
||||
"median": 267956.0,
|
||||
"p25": 267612.0,
|
||||
"p75": 268326.0,
|
||||
"min": 267308,
|
||||
"max": 268428,
|
||||
"samples": [
|
||||
268428,
|
||||
267308,
|
||||
268260,
|
||||
267516,
|
||||
267652,
|
||||
268312,
|
||||
268368,
|
||||
267644
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"experiment_note": "Experimental projection variant (reused sounds, Arc-keyed string pools and direct flags); reverted after mixed CPU/RSS evidence. Current in this file is not the final PR version."
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
{
|
||||
"scope": "Assets + BlockFacts + Behaviours loader only; no full startup or first frame",
|
||||
"resources": "/srv/bob-data/horizon/.local/horizon-client/26.3",
|
||||
"repeats": 10,
|
||||
"ordering": [
|
||||
[
|
||||
"baseline",
|
||||
"a01d47f2",
|
||||
"cf837118",
|
||||
"cd61e11b",
|
||||
"current"
|
||||
],
|
||||
[
|
||||
"current",
|
||||
"cd61e11b",
|
||||
"cf837118",
|
||||
"a01d47f2",
|
||||
"baseline"
|
||||
],
|
||||
[
|
||||
"baseline",
|
||||
"a01d47f2",
|
||||
"cf837118",
|
||||
"cd61e11b",
|
||||
"current"
|
||||
],
|
||||
[
|
||||
"current",
|
||||
"cd61e11b",
|
||||
"cf837118",
|
||||
"a01d47f2",
|
||||
"baseline"
|
||||
],
|
||||
[
|
||||
"baseline",
|
||||
"a01d47f2",
|
||||
"cf837118",
|
||||
"cd61e11b",
|
||||
"current"
|
||||
],
|
||||
[
|
||||
"current",
|
||||
"cd61e11b",
|
||||
"cf837118",
|
||||
"a01d47f2",
|
||||
"baseline"
|
||||
],
|
||||
[
|
||||
"baseline",
|
||||
"a01d47f2",
|
||||
"cf837118",
|
||||
"cd61e11b",
|
||||
"current"
|
||||
],
|
||||
[
|
||||
"current",
|
||||
"cd61e11b",
|
||||
"cf837118",
|
||||
"a01d47f2",
|
||||
"baseline"
|
||||
],
|
||||
[
|
||||
"baseline",
|
||||
"a01d47f2",
|
||||
"cf837118",
|
||||
"cd61e11b",
|
||||
"current"
|
||||
],
|
||||
[
|
||||
"current",
|
||||
"cd61e11b",
|
||||
"cf837118",
|
||||
"a01d47f2",
|
||||
"baseline"
|
||||
]
|
||||
],
|
||||
"load_average_before": [
|
||||
11.4921875,
|
||||
7.46875,
|
||||
6.69677734375
|
||||
],
|
||||
"load_average_after": [
|
||||
17.763671875,
|
||||
11.39013671875,
|
||||
8.20556640625
|
||||
],
|
||||
"executables": {
|
||||
"baseline": {
|
||||
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-e422bd13",
|
||||
"mode": "baseline",
|
||||
"sha256": "bf61f2e6c92e001be3c3bc93cf8072cc6af6fd1d7bcfa7273045960948ec89e7"
|
||||
},
|
||||
"a01d47f2": {
|
||||
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-a01d47f2",
|
||||
"mode": "shared",
|
||||
"sha256": "88be56f8dbd28d0b4abd6c672486ba758df96ba18f68da45b94db14231dd3eea"
|
||||
},
|
||||
"cf837118": {
|
||||
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-cf837118",
|
||||
"mode": "deferred",
|
||||
"sha256": "f1b044cd9ac2f67c644fb40bd7ecbe38128ec739a8112d57fc36f05fe9036a52"
|
||||
},
|
||||
"cd61e11b": {
|
||||
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-cd61e11b",
|
||||
"mode": "deferred",
|
||||
"sha256": "fb2d3725b09ee38f85b49901f7053a6fe339b980a2b2dac6930a8ded22c139e8"
|
||||
},
|
||||
"current": {
|
||||
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-integrated",
|
||||
"mode": "deferred",
|
||||
"sha256": "a52d6a7efc7b3198ef0dd8150325f56a3fe5d0c5883fd6d14c4b0c281866ce7c"
|
||||
}
|
||||
},
|
||||
"metrics": {
|
||||
"baseline": {
|
||||
"phase_ms": {
|
||||
"median": 1506.588,
|
||||
"p25": 1388.314,
|
||||
"p75": 2119.85,
|
||||
"min": 1319.319,
|
||||
"max": 3143.124,
|
||||
"samples": [
|
||||
3143.124,
|
||||
2483.307,
|
||||
2124.952,
|
||||
2104.544,
|
||||
1525.153,
|
||||
1396.75,
|
||||
1381.771,
|
||||
1488.022,
|
||||
1385.502,
|
||||
1319.319
|
||||
]
|
||||
},
|
||||
"total_ms": {
|
||||
"median": 1962.122,
|
||||
"p25": 1858.188,
|
||||
"p75": 3152.608,
|
||||
"min": 1804.713,
|
||||
"max": 4136.912,
|
||||
"samples": [
|
||||
4136.912,
|
||||
3250.285,
|
||||
3134.13,
|
||||
3158.767,
|
||||
2001.229,
|
||||
1883.076,
|
||||
1849.892,
|
||||
1923.016,
|
||||
1818.34,
|
||||
1804.713
|
||||
]
|
||||
},
|
||||
"cpu_ms": {
|
||||
"median": 1969.296,
|
||||
"p25": 1893.348,
|
||||
"p75": 2694.824,
|
||||
"min": 1842.049,
|
||||
"max": 3059.824,
|
||||
"samples": [
|
||||
3059.824,
|
||||
2765.392,
|
||||
2698.837,
|
||||
2682.786,
|
||||
2026.89,
|
||||
1911.703,
|
||||
1888.282,
|
||||
1908.545,
|
||||
1859.233,
|
||||
1842.049
|
||||
]
|
||||
},
|
||||
"peak_rss_kib": {
|
||||
"median": 819952.0,
|
||||
"p25": 819778.0,
|
||||
"p75": 820164.0,
|
||||
"min": 818944,
|
||||
"max": 820304,
|
||||
"samples": [
|
||||
819956,
|
||||
819744,
|
||||
818944,
|
||||
818984,
|
||||
820128,
|
||||
820176,
|
||||
819948,
|
||||
820236,
|
||||
819880,
|
||||
820304
|
||||
]
|
||||
}
|
||||
},
|
||||
"a01d47f2": {
|
||||
"phase_ms": {
|
||||
"median": 666.765,
|
||||
"p25": 520.278,
|
||||
"p75": 1156.022,
|
||||
"min": 450.677,
|
||||
"max": 1882.249,
|
||||
"samples": [
|
||||
1001.208,
|
||||
1309.911,
|
||||
1207.627,
|
||||
1882.249,
|
||||
747.233,
|
||||
544.933,
|
||||
512.06,
|
||||
450.677,
|
||||
466.596,
|
||||
586.298
|
||||
]
|
||||
},
|
||||
"total_ms": {
|
||||
"median": 1141.968,
|
||||
"p25": 974.723,
|
||||
"p75": 2326.553,
|
||||
"min": 922.496,
|
||||
"max": 3158.114,
|
||||
"samples": [
|
||||
2374.699,
|
||||
2275.18,
|
||||
2343.677,
|
||||
3158.114,
|
||||
1267.594,
|
||||
1006.411,
|
||||
964.16,
|
||||
922.496,
|
||||
936.687,
|
||||
1016.342
|
||||
]
|
||||
},
|
||||
"cpu_ms": {
|
||||
"median": 1106.901,
|
||||
"p25": 973.629,
|
||||
"p75": 1525.998,
|
||||
"min": 929.573,
|
||||
"max": 1568.573,
|
||||
"samples": [
|
||||
1545.278,
|
||||
1568.573,
|
||||
1468.158,
|
||||
1563.377,
|
||||
1196.236,
|
||||
1017.565,
|
||||
960.219,
|
||||
929.573,
|
||||
946.096,
|
||||
1013.861
|
||||
]
|
||||
},
|
||||
"peak_rss_kib": {
|
||||
"median": 351564.0,
|
||||
"p25": 351148.0,
|
||||
"p75": 352106.0,
|
||||
"min": 350416,
|
||||
"max": 353368,
|
||||
"samples": [
|
||||
351572,
|
||||
351268,
|
||||
351108,
|
||||
350416,
|
||||
351656,
|
||||
353368,
|
||||
352256,
|
||||
351556,
|
||||
350964,
|
||||
352308
|
||||
]
|
||||
}
|
||||
},
|
||||
"cf837118": {
|
||||
"phase_ms": {
|
||||
"median": 505.124,
|
||||
"p25": 435.315,
|
||||
"p75": 1103.87,
|
||||
"min": 417.056,
|
||||
"max": 1875.147,
|
||||
"samples": [
|
||||
1875.147,
|
||||
1129.722,
|
||||
1066.305,
|
||||
1116.391,
|
||||
461.019,
|
||||
423.38,
|
||||
417.056,
|
||||
445.353,
|
||||
549.229,
|
||||
431.969
|
||||
]
|
||||
},
|
||||
"total_ms": {
|
||||
"median": 1209.375,
|
||||
"p25": 895.862,
|
||||
"p75": 2133.144,
|
||||
"min": 872.434,
|
||||
"max": 2936.234,
|
||||
"samples": [
|
||||
2936.234,
|
||||
2215.538,
|
||||
1885.964,
|
||||
2695.131,
|
||||
1378.495,
|
||||
879.351,
|
||||
897.968,
|
||||
895.16,
|
||||
1040.255,
|
||||
872.434
|
||||
]
|
||||
},
|
||||
"cpu_ms": {
|
||||
"median": 1068.41,
|
||||
"p25": 896.926,
|
||||
"p75": 1488.578,
|
||||
"min": 882.398,
|
||||
"max": 1570.652,
|
||||
"samples": [
|
||||
1530.601,
|
||||
1520.851,
|
||||
1391.76,
|
||||
1570.652,
|
||||
1116.403,
|
||||
900.779,
|
||||
894.039,
|
||||
895.642,
|
||||
1020.417,
|
||||
882.398
|
||||
]
|
||||
},
|
||||
"peak_rss_kib": {
|
||||
"median": 273832.0,
|
||||
"p25": 273592.0,
|
||||
"p75": 274131.0,
|
||||
"min": 272732,
|
||||
"max": 274976,
|
||||
"samples": [
|
||||
274080,
|
||||
272732,
|
||||
273560,
|
||||
273468,
|
||||
274200,
|
||||
274148,
|
||||
273728,
|
||||
273936,
|
||||
274976,
|
||||
273688
|
||||
]
|
||||
}
|
||||
},
|
||||
"cd61e11b": {
|
||||
"phase_ms": {
|
||||
"median": 429.481,
|
||||
"p25": 410.043,
|
||||
"p75": 1178.091,
|
||||
"min": 399.576,
|
||||
"max": 1644.881,
|
||||
"samples": [
|
||||
1194.624,
|
||||
1128.491,
|
||||
1644.881,
|
||||
1428.78,
|
||||
405.544,
|
||||
434.62,
|
||||
424.341,
|
||||
414.829,
|
||||
408.447,
|
||||
399.576
|
||||
]
|
||||
},
|
||||
"total_ms": {
|
||||
"median": 902.865,
|
||||
"p25": 868.889,
|
||||
"p75": 2443.447,
|
||||
"min": 827.16,
|
||||
"max": 2961.261,
|
||||
"samples": [
|
||||
2682.056,
|
||||
2199.278,
|
||||
2524.837,
|
||||
2961.261,
|
||||
884.838,
|
||||
885.282,
|
||||
848.79,
|
||||
863.573,
|
||||
920.448,
|
||||
827.16
|
||||
]
|
||||
},
|
||||
"cpu_ms": {
|
||||
"median": 920.917,
|
||||
"p25": 869.777,
|
||||
"p75": 1475.629,
|
||||
"min": 840.036,
|
||||
"max": 1626.459,
|
||||
"samples": [
|
||||
1518.201,
|
||||
1496.456,
|
||||
1413.149,
|
||||
1626.459,
|
||||
893.762,
|
||||
899.049,
|
||||
861.782,
|
||||
861.417,
|
||||
942.785,
|
||||
840.036
|
||||
]
|
||||
},
|
||||
"peak_rss_kib": {
|
||||
"median": 267762.0,
|
||||
"p25": 267612.0,
|
||||
"p75": 267966.0,
|
||||
"min": 266300,
|
||||
"max": 268636,
|
||||
"samples": [
|
||||
266672,
|
||||
268636,
|
||||
266300,
|
||||
267596,
|
||||
267660,
|
||||
268280,
|
||||
267968,
|
||||
267680,
|
||||
267960,
|
||||
267844
|
||||
]
|
||||
}
|
||||
},
|
||||
"current": {
|
||||
"phase_ms": {
|
||||
"median": 460.489,
|
||||
"p25": 429.004,
|
||||
"p75": 986.935,
|
||||
"min": 404.103,
|
||||
"max": 1300.033,
|
||||
"samples": [
|
||||
992.891,
|
||||
1126.098,
|
||||
1300.033,
|
||||
969.067,
|
||||
443.604,
|
||||
408.397,
|
||||
472.429,
|
||||
424.137,
|
||||
448.549,
|
||||
404.103
|
||||
]
|
||||
},
|
||||
"total_ms": {
|
||||
"median": 935.623,
|
||||
"p25": 874.18,
|
||||
"p75": 2282.679,
|
||||
"min": 839.642,
|
||||
"max": 2965.198,
|
||||
"samples": [
|
||||
2320.566,
|
||||
2169.019,
|
||||
2965.198,
|
||||
2531.591,
|
||||
924.347,
|
||||
839.642,
|
||||
946.899,
|
||||
873.617,
|
||||
875.87,
|
||||
854.328
|
||||
]
|
||||
},
|
||||
"cpu_ms": {
|
||||
"median": 950.702,
|
||||
"p25": 892.992,
|
||||
"p75": 1540.918,
|
||||
"min": 861.669,
|
||||
"max": 1586.478,
|
||||
"samples": [
|
||||
1557.765,
|
||||
1503.564,
|
||||
1553.369,
|
||||
1586.478,
|
||||
931.391,
|
||||
861.669,
|
||||
970.013,
|
||||
888.14,
|
||||
907.549,
|
||||
868.73
|
||||
]
|
||||
},
|
||||
"peak_rss_kib": {
|
||||
"median": 268010.0,
|
||||
"p25": 267013.0,
|
||||
"p75": 268385.0,
|
||||
"min": 266144,
|
||||
"max": 268876,
|
||||
"samples": [
|
||||
266824,
|
||||
267580,
|
||||
266516,
|
||||
266144,
|
||||
267976,
|
||||
268304,
|
||||
268044,
|
||||
268480,
|
||||
268412,
|
||||
268876
|
||||
]
|
||||
},
|
||||
"behaviour_json_ms": {
|
||||
"median": 34.288,
|
||||
"p25": 23.085,
|
||||
"p75": 37.474,
|
||||
"min": 20.137,
|
||||
"max": 70.397,
|
||||
"samples": [
|
||||
37.07,
|
||||
70.397,
|
||||
37.609,
|
||||
38.225,
|
||||
34.568,
|
||||
21.094,
|
||||
23.469,
|
||||
22.957,
|
||||
34.008,
|
||||
20.137
|
||||
]
|
||||
},
|
||||
"facts_ms": {
|
||||
"median": 272.839,
|
||||
"p25": 256.553,
|
||||
"p75": 618.472,
|
||||
"min": 248.146,
|
||||
"max": 780.388,
|
||||
"samples": [
|
||||
625.908,
|
||||
693.735,
|
||||
780.388,
|
||||
596.165,
|
||||
272.718,
|
||||
249.954,
|
||||
272.959,
|
||||
264.913,
|
||||
253.767,
|
||||
248.146
|
||||
]
|
||||
},
|
||||
"behaviours_ms": {
|
||||
"median": 162.633,
|
||||
"p25": 133.345,
|
||||
"p75": 326.117,
|
||||
"min": 132.828,
|
||||
"max": 476.9,
|
||||
"samples": [
|
||||
325.198,
|
||||
356.28,
|
||||
476.9,
|
||||
326.424,
|
||||
132.828,
|
||||
133.946,
|
||||
167.929,
|
||||
133.144,
|
||||
157.337,
|
||||
132.934
|
||||
]
|
||||
},
|
||||
"post_rss_kib": {
|
||||
"median": 268408.0,
|
||||
"p25": 267369.0,
|
||||
"p75": 268861.0,
|
||||
"min": 267052.0,
|
||||
"max": 269072.0,
|
||||
"samples": [
|
||||
267084.0,
|
||||
268056.0,
|
||||
267052.0,
|
||||
267140.0,
|
||||
268184.0,
|
||||
268632.0,
|
||||
268660.0,
|
||||
269072.0,
|
||||
268928.0,
|
||||
268960.0
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
{
|
||||
"scope": "Assets + block facts + behaviours loader only; excludes full startup",
|
||||
"resources": ".local/horizon-client/26.3",
|
||||
"repeats": 5,
|
||||
"comparison": "historical cf837118 release binary",
|
||||
"reference_binary": "target/release/examples/shared_json_startup_profile-cf837118",
|
||||
"metrics": {
|
||||
"baseline": {
|
||||
"phase_ms": {
|
||||
"median": 1462.003,
|
||||
"samples": [
|
||||
1639.999,
|
||||
1506.864,
|
||||
1429.844,
|
||||
1462.003,
|
||||
1346.061
|
||||
]
|
||||
},
|
||||
"total_ms": {
|
||||
"median": 1922.795,
|
||||
"samples": [
|
||||
3383.516,
|
||||
1990.554,
|
||||
1912.354,
|
||||
1922.795,
|
||||
1777.912
|
||||
]
|
||||
},
|
||||
"cpu_ms": {
|
||||
"median": 1961.459,
|
||||
"samples": [
|
||||
2419.086,
|
||||
2021.457,
|
||||
1951.587,
|
||||
1961.459,
|
||||
1839.984
|
||||
]
|
||||
},
|
||||
"peak_rss_kib": {
|
||||
"median": 820912,
|
||||
"samples": [
|
||||
819956,
|
||||
821052,
|
||||
820912,
|
||||
821792,
|
||||
820424
|
||||
]
|
||||
}
|
||||
},
|
||||
"pr607": {
|
||||
"phase_ms": {
|
||||
"median": 486.715,
|
||||
"samples": [
|
||||
460.738,
|
||||
442.013,
|
||||
623.053,
|
||||
486.715,
|
||||
550.652
|
||||
]
|
||||
},
|
||||
"total_ms": {
|
||||
"median": 948.089,
|
||||
"samples": [
|
||||
923.057,
|
||||
935.599,
|
||||
1109.64,
|
||||
948.089,
|
||||
1052.546
|
||||
]
|
||||
},
|
||||
"cpu_ms": {
|
||||
"median": 968.424,
|
||||
"samples": [
|
||||
928.489,
|
||||
946.932,
|
||||
1128.431,
|
||||
968.424,
|
||||
1079.642
|
||||
]
|
||||
},
|
||||
"peak_rss_kib": {
|
||||
"median": 351796,
|
||||
"samples": [
|
||||
351796,
|
||||
351844,
|
||||
351536,
|
||||
351532,
|
||||
351988
|
||||
]
|
||||
}
|
||||
},
|
||||
"cf837118": {
|
||||
"phase_ms": {
|
||||
"median": 474.268,
|
||||
"samples": [
|
||||
443.122,
|
||||
474.268,
|
||||
520.497,
|
||||
433.525,
|
||||
532.058
|
||||
]
|
||||
},
|
||||
"total_ms": {
|
||||
"median": 945.87,
|
||||
"samples": [
|
||||
934.722,
|
||||
945.87,
|
||||
1006.539,
|
||||
888.201,
|
||||
1032.6
|
||||
]
|
||||
},
|
||||
"cpu_ms": {
|
||||
"median": 953.668,
|
||||
"samples": [
|
||||
941.957,
|
||||
953.668,
|
||||
1029.122,
|
||||
903.574,
|
||||
1065.025
|
||||
]
|
||||
},
|
||||
"peak_rss_kib": {
|
||||
"median": 274476,
|
||||
"samples": [
|
||||
273448,
|
||||
274844,
|
||||
274476,
|
||||
274556,
|
||||
274252
|
||||
]
|
||||
}
|
||||
},
|
||||
"current": {
|
||||
"phase_ms": {
|
||||
"median": 445.924,
|
||||
"samples": [
|
||||
472.165,
|
||||
427.797,
|
||||
419.288,
|
||||
445.924,
|
||||
524.211
|
||||
]
|
||||
},
|
||||
"total_ms": {
|
||||
"median": 999.669,
|
||||
"samples": [
|
||||
999.669,
|
||||
892.616,
|
||||
1051.427,
|
||||
940.595,
|
||||
1112.158
|
||||
]
|
||||
},
|
||||
"cpu_ms": {
|
||||
"median": 1001.24,
|
||||
"samples": [
|
||||
1001.24,
|
||||
887.874,
|
||||
1051.732,
|
||||
951.495,
|
||||
1132.208
|
||||
]
|
||||
},
|
||||
"peak_rss_kib": {
|
||||
"median": 267604,
|
||||
"samples": [
|
||||
267716,
|
||||
267920,
|
||||
267448,
|
||||
267604,
|
||||
267344
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"builds": {
|
||||
"current": "release example built at cf837118 + current uncommitted delta in PR #607 worktree",
|
||||
"historical": "release example built at exact cf837118 commit, with same Cargo release profile and shared dependency target"
|
||||
},
|
||||
"mode_commits": {
|
||||
"baseline": "independent loader algorithm at e422bd13, executed in current binary",
|
||||
"pr607": "shared early construction algorithm at a01d47f2, executed in current binary",
|
||||
"cf837118": "exact historical binary at cf837118, deferred mode",
|
||||
"current": "current PR worktree, deferred mode with interned strings"
|
||||
},
|
||||
"limitations": "Loader-only (Assets + BlockFacts + Behaviours), no full client startup or first-frame timing. Same release profile but different compilation contexts for historical binary; hardware activity and cache warmth vary. CPU and elapsed differences between cf837118 and current are mixed and may be noise. Peak RSS is whole-process Linux ru_maxrss."
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"scope": "Assets + block facts + behaviours loader only; excludes full startup",
|
||||
"resources": ".local/horizon-client/26.3",
|
||||
"repeats": 5,
|
||||
"metrics": {
|
||||
"baseline": {
|
||||
"phase_ms": {
|
||||
"median": 4730.402,
|
||||
"samples": [
|
||||
3258.541,
|
||||
6539.764,
|
||||
4773.735,
|
||||
3980.032,
|
||||
4730.402
|
||||
]
|
||||
},
|
||||
"total_ms": {
|
||||
"median": 6325.026,
|
||||
"samples": [
|
||||
6596.141,
|
||||
7627.893,
|
||||
6296.443,
|
||||
5435.408,
|
||||
6325.026
|
||||
]
|
||||
},
|
||||
"cpu_ms": {
|
||||
"median": 3199.722,
|
||||
"samples": [
|
||||
3322.801,
|
||||
3199.722,
|
||||
3227.438,
|
||||
3042.687,
|
||||
3144.658
|
||||
]
|
||||
},
|
||||
"peak_rss_kib": {
|
||||
"median": 820000,
|
||||
"samples": [
|
||||
818540,
|
||||
820400,
|
||||
819352,
|
||||
820000,
|
||||
820128
|
||||
]
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
"phase_ms": {
|
||||
"median": 1180.588,
|
||||
"samples": [
|
||||
869.228,
|
||||
842.213,
|
||||
1180.588,
|
||||
1560.087,
|
||||
1728.575
|
||||
]
|
||||
},
|
||||
"total_ms": {
|
||||
"median": 2260.709,
|
||||
"samples": [
|
||||
1632.489,
|
||||
1553.753,
|
||||
2260.709,
|
||||
2879.728,
|
||||
3388.639
|
||||
]
|
||||
},
|
||||
"cpu_ms": {
|
||||
"median": 1603.393,
|
||||
"samples": [
|
||||
1568.288,
|
||||
1432.106,
|
||||
1603.393,
|
||||
1604.423,
|
||||
1619.02
|
||||
]
|
||||
},
|
||||
"peak_rss_kib": {
|
||||
"median": 351728,
|
||||
"samples": [
|
||||
353256,
|
||||
353664,
|
||||
351728,
|
||||
350724,
|
||||
351668
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"build": "cargo build --release -p horizon-client --example shared_json_startup_profile",
|
||||
"limitations": "Loader-only wall-clock timings under concurrent machine load; not full startup or time to first frame. RSS includes Assets::load."
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
{
|
||||
"scope": "Assets + block facts + behaviours loader only; excludes full startup",
|
||||
"resources": ".local/horizon-client/26.3",
|
||||
"repeats": 5,
|
||||
"metrics": {
|
||||
"baseline": {
|
||||
"phase_ms": {
|
||||
"median": 1996.921,
|
||||
"samples": [
|
||||
2213.335,
|
||||
1453.207,
|
||||
1637.901,
|
||||
1996.921,
|
||||
2010.335
|
||||
],
|
||||
"range": [
|
||||
1453.207,
|
||||
2213.335
|
||||
]
|
||||
},
|
||||
"total_ms": {
|
||||
"median": 4728.136,
|
||||
"samples": [
|
||||
5628.975,
|
||||
2147.329,
|
||||
2162.696,
|
||||
4728.136,
|
||||
6404.524
|
||||
],
|
||||
"range": [
|
||||
2147.329,
|
||||
6404.524
|
||||
]
|
||||
},
|
||||
"cpu_ms": {
|
||||
"median": 2514.776,
|
||||
"samples": [
|
||||
2919.908,
|
||||
2088.239,
|
||||
2212.892,
|
||||
2716.104,
|
||||
2514.776
|
||||
],
|
||||
"range": [
|
||||
2088.239,
|
||||
2919.908
|
||||
]
|
||||
},
|
||||
"peak_rss_kib": {
|
||||
"median": 819824,
|
||||
"samples": [
|
||||
819824,
|
||||
820872,
|
||||
820744,
|
||||
818980,
|
||||
818816
|
||||
]
|
||||
}
|
||||
},
|
||||
"pr607": {
|
||||
"phase_ms": {
|
||||
"median": 586.707,
|
||||
"samples": [
|
||||
464.424,
|
||||
739.67,
|
||||
586.707,
|
||||
1373.307,
|
||||
509.266
|
||||
],
|
||||
"range": [
|
||||
464.424,
|
||||
1373.307
|
||||
]
|
||||
},
|
||||
"total_ms": {
|
||||
"median": 1128.6,
|
||||
"samples": [
|
||||
954.73,
|
||||
1764.614,
|
||||
1128.6,
|
||||
4312.528,
|
||||
1030.735
|
||||
],
|
||||
"range": [
|
||||
954.73,
|
||||
4312.528
|
||||
]
|
||||
},
|
||||
"cpu_ms": {
|
||||
"median": 1145.826,
|
||||
"samples": [
|
||||
981.058,
|
||||
1465.79,
|
||||
1145.826,
|
||||
1736.807,
|
||||
1056.992
|
||||
],
|
||||
"range": [
|
||||
981.058,
|
||||
1736.807
|
||||
]
|
||||
},
|
||||
"peak_rss_kib": {
|
||||
"median": 351900,
|
||||
"samples": [
|
||||
352164,
|
||||
351816,
|
||||
351900,
|
||||
352348,
|
||||
350492
|
||||
]
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
"phase_ms": {
|
||||
"median": 560.117,
|
||||
"samples": [
|
||||
522.284,
|
||||
1255.75,
|
||||
560.117,
|
||||
2151.064,
|
||||
479.329
|
||||
],
|
||||
"range": [
|
||||
479.329,
|
||||
2151.064
|
||||
]
|
||||
},
|
||||
"total_ms": {
|
||||
"median": 1101.305,
|
||||
"samples": [
|
||||
1011.715,
|
||||
2565.449,
|
||||
1101.305,
|
||||
2960.186,
|
||||
1006.817
|
||||
],
|
||||
"range": [
|
||||
1006.817,
|
||||
2960.186
|
||||
]
|
||||
},
|
||||
"cpu_ms": {
|
||||
"median": 1128.214,
|
||||
"samples": [
|
||||
1035.19,
|
||||
1565.383,
|
||||
1128.214,
|
||||
1494.36,
|
||||
1031.198
|
||||
],
|
||||
"range": [
|
||||
1031.198,
|
||||
1565.383
|
||||
]
|
||||
},
|
||||
"peak_rss_kib": {
|
||||
"median": 351532,
|
||||
"samples": [
|
||||
351624,
|
||||
350540,
|
||||
351532,
|
||||
351716,
|
||||
350076
|
||||
]
|
||||
}
|
||||
},
|
||||
"deferred": {
|
||||
"phase_ms": {
|
||||
"median": 564.046,
|
||||
"samples": [
|
||||
433.35,
|
||||
665.149,
|
||||
587.456,
|
||||
564.046,
|
||||
497.422
|
||||
],
|
||||
"range": [
|
||||
433.35,
|
||||
665.149
|
||||
]
|
||||
},
|
||||
"total_ms": {
|
||||
"median": 1143.129,
|
||||
"samples": [
|
||||
885.053,
|
||||
1143.129,
|
||||
1195.869,
|
||||
1074.27,
|
||||
1162.103
|
||||
],
|
||||
"range": [
|
||||
885.053,
|
||||
1195.869
|
||||
]
|
||||
},
|
||||
"cpu_ms": {
|
||||
"median": 1114.261,
|
||||
"samples": [
|
||||
910.946,
|
||||
1128.96,
|
||||
1194.638,
|
||||
1114.261,
|
||||
1073.823
|
||||
],
|
||||
"range": [
|
||||
910.946,
|
||||
1194.638
|
||||
]
|
||||
},
|
||||
"peak_rss_kib": {
|
||||
"median": 273776,
|
||||
"samples": [
|
||||
274320,
|
||||
273860,
|
||||
272548,
|
||||
273776,
|
||||
272520
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"baseline_commit": "e422bd13",
|
||||
"pr607_commit": "a01d47f2",
|
||||
"measurement": "same optimized binary, isolated processes in alternating forward/reverse four-mode runs",
|
||||
"limitations": "Includes Assets::load, BlockFacts, Behaviours; excludes complete client startup, pack/mod root resolution, graphics and first frame. Different modes emulate the original independent, PR #607 at a01d47f2, paired and deferred startup sequences in the same release build. Machine was not idle. Elapsed times show high variance; no statistically established gain between PR, paired and deferred modes."
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
{
|
||||
"scope": "Assets + BlockFacts + Behaviours loader only; no full startup or first frame",
|
||||
"resources": "/srv/bob-data/horizon/.local/horizon-client/26.3",
|
||||
"repeats": 8,
|
||||
"ordering": [
|
||||
[
|
||||
"cf837118",
|
||||
"cd61e11b",
|
||||
"arc-key",
|
||||
"reused-sounds",
|
||||
"current"
|
||||
],
|
||||
[
|
||||
"current",
|
||||
"reused-sounds",
|
||||
"arc-key",
|
||||
"cd61e11b",
|
||||
"cf837118"
|
||||
],
|
||||
[
|
||||
"cf837118",
|
||||
"cd61e11b",
|
||||
"arc-key",
|
||||
"reused-sounds",
|
||||
"current"
|
||||
],
|
||||
[
|
||||
"current",
|
||||
"reused-sounds",
|
||||
"arc-key",
|
||||
"cd61e11b",
|
||||
"cf837118"
|
||||
],
|
||||
[
|
||||
"cf837118",
|
||||
"cd61e11b",
|
||||
"arc-key",
|
||||
"reused-sounds",
|
||||
"current"
|
||||
],
|
||||
[
|
||||
"current",
|
||||
"reused-sounds",
|
||||
"arc-key",
|
||||
"cd61e11b",
|
||||
"cf837118"
|
||||
],
|
||||
[
|
||||
"cf837118",
|
||||
"cd61e11b",
|
||||
"arc-key",
|
||||
"reused-sounds",
|
||||
"current"
|
||||
],
|
||||
[
|
||||
"current",
|
||||
"reused-sounds",
|
||||
"arc-key",
|
||||
"cd61e11b",
|
||||
"cf837118"
|
||||
]
|
||||
],
|
||||
"load_average_before": [
|
||||
10.9873046875,
|
||||
8.37646484375,
|
||||
6.91015625
|
||||
],
|
||||
"load_average_after": [
|
||||
8.6962890625,
|
||||
8.1591796875,
|
||||
6.90087890625
|
||||
],
|
||||
"executables": {
|
||||
"cf837118": {
|
||||
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-cf837118",
|
||||
"mode": "deferred",
|
||||
"sha256": "f1b044cd9ac2f67c644fb40bd7ecbe38128ec739a8112d57fc36f05fe9036a52"
|
||||
},
|
||||
"cd61e11b": {
|
||||
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-cd61e11b",
|
||||
"mode": "deferred",
|
||||
"sha256": "fb2d3725b09ee38f85b49901f7053a6fe339b980a2b2dac6930a8ded22c139e8"
|
||||
},
|
||||
"arc-key": {
|
||||
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-arcpool",
|
||||
"mode": "deferred",
|
||||
"sha256": "8cbb53d01afedf5c7a5da0e36c9f8c3569f34def9bc51488fe8699e7f615a131"
|
||||
},
|
||||
"reused-sounds": {
|
||||
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-reusesounds",
|
||||
"mode": "deferred",
|
||||
"sha256": "f45794bfbdbb3f9b5d34c375d397191ac67984b3ae91268aa3fdd02f2c1b7b98"
|
||||
},
|
||||
"current": {
|
||||
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-new",
|
||||
"mode": "deferred",
|
||||
"sha256": "84eb1baae8e138f52e300b0b44f1339fca38e70db6e06126e78db776ea3e5cd0"
|
||||
}
|
||||
},
|
||||
"metrics": {
|
||||
"cf837118": {
|
||||
"phase_ms": {
|
||||
"median": 424.082,
|
||||
"p25": 417.508,
|
||||
"p75": 430.923,
|
||||
"min": 395.439,
|
||||
"max": 948.609,
|
||||
"samples": [
|
||||
948.609,
|
||||
425.085,
|
||||
425.571,
|
||||
417.945,
|
||||
395.439,
|
||||
416.197,
|
||||
423.078,
|
||||
446.98
|
||||
]
|
||||
},
|
||||
"total_ms": {
|
||||
"median": 853.026,
|
||||
"p25": 839.421,
|
||||
"p75": 867.731,
|
||||
"min": 813.144,
|
||||
"max": 1906.081,
|
||||
"samples": [
|
||||
1906.081,
|
||||
861.641,
|
||||
864.233,
|
||||
844.411,
|
||||
813.144,
|
||||
830.783,
|
||||
842.3,
|
||||
878.226
|
||||
]
|
||||
},
|
||||
"cpu_ms": {
|
||||
"median": 876.143,
|
||||
"p25": 862.934,
|
||||
"p75": 891.4,
|
||||
"min": 837.769,
|
||||
"max": 1517.372,
|
||||
"samples": [
|
||||
1517.372,
|
||||
885.13,
|
||||
887.888,
|
||||
865.404,
|
||||
837.769,
|
||||
855.526,
|
||||
867.157,
|
||||
901.937
|
||||
]
|
||||
},
|
||||
"peak_rss_kib": {
|
||||
"median": 273904.0,
|
||||
"p25": 273717.0,
|
||||
"p75": 274142.0,
|
||||
"min": 273648,
|
||||
"max": 274836,
|
||||
"samples": [
|
||||
273648,
|
||||
273936,
|
||||
273872,
|
||||
274836,
|
||||
274064,
|
||||
274376,
|
||||
273740,
|
||||
273648
|
||||
]
|
||||
}
|
||||
},
|
||||
"cd61e11b": {
|
||||
"phase_ms": {
|
||||
"median": 422.356,
|
||||
"p25": 406.294,
|
||||
"p75": 425.846,
|
||||
"min": 394.111,
|
||||
"max": 472.536,
|
||||
"samples": [
|
||||
425.832,
|
||||
420.666,
|
||||
472.536,
|
||||
425.886,
|
||||
400.588,
|
||||
394.111,
|
||||
424.047,
|
||||
408.196
|
||||
]
|
||||
},
|
||||
"total_ms": {
|
||||
"median": 852.475,
|
||||
"p25": 828.474,
|
||||
"p75": 876.413,
|
||||
"min": 817.3,
|
||||
"max": 951.587,
|
||||
"samples": [
|
||||
951.587,
|
||||
865.442,
|
||||
899.564,
|
||||
868.696,
|
||||
823.74,
|
||||
817.3,
|
||||
839.508,
|
||||
830.052
|
||||
]
|
||||
},
|
||||
"cpu_ms": {
|
||||
"median": 875.04,
|
||||
"p25": 850.012,
|
||||
"p75": 900.57,
|
||||
"min": 837.672,
|
||||
"max": 938.285,
|
||||
"samples": [
|
||||
938.285,
|
||||
888.533,
|
||||
926.846,
|
||||
891.812,
|
||||
845.956,
|
||||
837.672,
|
||||
861.548,
|
||||
851.364
|
||||
]
|
||||
},
|
||||
"peak_rss_kib": {
|
||||
"median": 268086.0,
|
||||
"p25": 267719.0,
|
||||
"p75": 268486.0,
|
||||
"min": 267444,
|
||||
"max": 268920,
|
||||
"samples": [
|
||||
267776,
|
||||
268660,
|
||||
267752,
|
||||
268920,
|
||||
268428,
|
||||
268396,
|
||||
267444,
|
||||
267620
|
||||
]
|
||||
}
|
||||
},
|
||||
"arc-key": {
|
||||
"phase_ms": {
|
||||
"median": 421.422,
|
||||
"p25": 401.781,
|
||||
"p75": 451.774,
|
||||
"min": 394.041,
|
||||
"max": 472.337,
|
||||
"samples": [
|
||||
446.155,
|
||||
472.337,
|
||||
468.632,
|
||||
420.636,
|
||||
400.91,
|
||||
394.041,
|
||||
422.207,
|
||||
402.072
|
||||
]
|
||||
},
|
||||
"total_ms": {
|
||||
"median": 866.601,
|
||||
"p25": 822.563,
|
||||
"p75": 918.038,
|
||||
"min": 817.092,
|
||||
"max": 1001.287,
|
||||
"samples": [
|
||||
1001.287,
|
||||
918.457,
|
||||
917.898,
|
||||
892.666,
|
||||
817.092,
|
||||
824.225,
|
||||
840.536,
|
||||
817.576
|
||||
]
|
||||
},
|
||||
"cpu_ms": {
|
||||
"median": 887.84,
|
||||
"p25": 844.533,
|
||||
"p75": 937.331,
|
||||
"min": 838.687,
|
||||
"max": 991.212,
|
||||
"samples": [
|
||||
991.212,
|
||||
949.048,
|
||||
933.425,
|
||||
911.921,
|
||||
838.687,
|
||||
845.021,
|
||||
863.759,
|
||||
843.069
|
||||
]
|
||||
},
|
||||
"peak_rss_kib": {
|
||||
"median": 267728.0,
|
||||
"p25": 267598.0,
|
||||
"p75": 268039.0,
|
||||
"min": 267452,
|
||||
"max": 268636,
|
||||
"samples": [
|
||||
268264,
|
||||
267616,
|
||||
267840,
|
||||
267964,
|
||||
268636,
|
||||
267452,
|
||||
267580,
|
||||
267604
|
||||
]
|
||||
}
|
||||
},
|
||||
"reused-sounds": {
|
||||
"phase_ms": {
|
||||
"median": 421.683,
|
||||
"p25": 412.913,
|
||||
"p75": 439.098,
|
||||
"min": 395.338,
|
||||
"max": 487.446,
|
||||
"samples": [
|
||||
487.446,
|
||||
434.111,
|
||||
402.815,
|
||||
416.279,
|
||||
424.524,
|
||||
454.058,
|
||||
395.338,
|
||||
418.842
|
||||
]
|
||||
},
|
||||
"total_ms": {
|
||||
"median": 879.846,
|
||||
"p25": 845.021,
|
||||
"p75": 901.166,
|
||||
"min": 823.721,
|
||||
"max": 1013.539,
|
||||
"samples": [
|
||||
1013.539,
|
||||
892.033,
|
||||
928.565,
|
||||
884.284,
|
||||
847.853,
|
||||
875.407,
|
||||
823.721,
|
||||
836.527
|
||||
]
|
||||
},
|
||||
"cpu_ms": {
|
||||
"median": 894.546,
|
||||
"p25": 871.499,
|
||||
"p75": 921.844,
|
||||
"min": 844.891,
|
||||
"max": 1028.688,
|
||||
"samples": [
|
||||
1028.688,
|
||||
913.48,
|
||||
946.935,
|
||||
903.785,
|
||||
868.684,
|
||||
885.308,
|
||||
844.891,
|
||||
872.438
|
||||
]
|
||||
},
|
||||
"peak_rss_kib": {
|
||||
"median": 267960.0,
|
||||
"p25": 267678.0,
|
||||
"p75": 268133.0,
|
||||
"min": 267212,
|
||||
"max": 268404,
|
||||
"samples": [
|
||||
267212,
|
||||
267888,
|
||||
268404,
|
||||
267692,
|
||||
268096,
|
||||
267636,
|
||||
268244,
|
||||
268032
|
||||
]
|
||||
}
|
||||
},
|
||||
"current": {
|
||||
"phase_ms": {
|
||||
"median": 411.403,
|
||||
"p25": 406.814,
|
||||
"p75": 452.869,
|
||||
"min": 391.768,
|
||||
"max": 532.402,
|
||||
"samples": [
|
||||
532.402,
|
||||
442.563,
|
||||
408.857,
|
||||
410.202,
|
||||
483.788,
|
||||
391.768,
|
||||
400.683,
|
||||
412.604
|
||||
]
|
||||
},
|
||||
"total_ms": {
|
||||
"median": 905.017,
|
||||
"p25": 851.488,
|
||||
"p75": 937.934,
|
||||
"min": 818.206,
|
||||
"max": 1075.445,
|
||||
"samples": [
|
||||
1075.445,
|
||||
878.155,
|
||||
837.627,
|
||||
955.655,
|
||||
932.027,
|
||||
818.206,
|
||||
856.108,
|
||||
931.88
|
||||
]
|
||||
},
|
||||
"cpu_ms": {
|
||||
"median": 922.9,
|
||||
"p25": 874.849,
|
||||
"p75": 956.282,
|
||||
"min": 839.639,
|
||||
"max": 1067.693,
|
||||
"samples": [
|
||||
1067.693,
|
||||
900.18,
|
||||
862.137,
|
||||
970.356,
|
||||
951.591,
|
||||
839.639,
|
||||
879.086,
|
||||
945.62
|
||||
]
|
||||
},
|
||||
"peak_rss_kib": {
|
||||
"median": 268354.0,
|
||||
"p25": 267889.0,
|
||||
"p75": 268492.0,
|
||||
"min": 267056,
|
||||
"max": 268820,
|
||||
"samples": [
|
||||
268008,
|
||||
268384,
|
||||
268564,
|
||||
268820,
|
||||
267056,
|
||||
267532,
|
||||
268324,
|
||||
268468
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"experiment_note": "Exploratory variants (Arc-key pool, reused sounds, combined proposal) alongside cd61e11b; none gave a robust consistent improvement and all were reverted."
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "cargo-horizon-mod"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.86"
|
||||
description = "cargo horizon-mod new/build/run: make Horizon mods in Rust (docs/mods.md)"
|
||||
|
||||
[dependencies]
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["pack"] }
|
||||
serde_json = "1.0.151"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,201 @@
|
||||
//! `cargo horizon-mod`: make Horizon mods in Rust (`docs/mods.md`).
|
||||
//!
|
||||
//! - `new <path> [--name "Name"] [--sdk-path <dir>]`: a mod crate from the template.
|
||||
//! - `build [--debug]`: the `.hmod` in `target/horizon-mod/`.
|
||||
//! - `run [--game <horizon-client>] [-- <game args>]`: build, then play with
|
||||
//! the mod; run `build` again and the game reloads it.
|
||||
use horizon_mods::{
|
||||
Manifest, Package,
|
||||
manifest::{MANIFEST, component_imports, componentize},
|
||||
};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, ExitCode, Stdio},
|
||||
};
|
||||
|
||||
const TARGET: &str = "wasm32-unknown-unknown";
|
||||
const SDK_GIT: &str = "{ git = \"https://github.com/ludovic111/horizon\" }";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
// Cargo runs `cargo-horizon-mod horizon-mod <args>`.
|
||||
let mut args: Vec<String> = std::env::args().skip(1).collect();
|
||||
if args.first().is_some_and(|a| a == "horizon-mod") {
|
||||
args.remove(0);
|
||||
}
|
||||
let result = match args.first().map(String::as_str) {
|
||||
Some("new") => new(&args[1..]),
|
||||
Some("build") => build(!args.iter().any(|a| a == "--debug")).map(|p| {
|
||||
println!("Built {}", p.display());
|
||||
}),
|
||||
Some("run") => run(&args[1..]),
|
||||
_ => Err("usage: cargo horizon-mod new <path> | build [--debug] | run [--game <horizon-client>] [-- <game args>]".into()),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("error: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The value after `flag` in `args`.
|
||||
fn flag<'a>(args: &'a [String], flag: &str) -> Option<&'a str> {
|
||||
let i = args.iter().position(|a| a == flag)?;
|
||||
args.get(i + 1).map(String::as_str)
|
||||
}
|
||||
|
||||
fn new(args: &[String]) -> Result<(), String> {
|
||||
let path = args
|
||||
.first()
|
||||
.filter(|a| !a.starts_with("--"))
|
||||
.ok_or("cargo horizon-mod new <path>")?;
|
||||
let dir = Path::new(path);
|
||||
if dir.exists() {
|
||||
return Err(format!("{} already exists", dir.display()));
|
||||
}
|
||||
let base = dir
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.ok_or("the path needs a folder name")?;
|
||||
let id: String = base
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
c.to_ascii_lowercase()
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let name = flag(args, "--name").map_or_else(|| base.replace(['-', '_'], " "), str::to_string);
|
||||
let sdk = match flag(args, "--sdk-path") {
|
||||
Some(p) => {
|
||||
let p = std::fs::canonicalize(p).map_err(|e| format!("--sdk-path {p}: {e}"))?;
|
||||
format!("{{ path = {:?} }}", p.display().to_string())
|
||||
}
|
||||
None => SDK_GIT.to_string(),
|
||||
};
|
||||
let fill = |t: &str| {
|
||||
t.replace("{crate}", &id.replace('_', "-"))
|
||||
.replace("{id}", &id)
|
||||
.replace("{name}", &name)
|
||||
.replace("{sdk}", &sdk)
|
||||
};
|
||||
let files = [
|
||||
("Cargo.toml", include_str!("../template/Cargo.toml.in")),
|
||||
(MANIFEST, include_str!("../template/horizon-mod.toml")),
|
||||
("src/lib.rs", include_str!("../template/src/lib.rs")),
|
||||
(".gitignore", include_str!("../template/gitignore")),
|
||||
];
|
||||
for (file, text) in files {
|
||||
let to = dir.join(file);
|
||||
std::fs::create_dir_all(to.parent().expect("a file in the mod"))
|
||||
.map_err(|e| e.to_string())?;
|
||||
std::fs::write(&to, fill(text)).map_err(|e| format!("{}: {e}", to.display()))?;
|
||||
}
|
||||
Manifest::parse(&std::fs::read_to_string(dir.join(MANIFEST)).map_err(|e| e.to_string())?)?;
|
||||
println!(
|
||||
"Created {} (mod id {id}). Next: cd {path} && cargo horizon-mod run",
|
||||
dir.display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Builds the crate in the current folder and packs `target/horizon-mod/<id>.hmod`.
|
||||
fn build(release: bool) -> Result<PathBuf, String> {
|
||||
let manifest_text = std::fs::read_to_string(MANIFEST)
|
||||
.map_err(|e| format!("{MANIFEST}: {e} (run this in a mod's folder)"))?;
|
||||
let manifest = Manifest::parse(&manifest_text)?;
|
||||
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
|
||||
let mut cmd = Command::new(cargo);
|
||||
cmd.args([
|
||||
"build",
|
||||
"--lib",
|
||||
"--target",
|
||||
TARGET,
|
||||
"--message-format=json-render-diagnostics",
|
||||
]);
|
||||
if release {
|
||||
cmd.arg("--release");
|
||||
}
|
||||
let out = cmd
|
||||
.stderr(Stdio::inherit())
|
||||
.output()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !out.status.success() {
|
||||
return Err(format!(
|
||||
"cargo build failed (is the target installed? rustup target add {TARGET})"
|
||||
));
|
||||
}
|
||||
let core = String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
|
||||
.filter(|m| m["reason"] == "compiler-artifact")
|
||||
.flat_map(|m| m["filenames"].as_array().cloned().unwrap_or_default())
|
||||
.filter_map(|f| f.as_str().map(PathBuf::from))
|
||||
.rfind(|f| f.extension().is_some_and(|e| e == "wasm"))
|
||||
.ok_or("the build made no .wasm: is the crate a cdylib?")?;
|
||||
let wasm = componentize(&std::fs::read(&core).map_err(|e| e.to_string())?)?;
|
||||
// The game refuses a component importing capabilities the manifest lacks.
|
||||
manifest.check_imports(component_imports(&wasm)?.iter().map(String::as_str))?;
|
||||
let target_dir = core.ancestors().nth(3).ok_or("unexpected target folder")?;
|
||||
let dir = target_dir.join("horizon-mod");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
let path = dir.join(format!("{}.hmod", manifest.id));
|
||||
// Write then rename, so a running game never reads half a file.
|
||||
let partial = path.with_extension("hmod.partial");
|
||||
std::fs::write(&partial, Package::write(&manifest_text, &wasm)?).map_err(|e| e.to_string())?;
|
||||
std::fs::rename(&partial, &path).map_err(|e| e.to_string())?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> Result<(), String> {
|
||||
let path = build(true)?;
|
||||
let game = flag(args, "--game")
|
||||
.map(str::to_string)
|
||||
.or_else(|| std::env::var("HORIZON_CLIENT").ok())
|
||||
.unwrap_or_else(|| "horizon-client".into());
|
||||
let extra = args.iter().skip_while(|a| *a != "--").skip(1);
|
||||
println!(
|
||||
"Starting {game} with {} (rebuild with `cargo horizon-mod build` to reload it)",
|
||||
path.display()
|
||||
);
|
||||
let status = Command::new(&game)
|
||||
.arg("--dev-mod")
|
||||
.arg(&path)
|
||||
.args(extra)
|
||||
.status()
|
||||
.map_err(|e| {
|
||||
format!("{game}: {e} (pass --game <path to horizon-client> or set HORIZON_CLIENT)")
|
||||
})?;
|
||||
status
|
||||
.success()
|
||||
.then_some(())
|
||||
.ok_or_else(|| format!("the game exited with {status}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_template_is_a_valid_mod() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mod-new-{}", std::process::id()));
|
||||
let path = dir.join("My-Mod");
|
||||
new(&[path.display().to_string(), "--name".into(), "My Mod".into()]).unwrap();
|
||||
let manifest =
|
||||
Manifest::parse(&std::fs::read_to_string(path.join(MANIFEST)).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
(manifest.id.as_str(), manifest.name.as_str()),
|
||||
("my_mod", "My Mod")
|
||||
);
|
||||
let cargo = std::fs::read_to_string(path.join("Cargo.toml")).unwrap();
|
||||
assert!(cargo.contains("name = \"my-mod\"") && cargo.contains(SDK_GIT));
|
||||
assert!(
|
||||
new(&[path.display().to_string()]).is_err(),
|
||||
"never overwrites"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "{crate}"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
horizon-mod-sdk = {sdk}
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
@@ -0,0 +1 @@
|
||||
/target
|
||||
@@ -0,0 +1,12 @@
|
||||
id = "{id}"
|
||||
name = "{name}"
|
||||
version = "0.1.0"
|
||||
description = ""
|
||||
authors = []
|
||||
# The Horizon mod API the SDK targets.
|
||||
api = "0.1"
|
||||
# client: runs in the player's game.
|
||||
side = "client"
|
||||
# What the mod may do, shown to players before they turn it on:
|
||||
# game-read (position, health, time), chat (show messages), hud (draw text).
|
||||
capabilities = ["chat"]
|
||||
@@ -0,0 +1,19 @@
|
||||
use horizon_mod_sdk::{Mod, Registration, chat};
|
||||
|
||||
struct MyMod {
|
||||
greeted: u32,
|
||||
}
|
||||
|
||||
impl Mod for MyMod {
|
||||
fn init() -> Result<(Self, Registration), String> {
|
||||
Ok((MyMod { greeted: 0 }, Registration::new().command("hello")))
|
||||
}
|
||||
|
||||
fn command(&mut self, _name: &str, args: &str) -> Result<Option<String>, String> {
|
||||
self.greeted += 1;
|
||||
chat::show(&format!("Hello {args}! ({} so far)", self.greeted));
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
horizon_mod_sdk::export_mod!(MyMod);
|
||||
@@ -50,29 +50,47 @@ impl ModelLibrary {
|
||||
/// A library that also knows `builtin/generated`, the parent of every
|
||||
/// flat item model; the native client registers it as a special model.
|
||||
pub fn with_item_generator(mut models: BTreeMap<String, CuboidModel>) -> Result<Self> {
|
||||
models.insert(
|
||||
GENERATED_MODEL.to_owned(),
|
||||
CuboidModel {
|
||||
parent: None,
|
||||
ambient_occlusion: None,
|
||||
gui_light: Some(GuiLight::Front),
|
||||
textures: BTreeMap::from([(
|
||||
"particle".to_owned(),
|
||||
TextureSlot::Reference("layer0".to_owned()),
|
||||
)]),
|
||||
elements: None,
|
||||
display: None,
|
||||
},
|
||||
);
|
||||
models.insert(GENERATED_MODEL.to_owned(), Self::item_generator_model());
|
||||
Self::new(models)
|
||||
}
|
||||
/// Register the item built-in while reusing already decoded resources.
|
||||
pub fn with_shared_item_generator(
|
||||
mut models: BTreeMap<String, Arc<CuboidModel>>,
|
||||
) -> Result<Self> {
|
||||
models.insert(
|
||||
GENERATED_MODEL.to_owned(),
|
||||
Arc::new(Self::item_generator_model()),
|
||||
);
|
||||
Self::from_shared(models)
|
||||
}
|
||||
fn item_generator_model() -> CuboidModel {
|
||||
CuboidModel {
|
||||
parent: None,
|
||||
ambient_occlusion: None,
|
||||
gui_light: Some(GuiLight::Front),
|
||||
textures: BTreeMap::from([(
|
||||
"particle".to_owned(),
|
||||
TextureSlot::Reference("layer0".to_owned()),
|
||||
)]),
|
||||
elements: None,
|
||||
display: None,
|
||||
}
|
||||
}
|
||||
pub fn new(models: BTreeMap<String, CuboidModel>) -> Result<Self> {
|
||||
Self::from_models(
|
||||
models
|
||||
.into_iter()
|
||||
.map(|(name, model)| (name, Arc::new(model))),
|
||||
)
|
||||
}
|
||||
/// Build an immutable resolution view without copying model geometry.
|
||||
pub fn from_shared(models: BTreeMap<String, Arc<CuboidModel>>) -> Result<Self> {
|
||||
Self::from_models(models)
|
||||
}
|
||||
fn from_models(models: impl IntoIterator<Item = (String, Arc<CuboidModel>)>) -> Result<Self> {
|
||||
let mut decoded = BTreeMap::new();
|
||||
for (name, model) in models {
|
||||
if decoded
|
||||
.insert(identifier(&name)?, Arc::new(model))
|
||||
.is_some()
|
||||
{
|
||||
if decoded.insert(identifier(&name)?, model).is_some() {
|
||||
return Err(error("duplicate normalized model identifier"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "horizon-bots"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "Load-test bots for Minecraft Java Edition 26.3 servers (docs/server.md, section 9)"
|
||||
|
||||
[dependencies]
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
serde_json = "1.0.151"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,589 @@
|
||||
//! `horizon-bots`: load-test bots for any Minecraft Java Edition 26.3 server, vanilla, Paper or Horizon
|
||||
//! (docs/server.md, section 9). Each bot logs in offline, goes through configuration as vanilla's client
|
||||
//! does, answers keep-alives and pings, accepts teleports, acknowledges chunk batches, and moves at 20 Hz.
|
||||
//!
|
||||
//! Bots fly a little above the ground, so the server must give them creative mode (`gamemode=creative`
|
||||
//! and `force-gamemode=true`): they never touch blocks, so they need no world of their own, and no server
|
||||
//! can refuse their moves for walking into terrain. Every behaviour is deterministic from `--seed`.
|
||||
//!
|
||||
//! horizon-bots --server 127.0.0.1:25565 --bots 25 --behaviour mixed --seconds 360 --report bots.json
|
||||
//!
|
||||
//! It prints one JSON line a second (bots online, chunks received) and writes a summary with `--report`.
|
||||
|
||||
use horizon_protocol::{
|
||||
PROTOCOL_VERSION,
|
||||
buf::{Reader, Writer},
|
||||
connection::{ConnWriter, Connection},
|
||||
crypto::offline_uuid,
|
||||
ids,
|
||||
packets::{
|
||||
Empty, configuration,
|
||||
handshake::{self, Intention},
|
||||
login::{self, ServerHello},
|
||||
play,
|
||||
},
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::{
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
const HELP: &str = "\
|
||||
horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline mode, creative)
|
||||
|
||||
--server HOST:PORT default 127.0.0.1:25565
|
||||
--bots N default 10
|
||||
--behaviour B idle, walk, explore or mixed (60 % walk, 20 % explore, 20 % idle); default mixed
|
||||
--join-every SECONDS default 1
|
||||
--seconds N how long to run after the first bot joins; default 60
|
||||
--view-distance N what the bots ask for; default 10
|
||||
--seed N default 12345
|
||||
--name PREFIX bot names: PREFIX0, PREFIX1...; default Bot
|
||||
--report FILE a JSON summary at the end
|
||||
";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Behaviour {
|
||||
Idle,
|
||||
Walk,
|
||||
Explore,
|
||||
}
|
||||
|
||||
impl Behaviour {
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Idle => "idle",
|
||||
Self::Walk => "walk",
|
||||
Self::Explore => "explore",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Options {
|
||||
host: String,
|
||||
port: u16,
|
||||
bots: usize,
|
||||
behaviour: String,
|
||||
join_every: f64,
|
||||
seconds: u64,
|
||||
view_distance: i8,
|
||||
seed: u64,
|
||||
name: String,
|
||||
report: Option<String>,
|
||||
}
|
||||
|
||||
fn options() -> Result<Options, String> {
|
||||
let mut o = Options {
|
||||
host: "127.0.0.1".into(),
|
||||
port: 25565,
|
||||
bots: 10,
|
||||
behaviour: "mixed".into(),
|
||||
join_every: 1.0,
|
||||
seconds: 60,
|
||||
view_distance: 10,
|
||||
seed: 12345,
|
||||
name: "Bot".into(),
|
||||
report: None,
|
||||
};
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
if arg == "-h" || arg == "--help" {
|
||||
print!("{HELP}");
|
||||
std::process::exit(0);
|
||||
}
|
||||
let value = args.next().ok_or(format!("{arg} needs a value"))?;
|
||||
let number = |what: &str| format!("{arg} needs {what}, not {value:?}");
|
||||
match arg.as_str() {
|
||||
"--server" => {
|
||||
let (host, port) = value.rsplit_once(':').unwrap_or((&value, "25565"));
|
||||
o.host = host.into();
|
||||
o.port = port.parse().map_err(|_| number("a port"))?;
|
||||
}
|
||||
"--bots" => o.bots = value.parse().map_err(|_| number("a number"))?,
|
||||
"--behaviour" => o.behaviour = value,
|
||||
"--join-every" => o.join_every = value.parse().map_err(|_| number("seconds"))?,
|
||||
"--seconds" => o.seconds = value.parse().map_err(|_| number("seconds"))?,
|
||||
"--view-distance" => o.view_distance = value.parse().map_err(|_| number("2 to 32"))?,
|
||||
"--seed" => o.seed = value.parse().map_err(|_| number("a number"))?,
|
||||
"--name" => o.name = value,
|
||||
"--report" => o.report = Some(value),
|
||||
_ => return Err(format!("unknown option {arg}; --help lists them")),
|
||||
}
|
||||
}
|
||||
if !["idle", "walk", "explore", "mixed"].contains(&o.behaviour.as_str()) {
|
||||
return Err(format!(
|
||||
"--behaviour {}: use idle, walk, explore or mixed",
|
||||
o.behaviour
|
||||
));
|
||||
}
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
/// What one bot is doing, shared between its reader and its ticks.
|
||||
#[derive(Default)]
|
||||
struct Bot {
|
||||
online: AtomicBool,
|
||||
chunks: AtomicU64,
|
||||
/// Where the server last put the bot (a teleport), to start from.
|
||||
teleport: Mutex<Option<[f64; 3]>>,
|
||||
error: Mutex<Option<String>>,
|
||||
joined_after_ms: AtomicU64,
|
||||
}
|
||||
|
||||
/// A small deterministic generator (SplitMix64), so every run moves the bots the same way.
|
||||
struct Random(u64);
|
||||
impl Random {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut z = self.0;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
fn unit(&mut self) -> f64 {
|
||||
(self.next() >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
}
|
||||
|
||||
fn raw(id: i32, write: impl FnOnce(&mut Writer)) -> Vec<u8> {
|
||||
let mut w = Writer::new();
|
||||
w.varint(id);
|
||||
write(&mut w);
|
||||
w.data
|
||||
}
|
||||
|
||||
fn err(e: impl std::fmt::Display) -> String {
|
||||
e.to_string()
|
||||
}
|
||||
|
||||
/// Handshake, offline login and configuration; returns the connection in the play state.
|
||||
fn join(o: &Options, name: &str) -> Result<Connection, String> {
|
||||
let mut c = Connection::connect(&o.host, o.port, Duration::from_secs(10)).map_err(err)?;
|
||||
c.send(
|
||||
&handshake::Serverbound::Intention(Intention {
|
||||
protocol: PROTOCOL_VERSION,
|
||||
host: o.host.clone(),
|
||||
port: o.port,
|
||||
intent: handshake::LOGIN,
|
||||
})
|
||||
.encode(),
|
||||
)
|
||||
.map_err(err)?;
|
||||
c.send(
|
||||
&login::Serverbound::Hello(ServerHello {
|
||||
name: name.into(),
|
||||
uuid: offline_uuid(name),
|
||||
})
|
||||
.encode(),
|
||||
)
|
||||
.map_err(err)?;
|
||||
loop {
|
||||
let (id, body) = c.recv().map_err(err)?;
|
||||
match login::Clientbound::decode(id, &body).map_err(err)? {
|
||||
login::Clientbound::Compression(p) => c.set_compression(p.threshold),
|
||||
login::Clientbound::LoginFinished(_) => {
|
||||
c.send(&login::Serverbound::LoginAcknowledged(Empty).encode())
|
||||
.map_err(err)?;
|
||||
break;
|
||||
}
|
||||
login::Clientbound::Disconnect(p) => return Err(format!("refused: {}", p.reason_json)),
|
||||
login::Clientbound::Hello(_) => {
|
||||
return Err("the server is in online mode; bots need online-mode=false".into());
|
||||
}
|
||||
login::Clientbound::CustomQuery(p) => c
|
||||
.send(
|
||||
&login::Serverbound::CustomQueryAnswer(login::CustomQueryAnswer {
|
||||
transaction: p.transaction,
|
||||
payload: None,
|
||||
})
|
||||
.encode(),
|
||||
)
|
||||
.map_err(err)?,
|
||||
login::Clientbound::CookieRequest(p) => c
|
||||
.send(
|
||||
&login::Serverbound::CookieResponse(login::CookieResponse {
|
||||
key: p.key,
|
||||
payload: None,
|
||||
})
|
||||
.encode(),
|
||||
)
|
||||
.map_err(err)?,
|
||||
login::Clientbound::Unknown(..) => {}
|
||||
}
|
||||
}
|
||||
use ids::configuration::clientbound as cb;
|
||||
c.send(
|
||||
&configuration::Serverbound::ClientInformation(configuration::ClientInformation::new(
|
||||
o.view_distance,
|
||||
))
|
||||
.encode(),
|
||||
)
|
||||
.map_err(err)?;
|
||||
loop {
|
||||
let (id, body) = c.recv().map_err(err)?;
|
||||
let reply = match id {
|
||||
cb::SELECT_KNOWN_PACKS
|
||||
| cb::KEEP_ALIVE
|
||||
| cb::PING
|
||||
| cb::CODE_OF_CONDUCT
|
||||
| cb::COOKIE_REQUEST
|
||||
| cb::DISCONNECT
|
||||
| cb::FINISH_CONFIGURATION => {
|
||||
match configuration::Clientbound::decode(id, &body).map_err(err)? {
|
||||
configuration::Clientbound::SelectKnownPacks(p) => {
|
||||
configuration::Serverbound::SelectKnownPacks(p)
|
||||
}
|
||||
configuration::Clientbound::KeepAlive(p) => {
|
||||
configuration::Serverbound::KeepAlive(p)
|
||||
}
|
||||
configuration::Clientbound::Ping(p) => configuration::Serverbound::Pong(p),
|
||||
configuration::Clientbound::CodeOfConduct(_) => {
|
||||
configuration::Serverbound::AcceptCodeOfConduct(Empty)
|
||||
}
|
||||
configuration::Clientbound::CookieRequest(p) => {
|
||||
configuration::Serverbound::CookieResponse(configuration::CookieResponse {
|
||||
key: p.key,
|
||||
payload: None,
|
||||
})
|
||||
}
|
||||
configuration::Clientbound::Disconnect(_) => {
|
||||
return Err("disconnected during configuration".into());
|
||||
}
|
||||
configuration::Clientbound::FinishConfiguration(_) => {
|
||||
c.send(&configuration::Serverbound::FinishConfiguration(Empty).encode())
|
||||
.map_err(err)?;
|
||||
return Ok(c);
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
c.send(&reply.encode()).map_err(err)?;
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the play state: answers what a client must answer, counts chunks, records teleports.
|
||||
fn read(
|
||||
mut reader: horizon_protocol::connection::ConnReader,
|
||||
writer: Arc<Mutex<ConnWriter>>,
|
||||
bot: Arc<Bot>,
|
||||
) {
|
||||
use ids::play::clientbound as cb;
|
||||
use ids::play::serverbound as sb;
|
||||
let send = |body: Vec<u8>| writer.lock().unwrap().send(&body);
|
||||
loop {
|
||||
let (id, body) = match reader.recv() {
|
||||
Ok(packet) => packet,
|
||||
Err(e) => {
|
||||
if bot.online.swap(false, Ordering::SeqCst) {
|
||||
*bot.error.lock().unwrap() = Some(e.to_string());
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut r = Reader::new(&body);
|
||||
let sent = match id {
|
||||
cb::KEEP_ALIVE => match r.i64() {
|
||||
Ok(v) => send(raw(sb::KEEP_ALIVE, |w| w.i64(v))),
|
||||
Err(_) => Ok(()),
|
||||
},
|
||||
cb::PING => match r.i32() {
|
||||
Ok(v) => send(raw(sb::PONG, |w| w.i32(v))),
|
||||
Err(_) => Ok(()),
|
||||
},
|
||||
cb::LEVEL_CHUNK_WITH_LIGHT => {
|
||||
bot.chunks.fetch_add(1, Ordering::Relaxed);
|
||||
Ok(())
|
||||
}
|
||||
cb::CHUNK_BATCH_FINISHED => send(
|
||||
play::Serverbound::ChunkBatchReceived(play::ChunkBatchReceived {
|
||||
chunks_per_tick: 25.0,
|
||||
})
|
||||
.encode(),
|
||||
),
|
||||
cb::PLAYER_POSITION => match play::Clientbound::decode(id, &body) {
|
||||
Ok(play::Clientbound::PlayerPosition(p)) => {
|
||||
let mut at = bot.teleport.lock().unwrap();
|
||||
let previous = at.unwrap_or([0.0; 3]);
|
||||
// Relative flags: X 1, Y 2, Z 4.
|
||||
let pos: [f64; 3] = std::array::from_fn(|i| {
|
||||
if p.relatives & (1 << i) != 0 {
|
||||
previous[i] + p.position[i]
|
||||
} else {
|
||||
p.position[i]
|
||||
}
|
||||
});
|
||||
*at = Some(pos);
|
||||
send(
|
||||
play::Serverbound::AcceptTeleportation(play::AcceptTeleportation {
|
||||
id: p.teleport,
|
||||
x: pos[0],
|
||||
y: pos[1],
|
||||
z: pos[2],
|
||||
yaw: p.yaw,
|
||||
pitch: p.pitch,
|
||||
})
|
||||
.encode(),
|
||||
)
|
||||
}
|
||||
_ => Ok(()),
|
||||
},
|
||||
cb::DISCONNECT | cb::START_CONFIGURATION => {
|
||||
bot.online.store(false, Ordering::SeqCst);
|
||||
*bot.error.lock().unwrap() = Some(if id == cb::DISCONNECT {
|
||||
format!(
|
||||
"disconnected: {}",
|
||||
String::from_utf8_lossy(&body)
|
||||
.chars()
|
||||
.filter(|c| !c.is_control())
|
||||
.collect::<String>()
|
||||
)
|
||||
} else {
|
||||
"sent back to configuration".into()
|
||||
});
|
||||
return;
|
||||
}
|
||||
_ => Ok(()),
|
||||
};
|
||||
if sent.is_err() {
|
||||
bot.online.store(false, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One bot: join, then move every 50 ms until `until`.
|
||||
fn run_bot(
|
||||
o: Arc<Options>,
|
||||
index: usize,
|
||||
behaviour: Behaviour,
|
||||
bot: Arc<Bot>,
|
||||
until: Arc<Mutex<Option<Instant>>>,
|
||||
) {
|
||||
let name = format!("{}{index}", o.name);
|
||||
let asked = Instant::now();
|
||||
let connection = match join(&o, &name) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
*bot.error.lock().unwrap() = Some(e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (reader, writer) = connection.split();
|
||||
let writer = Arc::new(Mutex::new(writer));
|
||||
bot.online.store(true, Ordering::SeqCst);
|
||||
{
|
||||
let (writer, bot) = (writer.clone(), bot.clone());
|
||||
thread::spawn(move || read(reader, writer, bot));
|
||||
}
|
||||
use ids::play::serverbound as sb;
|
||||
// Wait for the spawn position.
|
||||
let spawn = loop {
|
||||
if let Some(at) = *bot.teleport.lock().unwrap() {
|
||||
break at;
|
||||
}
|
||||
if !bot.online.load(Ordering::SeqCst) || asked.elapsed() > Duration::from_secs(60) {
|
||||
if bot.error.lock().unwrap().is_none() {
|
||||
*bot.error.lock().unwrap() = Some("no spawn position within 60 s".into());
|
||||
}
|
||||
return;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(20));
|
||||
};
|
||||
bot.joined_after_ms
|
||||
.store(asked.elapsed().as_millis() as u64, Ordering::SeqCst);
|
||||
let send = |body: Vec<u8>| writer.lock().unwrap().send(&body).is_ok();
|
||||
send(raw(sb::PLAYER_LOADED, |_| {}));
|
||||
let mut random = Random(o.seed ^ (index as u64).wrapping_mul(0x2545_F491_4F6C_DD1D));
|
||||
let mut pos = spawn;
|
||||
let cruise = spawn[1] + 24.0;
|
||||
let heading = random.unit() * std::f64::consts::TAU;
|
||||
let mut target = pos;
|
||||
let mut next = Instant::now();
|
||||
loop {
|
||||
if until.lock().unwrap().is_some_and(|u| Instant::now() >= u)
|
||||
|| !bot.online.load(Ordering::SeqCst)
|
||||
{
|
||||
break;
|
||||
}
|
||||
// A teleport from the server wins over where the bot thought it was.
|
||||
if let Some(at) = bot.teleport.lock().unwrap().take() {
|
||||
pos = at;
|
||||
}
|
||||
if pos[1] < cruise {
|
||||
pos[1] = (pos[1] + 0.5).min(cruise);
|
||||
} else {
|
||||
match behaviour {
|
||||
Behaviour::Idle => {}
|
||||
Behaviour::Walk => {
|
||||
let (dx, dz) = (target[0] - pos[0], target[2] - pos[2]);
|
||||
let distance = dx.hypot(dz);
|
||||
if distance < 1.0 {
|
||||
let angle = random.unit() * std::f64::consts::TAU;
|
||||
let reach = 8.0 + random.unit() * 40.0;
|
||||
target = [
|
||||
spawn[0] + angle.cos() * reach,
|
||||
cruise,
|
||||
spawn[2] + angle.sin() * reach,
|
||||
];
|
||||
} else {
|
||||
// Sprinting: 5.6 blocks a second.
|
||||
let step = 0.28_f64.min(distance);
|
||||
pos[0] += dx / distance * step;
|
||||
pos[2] += dz / distance * step;
|
||||
}
|
||||
}
|
||||
Behaviour::Explore => {
|
||||
// Flying fast in creative: about 22 blocks a second, which keeps new chunks coming.
|
||||
pos[0] += heading.cos() * 1.1;
|
||||
pos[2] += heading.sin() * 1.1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let ok = send(
|
||||
play::Serverbound::MovePlayerPos(play::MovePlayerPos {
|
||||
x: pos[0],
|
||||
y: pos[1],
|
||||
z: pos[2],
|
||||
flags: 0,
|
||||
})
|
||||
.encode(),
|
||||
) && send(raw(sb::CLIENT_TICK_END, |_| {}));
|
||||
if !ok {
|
||||
bot.online.store(false, Ordering::SeqCst);
|
||||
break;
|
||||
}
|
||||
next += Duration::from_millis(50);
|
||||
let now = Instant::now();
|
||||
if next > now {
|
||||
thread::sleep(next - now);
|
||||
} else if now - next > Duration::from_secs(1) {
|
||||
next = now;
|
||||
}
|
||||
}
|
||||
// Offline first, so the reader doesn't count the closed connection as a failure.
|
||||
bot.online.store(false, Ordering::SeqCst);
|
||||
writer.lock().unwrap().shutdown();
|
||||
}
|
||||
|
||||
fn behaviour_of(mode: &str, index: usize) -> Behaviour {
|
||||
match mode {
|
||||
"idle" => Behaviour::Idle,
|
||||
"walk" => Behaviour::Walk,
|
||||
"explore" => Behaviour::Explore,
|
||||
// mixed: of every five bots, three walk, one explores, one stands.
|
||||
_ => match index % 5 {
|
||||
0 | 2 | 4 => Behaviour::Walk,
|
||||
1 => Behaviour::Explore,
|
||||
_ => Behaviour::Idle,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let o = match options() {
|
||||
Ok(o) => Arc::new(o),
|
||||
Err(e) => {
|
||||
eprintln!("horizon-bots: {e}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
let start = Instant::now();
|
||||
let until: Arc<Mutex<Option<Instant>>> = Arc::new(Mutex::new(None));
|
||||
let bots: Vec<Arc<Bot>> = (0..o.bots).map(|_| Arc::new(Bot::default())).collect();
|
||||
let launcher = {
|
||||
let (o, bots, until) = (o.clone(), bots.clone(), until.clone());
|
||||
thread::spawn(move || {
|
||||
let mut handles = Vec::new();
|
||||
for (i, bot) in bots.iter().enumerate() {
|
||||
let (o2, bot, until) = (o.clone(), bot.clone(), until.clone());
|
||||
let behaviour = behaviour_of(&o.behaviour, i);
|
||||
handles.push(thread::spawn(move || run_bot(o2, i, behaviour, bot, until)));
|
||||
thread::sleep(Duration::from_secs_f64(o.join_every));
|
||||
}
|
||||
handles
|
||||
})
|
||||
};
|
||||
// The run lasts --seconds after the first bot is in.
|
||||
let mut previous_chunks = 0;
|
||||
let mut first = None;
|
||||
loop {
|
||||
thread::sleep(Duration::from_secs(1));
|
||||
let online = bots
|
||||
.iter()
|
||||
.filter(|b| b.online.load(Ordering::SeqCst))
|
||||
.count();
|
||||
let chunks: u64 = bots.iter().map(|b| b.chunks.load(Ordering::Relaxed)).sum();
|
||||
let failed = bots
|
||||
.iter()
|
||||
.filter(|b| b.error.lock().unwrap().is_some())
|
||||
.count();
|
||||
if first.is_none() && online > 0 {
|
||||
first = Some(Instant::now());
|
||||
*until.lock().unwrap() = Some(Instant::now() + Duration::from_secs(o.seconds));
|
||||
}
|
||||
println!(
|
||||
"{}",
|
||||
json!({
|
||||
"t": start.elapsed().as_secs(),
|
||||
"online": online,
|
||||
"failed": failed,
|
||||
"chunks": chunks,
|
||||
"chunks_per_second": chunks - previous_chunks,
|
||||
})
|
||||
);
|
||||
previous_chunks = chunks;
|
||||
let over = until.lock().unwrap().is_some_and(|u| Instant::now() >= u);
|
||||
let all_failed = failed == o.bots;
|
||||
if over || all_failed || (first.is_none() && start.elapsed() > Duration::from_secs(120)) {
|
||||
*until.lock().unwrap() = Some(Instant::now());
|
||||
break;
|
||||
}
|
||||
}
|
||||
for handle in launcher.join().unwrap_or_default() {
|
||||
let _ = handle.join();
|
||||
}
|
||||
let summary = json!({
|
||||
"server": format!("{}:{}", o.host, o.port),
|
||||
"bots": o.bots,
|
||||
"behaviour": o.behaviour,
|
||||
"seed": o.seed,
|
||||
"view_distance": o.view_distance,
|
||||
"seconds": o.seconds,
|
||||
"joined": bots.iter().filter(|b| b.joined_after_ms.load(Ordering::SeqCst) > 0).count(),
|
||||
"chunks": bots.iter().map(|b| b.chunks.load(Ordering::Relaxed)).sum::<u64>(),
|
||||
"per_bot": bots.iter().enumerate().map(|(i, b)| json!({
|
||||
"name": format!("{}{i}", o.name),
|
||||
"behaviour": behaviour_of(&o.behaviour, i).name(),
|
||||
"joined_after_ms": b.joined_after_ms.load(Ordering::SeqCst),
|
||||
"chunks": b.chunks.load(Ordering::Relaxed),
|
||||
"error": *b.error.lock().unwrap(),
|
||||
})).collect::<Vec<_>>(),
|
||||
});
|
||||
if let Some(path) = &o.report {
|
||||
if let Err(e) = std::fs::write(path, format!("{summary:#}\n")) {
|
||||
eprintln!("horizon-bots: {path}: {e}");
|
||||
}
|
||||
}
|
||||
let errors: Vec<String> = bots
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, b)| {
|
||||
b.error
|
||||
.lock()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.map(|e| format!("{}{i}: {e}", o.name))
|
||||
})
|
||||
.collect();
|
||||
for e in errors.iter().take(10) {
|
||||
eprintln!("horizon-bots: {e}");
|
||||
}
|
||||
std::process::exit(if errors.is_empty() { 0 } else { 1 });
|
||||
}
|
||||
@@ -12,7 +12,11 @@ horizon-assets = { path = "../horizon-assets" }
|
||||
horizon-core = { path = "../horizon-core" }
|
||||
horizon-world = { path = "../horizon-world" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
# server.properties, eula.txt and the user lists, shared with horizon-server.
|
||||
horizon-server-files = { path = "../horizon-server-files" }
|
||||
horizon-friends = { path = "../horizon-friends" }
|
||||
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
|
||||
horizon-mods = { path = "../horizon-mods" }
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl"] }
|
||||
winit = "0.30.13"
|
||||
pollster = "0.4"
|
||||
@@ -22,6 +26,11 @@ png = "0.18"
|
||||
sha2 = "0.10.9"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1.0.151", features = ["raw_value"] }
|
||||
# Shader packs: pack.toml, and WGSL checked before it reaches wgpu.
|
||||
toml = "1.1"
|
||||
naga = { version = "30.0.1", features = ["wgsl-in"] }
|
||||
# Java (OptiFine/Iris) shader packs, translated to WGSL.
|
||||
horizon-shaderpack = { path = "../horizon-shaderpack" }
|
||||
cpal = "0.16"
|
||||
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
|
||||
miniz_oxide = "0.8"
|
||||
@@ -37,6 +46,8 @@ arboard = { version = "3.6.1", default-features = false, features = ["wayland-da
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input).
|
||||
gilrs = "0.11"
|
||||
# The DualSense's rumble, adaptive triggers and lights over HID (gilrs only reads it).
|
||||
horizon-dualsense = { path = "../horizon-dualsense" }
|
||||
# HTTPS to the Minecraft services (online-mode session join).
|
||||
ureq = { version = "2", default-features = false, features = ["tls"] }
|
||||
|
||||
@@ -50,7 +61,3 @@ rng-trace = ["horizon-core/rng-trace"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
# Parses and validates the composed entity shader (already built for wgpu).
|
||||
naga = { version = "30.0.1", features = ["wgsl-in"] }
|
||||
@@ -131,6 +131,7 @@ fn main() {
|
||||
let tick = TickContext {
|
||||
player_underwater: ambience.was_underwater(),
|
||||
player_removed: removed,
|
||||
..TickContext::default()
|
||||
};
|
||||
engine.tick(false, &tick, &mut backend);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
use horizon_client::{
|
||||
Result,
|
||||
arrows::{
|
||||
self, ArrowEvent, ArrowParticle, ArrowScene, ArrowSound, BlockReaction, Pickup, Touch,
|
||||
self, ArrowEvent, ArrowParticle, ArrowScene, ArrowSound, BlockReaction, EntityHit, Pickup,
|
||||
Touch,
|
||||
},
|
||||
assets::Assets,
|
||||
blocks::BlockFacts,
|
||||
@@ -12,11 +13,11 @@ use horizon_client::{
|
||||
items::ItemStack,
|
||||
mobs::Who,
|
||||
motion::{LAVA, MotionResources, WATER},
|
||||
projectiles::{Projectile, ProjectileKind},
|
||||
projectiles::{Hit, Projectile, ProjectileKind},
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::Vec3,
|
||||
geometry::{Aabb, Vec3},
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
use serde_json::Value;
|
||||
@@ -89,11 +90,16 @@ fn main() -> Result<()> {
|
||||
&ItemStack::new("minecraft:arrow", 1),
|
||||
f["intangible"].as_bool().unwrap(),
|
||||
));
|
||||
// The entity in the way: its box and whether it takes the damage.
|
||||
let mut target: Option<(Aabb, bool)> = None;
|
||||
for op in f["ops"].as_array().unwrap() {
|
||||
let mut events = Vec::new();
|
||||
let mut touched = "-".to_string();
|
||||
match op["mode"].as_str().unwrap() {
|
||||
"tick" => {
|
||||
// The game's part of an entity hit: the damage it deals
|
||||
// (recorded after the tick's own events).
|
||||
let mut hurt = None;
|
||||
if !arrow.arrow.as_ref().unwrap().removed {
|
||||
let no_rain = |_| false;
|
||||
let view = ArrowScene {
|
||||
@@ -106,9 +112,35 @@ fn main() -> Result<()> {
|
||||
mob_griefing: true,
|
||||
tnt_explodes: true,
|
||||
};
|
||||
arrows::tick(&mut arrow, &view, &[]);
|
||||
let targets: Vec<_> = target.iter().map(|t| (Who::Mob(2), t.0)).collect();
|
||||
let (_, hit) = arrows::tick(&mut arrow, &view, &targets);
|
||||
if let (Hit::Entity { .. }, Some((_, takes))) = (hit, target) {
|
||||
let mut st = arrow.arrow.take().unwrap();
|
||||
let damage = arrow.arrow_damage(&mut st.random);
|
||||
hurt = Some((st.events.len(), damage));
|
||||
arrow.arrow = Some(st);
|
||||
let outcome = if takes {
|
||||
EntityHit::Hurt
|
||||
} else {
|
||||
EntityHit::Refused
|
||||
};
|
||||
arrows::finish_entity_hit(&mut arrow, &view, outcome);
|
||||
}
|
||||
}
|
||||
for event in arrow.arrow.as_mut().unwrap().events.drain(..) {
|
||||
let a = arrow.arrow.as_mut().unwrap();
|
||||
let (drop_at, dropped) = (arrow.position, a.pickup_item.count);
|
||||
let raised = std::mem::take(&mut a.events);
|
||||
let count = raised.len();
|
||||
for (i, event) in raised.into_iter().map(Some).chain([None]).enumerate() {
|
||||
if let Some((at, damage)) = hurt
|
||||
&& at == i
|
||||
{
|
||||
events.push(format!("hurt {:08x}", damage.to_bits()));
|
||||
}
|
||||
let Some(event) = event else {
|
||||
debug_assert_eq!(i, count);
|
||||
break;
|
||||
};
|
||||
match event {
|
||||
ArrowEvent::Particle {
|
||||
kind,
|
||||
@@ -191,9 +223,28 @@ fn main() -> Result<()> {
|
||||
},
|
||||
ArrowEvent::SplashEvent => events.push("splash-event".into()),
|
||||
ArrowEvent::ShootEvent => events.push("shoot-event".into()),
|
||||
ArrowEvent::Drop => events.push(format!(
|
||||
"drop {} {dropped}",
|
||||
bits(Vec3::new(
|
||||
drop_at.x,
|
||||
drop_at.y + f64::from(0.1f32),
|
||||
drop_at.z
|
||||
))
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
"target" => {
|
||||
// EntityDimensions.scalable(0.6, 1.8).makeBoundingBox.
|
||||
let at = vector(&op["position"]);
|
||||
let w = f64::from(0.6f32) / 2.0;
|
||||
let h = f64::from(1.8f32);
|
||||
let b = Aabb::new(
|
||||
Vec3::new(at.x - w, at.y, at.z - w),
|
||||
Vec3::new(at.x + w, at.y + h, at.z + w),
|
||||
);
|
||||
target = Some((b, op["takes"].as_bool().unwrap()));
|
||||
}
|
||||
"block" => {
|
||||
let p = &op["position"];
|
||||
let mut state = assets
|
||||
|
||||
@@ -100,6 +100,7 @@ fn main() -> Result<()> {
|
||||
player.auto_jump_enabled = p["enabled"].as_bool().unwrap();
|
||||
player.auto_jump_time = p["time"].as_i64().unwrap() as i32;
|
||||
MotionWorld {
|
||||
entities: &[],
|
||||
player: &mut player,
|
||||
scene: &mut scene,
|
||||
assets: &assets,
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Full production fact loading, including file reads, interning and temporary destruction.
|
||||
//! cargo run --profile fast -p horizon-client --example block_facts_bench -- ROOT [SAMPLES]
|
||||
//! Use SAMPLES=1 with an OS peak-RSS wrapper; `digest` checks all facts
|
||||
//! and the shape sharing partition (including sharing between outlines and edges).
|
||||
use horizon_client::blocks::BlockFacts;
|
||||
use horizon_world::{DATA_VERSION, nbt::Tag, registry::BlockRegistry};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::{HashMap, hash_map::DefaultHasher},
|
||||
fs,
|
||||
hash::{Hash, Hasher},
|
||||
hint::black_box,
|
||||
path::Path,
|
||||
time::Instant,
|
||||
};
|
||||
type Result<T> = horizon_client::Result<T>;
|
||||
|
||||
fn tag(value: &Value) -> Result<Tag> {
|
||||
Ok(match value {
|
||||
Value::String(s) => Tag::String(s.as_str().into()),
|
||||
Value::Number(n) => Tag::Int(i32::try_from(n.as_i64().ok_or("registry number")?)?),
|
||||
Value::Array(a) => Tag::List(a.iter().map(tag).collect::<Result<_>>()?),
|
||||
Value::Object(o) => Tag::Compound(
|
||||
o.iter()
|
||||
.map(|(k, v)| Ok((k.as_str().into(), tag(v)?)))
|
||||
.collect::<Result<_>>()?,
|
||||
),
|
||||
_ => return Err("registry value".into()),
|
||||
})
|
||||
}
|
||||
fn registry(root: &Path) -> Result<BlockRegistry> {
|
||||
let facts: Value = serde_json::from_slice(&fs::read(root.join("facts.json"))?)?;
|
||||
Ok(BlockRegistry::from_nbt(&Tag::compound([
|
||||
("DataVersion", Tag::Int(DATA_VERSION)),
|
||||
(
|
||||
"states",
|
||||
Tag::List(
|
||||
facts["states"]
|
||||
.as_array()
|
||||
.ok_or("registry states")?
|
||||
.iter()
|
||||
.map(|s| tag(&s[4]))
|
||||
.collect::<Result<_>>()?,
|
||||
),
|
||||
),
|
||||
("defaults", tag(&facts["defaults"])?),
|
||||
]))?)
|
||||
}
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<_> = std::env::args().skip(1).collect();
|
||||
let root = Path::new(
|
||||
args.first()
|
||||
.ok_or("usage: block_facts_bench ROOT [SAMPLES|digest]")?,
|
||||
);
|
||||
let setup = Instant::now();
|
||||
let registry = registry(root)?;
|
||||
println!(
|
||||
"registry_setup_ms={:.3}",
|
||||
setup.elapsed().as_secs_f64() * 1000.0
|
||||
);
|
||||
if args.get(1).is_some_and(|s| s == "digest") {
|
||||
let facts = BlockFacts::load(root, ®istry)?;
|
||||
let mut hash = DefaultHasher::new();
|
||||
let mut shapes = HashMap::new();
|
||||
facts.air.hash(&mut hash);
|
||||
for state in &facts.states {
|
||||
format!("{state:?}").hash(&mut hash);
|
||||
for shape in [&state.outline, &state.outline_edges] {
|
||||
let next = shapes.len();
|
||||
shapes
|
||||
.entry((shape.as_ptr(), shape.len()))
|
||||
.or_insert(next)
|
||||
.hash(&mut hash);
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"states={} shared_shapes={} digest={:016x}",
|
||||
facts.states.len(),
|
||||
shapes.len(),
|
||||
hash.finish()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
let samples: usize = args.get(1).map(|s| s.parse()).transpose()?.unwrap_or(9);
|
||||
assert!(samples > 0);
|
||||
// First load is reported separately; OS file caches are not forcibly evicted.
|
||||
let load = || -> Result<f64> {
|
||||
let start = Instant::now();
|
||||
let facts = black_box(BlockFacts::load(black_box(root), black_box(®istry))?);
|
||||
let ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||
drop(facts);
|
||||
Ok(ms)
|
||||
};
|
||||
println!("first_load_ms={:.3}", load()?);
|
||||
if samples > 1 {
|
||||
load()?;
|
||||
load()?;
|
||||
let mut times = (0..samples).map(|_| load()).collect::<Result<Vec<_>>>()?;
|
||||
println!("samples_ms={times:.3?}");
|
||||
times.sort_by(f64::total_cmp);
|
||||
println!("median_ms={:.3}", times[times.len() / 2]);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -139,6 +139,7 @@ fn main() -> Result<()> {
|
||||
blend_radius: 2,
|
||||
smooth_lighting: true,
|
||||
cutout_leaves: true,
|
||||
cull_leaves: false,
|
||||
};
|
||||
let mut best = f64::MAX;
|
||||
let mut quads = 0usize;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
//! Profile the two mandatory passes over gameplay-facts.json independently.
|
||||
//! Build with `cargo build --release -p horizon-client --example gameplay_decode_profile`.
|
||||
//! Each invocation runs one phase in a separate process for CPU/RSS measurements.
|
||||
#[allow(dead_code)]
|
||||
#[path = "../src/blocks/decode.rs"]
|
||||
mod decode;
|
||||
|
||||
use std::{hint::black_box, path::Path, time::Instant};
|
||||
|
||||
// The included private decoder expects the parent crate Result alias.
|
||||
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let root = args.next().ok_or("expected resource directory")?;
|
||||
let mode = args.next().ok_or("expected validate or typed")?;
|
||||
if mode != "validate" && mode != "typed" {
|
||||
return Err("expected validate or typed".into());
|
||||
}
|
||||
let total = Instant::now();
|
||||
let bytes = std::fs::read(Path::new(&root).join("gameplay-facts.json"))?;
|
||||
let started = Instant::now();
|
||||
if mode == "validate" {
|
||||
let parsed: decode::Validate = serde_json::from_slice(&bytes)?;
|
||||
black_box(parsed);
|
||||
} else {
|
||||
let parsed: decode::Gameplay<'_> = serde_json::from_slice(&bytes)?;
|
||||
black_box(parsed);
|
||||
}
|
||||
println!("phase_ms={:.3}", started.elapsed().as_secs_f64() * 1000.0);
|
||||
println!("total_ms={:.3}", total.elapsed().as_secs_f64() * 1000.0);
|
||||
Ok(())
|
||||
}
|
||||
@@ -88,6 +88,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
seed: 0,
|
||||
max_players: 8,
|
||||
dedicated: false,
|
||||
integrated: false,
|
||||
},
|
||||
);
|
||||
let mut next_entity = 100u32;
|
||||
|
||||
@@ -103,8 +103,17 @@ fn main() -> horizon_client::Result<()> {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let kind = Input::parse(op[2].as_str().unwrap_or_default()).ok_or("input")?;
|
||||
let clicked = menu.click(&env, slot, button, kind);
|
||||
let clicked = if op[2] == "QUICK_MOVE_ALL" {
|
||||
// A Shift double click: lastQuickMoved is the op's fourth field.
|
||||
let moved = parse_stack(op[3].as_str().unwrap_or("-"))?;
|
||||
match moved {
|
||||
Some(moved) => menu.quick_move_all(&env, slot as usize, &moved),
|
||||
None => Ok(()),
|
||||
}
|
||||
} else {
|
||||
let kind = Input::parse(op[2].as_str().unwrap_or_default()).ok_or("input")?;
|
||||
menu.click(&env, slot, button, kind)
|
||||
};
|
||||
if let Some(f) = furnace.as_mut() {
|
||||
let before = f.items[0].clone();
|
||||
f.items = [0, 1, 2].map(|i| menu.slot(i).cloned());
|
||||
|
||||
@@ -31,14 +31,7 @@ fn main() -> horizon_client::Result<()> {
|
||||
};
|
||||
let s = sprites.mips[layer as usize];
|
||||
writeln!(out, "== {name}")?;
|
||||
for mip in horizon_client::mipmap::generate(
|
||||
base,
|
||||
size.trailing_zeros(),
|
||||
s.strategy,
|
||||
s.alpha_cutoff_bias,
|
||||
s.has_transparent,
|
||||
s.item,
|
||||
) {
|
||||
for mip in horizon_client::mipmap::generate(base, size.trailing_zeros(), s) {
|
||||
write!(out, "{}x{}", mip.width, mip.height)?;
|
||||
for row in mip.pixels.chunks(mip.width as usize) {
|
||||
out.push(' ');
|
||||
|
||||
@@ -19,7 +19,7 @@ use horizon_client::{
|
||||
},
|
||||
mobs::{
|
||||
Mob, MobData, MobEvent, MobTypes, MobWorld, PlayerView, Seen, Species, Who,
|
||||
control::Operation, spawning::LocalDifficulty,
|
||||
control::Operation, seen_grid::SeenGrid, spawning::LocalDifficulty,
|
||||
},
|
||||
world::World,
|
||||
};
|
||||
@@ -902,6 +902,10 @@ fn main() -> horizon_client::Result<()> {
|
||||
for flag in &spec.flags {
|
||||
match flag.as_str() {
|
||||
"persistent" => m.persistent = true,
|
||||
"ridden" => {
|
||||
m.extra.head = Some("minecraft:red_harness".into());
|
||||
m.rider = Some(horizon_client::mobs::equine::Rider::default());
|
||||
}
|
||||
"noai" => m.no_ai = true,
|
||||
// Warden.finalizeSpawn's own part.
|
||||
"digcooldown" | "emerge" => horizon_client::mobs::warden::finalize(
|
||||
@@ -1007,6 +1011,9 @@ fn main() -> horizon_client::Result<()> {
|
||||
// The MobWorld every step sees, over a snapshot of the mobs.
|
||||
macro_rules! mob_world {
|
||||
($snapshot:expr) => {
|
||||
mob_world!($snapshot, None)
|
||||
};
|
||||
($snapshot:expr, $grid:expr) => {
|
||||
MobWorld {
|
||||
teams: Default::default(),
|
||||
world: &world,
|
||||
@@ -1017,6 +1024,7 @@ fn main() -> horizon_client::Result<()> {
|
||||
data: &data,
|
||||
player: player.clone(),
|
||||
others: $snapshot,
|
||||
others_grid: $grid,
|
||||
daylight: (world.day_time.rem_euclid(24000)) < 12000,
|
||||
game_time: world.game_time,
|
||||
day_time: world.day_time,
|
||||
@@ -1061,6 +1069,8 @@ fn main() -> horizon_client::Result<()> {
|
||||
p.yaw = yaw;
|
||||
}
|
||||
let mut snapshot: Vec<Seen> = mobs.iter().filter(|m| !m.removed).map(seen).collect();
|
||||
// The snapshot by position, kept as the game keeps it.
|
||||
let mut grid = SeenGrid::new(snapshot.iter().map(|e| e.bounds));
|
||||
let w = mob_world!(&snapshot);
|
||||
let mut events = Vec::new();
|
||||
let mut heart_events = Vec::new();
|
||||
@@ -1126,7 +1136,7 @@ fn main() -> horizon_client::Result<()> {
|
||||
if mobs[i].removed {
|
||||
continue;
|
||||
}
|
||||
let w = mob_world!(&snapshot);
|
||||
let w = mob_world!(&snapshot, Some(&grid));
|
||||
let m = &mut mobs[i];
|
||||
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
|
||||
let from = events.len();
|
||||
@@ -1242,9 +1252,10 @@ fn main() -> horizon_client::Result<()> {
|
||||
}
|
||||
// ServerLevel ticks entities one after another: the ones
|
||||
// after this mob see it as it is now.
|
||||
if let Some(e) = snapshot.iter_mut().find(|e| e.id == mobs[i].id) {
|
||||
*e = seen(&mobs[i]);
|
||||
e.alive &= !mobs[i].removed;
|
||||
if let Some(k) = snapshot.iter().position(|e| e.id == mobs[i].id) {
|
||||
snapshot[k] = seen(&mobs[i]);
|
||||
snapshot[k].alive &= !mobs[i].removed;
|
||||
grid.update(k, snapshot[k].bounds);
|
||||
}
|
||||
}
|
||||
for m in &mut mobs {
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
//! The Horizon side of a mod port's harness (tools/mod_port_harness.py,
|
||||
//! docs/modpacks.md): builds the content root for carried-over mods and
|
||||
//! writes what Horizon makes of every block state, drop, recipe and tag the
|
||||
//! NeoForge oracle exported.
|
||||
//!
|
||||
//! cargo run -p horizon-client --example mod_port_probe -- PREPARED WORK OUT.json [MOD...]
|
||||
//!
|
||||
//! WORK is a `horizon-port-pack` work folder: `oracle/facts.json` and
|
||||
//! `mods/<id>/carry/`. The root is built in `WORK/horizon-root`.
|
||||
use horizon_client::{
|
||||
Result,
|
||||
assets::Assets,
|
||||
blocks::BlockFacts,
|
||||
crafting::{Recipe, Recipes},
|
||||
items::{ItemStack, Items},
|
||||
mod_content::{self, ContentMod},
|
||||
stations::StationRecipes,
|
||||
};
|
||||
use horizon_core::{geometry::Axis, random::LegacyRandom};
|
||||
use horizon_world::chunk::BlockState;
|
||||
use serde_json::{Map, Value, json};
|
||||
use std::{collections::BTreeMap, fs, path::Path};
|
||||
|
||||
fn boxes(list: impl IntoIterator<Item = [f64; 6]>) -> Value {
|
||||
let mut v: Vec<[f64; 6]> = list.into_iter().collect();
|
||||
v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
json!(v)
|
||||
}
|
||||
|
||||
fn sound(s: &horizon_client::blocks::Sounds) -> Value {
|
||||
json!({"volume": s.volume, "pitch": s.pitch, "break": &*s.break_sound, "step": &*s.step,
|
||||
"place": &*s.place, "hit": &*s.hit, "fall": &*s.fall})
|
||||
}
|
||||
|
||||
fn tool(name: &str) -> Option<ItemStack> {
|
||||
match name {
|
||||
"hand" => None,
|
||||
"silk_touch_pickaxe" => {
|
||||
let mut pick = ItemStack::new("minecraft:diamond_pickaxe", 1);
|
||||
pick.enchant("minecraft:silk_touch", 1);
|
||||
Some(pick)
|
||||
}
|
||||
other => Some(ItemStack::new(&format!("minecraft:{other}"), 1)),
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let [base, work, out, wanted @ ..] = args.as_slice() else {
|
||||
return Err("usage: mod_port_probe PREPARED WORK OUT.json [MOD...]".into());
|
||||
};
|
||||
let (base, work) = (Path::new(base), Path::new(work));
|
||||
let oracle: Value = serde_json::from_slice(&fs::read(work.join("oracle/facts.json"))?)?;
|
||||
let mut mods = Vec::new();
|
||||
for entry in fs::read_dir(work.join("mods"))?.flatten() {
|
||||
let id = entry.file_name().to_string_lossy().into_owned();
|
||||
let dir = entry.path().join("carry");
|
||||
if !(wanted.is_empty() || wanted.contains(&id)) || !dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let package = horizon_mods::Package::read(&dir)?;
|
||||
let content = horizon_mods::Content::read(&dir, &package.manifest)?;
|
||||
mods.push(ContentMod {
|
||||
id: package.manifest.id.clone(),
|
||||
content,
|
||||
});
|
||||
}
|
||||
mods.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
let root = work.join("horizon-root");
|
||||
let began = std::time::Instant::now();
|
||||
let step = |what: &str| {
|
||||
let what = what.to_owned();
|
||||
move |e: Box<dyn std::error::Error>| format!("{what}: {e}")
|
||||
};
|
||||
mod_content::build(base, &mods, &root).map_err(step("content root"))?;
|
||||
let built = began.elapsed().as_secs_f64();
|
||||
let assets = Assets::load(&root).map_err(step("assets"))?;
|
||||
let registry = assets.registry;
|
||||
let facts = BlockFacts::load(&root, registry).map_err(step("block facts"))?;
|
||||
let items = Items::load(&root, registry, &facts).map_err(step("items"))?;
|
||||
let recipes = Recipes::load(&root, &items).map_err(step("recipes"))?;
|
||||
let stations = StationRecipes::load(&root, &items);
|
||||
let collisions =
|
||||
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))
|
||||
};
|
||||
let tools: Vec<String> = serde_json::from_value(oracle["tools"].clone())?;
|
||||
|
||||
let mut blocks = Map::new();
|
||||
for (name, block) in oracle["blocks"].as_object().ok_or("oracle: no blocks")? {
|
||||
if !ours(name) {
|
||||
continue;
|
||||
}
|
||||
let Some(default) = registry.default_state(name) else {
|
||||
blocks.insert(name.clone(), Value::Null);
|
||||
continue;
|
||||
};
|
||||
let mut states = Map::new();
|
||||
for (key, o) in block["states"].as_object().into_iter().flatten() {
|
||||
let properties: BTreeMap<String, String> = key
|
||||
.split(',')
|
||||
.filter(|p| !p.is_empty())
|
||||
.filter_map(|p| p.split_once('='))
|
||||
.map(|(k, v)| (k.to_owned(), v.to_owned()))
|
||||
.collect();
|
||||
let descriptor = BlockState {
|
||||
name: name.clone(),
|
||||
properties,
|
||||
};
|
||||
let Some(id) = registry.id(&descriptor) else {
|
||||
states.insert(key.clone(), Value::Null);
|
||||
continue;
|
||||
};
|
||||
let f = facts.get(id);
|
||||
let collision = collisions
|
||||
.cached_shapes()
|
||||
.cached_shape(id)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|s| {
|
||||
boxes(s.boxes().iter().map(|b| {
|
||||
[
|
||||
b.min(Axis::X),
|
||||
b.min(Axis::Y),
|
||||
b.min(Axis::Z),
|
||||
b.max(Axis::X),
|
||||
b.max(Axis::Y),
|
||||
b.max(Axis::Z),
|
||||
]
|
||||
}))
|
||||
});
|
||||
let mut row = json!({
|
||||
"hardness": f.hardness,
|
||||
"explosion_resistance": f.explosion_resistance,
|
||||
"emission": f.emission,
|
||||
"requires_tool": f.requires_tool,
|
||||
"friction": f.friction,
|
||||
"map_color_id": f.map_color,
|
||||
"can_occlude": f.can_occlude,
|
||||
"sound": sound(&f.sounds),
|
||||
"outline": boxes(f.outline.iter().copied()),
|
||||
"collision": collision,
|
||||
"classes": f.classes.iter().map(|c| &**c).collect::<Vec<_>>(),
|
||||
});
|
||||
if o.get("drops").is_some() {
|
||||
let mut drops = Map::new();
|
||||
for t in &tools {
|
||||
let mut random = LegacyRandom::new(0);
|
||||
let held = tool(t);
|
||||
let mut counts: BTreeMap<String, u32> = BTreeMap::new();
|
||||
for s in items.block_drops(registry, id, held.as_ref(), &mut random) {
|
||||
*counts.entry(s.item.to_string()).or_default() += s.count;
|
||||
}
|
||||
drops.insert(t.clone(), json!(counts));
|
||||
}
|
||||
row["drops"] = Value::Object(drops);
|
||||
}
|
||||
states.insert(key.clone(), row);
|
||||
}
|
||||
let item = facts.get(default).item.to_string();
|
||||
blocks.insert(
|
||||
name.clone(),
|
||||
json!({"states": states, "item": item, "item_known": items.items.contains_key(&*item)}),
|
||||
);
|
||||
}
|
||||
|
||||
// Vanilla's sounds, by block: the harness maps the oracle's version's
|
||||
// sound events to this one's through them.
|
||||
let mut vanilla_sounds = Map::new();
|
||||
for name in oracle["blocks"]
|
||||
.as_object()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|(k, _)| k)
|
||||
{
|
||||
if let Some(id) = name
|
||||
.starts_with("minecraft:")
|
||||
.then(|| registry.default_state(name))
|
||||
.flatten()
|
||||
{
|
||||
vanilla_sounds.insert(name.clone(), sound(&facts.get(id).sounds));
|
||||
}
|
||||
}
|
||||
|
||||
let mut item_rows = Map::new();
|
||||
for name in oracle["items"]
|
||||
.as_object()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|(k, _)| k)
|
||||
{
|
||||
item_rows.insert(
|
||||
name.clone(),
|
||||
match items.items.get(name.as_str()) {
|
||||
Some(i) => json!({"max_stack_size": i.max_stack, "block": i.block.map(|b| b.0)}),
|
||||
None => Value::Null,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let sorted = |list: &[String]| {
|
||||
let mut v = list.to_vec();
|
||||
v.sort();
|
||||
v.dedup();
|
||||
v
|
||||
};
|
||||
let mut recipe_rows = Map::new();
|
||||
for (name, recipe) in &recipes.crafting {
|
||||
if !ours(name) {
|
||||
continue;
|
||||
}
|
||||
let row = match recipe {
|
||||
Recipe::Shaped { cells, result, .. } => json!({
|
||||
"result": result.item.as_deref(), "count": result.count,
|
||||
"ingredients": cells.iter().flatten().map(|i| sorted(&i.1)).collect::<Vec<_>>()}),
|
||||
Recipe::Shapeless {
|
||||
ingredients,
|
||||
result,
|
||||
} => json!({
|
||||
"result": result.item.as_deref(), "count": result.count,
|
||||
"ingredients": ingredients.iter().map(|i| sorted(&i.1)).collect::<Vec<_>>()}),
|
||||
Recipe::Special(_) => json!({"special": true}),
|
||||
};
|
||||
recipe_rows.insert(name.clone(), row);
|
||||
}
|
||||
for c in recipes.cooking.iter().filter(|c| ours(&c.id)) {
|
||||
recipe_rows.insert(
|
||||
c.id.clone(),
|
||||
json!({"result": &*c.result.item, "count": c.result.count,
|
||||
"ingredients": [sorted(&c.ingredient.1)]}),
|
||||
);
|
||||
}
|
||||
for s in stations.stonecutting.iter().filter(|s| ours(&s.id)) {
|
||||
recipe_rows.insert(
|
||||
s.id.clone(),
|
||||
json!({"result": &*s.result.item, "count": s.result.count,
|
||||
"ingredients": [sorted(&s.input.1)]}),
|
||||
);
|
||||
}
|
||||
|
||||
let mut block_tags = Map::new();
|
||||
for (tag, members) in &items.block_tags {
|
||||
let mut ids: Vec<&String> = members.iter().filter(|m| ours(m)).collect();
|
||||
if !ids.is_empty() {
|
||||
ids.sort();
|
||||
block_tags.insert(tag.clone(), json!(ids));
|
||||
}
|
||||
}
|
||||
|
||||
let report = json!({
|
||||
"states": registry.len(),
|
||||
"build_seconds": built,
|
||||
"load_seconds": loaded,
|
||||
"blocks": blocks,
|
||||
"items": item_rows,
|
||||
"recipes": recipe_rows,
|
||||
"block_tags": block_tags,
|
||||
"vanilla_sounds": vanilla_sounds,
|
||||
});
|
||||
fs::write(out, serde_json::to_vec(&report)?)?;
|
||||
println!(
|
||||
"{out}: {} blocks, {} items, {} recipes; root built in {built:.2} s, loaded in {loaded:.2} s",
|
||||
blocks.len(),
|
||||
item_rows.len(),
|
||||
recipe_rows.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
//! Full block/assets + item startup. Compile with HORIZON_BENCH_COUNT_ALLOCATIONS=1
|
||||
//! for live requested heap bytes; leave it unset for latency measurements.
|
||||
//! cargo run --profile fast -p horizon-client --example model_library_bench -- ROOT [--parity]
|
||||
//! Run fresh processes, warm up twice, then compare multiple alternating baseline/candidate runs.
|
||||
use horizon_client::{assets::Assets, item_render::ItemRenderer};
|
||||
use std::{
|
||||
alloc::{GlobalAlloc, Layout, System},
|
||||
hash::{Hash, Hasher},
|
||||
path::Path,
|
||||
sync::atomic::{AtomicUsize, Ordering::Relaxed},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
struct CountingAllocator;
|
||||
const TRACK: bool = option_env!("HORIZON_BENCH_COUNT_ALLOCATIONS").is_some();
|
||||
static LIVE: AtomicUsize = AtomicUsize::new(0);
|
||||
// The allocator delegates every operation and layout unchanged to System.
|
||||
unsafe impl GlobalAlloc for CountingAllocator {
|
||||
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
|
||||
let ptr = unsafe { System.alloc(layout) };
|
||||
if TRACK && !ptr.is_null() {
|
||||
LIVE.fetch_add(layout.size(), Relaxed);
|
||||
}
|
||||
ptr
|
||||
}
|
||||
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
|
||||
let ptr = unsafe { System.alloc_zeroed(layout) };
|
||||
if TRACK && !ptr.is_null() {
|
||||
LIVE.fetch_add(layout.size(), Relaxed);
|
||||
}
|
||||
ptr
|
||||
}
|
||||
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
|
||||
if TRACK {
|
||||
LIVE.fetch_sub(layout.size(), Relaxed);
|
||||
}
|
||||
unsafe { System.dealloc(ptr, layout) };
|
||||
}
|
||||
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, size: usize) -> *mut u8 {
|
||||
let out = unsafe { System.realloc(ptr, layout, size) };
|
||||
if TRACK && !out.is_null() {
|
||||
LIVE.fetch_sub(layout.size(), Relaxed);
|
||||
LIVE.fetch_add(size, Relaxed);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
#[global_allocator]
|
||||
static ALLOCATOR: CountingAllocator = CountingAllocator;
|
||||
|
||||
fn main() -> horizon_client::Result<()> {
|
||||
let root = std::env::args()
|
||||
.nth(1)
|
||||
.ok_or("usage: model_library_bench ROOT [--parity]")?;
|
||||
let root = Path::new(&root);
|
||||
let initial = LIVE.load(Relaxed);
|
||||
let start = Instant::now();
|
||||
let assets = Assets::load(root)?;
|
||||
let block_ms = start.elapsed().as_secs_f64() * 1000.;
|
||||
let block_retained = LIVE.load(Relaxed) - initial;
|
||||
let start_items = Instant::now();
|
||||
let items = ItemRenderer::load_with_library(root, assets.model_library.clone())?;
|
||||
let item_ms = start_items.elapsed().as_secs_f64() * 1000.;
|
||||
let total_ms = start.elapsed().as_secs_f64() * 1000.;
|
||||
let retained = LIVE.load(Relaxed) - initial;
|
||||
println!(
|
||||
"block_ms={block_ms:.3} item_ms={item_ms:.3} total_ms={total_ms:.3} retained_bytes={retained} block_retained_bytes={block_retained}"
|
||||
);
|
||||
if std::env::args().any(|a| a == "--parity") {
|
||||
let mut hash = std::collections::hash_map::DefaultHasher::new();
|
||||
for id in 0..assets.registry.len() {
|
||||
let id = horizon_world::registry::BlockStateId(id as u32);
|
||||
for pos in [
|
||||
horizon_core::position::BlockPos::new(0, 0, 0),
|
||||
horizon_core::position::BlockPos::new(-17, 65, 31),
|
||||
] {
|
||||
if let Ok(selected) = assets.model_catalog.collect(id, pos) {
|
||||
for (variant, model) in selected {
|
||||
format!("{variant:?}").hash(&mut hash);
|
||||
model.ambient_occlusion.hash(&mut hash);
|
||||
for face in &model.faces {
|
||||
format!("{:?}", face.quad).hash(&mut hash);
|
||||
face.layer.hash(&mut hash);
|
||||
face.opaque.hash(&mut hash);
|
||||
face.translucent.hash(&mut hash);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut names = Vec::new();
|
||||
for ns in std::fs::read_dir(root.join("assets"))? {
|
||||
let ns = ns?;
|
||||
let base = ns.path().join("models");
|
||||
let mut stack = vec![base.clone()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries {
|
||||
let path = entry?.path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
} else if path.extension().is_some_and(|e| e == "json") {
|
||||
names.push(format!(
|
||||
"{}:{}",
|
||||
ns.file_name().to_string_lossy(),
|
||||
path.strip_prefix(&base)?
|
||||
.with_extension("")
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/")
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
names.sort();
|
||||
for name in &names {
|
||||
format!("{:?}", items.bake(name)).hash(&mut hash);
|
||||
}
|
||||
println!(
|
||||
"parity_models={} fingerprint={:016x}",
|
||||
names.len(),
|
||||
hash.finish()
|
||||
);
|
||||
}
|
||||
std::hint::black_box((&assets, &items));
|
||||
Ok(())
|
||||
}
|
||||
@@ -17,13 +17,16 @@
|
||||
//! a `mobs::trial_spawner::TrialSpawner` with the game time advancing and
|
||||
//! print its states, events, spawns and ejected items.
|
||||
//!
|
||||
//! Vault cases (`mode=vault`, tools/test_vault_parity.py) tick a
|
||||
//! `vault::Vault` the same way, with players moving and using keys on it.
|
||||
//!
|
||||
//! usage: natural_spawn_fixture RESOURCES INPUT OUTPUT
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
biome::{Biomes, SpawnEntry},
|
||||
blocks::BlockFacts,
|
||||
collisions::{self, LookupCollisions, no_collision_empty},
|
||||
items::Items,
|
||||
items::{ItemStack, Items},
|
||||
mobs::{
|
||||
MobTypes, Species,
|
||||
data::MobData,
|
||||
@@ -35,8 +38,12 @@ use horizon_client::{
|
||||
},
|
||||
spawner::{Spawner, SpawnerAdded, SpawnerEvent, SpawnerLevel, SpawnerSpawn},
|
||||
species::{self, Baby},
|
||||
trial_spawner::{TrialEvent, TrialPlayer, TrialSpawner, TrialSpawnerLevel, TrialState},
|
||||
trial_spawner::{
|
||||
Omen, TRIAL_OMEN_TICKS_PER_LEVEL, TrialEvent, TrialPlayer, TrialSpawner,
|
||||
TrialSpawnerLevel, TrialState,
|
||||
},
|
||||
},
|
||||
vault::{Vault, VaultEvent, VaultLevel, VaultPlayer, VaultState},
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::Aabb,
|
||||
@@ -115,8 +122,14 @@ struct World<'a> {
|
||||
placed: RefCell<Vec<([i32; 3], BlockStateId)>>,
|
||||
/// The game time (trial cases advance it).
|
||||
time: Cell<i64>,
|
||||
/// The mobs a trial spawner added: UUID, position and box.
|
||||
trial_mobs: RefCell<Vec<([i32; 4], [f64; 3], Aabb)>>,
|
||||
/// The mobs a trial spawner added: UUID, position, height and box.
|
||||
trial_mobs: RefCell<Vec<([i32; 4], [f64; 3], f32, Aabb)>>,
|
||||
/// How many mobs a trial spawner added (their UUIDs count up).
|
||||
trial_count: Cell<i32>,
|
||||
/// Each player's omen and its amplifier (`/trial_omen:A`, `/bad_omen:A`).
|
||||
omens: RefCell<Vec<Option<(Omen, i32)>>>,
|
||||
/// Loot tables (trial cases).
|
||||
loot: Option<std::sync::Arc<horizon_world::loot::LootData>>,
|
||||
}
|
||||
impl World<'_> {
|
||||
fn biome_at(&self, x: i32, y: i32, z: i32) -> &str {
|
||||
@@ -378,6 +391,16 @@ impl TrialSpawnerLevel for World<'_> {
|
||||
eye: [p[0], p[1] + f64::from(1.62f32), p[2]],
|
||||
creative: mode == "creative",
|
||||
spectator: mode == "spectator",
|
||||
position: *p,
|
||||
height: 1.8,
|
||||
alive: true,
|
||||
omen: self
|
||||
.omens
|
||||
.borrow()
|
||||
.get(i)
|
||||
.copied()
|
||||
.flatten()
|
||||
.map(|(o, _)| o),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -400,9 +423,38 @@ impl TrialSpawnerLevel for World<'_> {
|
||||
}
|
||||
fn entity_block(&self, uuid: [i32; 4]) -> Option<[i32; 3]> {
|
||||
let mobs = self.trial_mobs.borrow();
|
||||
let (_, p, _) = mobs.iter().find(|(u, ..)| *u == uuid)?;
|
||||
let (_, p, ..) = mobs.iter().find(|(u, ..)| *u == uuid)?;
|
||||
Some(p.map(|v| v.floor() as i32))
|
||||
}
|
||||
fn entity_box(&self, uuid: [i32; 4]) -> Option<([f64; 3], f32)> {
|
||||
let mobs = self.trial_mobs.borrow();
|
||||
let (_, p, h, _) = mobs.iter().find(|(u, ..)| *u == uuid)?;
|
||||
Some((*p, *h))
|
||||
}
|
||||
fn no_block_collision(&self, block: [i32; 3]) -> bool {
|
||||
let lookup = |x: i32, y: i32, z: i32| self.state(x, y, z);
|
||||
let mut scene = LookupCollisions {
|
||||
block: &lookup,
|
||||
binding: self.s.collision,
|
||||
};
|
||||
let state = self.state(block[0], block[1], block[2]);
|
||||
self.s
|
||||
.collision
|
||||
.collision_shape(&mut scene, state, block)
|
||||
.map_or(true, |shape| shape.is_empty())
|
||||
}
|
||||
fn loot_items(&self, table: &str, seed: i64) -> Vec<horizon_client::items::ItemStack> {
|
||||
let Some(loot) = &self.loot else {
|
||||
return Vec::new();
|
||||
};
|
||||
loot.roll_split(table, &mut LegacyRandom::new(seed))
|
||||
.iter()
|
||||
.map(horizon_client::structure_blocks::loot_item_with_components)
|
||||
.collect()
|
||||
}
|
||||
fn seed(&self) -> i64 {
|
||||
self.seed
|
||||
}
|
||||
}
|
||||
|
||||
/// The box a mob is left with by `finalizeSpawn` under the oracle's fixed random.
|
||||
@@ -472,7 +524,7 @@ fn main() -> horizon_client::Result<()> {
|
||||
let data = MobData::load(root, &facts);
|
||||
let items = Items::tags_only(root)?;
|
||||
// Trial spawner rewards: the loot tables and the configs.
|
||||
let loot = if input.contains("mode=trial") {
|
||||
let loot = if input.contains("mode=trial") || input.contains("mode=vault") {
|
||||
horizon_client::mobs::trial_spawner::load_registry(root);
|
||||
horizon_client::structure_blocks::loot_data(root)
|
||||
} else {
|
||||
@@ -581,6 +633,19 @@ fn main() -> horizon_client::Result<()> {
|
||||
bounds: Bx(b),
|
||||
});
|
||||
}
|
||||
// A player's third field: an omen and its amplifier.
|
||||
let omens = indexed("p")
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let (effect, amplifier) = p.split('/').nth(2)?.split_once(':')?;
|
||||
let omen = if effect == "trial_omen" {
|
||||
Omen::Trial
|
||||
} else {
|
||||
Omen::Bad
|
||||
};
|
||||
Some((omen, amplifier.parse().ok()?))
|
||||
})
|
||||
.collect();
|
||||
let players = indexed("p")
|
||||
.into_iter()
|
||||
.map(|p| {
|
||||
@@ -667,6 +732,9 @@ fn main() -> horizon_client::Result<()> {
|
||||
placed: RefCell::new(Vec::new()),
|
||||
time: Cell::new(0),
|
||||
trial_mobs: RefCell::new(Vec::new()),
|
||||
trial_count: Cell::new(0),
|
||||
omens: RefCell::new(omens),
|
||||
loot: loot.clone(),
|
||||
entities: RefCell::new(
|
||||
case_entities
|
||||
.iter()
|
||||
@@ -706,6 +774,9 @@ fn main() -> horizon_client::Result<()> {
|
||||
} else if mode == "trial" {
|
||||
let loot = loot.as_ref().ok_or("no loot tables")?;
|
||||
trial_case(&world, registry, loot, &get, &mut random, &mut log)?;
|
||||
} else if mode == "vault" {
|
||||
let loot = loot.as_ref().ok_or("no loot tables")?;
|
||||
vault_case(world.players.clone(), loot, &get, &mut random, &mut log)?;
|
||||
} else if mode == "gen" {
|
||||
let mut gen_random = LegacyRandom::new(get("gseed", "0").parse()?);
|
||||
// The level random of the spawn predicates: no creature's predicate
|
||||
@@ -972,8 +1043,22 @@ fn trial_case(
|
||||
let scale = size as f32;
|
||||
let bounds = spawn_box(x, y, z, t.width as f32 * scale, t.height as f32 * scale);
|
||||
l.entities.borrow_mut().push((r.id.to_owned(), bounds));
|
||||
let uuid = [1, 0, 0, l.trial_mobs.borrow().len() as i32];
|
||||
l.trial_mobs.borrow_mut().push((uuid, r.position, bounds));
|
||||
let uuid = [1, 0, 0, l.trial_count.get()];
|
||||
l.trial_count.set(l.trial_count.get() + 1);
|
||||
// Zombie.readAdditionalSaveData: IsBaby halves the box
|
||||
// (getAgeScale); finalizeSpawn's fixed random never makes one.
|
||||
let baby = match r
|
||||
.entity
|
||||
.get(&horizon_world::nbt::JavaString::from("IsBaby"))
|
||||
{
|
||||
Some(Tag::Byte(v)) => *v != 0,
|
||||
Some(Tag::Int(v)) => *v != 0,
|
||||
_ => false,
|
||||
};
|
||||
let height = t.height as f32 * scale * if baby { 0.5 } else { 1.0 };
|
||||
l.trial_mobs
|
||||
.borrow_mut()
|
||||
.push((uuid, r.position, height, bounds));
|
||||
spawned.push_back(format!(
|
||||
"{tick} spawn {} {} {} {} {}",
|
||||
r.id,
|
||||
@@ -1028,7 +1113,7 @@ fn trial_case(
|
||||
for e in events {
|
||||
match e {
|
||||
TrialEvent::BlockUpdated => log.push(format!("{tick} update")),
|
||||
TrialEvent::LevelEvent(id, _, data) => {
|
||||
TrialEvent::LevelEvent(id, p, data) => {
|
||||
// A spawn's events follow its tryAddFreshEntityWithPassengers,
|
||||
// a reward's its items.
|
||||
match id {
|
||||
@@ -1036,7 +1121,13 @@ fn trial_case(
|
||||
3014 => log.append(&mut items),
|
||||
_ => {}
|
||||
}
|
||||
log.push(format!("{tick} levent {id} {data}"));
|
||||
match id {
|
||||
3020 | 3012 => log.push(format!(
|
||||
"{tick} levent {id} {data} at {},{},{}",
|
||||
p[0], p[1], p[2]
|
||||
)),
|
||||
_ => log.push(format!("{tick} levent {id} {data}")),
|
||||
}
|
||||
}
|
||||
TrialEvent::Sound(name) => log.push(format!(
|
||||
"{tick} sound minecraft:{name} {} {} {} 1.0 1.0",
|
||||
@@ -1048,6 +1139,44 @@ fn trial_case(
|
||||
"{tick} gevent minecraft:entity_place {} {} {}",
|
||||
p[0], p[1], p[2]
|
||||
)),
|
||||
TrialEvent::TrialOmen(uuid) => {
|
||||
let i = (uuid[3] - 1) as usize;
|
||||
let mut omens = world.omens.borrow_mut();
|
||||
let amplifier = omens.get(i).copied().flatten().map_or(0, |(_, a)| a);
|
||||
log.push(format!("{tick} effect remove minecraft:bad_omen"));
|
||||
log.push(format!(
|
||||
"{tick} effect add minecraft:trial_omen {} 0",
|
||||
TRIAL_OMEN_TICKS_PER_LEVEL * (amplifier + 1)
|
||||
));
|
||||
if let Some(o) = omens.get_mut(i) {
|
||||
*o = Some((Omen::Trial, 0));
|
||||
}
|
||||
}
|
||||
TrialEvent::Discard(uuid) => {
|
||||
let mut mobs = world.trial_mobs.borrow_mut();
|
||||
if let Some(i) = mobs.iter().position(|(u, ..)| *u == uuid) {
|
||||
let (.., b) = mobs.remove(i);
|
||||
world.entities.borrow_mut().retain(|(_, e)| *e != b);
|
||||
}
|
||||
}
|
||||
TrialEvent::SoundAt(name, b, pitch) => log.push(format!(
|
||||
"{tick} sound minecraft:{name} {} {} {} 1.0 {pitch:?}",
|
||||
bits(f64::from(b[0]) + 0.5),
|
||||
bits(f64::from(b[1]) + 0.5),
|
||||
bits(f64::from(b[2]) + 0.5)
|
||||
)),
|
||||
TrialEvent::ItemSpawner {
|
||||
item,
|
||||
position,
|
||||
delay,
|
||||
} => log.push(format!(
|
||||
"{tick} ispawner {} {} {} {} {} {delay}",
|
||||
item.item,
|
||||
item.count,
|
||||
bits(position[0]),
|
||||
bits(position[1]),
|
||||
bits(position[2])
|
||||
)),
|
||||
TrialEvent::OminousSet(set) => {
|
||||
ominous = set;
|
||||
world
|
||||
@@ -1101,3 +1230,195 @@ fn trial_case(
|
||||
log.push(format!("next {}", random.next_int_bound(1000).unwrap_or(0)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What a vault reads: the game time and the case's players, moved by `move`.
|
||||
struct VaultWorld {
|
||||
time: i64,
|
||||
players: Vec<([f64; 3], String)>,
|
||||
}
|
||||
impl VaultLevel for VaultWorld {
|
||||
fn game_time(&self) -> i64 {
|
||||
self.time
|
||||
}
|
||||
fn players(&self) -> Vec<VaultPlayer> {
|
||||
self.players
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (p, mode))| VaultPlayer {
|
||||
uuid: [0, 0, 0, i as i32 + 1],
|
||||
block: p.map(|v| v.floor() as i32),
|
||||
spectator: mode == "spectator",
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
/// A UUID's low 64 bits, as the oracle prints players.
|
||||
fn uuid_low(u: &[i32; 4]) -> String {
|
||||
((i64::from(u[2]) << 32) | i64::from(u[3] as u32)).to_string()
|
||||
}
|
||||
/// The display item and the connected players as the oracle prints them.
|
||||
fn vault_shared(v: &Vault) -> String {
|
||||
let mut connected: Vec<String> = v.connected_players.iter().map(uuid_low).collect();
|
||||
connected.sort();
|
||||
format!(
|
||||
"{} [{}]",
|
||||
v.display_item
|
||||
.as_ref()
|
||||
.map_or_else(|| "-".to_owned(), |s| format!("{}*{}", s.item, s.count)),
|
||||
connected.join(",")
|
||||
)
|
||||
}
|
||||
|
||||
/// `VaultBlockEntity.Server.tick` `ticks` times from game time `t0` at `spos`
|
||||
/// on a vault block in state `state` (ominous with `ominous=1`) loaded from
|
||||
/// `snbt` (base64 SNBT), with the case's key uses (`use`) and player moves
|
||||
/// (`move`) before each tick, as the oracle logs it.
|
||||
fn vault_case(
|
||||
players: Vec<([f64; 3], String)>,
|
||||
loot: &horizon_world::loot::LootData,
|
||||
get: &dyn Fn(&str, &'static str) -> String,
|
||||
random: &mut LegacyRandom,
|
||||
log: &mut Vec<String>,
|
||||
) -> horizon_client::Result<()> {
|
||||
let v: Vec<i32> = get("spos", "0,0,0")
|
||||
.split(',')
|
||||
.map(|n| n.parse().unwrap_or(0))
|
||||
.collect();
|
||||
let pos = [v[0], v[1], v[2]];
|
||||
let mut state = VaultState::from_name(&get("state", "inactive")).ok_or("bad state")?;
|
||||
let ominous = get("ominous", "0") == "1";
|
||||
let text = horizon_client::skins::base64_decode(&get("snbt", "")).ok_or("bad snbt")?;
|
||||
let tag = horizon_world::nbt::parse_snbt(&String::from_utf8(text)?)?.0;
|
||||
let mut vault = Vault::load(&tag);
|
||||
let schedule = |key: &str| -> Vec<Vec<String>> {
|
||||
get(key, "")
|
||||
.split(';')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.split('/').map(str::to_owned).collect())
|
||||
.collect()
|
||||
};
|
||||
let (uses, moves) = (schedule("use"), schedule("move"));
|
||||
let ticks: i64 = get("ticks", "1").parse()?;
|
||||
let t0: i64 = get("t0", "0").parse()?;
|
||||
let seed: i64 = get("wseed", "0").parse()?;
|
||||
let mut sequences = horizon_client::random_sequences::RandomSequences::default();
|
||||
let mut world = VaultWorld { time: t0, players };
|
||||
let center = pos.map(|v| f64::from(v) + 0.5);
|
||||
for tick in 0..ticks {
|
||||
world.time = t0 + tick;
|
||||
let at = |s: &[String]| s[0].parse() == Ok(tick);
|
||||
for m in moves.iter().filter(|m| at(m)) {
|
||||
let v: Vec<f64> = m[2].split(',').map(|n| n.parse().unwrap_or(0.0)).collect();
|
||||
world.players[m[1].parse::<usize>()?].0 = [v[0], v[1], v[2]];
|
||||
}
|
||||
let mut events = Vec::new();
|
||||
let mut roll = |table: &str, random: &mut LegacyRandom, sequence: bool| {
|
||||
let drops = match loot.random_sequence(table).filter(|_| sequence) {
|
||||
Some(s) => loot.roll_split(table, sequences.get(s, seed)),
|
||||
None => loot.roll_split(table, random),
|
||||
};
|
||||
drops
|
||||
.iter()
|
||||
.map(horizon_client::structure_blocks::loot_item_with_components)
|
||||
.collect()
|
||||
};
|
||||
for u in uses.iter().filter(|u| at(u)) {
|
||||
let player: usize = u[1].parse()?;
|
||||
let mut stack = ItemStack::new(&u[2], u[3].parse()?);
|
||||
let used = vault.use_item_on(
|
||||
&world,
|
||||
random,
|
||||
pos,
|
||||
state,
|
||||
ominous,
|
||||
[0, 0, 0, player as i32 + 1],
|
||||
Some(&stack),
|
||||
&mut roll,
|
||||
&mut events,
|
||||
);
|
||||
if world.players[player].1 != "creative" {
|
||||
stack.count -= used.unwrap_or(0);
|
||||
}
|
||||
vault_events(&mut events, &mut state, ominous, tick, center, &vault, log);
|
||||
log.push(format!(
|
||||
"{tick} use {} {}",
|
||||
if used.is_some() { "ok" } else { "pass" },
|
||||
stack.count
|
||||
));
|
||||
}
|
||||
vault.server_tick(&world, random, pos, state, ominous, &mut roll, &mut events);
|
||||
vault_events(&mut events, &mut state, ominous, tick, center, &vault, log);
|
||||
}
|
||||
let saved = vault.save();
|
||||
let config = saved
|
||||
.iter()
|
||||
.find(|(k, _)| *k == "config")
|
||||
.and_then(|(_, t)| match t {
|
||||
Tag::Compound(c) => {
|
||||
let mut keys: Vec<String> = c.keys().map(|k| k.to_string_lossy()).collect();
|
||||
keys.sort();
|
||||
Some(keys.join(","))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let eject: Vec<String> = vault
|
||||
.items_to_eject
|
||||
.iter()
|
||||
.map(|s| format!("{}*{}", s.item, s.count))
|
||||
.collect();
|
||||
log.push(format!(
|
||||
"end state={} rewarded={} resumes={} eject={} total={} shared={} config={config}",
|
||||
state.name(),
|
||||
vault
|
||||
.rewarded_players
|
||||
.iter()
|
||||
.map(uuid_low)
|
||||
.collect::<Vec<_>>()
|
||||
.join(","),
|
||||
vault.state_updating_resumes_at,
|
||||
eject.join(","),
|
||||
vault.total_ejections_needed,
|
||||
vault_shared(&vault),
|
||||
));
|
||||
log.push(format!("next {}", random.next_int_bound(1000).unwrap_or(0)));
|
||||
Ok(())
|
||||
}
|
||||
/// Logs a vault's events and applies its state changes.
|
||||
fn vault_events(
|
||||
events: &mut Vec<VaultEvent>,
|
||||
state: &mut VaultState,
|
||||
ominous: bool,
|
||||
tick: i64,
|
||||
center: [f64; 3],
|
||||
vault: &Vault,
|
||||
log: &mut Vec<String>,
|
||||
) {
|
||||
for e in events.drain(..) {
|
||||
match e {
|
||||
VaultEvent::State(s) => {
|
||||
*state = s;
|
||||
log.push(format!("{tick} state {} {ominous}", s.name()));
|
||||
}
|
||||
VaultEvent::BlockUpdated => log.push(format!("{tick} update {}", vault_shared(vault))),
|
||||
VaultEvent::LevelEvent(id, data) => log.push(format!("{tick} levent {id} {data}")),
|
||||
VaultEvent::Sound(name, volume, pitch) => log.push(format!(
|
||||
"{tick} sound minecraft:{name} {} {} {} {volume:?} {pitch:?}",
|
||||
bits(center[0]),
|
||||
bits(center[1]),
|
||||
bits(center[2])
|
||||
)),
|
||||
VaultEvent::Eject(stack, v) => log.push(format!(
|
||||
"{tick} item {} {} {} {} {} {} {} {}",
|
||||
stack.item,
|
||||
stack.count,
|
||||
bits(center[0]),
|
||||
bits(center[1] + 0.7 - 0.125),
|
||||
bits(center[2]),
|
||||
bits(v[0]),
|
||||
bits(v[1]),
|
||||
bits(v[2])
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! Loads and saves option files like `Options.load`/`save`, for the
|
||||
//! differential harness (tools/test_options_parity.py).
|
||||
use horizon_client::{Result, keybinds, options_file::OptionsFile};
|
||||
use horizon_client::{
|
||||
Result, keybinds,
|
||||
options_file::{OptionsFile, V},
|
||||
};
|
||||
use std::{fs, io::Write};
|
||||
|
||||
fn main() -> Result<()> {
|
||||
@@ -14,11 +17,16 @@ fn main() -> Result<()> {
|
||||
let mut first = true;
|
||||
for path in names {
|
||||
let name = path.file_name().unwrap().to_string_lossy().into_owned();
|
||||
let file = if name == "absent.txt" {
|
||||
let mut file = if name == "absent.txt" {
|
||||
OptionsFile::default()
|
||||
} else {
|
||||
OptionsFile::parse(&String::from_utf8_lossy(&fs::read(&path)?))
|
||||
};
|
||||
// Horizon extends the slider to 64; vanilla rejects 33..64 to its default.
|
||||
// Compare that deliberate extension as vanilla would serialize it.
|
||||
if file.int("renderDistance") > 32 {
|
||||
file.set("renderDistance", V::Int(12));
|
||||
}
|
||||
write!(out, "== {name}\n{}", file.serialize())?;
|
||||
if first {
|
||||
first = false;
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
//! Block entity data on items for tools/test_pick_block_data_parity.py: reads
|
||||
//! one JSON case per line (see VanillaPickBlockDataOracle) and prints the
|
||||
//! picked item's component patch, a tab, then the placed block entity.
|
||||
//!
|
||||
//! usage: pick_block_data_fixture INPUT OUTPUT
|
||||
use horizon_client::block_entity_item::{pick, place};
|
||||
use horizon_client::block_entity_nbt::canonical;
|
||||
use horizon_client::java_text::{double_to_string, float_to_string};
|
||||
use horizon_world::nbt::{Tag, parse_snbt};
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
|
||||
/// ParityData.canon: sorted keys, typed numbers, Java's float text.
|
||||
fn canon(tag: &Tag, out: &mut String) {
|
||||
let join = |out: &mut String, prefix: &str, items: Vec<String>| {
|
||||
out.push_str(prefix);
|
||||
out.push_str(&items.join(","));
|
||||
out.push(']');
|
||||
};
|
||||
match tag {
|
||||
Tag::Float(v) => out.push_str(&format!("{}f", float_to_string(f32::from_bits(*v)))),
|
||||
Tag::Double(v) => out.push_str(&format!("{}d", double_to_string(f64::from_bits(*v)))),
|
||||
Tag::List(items) => join(
|
||||
out,
|
||||
"[",
|
||||
items
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let mut s = String::new();
|
||||
canon(t, &mut s);
|
||||
s
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
Tag::Compound(map) => {
|
||||
let mut keys: Vec<_> = map.iter().map(|(k, v)| (k.to_string_lossy(), v)).collect();
|
||||
keys.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
out.push('{');
|
||||
for (i, (k, v)) in keys.into_iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
out.push_str(&k);
|
||||
out.push(':');
|
||||
canon(v, out);
|
||||
}
|
||||
out.push('}');
|
||||
}
|
||||
other => out.push_str(&horizon_client::item_data::canon_tag(other)),
|
||||
}
|
||||
}
|
||||
|
||||
fn snbt(value: &Value) -> Option<Tag> {
|
||||
parse_snbt(value.as_str()?).ok().map(|(tag, _)| tag)
|
||||
}
|
||||
|
||||
fn case(line: &str) -> Option<String> {
|
||||
let case: Value = serde_json::from_str(line).ok()?;
|
||||
let saved = canonical(&snbt(&case["nbt"])?)?;
|
||||
let picked = pick(&saved);
|
||||
let patch = match case.get("patch") {
|
||||
Some(p) => match snbt(p)? {
|
||||
Tag::Compound(c) => c,
|
||||
_ => return None,
|
||||
},
|
||||
None => picked.clone(),
|
||||
};
|
||||
let target = canonical(&snbt(&case["target"])?)?;
|
||||
let Tag::Compound(mut placed) = place(&target, &patch, case["op"].as_bool()?) else {
|
||||
return None;
|
||||
};
|
||||
// keepPacked is the chunk's, not saveWithFullMetadata's.
|
||||
placed.remove(&"keepPacked".into());
|
||||
let mut out = String::new();
|
||||
canon(&Tag::Compound(picked), &mut out);
|
||||
out.push('\t');
|
||||
canon(&Tag::Compound(placed), &mut out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let input = fs::read_to_string(
|
||||
args.get(1)
|
||||
.ok_or("usage: pick_block_data_fixture INPUT OUTPUT")?,
|
||||
)?;
|
||||
let lines: Vec<String> = input
|
||||
.lines()
|
||||
.map(|line| case(line).unwrap_or_else(|| "error".to_owned()))
|
||||
.collect();
|
||||
fs::write(
|
||||
args.get(2).ok_or("missing OUTPUT")?,
|
||||
lines.join("\n") + "\n",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
//! Selection cost only (not block simulation). Run with prepared client resources.
|
||||
//! cargo run --profile fast -p horizon-client --example random_tick_eligibility_bench -- RESOURCES
|
||||
use horizon_client::{
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, MIN_Y, Section},
|
||||
fast_hash::FxHashMap,
|
||||
random_tick_sections::RandomTickSections,
|
||||
};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::{hint::black_box, path::Path, sync::Arc, time::Instant};
|
||||
|
||||
fn select_baseline(chunks: &FxHashMap<(i32, i32), Arc<Chunk>>, facts: &BlockFacts) -> usize {
|
||||
let mut ticking = Vec::new();
|
||||
for (&(x, z), chunk) in chunks {
|
||||
if x.abs() > 8 || z.abs() > 8 || !chunk.lit {
|
||||
continue;
|
||||
}
|
||||
let sections: Vec<i32> = chunk
|
||||
.sections
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, s)| {
|
||||
s.blocks
|
||||
.maybe_has(|id| facts.get(BlockStateId(id)).random_ticks)
|
||||
})
|
||||
.map(|(i, _)| MIN_Y + i as i32 * 16)
|
||||
.collect();
|
||||
ticking.push((x, z, sections));
|
||||
}
|
||||
black_box(ticking).iter().map(|(_, _, s)| s.len()).sum()
|
||||
}
|
||||
|
||||
fn select_cached(
|
||||
chunks: &FxHashMap<(i32, i32), Arc<Chunk>>,
|
||||
facts: &BlockFacts,
|
||||
cache: &mut RandomTickSections,
|
||||
) -> usize {
|
||||
let mut ticking = Vec::new();
|
||||
for (&(x, z), chunk) in chunks {
|
||||
if x.abs() > 8 || z.abs() > 8 || !chunk.lit {
|
||||
continue;
|
||||
}
|
||||
ticking.push((x, z, cache.get((x, z), chunk, facts)));
|
||||
}
|
||||
black_box(ticking).iter().map(|(_, _, s)| s.len()).sum()
|
||||
}
|
||||
|
||||
fn main() -> horizon_client::Result<()> {
|
||||
let root = std::env::args()
|
||||
.nth(1)
|
||||
.ok_or("expected prepared client resource directory")?;
|
||||
let start = Instant::now();
|
||||
let registry = horizon_world::level::data::load_registry(Path::new(&root))?;
|
||||
let facts = BlockFacts::load(Path::new(&root), ®istry)?;
|
||||
println!(
|
||||
"resource_load_ms={:.3}",
|
||||
start.elapsed().as_secs_f64() * 1000.0
|
||||
);
|
||||
let inactive: Vec<u16> = facts
|
||||
.states
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, f)| !f.random_ticks)
|
||||
.take(128)
|
||||
.map(|(i, _)| i as u16)
|
||||
.collect();
|
||||
let active = registry.default_state("minecraft:grass_block").unwrap().0 as u16;
|
||||
assert!(facts.get(BlockStateId(u32::from(active))).random_ticks);
|
||||
for (name, palette) in [("sparse", 1), ("dense", 128)] {
|
||||
let start = Instant::now();
|
||||
let mut chunks = FxHashMap::default();
|
||||
for z in -8..=8 {
|
||||
for x in -8..=8 {
|
||||
let mut chunk = Chunk::empty(x, z);
|
||||
chunk.lit = true;
|
||||
for (i, section) in chunk.sections.iter_mut().enumerate() {
|
||||
let mut values = std::array::from_fn(|j| inactive[j % palette]);
|
||||
if i % 3 == 0 {
|
||||
values[4095] = active;
|
||||
}
|
||||
Arc::make_mut(section).blocks = Section::from_blocks(&values);
|
||||
}
|
||||
chunks.insert((x, z), Arc::new(chunk));
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"{name} fixture_ms={:.3}",
|
||||
start.elapsed().as_secs_f64() * 1000.0
|
||||
);
|
||||
for (workload, changed) in [
|
||||
("steady", 0),
|
||||
("cold", 0),
|
||||
("reload", 0),
|
||||
("edit_1pct", 3),
|
||||
("edit_all", 289),
|
||||
] {
|
||||
let keys: Vec<_> = chunks.keys().copied().collect();
|
||||
let mut baseline = Vec::new();
|
||||
let mut candidate = Vec::new();
|
||||
let mut cache = RandomTickSections::default();
|
||||
for sample in 0..8 {
|
||||
// Alternate order; first pair warms both paths and is discarded.
|
||||
for cached in if sample % 2 == 0 {
|
||||
[false, true]
|
||||
} else {
|
||||
[true, false]
|
||||
} {
|
||||
let start = Instant::now();
|
||||
for tick in 0..300 {
|
||||
if cached && workload == "cold" {
|
||||
cache = RandomTickSections::default();
|
||||
}
|
||||
if cached && workload == "reload" {
|
||||
cache.clear();
|
||||
}
|
||||
for key in keys.iter().take(changed) {
|
||||
Arc::make_mut(chunks.get_mut(key).unwrap()).set(
|
||||
0,
|
||||
MIN_Y,
|
||||
0,
|
||||
inactive[tick % 2],
|
||||
);
|
||||
}
|
||||
black_box(if cached {
|
||||
select_cached(&chunks, &facts, &mut cache)
|
||||
} else {
|
||||
select_baseline(&chunks, &facts)
|
||||
});
|
||||
}
|
||||
// Include teardown, and remove weak handles before baseline edits.
|
||||
if cached {
|
||||
cache = RandomTickSections::default();
|
||||
}
|
||||
if sample > 0 {
|
||||
let us = start.elapsed().as_secs_f64() * 1e6 / 300.0;
|
||||
if cached {
|
||||
candidate.push(us);
|
||||
} else {
|
||||
baseline.push(us);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("{name} {workload} baseline_us={baseline:.3?} candidate_us={candidate:.3?}");
|
||||
}
|
||||
}
|
||||
println!("chunk_wrapper_bytes={}", std::mem::size_of::<Chunk>());
|
||||
Ok(())
|
||||
}
|
||||
@@ -2,6 +2,11 @@
|
||||
#![forbid(unsafe_code)]
|
||||
use horizon_client::{assets, collisions, world};
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
use horizon_world::{
|
||||
collision_context::CollisionContext,
|
||||
entity_obstruction::EntityIdentity,
|
||||
world_movement::{self, CollisionCheckRequest, MovementRequest},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs,
|
||||
@@ -57,21 +62,55 @@ fn main() -> Result<()> {
|
||||
f["step"].as_str().ok_or("expected step bits")?,
|
||||
16,
|
||||
)?);
|
||||
let movement = scene.resolve(
|
||||
bounds,
|
||||
delta,
|
||||
step,
|
||||
f["grounded"].as_bool().ok_or("expected grounded flag")?,
|
||||
let entities = f["entities"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|v| {
|
||||
let a = v.as_array().ok_or("expected entity box")?;
|
||||
Ok(Aabb::new(
|
||||
vector(&Value::Array(a[..3].to_vec()))?,
|
||||
vector(&Value::Array(a[3..].to_vec()))?,
|
||||
))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let mut selected = collisions::EntityCollisions {
|
||||
blocks: scene,
|
||||
entities: &entities,
|
||||
};
|
||||
let movement = world_movement::resolve_world_movement(
|
||||
&mut selected,
|
||||
MovementRequest {
|
||||
entity: EntityIdentity(0),
|
||||
context: CollisionContext::position(bounds.min_position().y),
|
||||
bounds,
|
||||
movement: delta,
|
||||
max_step: step,
|
||||
on_ground: f["grounded"].as_bool().ok_or("expected grounded flag")?,
|
||||
candidate_budget: 1_000_000,
|
||||
},
|
||||
)?;
|
||||
let mut unobstructed = |bounds: Aabb| {
|
||||
world_movement::no_world_collision(
|
||||
&mut selected,
|
||||
CollisionCheckRequest {
|
||||
entity: EntityIdentity(0),
|
||||
context: CollisionContext::position(bounds.min_position().y),
|
||||
bounds,
|
||||
candidate_budget: 1_000_000,
|
||||
},
|
||||
)
|
||||
};
|
||||
writeln!(
|
||||
output,
|
||||
"move {:016x} {:016x} {:016x} clear {} {}",
|
||||
movement.x.to_bits(),
|
||||
movement.y.to_bits(),
|
||||
movement.z.to_bits(),
|
||||
scene.unobstructed(bounds)?,
|
||||
scene.unobstructed(bounds.move_by(movement))?
|
||||
unobstructed(bounds)?,
|
||||
unobstructed(bounds.move_by(movement))?
|
||||
)?;
|
||||
scene = selected.blocks;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -298,6 +298,7 @@ fn main() -> Result<()> {
|
||||
let mut travel: Option<horizon_core::position::BlockPos> = None;
|
||||
for op in f["ops"].as_array().unwrap() {
|
||||
let mut world = MotionWorld {
|
||||
entities: &[],
|
||||
player: &mut player,
|
||||
scene: &mut scene,
|
||||
assets: &assets,
|
||||
|
||||
@@ -134,6 +134,7 @@ fn scene(
|
||||
blend_radius,
|
||||
smooth_lighting,
|
||||
cutout_leaves,
|
||||
cull_leaves: false,
|
||||
};
|
||||
let section_y = int(&row["section_y"])?;
|
||||
let started = Instant::now();
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Profile the fact and behaviour table phase of client startup.
|
||||
//! Run each mode in a separate process to capture CPU usage and peak RSS.
|
||||
//! The deferred mode evaluates both phases contiguously for fair loader comparison;
|
||||
//! actual client startup performs other initialization between these phases.
|
||||
use horizon_client::{assets::Assets, blocks::BlockFacts};
|
||||
use horizon_world::level::Behaviours;
|
||||
use std::{path::Path, time::Instant};
|
||||
|
||||
fn resident_kib() -> Option<u64> {
|
||||
let status = std::fs::read_to_string("/proc/self/status").ok()?;
|
||||
let line = status.lines().find(|line| line.starts_with("VmRSS:"))?;
|
||||
line.split_whitespace().nth(1)?.parse().ok()
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let root = args.next().ok_or("expected resource directory")?;
|
||||
let mode = args
|
||||
.next()
|
||||
.ok_or("expected baseline, pr607, shared, deferred or deferred-held")?;
|
||||
if !["baseline", "pr607", "shared", "deferred", "deferred-held"].contains(&mode.as_str()) {
|
||||
return Err("expected baseline, pr607, shared, deferred or deferred-held".into());
|
||||
}
|
||||
let root = Path::new(&root);
|
||||
let total = Instant::now();
|
||||
let assets = Assets::load(root)?;
|
||||
let started = Instant::now();
|
||||
let mut retained_inputs = None;
|
||||
let mut subphases = None;
|
||||
let (facts, behaviours) = if mode == "baseline" {
|
||||
(
|
||||
BlockFacts::load(root, assets.registry)?,
|
||||
Behaviours::load(assets.registry, root)?,
|
||||
)
|
||||
} else if mode == "pr607" {
|
||||
// Original PR #607 sequence at a01d47f2, retained as a same-build comparator.
|
||||
let json: serde_json::Value = serde_json::from_reader(std::io::BufReader::new(
|
||||
std::fs::File::open(root.join("block-behaviours.json"))?,
|
||||
))?;
|
||||
BlockFacts::load_with_behaviours(root, assets.registry, &json)?
|
||||
} else if mode == "shared" {
|
||||
BlockFacts::load_pair(root, assets.registry)?
|
||||
} else {
|
||||
let parse_started = Instant::now();
|
||||
let json: serde_json::Value = serde_json::from_reader(std::io::BufReader::new(
|
||||
std::fs::File::open(root.join("block-behaviours.json"))?,
|
||||
))?;
|
||||
let parse_ms = parse_started.elapsed().as_secs_f64() * 1000.0;
|
||||
let facts_started = Instant::now();
|
||||
let (facts, gameplay) = BlockFacts::load_with_projection(root, assets.registry, &json)?;
|
||||
let facts_ms = facts_started.elapsed().as_secs_f64() * 1000.0;
|
||||
let behaviours_started = Instant::now();
|
||||
let behaviours = Behaviours::load_with_gameplay(assets.registry, root, &json, &gameplay)?;
|
||||
let behaviours_ms = behaviours_started.elapsed().as_secs_f64() * 1000.0;
|
||||
subphases = Some((parse_ms, facts_ms, behaviours_ms));
|
||||
if mode == "deferred-held" {
|
||||
retained_inputs = Some((json, gameplay));
|
||||
}
|
||||
(facts, behaviours)
|
||||
};
|
||||
println!("phase_ms={:.3}", started.elapsed().as_secs_f64() * 1000.0);
|
||||
println!("total_ms={:.3}", total.elapsed().as_secs_f64() * 1000.0);
|
||||
println!("states={}", facts.states.len());
|
||||
if let Some((parse, facts, behaviours)) = subphases {
|
||||
println!("behaviour_json_ms={parse:.3}");
|
||||
println!("facts_ms={facts:.3}");
|
||||
println!("behaviours_ms={behaviours:.3}");
|
||||
}
|
||||
if let Some(rss) = resident_kib() {
|
||||
println!("post_rss_kib={rss}");
|
||||
}
|
||||
std::hint::black_box((facts, behaviours, retained_inputs));
|
||||
Ok(())
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
mod common;
|
||||
use common::{Fake, d, describe, f, flush, result_name};
|
||||
use horizon_client::sound_engine::{
|
||||
Attenuation, Backend, InstanceId, SoundEngine, SoundInstance, TickContext,
|
||||
Attenuation, Backend, CartSound, InstanceId, SoundEngine, SoundInstance, TickContext,
|
||||
};
|
||||
use horizon_client::sound_events::SoundRegistry;
|
||||
use horizon_core::random::LegacyRandom;
|
||||
@@ -19,6 +19,9 @@ fn main() {
|
||||
let mut backend = Fake::default();
|
||||
let mut ctx = TickContext::default();
|
||||
let mut instances: BTreeMap<String, InstanceId> = BTreeMap::new();
|
||||
// Script cart names to entity ids (a removed cart leaves the level).
|
||||
let mut cart_ids: BTreeMap<String, u32> = BTreeMap::new();
|
||||
let mut removed: BTreeMap<u32, CartSound> = BTreeMap::new();
|
||||
let input = std::fs::read_to_string(&args[2]).expect("input");
|
||||
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3]).expect("output"));
|
||||
for line in input.lines() {
|
||||
@@ -49,6 +52,21 @@ fn main() {
|
||||
"loop" => SoundInstance::biome_loop(&event, random),
|
||||
"uloop" => SoundInstance::under_liquid_loop(&event, random),
|
||||
"usub" => SoundInstance::under_liquid_sub(&event, random),
|
||||
"cart" | "rcart" | "rcartu" => {
|
||||
let id = cart_ids[a[3]];
|
||||
let cart = ctx
|
||||
.carts
|
||||
.iter()
|
||||
.find(|c| c.id == id)
|
||||
.or_else(|| removed.get(&id))
|
||||
.copied()
|
||||
.expect("cart");
|
||||
if a[2] == "cart" {
|
||||
SoundInstance::minecart(&cart, random)
|
||||
} else {
|
||||
SoundInstance::riding_minecart(&cart, a[2] == "rcartu", random)
|
||||
}
|
||||
}
|
||||
_ => panic!("{line}"),
|
||||
};
|
||||
if let Some(old) = instances.remove(a[1]) {
|
||||
@@ -92,6 +110,26 @@ fn main() {
|
||||
ctx.player_underwater = a[1] == "1";
|
||||
ctx.player_removed = a[2] == "1";
|
||||
}
|
||||
"cart" => {
|
||||
let next = cart_ids.len() as u32 + 1;
|
||||
let id = *cart_ids.entry(a[1].to_owned()).or_insert(next);
|
||||
ctx.carts.retain(|c| c.id != id);
|
||||
let cart = CartSound {
|
||||
id,
|
||||
position: [d(a[2]), d(a[3]), d(a[4])],
|
||||
// Vec3.horizontalDistance.
|
||||
speed: (d(a[5]) * d(a[5]) + d(a[7]) * d(a[7])).sqrt(),
|
||||
};
|
||||
if a[8] == "1" {
|
||||
// Out of the level, still there for a later play.
|
||||
removed.insert(id, cart);
|
||||
} else {
|
||||
removed.remove(&id);
|
||||
ctx.carts.push(cart);
|
||||
}
|
||||
}
|
||||
"ride" => ctx.vehicle = cart_ids.get(a[1]).copied(),
|
||||
"freeze" => ctx.frozen = a[1] == "1",
|
||||
"tick" => engine.tick(a[1] == "1", &ctx, &mut backend),
|
||||
"forget" => {
|
||||
if let Some(id) = instances.remove(a[1]) {
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
//! The chunk pipeline (generation, decoration, lighting, meshing) on its worker
|
||||
//! pool, without a window: how fast a render distance fills with N workers, and
|
||||
//! what the render thread pays for it in `Streaming::update`.
|
||||
//!
|
||||
//! streaming_bench RESOURCES [--threads N] [--radius R] [--seed S] [--fly CHUNKS_PER_S] [--seconds T] [--hz F]
|
||||
//!
|
||||
//! First the area around (0, 0) fills; then, with `--fly`, the centre moves along +x
|
||||
//! at that speed for T seconds while frames run at F per second. Each phase reports
|
||||
//! its wall time, chunks generated and sections meshed, and the time `update`
|
||||
//! took on the calling thread (the render thread in the game): total, median,
|
||||
//! p99 and slowest. Compile with HORIZON_BENCH_COUNT_ALLOCATIONS=1 to count the
|
||||
//! allocations made on the calling thread during updates and by everything.
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
biome::Biomes,
|
||||
blocks::BlockFacts,
|
||||
light::LightTables,
|
||||
mesher::MeshTables,
|
||||
models,
|
||||
streaming::{Streaming, WorkerContext},
|
||||
vanillagen::VanillaTerrain,
|
||||
world::World,
|
||||
};
|
||||
use std::{
|
||||
alloc::{GlobalAlloc, Layout, System},
|
||||
cell::Cell,
|
||||
path::PathBuf,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU64, Ordering::Relaxed},
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
const TRACK: bool = option_env!("HORIZON_BENCH_COUNT_ALLOCATIONS").is_some();
|
||||
static ALLOCATIONS: AtomicU64 = AtomicU64::new(0);
|
||||
static BYTES: AtomicU64 = AtomicU64::new(0);
|
||||
thread_local! {
|
||||
static LOCAL: Cell<(u64, u64)> = const { Cell::new((0, 0)) };
|
||||
}
|
||||
fn count(size: usize) {
|
||||
if TRACK {
|
||||
ALLOCATIONS.fetch_add(1, Relaxed);
|
||||
BYTES.fetch_add(size as u64, Relaxed);
|
||||
let _ = LOCAL.try_with(|c| {
|
||||
let (n, b) = c.get();
|
||||
c.set((n + 1, b + size as u64));
|
||||
});
|
||||
}
|
||||
}
|
||||
struct CountingAllocator;
|
||||
// Delegates every operation and layout unchanged to System.
|
||||
unsafe impl GlobalAlloc for CountingAllocator {
|
||||
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
|
||||
count(layout.size());
|
||||
unsafe { System.alloc(layout) }
|
||||
}
|
||||
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
|
||||
count(layout.size());
|
||||
unsafe { System.alloc_zeroed(layout) }
|
||||
}
|
||||
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
|
||||
unsafe { System.dealloc(ptr, layout) };
|
||||
}
|
||||
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, size: usize) -> *mut u8 {
|
||||
count(size);
|
||||
unsafe { System.realloc(ptr, layout, size) }
|
||||
}
|
||||
}
|
||||
#[global_allocator]
|
||||
static ALLOCATOR: CountingAllocator = CountingAllocator;
|
||||
|
||||
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Phase {
|
||||
updates: Vec<f64>,
|
||||
local: (u64, u64),
|
||||
uploads: usize,
|
||||
}
|
||||
impl Phase {
|
||||
fn update(&mut self, streaming: &mut Streaming, world: &mut World) {
|
||||
let before = LOCAL.with(Cell::get);
|
||||
let started = Instant::now();
|
||||
let (uploads, _) = streaming.update(world);
|
||||
self.updates.push(started.elapsed().as_secs_f64() * 1e3);
|
||||
let after = LOCAL.with(Cell::get);
|
||||
self.local.0 += after.0 - before.0;
|
||||
self.local.1 += after.1 - before.1;
|
||||
self.uploads += uploads.len();
|
||||
drop(uploads);
|
||||
}
|
||||
fn report(&self, name: &str, wall: Duration, streaming: &Streaming, generated: u64) {
|
||||
let mut sorted = self.updates.clone();
|
||||
sorted.sort_by(f64::total_cmp);
|
||||
let at = |p: f64| sorted[((sorted.len() as f64 * p) as usize).min(sorted.len() - 1)];
|
||||
let total: f64 = sorted.iter().sum();
|
||||
println!(
|
||||
"{name}: {:.2} s, {} chunks generated, {} sections meshed; update {:.1} ms total over {} calls, p50 {:.3} p99 {:.3} max {:.2} ms",
|
||||
wall.as_secs_f64(),
|
||||
streaming.generated_total - generated,
|
||||
self.uploads,
|
||||
total,
|
||||
sorted.len(),
|
||||
at(0.5),
|
||||
at(0.99),
|
||||
sorted.last().copied().unwrap_or(0.0),
|
||||
);
|
||||
if TRACK {
|
||||
println!(
|
||||
" update allocations: {} ({:.1} MiB)",
|
||||
self.local.0,
|
||||
self.local.1 as f64 / 1048576.0
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Nothing queued or in flight. Sections at the edge of the area stay dirty
|
||||
/// (their outer neighbours are never lit), so they don't count.
|
||||
fn settled(streaming: &Streaming) -> bool {
|
||||
streaming
|
||||
.stages()
|
||||
.iter()
|
||||
.all(|&(name, n)| name == "protos" || n == 0)
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let root = PathBuf::from(args.first().ok_or("usage: streaming_bench RESOURCES")?);
|
||||
let flag = |name: &str, default: f64| -> f64 {
|
||||
args.iter()
|
||||
.position(|a| a == name)
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(default)
|
||||
};
|
||||
let auto = std::thread::available_parallelism().map_or(2, |n| n.get() - 1);
|
||||
let threads = flag("--threads", auto as f64) as usize;
|
||||
let radius = flag("--radius", 8.0) as i32;
|
||||
let seed = flag("--seed", 1234.0) as i64;
|
||||
let fly = flag("--fly", 0.0);
|
||||
let seconds = flag("--seconds", 10.0);
|
||||
let hz = flag("--hz", 240.0);
|
||||
|
||||
let assets = Arc::new(Assets::load(&root)?);
|
||||
let facts = BlockFacts::load(&root, assets.registry)?;
|
||||
let biomes = Arc::new(Biomes::load(&root)?);
|
||||
let tables = Arc::new(MeshTables::new(
|
||||
&assets,
|
||||
&facts,
|
||||
models::load_visibility(&root, assets.registry)?,
|
||||
)?);
|
||||
let light = Arc::new(LightTables::load(&root, assets.registry, &facts)?);
|
||||
let terrain = Arc::new(VanillaTerrain::new(
|
||||
&root.join("worldgen"),
|
||||
assets.registry,
|
||||
&biomes,
|
||||
seed,
|
||||
)?);
|
||||
let context = Arc::new(WorkerContext {
|
||||
assets,
|
||||
tables,
|
||||
biomes,
|
||||
light,
|
||||
});
|
||||
let mut world = World::new(facts.air, seed);
|
||||
let mut streaming = Streaming::new(terrain, context, threads);
|
||||
streaming.radius = radius;
|
||||
streaming.center = (0, 0);
|
||||
println!("{threads} workers, radius {radius}, seed {seed}");
|
||||
let (allocations, bytes) = (ALLOCATIONS.load(Relaxed), BYTES.load(Relaxed));
|
||||
|
||||
// Fill: update as often as the game would uncapped, until settled.
|
||||
let mut phase = Phase::default();
|
||||
let started = Instant::now();
|
||||
let mut quiet = 0;
|
||||
while quiet < 20 {
|
||||
let uploads = phase.uploads;
|
||||
phase.update(&mut streaming, &mut world);
|
||||
quiet = if settled(&streaming) && phase.uploads == uploads {
|
||||
quiet + 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
std::thread::sleep(Duration::from_micros(500));
|
||||
}
|
||||
phase.report("fill", started.elapsed(), &streaming, 0);
|
||||
|
||||
if fly > 0.0 {
|
||||
let mut phase = Phase::default();
|
||||
let generated = streaming.generated_total;
|
||||
let frame = Duration::from_secs_f64(1.0 / hz);
|
||||
let started = Instant::now();
|
||||
let mut behind = Vec::new();
|
||||
while started.elapsed().as_secs_f64() < seconds {
|
||||
let next = Instant::now() + frame;
|
||||
let x = (started.elapsed().as_secs_f64() * fly) as i32;
|
||||
streaming.center = (x, 0);
|
||||
phase.update(&mut streaming, &mut world);
|
||||
// Columns within 2 chunks of the player still missing their meshes.
|
||||
let near = (-2..=2)
|
||||
.flat_map(|dz| (-2..=2).map(move |dx| (x + dx, dz)))
|
||||
.filter(|&(cx, cz)| !streaming.is_lit(cx, cz))
|
||||
.count();
|
||||
behind.push(near);
|
||||
if let Some(wait) = next.checked_duration_since(Instant::now()) {
|
||||
std::thread::sleep(wait);
|
||||
}
|
||||
}
|
||||
phase.report("fly", started.elapsed(), &streaming, generated);
|
||||
let unlit = behind.iter().filter(|&&n| n > 0).count();
|
||||
println!(
|
||||
" frames with an unlit column within 2 chunks of the player: {unlit} of {} ({:.1}%)",
|
||||
behind.len(),
|
||||
unlit as f64 * 100.0 / behind.len().max(1) as f64
|
||||
);
|
||||
}
|
||||
if TRACK {
|
||||
println!(
|
||||
"all threads: {} allocations ({:.1} MiB)",
|
||||
ALLOCATIONS.load(Relaxed) - allocations,
|
||||
(BYTES.load(Relaxed) - bytes) as f64 / 1048576.0
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//! Transport for the vanilla quad sorting / viewpoint parity gate.
|
||||
use horizon_client::gfx::translucency::{SortState, Sorter, quad_center};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use std::{
|
||||
fs,
|
||||
io::{BufWriter, Write},
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Case {
|
||||
origin: [i32; 3],
|
||||
quads: Vec<[[f32; 3]; 4]>,
|
||||
cameras: Vec<[f64; 3]>,
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<_> = std::env::args().skip(1).collect();
|
||||
let [input, output] = args.as_slice() else {
|
||||
return Err("usage: translucency_fixture INPUT OUTPUT".into());
|
||||
};
|
||||
let mut out = BufWriter::new(fs::File::create(output)?);
|
||||
for line in fs::read_to_string(input)?.lines() {
|
||||
let case: Case = serde_json::from_str(line)?;
|
||||
let centers: Vec<_> = case.quads.iter().map(|q| quad_center(q[0], q[2])).collect();
|
||||
let mut state = SortState::new(centers.clone());
|
||||
let mut sorter = Sorter::default();
|
||||
let mut cache_sorter = Sorter::default();
|
||||
let frames: Vec<_> = case
|
||||
.cameras
|
||||
.iter()
|
||||
.map(|&camera| {
|
||||
let update = state
|
||||
.update(camera, case.origin, &mut cache_sorter)
|
||||
.is_some();
|
||||
let local = std::array::from_fn(|i| (camera[i] - f64::from(case.origin[i])) as f32);
|
||||
json!({"indices": sorter.sort(¢ers, local), "update": update})
|
||||
})
|
||||
.collect();
|
||||
let bits: Vec<_> = centers
|
||||
.iter()
|
||||
.map(|c| c.map(|f| f.to_bits() as i32))
|
||||
.collect();
|
||||
writeln!(out, "{}", json!({"centers": bits, "frames": frames}))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Packet payload oracle companion for tools/test_world_options_parity.py.
|
||||
use horizon_protocol::{buf::Reader, packets::play::*};
|
||||
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 entries = if fields.len() == 3 {
|
||||
vec![GameRuleEntry {
|
||||
name: format!("minecraft:{}", fields[1]),
|
||||
value: fields[2].into(),
|
||||
}]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let bytes = match fields[0] {
|
||||
"difficulty" => Serverbound::ChangeDifficulty(ChangeDifficultyRequest {
|
||||
difficulty: fields[1].parse()?,
|
||||
})
|
||||
.encode(),
|
||||
"lock" => Serverbound::LockDifficulty(LockDifficulty {
|
||||
locked: fields[1].parse()?,
|
||||
})
|
||||
.encode(),
|
||||
"set" => Serverbound::SetGameRule(SetGameRule { entries }).encode(),
|
||||
"values" => Clientbound::GameRuleValues(GameRuleValues { entries }).encode(),
|
||||
_ => return Err("unknown case".into()),
|
||||
};
|
||||
let mut reader = Reader::new(&bytes);
|
||||
reader.varint()?;
|
||||
for byte in reader.rest() {
|
||||
write!(output, "{byte:02x}")?;
|
||||
}
|
||||
output.push('\n');
|
||||
}
|
||||
std::fs::write(&args[2], output)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -175,6 +175,53 @@ fn parse(id: &str, v: &Value) -> Option<Advancement> {
|
||||
})
|
||||
}
|
||||
|
||||
/// `CriterionProgress`'s date as saved, "yyyy-MM-dd HH:mm:ss Z", in UTC.
|
||||
pub fn date_text(unix_secs: i64) -> String {
|
||||
let days = unix_secs.div_euclid(86400);
|
||||
let rem = unix_secs.rem_euclid(86400);
|
||||
// Civil from days (Howard Hinnant's algorithm).
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if m <= 2 { y + 1 } else { y };
|
||||
format!(
|
||||
"{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02} +0000",
|
||||
rem / 3600,
|
||||
rem % 3600 / 60,
|
||||
rem % 60
|
||||
)
|
||||
}
|
||||
|
||||
/// A saved date back to milliseconds since the epoch (the network form of
|
||||
/// `CriterionProgress`), the zone offset applied.
|
||||
pub fn date_millis(text: &str) -> Option<i64> {
|
||||
let (date, rest) = text.trim().split_once(' ')?;
|
||||
let (time, zone) = rest.split_once(' ').unwrap_or((rest, "+0000"));
|
||||
let mut d = date.split('-').map(|v| v.parse::<i64>().ok());
|
||||
let (y, m, day) = (d.next()??, d.next()??, d.next()??);
|
||||
let mut t = time.split(':').map(|v| v.parse::<i64>().ok());
|
||||
let (h, min, sec) = (t.next()??, t.next()??, t.next()??);
|
||||
let sign = if zone.starts_with('-') { -1 } else { 1 };
|
||||
let zone = zone.trim_start_matches(['+', '-']);
|
||||
let offset =
|
||||
zone.get(0..2)?.parse::<i64>().ok()? * 3600 + zone.get(2..4)?.parse::<i64>().ok()? * 60;
|
||||
// Days from civil (Howard Hinnant's algorithm).
|
||||
let y = if m <= 2 { y - 1 } else { y };
|
||||
let era = y.div_euclid(400);
|
||||
let yoe = y - era * 400;
|
||||
let mp = (m + 9) % 12;
|
||||
let doy = (153 * mp + 2) / 5 + day - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
let days = era * 146_097 + doe - 719_468;
|
||||
Some((days * 86400 + h * 3600 + min * 60 + sec - sign * offset) * 1000)
|
||||
}
|
||||
|
||||
/// The player's progress: advancement -> criterion -> obtained date.
|
||||
#[derive(Default, Clone)]
|
||||
pub struct Progress {
|
||||
@@ -1059,4 +1106,15 @@ mod tests {
|
||||
assert_eq!(l[4], Some((1, 2.0)));
|
||||
assert_eq!(l[1], Some((2, 2.0)));
|
||||
}
|
||||
#[test]
|
||||
fn saved_dates_read_back_to_their_instant() {
|
||||
for secs in [0, 951_782_400, 1_792_216_800, 4_107_542_399] {
|
||||
assert_eq!(date_millis(&date_text(secs)), Some(secs * 1000));
|
||||
}
|
||||
assert_eq!(
|
||||
date_millis("2026-10-17 07:30:00 +0200"),
|
||||
date_millis("2026-10-17 05:30:00 +0000")
|
||||
);
|
||||
assert_eq!(date_millis("not a date"), None);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
//! `AbstractArrow.tick` for arrows, tipped and spectral arrows: the flight with
|
||||
//! its water inertia and bubbles, the base tick's fluids (currents, splash),
|
||||
//! bubble columns and water along the path, sticking in blocks, falling out
|
||||
//! when the block goes, and despawning. Entity hits and their effects stay
|
||||
//! with the game (`projectile_hit`).
|
||||
//! when the block goes, and despawning. The tick stops at an entity it hits:
|
||||
//! the game hurts the entity (`projectile_hit`), then `finish_entity_hit`
|
||||
//! spends the arrow or turns it back and ends the tick.
|
||||
use crate::{
|
||||
blocks::BlockFacts,
|
||||
collisions::SceneCollisions,
|
||||
@@ -113,6 +114,9 @@ pub enum ArrowEvent {
|
||||
/// The SPLASH and PROJECTILE_SHOOT game events.
|
||||
SplashEvent,
|
||||
ShootEvent,
|
||||
/// `spawnAtLocation(pickupItem, 0.1)`: a turned-back arrow too slow to fly
|
||||
/// on drops what picking it up gives.
|
||||
Drop,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ArrowParticle {
|
||||
@@ -124,7 +128,7 @@ pub enum ArrowParticle {
|
||||
pub enum ArrowSound {
|
||||
/// `entity.generic.splash` (an arrow's splash and high-speed splash).
|
||||
Splash,
|
||||
/// The arrow's hit-ground sound (`entity.arrow.hit`).
|
||||
/// The arrow's `soundEvent` (`entity.arrow.hit`), for blocks and entities.
|
||||
HitGround,
|
||||
/// `entity.generic.extinguish_fire`.
|
||||
Extinguish,
|
||||
@@ -382,14 +386,17 @@ fn signum(v: f64) -> f64 {
|
||||
v
|
||||
}
|
||||
}
|
||||
/// One arrow tick: whether to keep it and what it hit.
|
||||
/// One arrow tick: whether to keep it and what it hit. An arrow that hit an
|
||||
/// entity stops there and is not kept: the game hands it to
|
||||
/// `finish_entity_hit` once it has hurt the entity.
|
||||
pub fn tick(p: &mut Projectile, scene: &ArrowScene, targets: &[(Who, Aabb)]) -> (bool, Hit) {
|
||||
let Some(mut st) = p.arrow.take() else {
|
||||
return (false, Hit::None);
|
||||
};
|
||||
// Entity.setOldPosAndRot: the renderer interpolates from here, in flight
|
||||
// and stuck alike.
|
||||
// and stuck alike; ServerLevel.tickNonPassenger counts the tick.
|
||||
p.previous = p.position;
|
||||
p.age += 1;
|
||||
let hit = Ticker {
|
||||
p,
|
||||
st: &mut st,
|
||||
@@ -397,10 +404,44 @@ pub fn tick(p: &mut Projectile, scene: &ArrowScene, targets: &[(Who, Aabb)]) ->
|
||||
}
|
||||
.tick(targets);
|
||||
p.stuck = st.in_ground;
|
||||
let keep = !st.removed;
|
||||
let keep = !st.removed && !matches!(hit, Hit::Entity { .. });
|
||||
p.arrow = Some(st);
|
||||
(keep, hit)
|
||||
}
|
||||
/// What became of the entity an arrow hit (`AbstractArrow.onHitEntity`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum EntityHit {
|
||||
/// `hurtOrSimulate` succeeded: the arrow is spent, with its hit sound.
|
||||
Hurt,
|
||||
/// It failed (a raised shield, invulnerability): the arrow turns back.
|
||||
Refused,
|
||||
/// The entity turned it first (`Entity.deflection`: breezes).
|
||||
Deflected,
|
||||
}
|
||||
/// The rest of the tick of an arrow that hit an entity: whether to keep it.
|
||||
pub fn finish_entity_hit(p: &mut Projectile, scene: &ArrowScene, outcome: EntityHit) -> bool {
|
||||
let Some(mut st) = p.arrow.take() else {
|
||||
return false;
|
||||
};
|
||||
let mut ticker = Ticker {
|
||||
p,
|
||||
st: &mut st,
|
||||
scene,
|
||||
};
|
||||
match outcome {
|
||||
EntityHit::Hurt => {
|
||||
ticker.hit_sound();
|
||||
ticker.st.removed = true;
|
||||
}
|
||||
EntityHit::Refused => ticker.turn_back(),
|
||||
EntityHit::Deflected => {}
|
||||
}
|
||||
ticker.finish();
|
||||
p.stuck = st.in_ground;
|
||||
let keep = !st.removed;
|
||||
p.arrow = Some(st);
|
||||
keep
|
||||
}
|
||||
struct Ticker<'p, 's, 'a, 'r> {
|
||||
p: &'p mut Projectile,
|
||||
st: &'s mut ArrowState,
|
||||
@@ -517,10 +558,20 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
);
|
||||
if physics {
|
||||
hit = self.step_move_and_hit(original, end, targets);
|
||||
if matches!(hit, Hit::Entity { .. }) {
|
||||
return hit;
|
||||
}
|
||||
} else {
|
||||
self.p.position = end;
|
||||
self.apply_effects_from_blocks(original, end);
|
||||
}
|
||||
self.finish();
|
||||
hit
|
||||
}
|
||||
/// The end of a tick in flight: drag and gravity, then `Projectile.tick`
|
||||
/// and `Entity.baseTick`'s fluid and fire phases.
|
||||
fn finish(&mut self) {
|
||||
let physics = !self.st.no_physics;
|
||||
if !self.st.was_touching_water {
|
||||
self.p.velocity = self.p.velocity.scale(f64::from(0.99f32));
|
||||
}
|
||||
@@ -547,7 +598,6 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
self.st.removed = true;
|
||||
}
|
||||
self.st.first_tick = false;
|
||||
hit
|
||||
}
|
||||
/// `getDeltaMovement().add(0, y, 0)`: the zeros added to x and z also
|
||||
/// turn a negative zero positive.
|
||||
@@ -589,16 +639,23 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
self.st.life = 0;
|
||||
}
|
||||
/// `AbstractArrow.stepMoveAndHit`: the nearest entity on the way, else the
|
||||
/// block (not piercing: the game removes an arrow that hits an entity).
|
||||
/// block (piercing is not modelled).
|
||||
fn step_move_and_hit(&mut self, from: Vec3, to: Vec3, targets: &[(Who, Aabb)]) -> Hit {
|
||||
let block = self.scene.clip(from, to);
|
||||
let end = block.map_or(to, |b| b.0);
|
||||
// findHitEntities: ProjectileUtil.computeMargin and the search box.
|
||||
let margin = f64::from(((self.p.age as i32 - 2) as f32 / 20.0).clamp(0.0, 0.3));
|
||||
let area = bounds(from)
|
||||
.expand_towards(self.p.velocity)
|
||||
.inflate(Vec3::new(1.0, 1.0, 1.0));
|
||||
let mut best: Option<(Who, Vec3, f64)> = None;
|
||||
for (who, b) in targets {
|
||||
if Some(*who) == self.p.owner && !self.p.left_owner {
|
||||
if Some(*who) == self.p.owner && !self.p.left_owner || !b.intersects(area) {
|
||||
continue;
|
||||
}
|
||||
if let Some(at) = b.inflate(Vec3::new(0.3, 0.3, 0.3)).clip(from, end) {
|
||||
if let Some(at) = self.entity_hit(b, from, end, margin) {
|
||||
// Sorted by the distance to the entity's position (the
|
||||
// stable sort keeps the first of equals).
|
||||
let c = b.center();
|
||||
let d = from.distance_squared(Vec3::new(c.x, b.min_position().y, c.z));
|
||||
if best.is_none_or(|bb| d < bb.2) {
|
||||
@@ -610,8 +667,6 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
self.p.position = next;
|
||||
self.apply_effects_from_blocks(from, next);
|
||||
if let Some((who, at, _)) = best {
|
||||
// The game hurts it; the arrow is spent (piercing is not modelled).
|
||||
self.st.removed = true;
|
||||
return Hit::Entity { who, at };
|
||||
}
|
||||
let Some((at, pos, face)) = block else {
|
||||
@@ -631,6 +686,49 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
face,
|
||||
}
|
||||
}
|
||||
/// `ProjectileUtil.getManyEntityHitResult` for one entity box: where the
|
||||
/// path enters it, else where it enters the box grown by `margin`, if a
|
||||
/// line from there towards the centre (stopped by blocks) reaches the box.
|
||||
fn entity_hit(&self, b: &Aabb, from: Vec3, to: Vec3, margin: f64) -> Option<Vec3> {
|
||||
if let Some(at) = b.clip(from, to) {
|
||||
return Some(at);
|
||||
}
|
||||
if margin <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let outside = b
|
||||
.inflate(Vec3::new(margin, margin, margin))
|
||||
.clip(from, to)?;
|
||||
let centre = b.center();
|
||||
let towards = self.scene.clip(outside, centre).map_or(centre, |h| h.0);
|
||||
b.clip(outside, towards).map(|_| outside)
|
||||
}
|
||||
/// `playSound(soundEvent, 1, 1.2 / (random * 0.2 + 0.9))`.
|
||||
fn hit_sound(&mut self) {
|
||||
let pitch = 1.2 / (self.st.random.next_float() * 0.2 + 0.9);
|
||||
self.st.events.push(ArrowEvent::Sound {
|
||||
sound: ArrowSound::HitGround,
|
||||
volume: 1.0,
|
||||
pitch,
|
||||
});
|
||||
}
|
||||
/// `onHitEntity` when the entity took no damage: `deflect(REVERSE, 0.2)`,
|
||||
/// a half turn give or take ten degrees at a tenth of the speed; one
|
||||
/// left almost still drops its pickup (ALLOWED) and is discarded.
|
||||
fn turn_back(&mut self) {
|
||||
let power = 0.2;
|
||||
let rotation = 170.0 + self.st.random.next_float() * 20.0;
|
||||
let v = self.p.velocity;
|
||||
let k = -power * 0.5;
|
||||
self.p.velocity = Vec3::new(v.x * k, v.y * k, v.z * k);
|
||||
self.p.yaw += rotation;
|
||||
if self.p.velocity.length_squared() < 1.0e-7 {
|
||||
if self.st.pickup == Pickup::Allowed {
|
||||
self.st.events.push(ArrowEvent::Drop);
|
||||
}
|
||||
self.st.removed = true;
|
||||
}
|
||||
}
|
||||
/// `AbstractArrow.onHitBlock`: back off the face by 0.05 along each axis
|
||||
/// it moved on, stop, and stick.
|
||||
fn on_hit_block(&mut self, pos: BlockPos, state: BlockStateId) {
|
||||
@@ -649,12 +747,7 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
self.p.position.z - signum(v.z) * offset,
|
||||
);
|
||||
self.p.velocity = Vec3::ZERO;
|
||||
let pitch = 1.2 / (self.st.random.next_float() * 0.2 + 0.9);
|
||||
self.st.events.push(ArrowEvent::Sound {
|
||||
sound: ArrowSound::HitGround,
|
||||
volume: 1.0,
|
||||
pitch,
|
||||
});
|
||||
self.hit_sound();
|
||||
self.st.in_ground = true;
|
||||
self.st.shake = 7;
|
||||
self.p.set_critical(false);
|
||||
|
||||
@@ -59,6 +59,8 @@ pub struct Assets {
|
||||
pub pixels: Vec<u8>,
|
||||
pub texture_layers: u32,
|
||||
pub model_catalog: crate::models::StateModels,
|
||||
/// Shared parsed resources; item-only validation errors surface at item preparation.
|
||||
pub model_library: std::result::Result<Arc<horizon_assets::resolve::ModelLibrary>, String>,
|
||||
pub model_placements: Vec<(horizon_core::position::BlockPos, BlockStateId)>,
|
||||
pub model_gallery: Vec<Value>,
|
||||
pub animations: Vec<crate::sprites::SpriteAnimation>,
|
||||
@@ -219,7 +221,9 @@ impl Assets {
|
||||
for name in MATERIALS {
|
||||
sprites.insert(root, &format!("minecraft:block/{name}"))?;
|
||||
}
|
||||
let models = crate::models::prepare(root, registry, &mut sprites)?;
|
||||
let parsed = crate::model_library::ParsedModels::load(root);
|
||||
let models = crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?;
|
||||
let model_library = parsed.into_item_library();
|
||||
// Fluids are drawn by the fluid renderer, not block models; breaking
|
||||
// progress uses the destroy-stage overlays.
|
||||
for name in [
|
||||
@@ -241,9 +245,10 @@ impl Assets {
|
||||
material_states,
|
||||
sprite_size: sprites.size,
|
||||
sprite_minimum_size: sprites.minimum_size,
|
||||
texture_layers: u32::try_from(sprites.layers.len())?,
|
||||
texture_layers: u32::try_from(sprites.count())?,
|
||||
pixels: sprites.pixels,
|
||||
model_catalog: models.catalog,
|
||||
model_library,
|
||||
model_placements: models.placements,
|
||||
model_gallery: models.states,
|
||||
animations: sprites.animations,
|
||||
|
||||
@@ -44,6 +44,7 @@ struct Mixer {
|
||||
channels: HashMap<ChannelId, Channel>,
|
||||
listener: [f64; 3],
|
||||
listener_yaw: f32,
|
||||
limiter_peak: f32,
|
||||
}
|
||||
/// A sound the player could have heard (`SoundEventListener.onPlaySound`):
|
||||
/// what the subtitle overlay needs.
|
||||
@@ -397,8 +398,17 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
c.position += step;
|
||||
}
|
||||
}
|
||||
for s in data.iter_mut() {
|
||||
*s = s.clamp(-1.0, 1.0);
|
||||
// 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()));
|
||||
mixer.limiter_peak = mixer.limiter_peak.max(peak);
|
||||
let release = (-1.0 / (0.05 * rate as f32)).exp();
|
||||
for frame in data.chunks_mut(channels) {
|
||||
let gain = mixer.limiter_peak.recip();
|
||||
for s in frame {
|
||||
*s *= gain;
|
||||
}
|
||||
mixer.limiter_peak = (mixer.limiter_peak * release).max(peak);
|
||||
}
|
||||
}
|
||||
/// Why the game should not open an output, if it should not:
|
||||
@@ -862,6 +872,46 @@ mod tests {
|
||||
assert_eq!(distance_gain(Some(16.0), 40.0), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_voices_are_limited_without_flattening_or_changing_stereo_balance() {
|
||||
let mut m = Mixer::default();
|
||||
let wave = vec![0.25, 0.5, 0.75, 1.0, -1.0, -0.75, -0.5, -0.25, 0.0];
|
||||
for id in 1..=4 {
|
||||
m.channels
|
||||
.insert(id, channel(wave.clone(), None, [-1.0, 0.0, 0.0], false));
|
||||
}
|
||||
let mut data = [0.0; 16];
|
||||
mix(&mut m, &mut data, 2, 48_000);
|
||||
for (frame, sample) in data.as_chunks::<2>().0.iter().zip(wave) {
|
||||
assert!((frame[0] - sample * 0.4).abs() < 1e-6, "{frame:?}");
|
||||
assert!((frame[1] - sample).abs() < 1e-6, "{frame:?}");
|
||||
}
|
||||
assert!(data.iter().all(|s| s.abs() <= 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limiter_leaves_quiet_audio_alone_and_recovers_across_callbacks() {
|
||||
let mut m = Mixer::default();
|
||||
m.channels
|
||||
.insert(1, channel(vec![0.25; 2], None, [0.0; 3], true));
|
||||
let mut data = [0.0; 16];
|
||||
mix(&mut m, &mut data, 2, 48_000);
|
||||
assert_eq!(data, [0.25; 16]);
|
||||
|
||||
for id in 2..=5 {
|
||||
m.channels
|
||||
.insert(id, channel(vec![1.0; 2], None, [0.0; 3], true));
|
||||
}
|
||||
mix(&mut m, &mut data, 2, 48_000);
|
||||
assert!(data.iter().all(|s| s.abs() <= 1.0));
|
||||
m.channels.retain(|&id, _| id == 1);
|
||||
let mut recovery = vec![0.0; 2 * 48_000];
|
||||
mix(&mut m, &mut recovery, 2, 48_000);
|
||||
assert!(recovery[0] > 0.0 && recovery[0] < 0.1);
|
||||
assert!(recovery.windows(2).all(|w| w[1] >= w[0]));
|
||||
assert_eq!(*recovery.last().unwrap(), 0.25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sources_play_attenuated_then_stop_or_loop() {
|
||||
let mut m = Mixer::default();
|
||||
|
||||
@@ -232,6 +232,87 @@ pub fn join_server(session: &AuthSession, server_hash: &str) -> Result<(), JoinE
|
||||
}
|
||||
}
|
||||
|
||||
/// What the session service says about a player joining this server
|
||||
/// (MinecraftServicesSessionService.hasJoinedServer).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct JoinedProfile {
|
||||
pub uuid: u128,
|
||||
pub properties: Vec<horizon_protocol::packets::login::Property>,
|
||||
}
|
||||
|
||||
/// HttpDiscoveryService.buildQuery: `URLEncoder.encode(value, UTF-8)`.
|
||||
fn url_encode(text: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for b in text.bytes() {
|
||||
match b {
|
||||
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'.' | b'-' | b'*' | b'_' => {
|
||||
out.push(char::from(b));
|
||||
}
|
||||
b' ' => out.push('+'),
|
||||
_ => out.push_str(&format!("%{b:02X}")),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The session service's verify endpoint, asked by an online-mode server
|
||||
/// whether `name` joined with `server_hash` (and from `ip`, when the server
|
||||
/// prevents proxy connections). `Ok(None)`: it didn't, or the service said
|
||||
/// no; `Err(Unavailable)`: no answer.
|
||||
pub fn has_joined(
|
||||
name: &str,
|
||||
server_hash: &str,
|
||||
ip: Option<std::net::IpAddr>,
|
||||
) -> Result<Option<JoinedProfile>, JoinError> {
|
||||
let doc = discovery()?;
|
||||
let url = endpoint(&doc, "session", "verify").ok_or(JoinError::Unavailable)?;
|
||||
let mut query = format!(
|
||||
"username={}&serverId={}",
|
||||
url_encode(name),
|
||||
url_encode(server_hash)
|
||||
);
|
||||
if let Some(ip) = ip {
|
||||
query.push_str(&format!("&ip={}", url_encode(&ip.to_string())));
|
||||
}
|
||||
let separator = if url.contains('?') { '&' } else { '?' };
|
||||
let body = match agent().get(&format!("{url}{separator}{query}")).call() {
|
||||
Ok(response) => response.into_string().unwrap_or_default(),
|
||||
Err(ureq::Error::Status(status, response)) => {
|
||||
let error =
|
||||
JoinError::from_response(status, &response.into_string().unwrap_or_default());
|
||||
return match error {
|
||||
JoinError::Unavailable => Err(error),
|
||||
_ => Ok(None),
|
||||
};
|
||||
}
|
||||
Err(ureq::Error::Transport(_)) => return Err(JoinError::Unavailable),
|
||||
};
|
||||
Ok(parse_joined(&body))
|
||||
}
|
||||
|
||||
/// HasJoinedMinecraftServerResponse: `id` and `properties`; no id is no profile.
|
||||
pub fn parse_joined(body: &str) -> Option<JoinedProfile> {
|
||||
let v: serde_json::Value = serde_json::from_str(body).ok()?;
|
||||
let uuid = parse_uuid(v.get("id")?.as_str()?)?;
|
||||
let properties = v
|
||||
.get("properties")
|
||||
.and_then(|p| p.as_array())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|p| {
|
||||
Some(horizon_protocol::packets::login::Property {
|
||||
name: p.get("name")?.as_str()?.to_owned(),
|
||||
value: p.get("value")?.as_str()?.to_owned(),
|
||||
signature: p
|
||||
.get("signature")
|
||||
.and_then(|s| s.as_str())
|
||||
.map(str::to_owned),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Some(JoinedProfile { uuid, properties })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -250,6 +331,20 @@ mod tests {
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_answers() {
|
||||
assert_eq!(url_encode("Tester_1"), "Tester_1");
|
||||
assert_eq!(url_encode("-5a f"), "-5a+f");
|
||||
assert_eq!(url_encode("::1"), "%3A%3A1");
|
||||
let body = r#"{"id":"069a79f444e94726a5befca90e38aaf5","name":"Notch","properties":[{"name":"textures","value":"e30=","signature":"c2ln"}]}"#;
|
||||
let p = parse_joined(body).unwrap();
|
||||
assert_eq!(p.uuid, UUID);
|
||||
assert_eq!(p.properties.len(), 1);
|
||||
assert_eq!(p.properties[0].signature.as_deref(), Some("c2ln"));
|
||||
assert_eq!(parse_joined(""), None);
|
||||
assert_eq!(parse_joined(r#"{"name":"Notch"}"#), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_files_are_read_once_and_must_be_private() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-auth-test-{}", std::process::id()));
|
||||
|
||||
@@ -199,6 +199,14 @@ pub fn dye_rgb(name: &str) -> u32 {
|
||||
pub fn layers(stack: &ItemStack) -> &[BannerLayer] {
|
||||
stack.data().map_or(&[], |d| &d.banner_patterns)
|
||||
}
|
||||
/// The ominous banner's component patch hides its pattern tooltip.
|
||||
pub fn tooltip_layers(stack: &ItemStack) -> &[BannerLayer] {
|
||||
if stack.data().is_some_and(|d| d.ominous_banner) {
|
||||
&[]
|
||||
} else {
|
||||
layers(stack)
|
||||
}
|
||||
}
|
||||
/// `LoomMenu.setupResultSlot`: one banner with the pattern added in the dye colour.
|
||||
pub fn loom_result(banner: &ItemStack, dye: &str, pattern: &str) -> ItemStack {
|
||||
let mut result = ItemStack {
|
||||
@@ -302,6 +310,24 @@ mod tests {
|
||||
}
|
||||
assert!(has_max_layers(&banner));
|
||||
}
|
||||
#[test]
|
||||
fn ominous_banner_hides_tooltip_layers_but_keeps_its_patterns() {
|
||||
let mut banner = crate::mobs::raider::ominous_banner();
|
||||
(banner.damage, banner.extra) = crate::item_data::decode_components(
|
||||
crate::item_data::encode_components(&banner).as_ref(),
|
||||
);
|
||||
assert_eq!(
|
||||
layers(&banner).len(),
|
||||
crate::item_data::OMINOUS_LAYERS.len()
|
||||
);
|
||||
assert!(tooltip_layers(&banner).is_empty());
|
||||
banner.data_mut().custom_name = Some("Captain's Standard".into());
|
||||
assert!(tooltip_layers(&banner).is_empty());
|
||||
banner.data_mut().ominous_banner = false;
|
||||
assert_eq!(tooltip_layers(&banner), layers(&banner));
|
||||
assert!(!tooltip_layers(&banner).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_round_trip_through_nbt() {
|
||||
let l = vec![
|
||||
|
||||
@@ -145,8 +145,16 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
["horizon-ctl launch --world Survival --seed 42 --wait", "horizon-ctl launch --world Flat --creative --render-distance 8"],
|
||||
&[flag("world", Ty::Str, "world folder to open or create"), flag("seed", Ty::Str, "seed for a new world"), flag("creative", Ty::Bool, "creative mode for a new world"),
|
||||
flag("version", Ty::Str, "an installed launcher version tag such as v2026.10.13"), flag("game", Ty::Str, "path to a horizon-client binary"),
|
||||
flag("wait", Ty::Bool, "also wait until the world is ready (default in MCP)")],
|
||||
flag("wait", Ty::Bool, "also wait until the world is ready (default in MCP)"),
|
||||
flag("offscreen", Ty::Bool, "render without showing a window (screenshots still work): locked or headless sessions")],
|
||||
mcp: true, local: true),
|
||||
cmd!("wait-for", Session, "wait-for screen NAME|widget ID|loaded|chat TEXT|event KIND [--timeout S] [--since SEQ]", "wait until the game reaches a state, then reply",
|
||||
"screen takes the names state shows (playing, inventory, paused, dead, title...); widget an id from ui; loaded means a world is open, the player is in it and no chunk is pending; chat a new line containing TEXT; event a kind from events (death, respawn, item, screen...). chat and event count from --since SEQ (take last from events before acting), else from now. A timeout (default 30 s) is an error with kind timeout.",
|
||||
["horizon-ctl wait-for screen inventory", "horizon-ctl wait-for loaded --timeout 120", "horizon-ctl wait-for chat joined the game", "horizon-ctl wait-for event death --timeout 600"],
|
||||
&[flag("timeout", Ty::Num, "seconds (default 30)"), flag("since", Ty::Int, "chat and event: count from this event seq"), p("condition", Ty::Enum(&["screen", "widget", "loaded", "chat", "event"]), "what to wait for"), opt("value", Ty::Rest, "the screen name, widget id, chat text or event kind")], mcp: true, local: false),
|
||||
cmd!("events", Observe, "events [--since SEQ]", "what happened since SEQ, oldest first: screens, chat, deaths, respawns, items gained or lost, dimensions, worlds",
|
||||
"Every event has seq and tick; pass the reply's last as --since next time. The game keeps the newest 256.",
|
||||
["horizon-ctl events", "horizon-ctl events --since 42"], &[flag("since", Ty::Int, "only events after this seq")], mcp: true, local: false),
|
||||
cmd!("wait-ready", Session, "wait-ready [SECONDS]", "wait until the player has spawned and no chunks are pending",
|
||||
"Polls status every 250 ms; replies with the status, or an error after SECONDS (default 120).",
|
||||
["horizon-ctl wait-ready 300"], &[opt("seconds", Ty::Num, "how long to wait (default 120)")], mcp: true, local: true),
|
||||
@@ -248,8 +256,9 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
&[opt("all", Ty::Bool, "drop the whole stack")], mcp: true, local: false),
|
||||
cmd!("hold", Act, "hold KEY SECONDS", "press a key, wait, release it", "Keys as for key.", ["horizon-ctl hold w 2"],
|
||||
&[p("key", Ty::Str, "key name"), p("seconds", Ty::Num, "how long")], mcp: false, local: true),
|
||||
cmd!("key", Act, "key KEY down|up|tap", "press or release a key (w a s d space shift ctrl e q esc f3 1-9 arrows ...)", "",
|
||||
["horizon-ctl key space tap"], &[p("key", Ty::Str, "key name"), p("action", Ty::Enum(&["down", "up", "tap"]), "what to do")], mcp: true, local: false),
|
||||
cmd!("key", Act, "key KEY down|up|tap|repeat", "press, release or auto-repeat a key (w a s d space shift ctrl e q esc f3 1-9 arrows ...)",
|
||||
"down holds the key until key ... up, release or stop; wait does not release it. move, goto, jump, break and the other actions release the movement keys when they end.",
|
||||
["horizon-ctl key space tap"], &[p("key", Ty::Str, "key name"), p("action", Ty::Enum(&["down", "up", "tap", "repeat"]), "what to do")], mcp: true, local: false),
|
||||
cmd!("mouse", Act, "mouse left|right|middle down|up|click", "press or release a mouse button in the world", "",
|
||||
["horizon-ctl mouse right click"], &[p("button", Ty::Enum(&["left", "right", "middle"]), "button"), p("action", Ty::Enum(&["down", "up", "click"]), "what to do")], mcp: false, local: false),
|
||||
cmd!("pad", Act, "pad BUTTON down|up|tap | pad stick left|right X Y | pad trigger left|right V | pad release | pad status", "a virtual controller: buttons, sticks and triggers as a real pad would send them",
|
||||
@@ -266,12 +275,34 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
["horizon-ctl click-slot 36", "horizon-ctl click-slot 100 right", "horizon-ctl click-slot 200 shift"],
|
||||
&[p("slot", Ty::Int, "slot id"), opt("right", Ty::Bool, "right-click"), opt("shift", Ty::Bool, "shift-click")], mcp: true, local: false),
|
||||
cmd!("close", Screens, "close", "close the open menu and return to the game", "", ["horizon-ctl close"], &[], mcp: true, local: false),
|
||||
cmd!("ui", Screens, "ui", "every widget of the open screen: id, kind, label, bounds, value",
|
||||
"Kinds: button, slider (value 0..1), field, row (worlds, servers, packs, languages), tab (selected), slot (slot-ID, with its stack). Ids come from labels (\"FOV: 70\" is fov, a repeat gets -2) or kind-N without one, and stay put when a value changes. Reflects the last drawn frame (frame counts them).",
|
||||
["horizon-ctl ui"], &[], mcp: true, local: false),
|
||||
cmd!("click", Screens, "click ID [right] [shift]", "click a widget of ui by its id, as the mouse would",
|
||||
"A field takes focus (then type); slot-ID clicks a slot like click-slot.",
|
||||
["horizon-ctl click done", "horizon-ctl click video-settings", "horizon-ctl click row-0", "horizon-ctl click slot-0 shift"],
|
||||
&[p("id", Ty::Str, "widget id from ui"), opt("right", Ty::Bool, "right-click"), opt("shift", Ty::Bool, "shift-click (slots)")], mcp: true, local: false),
|
||||
cmd!("set", Screens, "set ID VALUE", "move a slider of ui to VALUE (0..1), as a click on its track",
|
||||
"The option behind it rounds to its own steps; read the new label with ui.",
|
||||
["horizon-ctl set fov 0.5", "horizon-ctl set render-distance 0"],
|
||||
&[p("id", Ty::Str, "slider id from ui"), p("value", Ty::Num, "0..1 along the track")], mcp: true, local: false),
|
||||
cmd!("options-get", Screens, "options-get [KEY...]", "the game's options (options.txt) by key, with their type and range or choices",
|
||||
"Keys are vanilla's: fov, renderDistance, guiScale, gamma, maxFps, lang, resourcePacks, soundCategory_music, key_key.jump... Enums are given and set by name, key bindings by key name (key.keyboard.w).",
|
||||
["horizon-ctl options-get", "horizon-ctl options-get fov renderDistance"], &[opt("keys", Ty::Rest, "option keys (default: all)")], mcp: true, local: false),
|
||||
cmd!("options-set", Screens, "options-set KEY VALUE", "set one option, apply it at once and save options.txt, as the options screen does",
|
||||
"A value the option refuses is an error saying what it accepts. Lists are JSON: options-set resourcePacks [\"vanilla\"].",
|
||||
["horizon-ctl options-set fov 90", "horizon-ctl options-set renderClouds fast", "horizon-ctl options-set key_key.jump key.keyboard.j"],
|
||||
&[p("key", Ty::Str, "option key from options-get"), p("value", Ty::Rest, "the new value")], mcp: true, local: false),
|
||||
cmd!("click-button", Screens, "click-button LABEL", "click a button of the open screen or title menu by its label",
|
||||
"Case-insensitive; a unique part of the label is enough. screen lists the buttons.",
|
||||
["horizon-ctl click-button Singleplayer", "horizon-ctl click-button \"Create New World\"", "horizon-ctl click-button respawn"],
|
||||
&[p("label", Ty::Rest, "button label")], mcp: true, local: false),
|
||||
cmd!("click-at", Screens, "click-at X Y [right]", "click at GUI pixel X Y of the window (screen gives button rectangles)", "",
|
||||
["horizon-ctl click-at 200 120"], &[p("x", Ty::Num, "GUI x"), p("y", Ty::Num, "GUI y"), opt("right", Ty::Bool, "right-click")], mcp: true, local: false),
|
||||
cmd!("drag-at", Screens, "drag-at [right] X Y X Y...", "press at the first GUI point, move through the others, release at the last", "",
|
||||
["horizon-ctl drag-at 130 100 148 100 166 100", "horizon-ctl drag-at right 130 100 148 100"], &[p("points", Ty::Rest, "right, then GUI x y pairs")], mcp: true, local: false),
|
||||
cmd!("scroll-at", Screens, "scroll-at X Y STEPS", "turn the mouse wheel at GUI pixel X Y (positive scrolls up)", "",
|
||||
["horizon-ctl scroll-at 130 100 -1"], &[p("x", Ty::Num, "GUI x"), p("y", Ty::Num, "GUI y"), p("steps", Ty::Num, "wheel steps")], mcp: true, local: false),
|
||||
cmd!("type", Screens, "type [--field name|seed] [--replace] TEXT", "type into the focused text field (chat box, world name, anvil, sign...)",
|
||||
"On the title's create-world screen, --field name|seed picks the field and --replace clears it first; type \\b deletes one character.",
|
||||
["horizon-ctl type hello everyone", "horizon-ctl type --field name --replace Agent World", "horizon-ctl type --field seed 12345"], &[flag("field", Ty::Enum(&["name", "seed"]), "create-world field to type into"), flag("replace", Ty::Bool, "clear the field first"), p("text", Ty::Rest, "text to type")], mcp: true, local: false),
|
||||
@@ -293,7 +324,9 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
cmd!("summon", World, "summon KIND [X Y Z] [baby]", "spawn a mob", "", ["horizon-ctl summon cow"], &[p("kind", Ty::Str, "mob type")], mcp: true, local: false),
|
||||
cmd!("effect", World, "effect ID SECONDS [AMPLIFIER] | effect clear", "give a status effect", "", ["horizon-ctl effect speed 30 1"], &[], mcp: false, local: false),
|
||||
cmd!("render-distance", World, "render-distance N", "chunks, 2..64", "", ["horizon-ctl render-distance 8"], &[p("chunks", Ty::Int, "render distance")], mcp: false, local: false),
|
||||
cmd!("wait", World, "wait TICKS", "wait until TICKS game ticks have passed (20 per second)", "", ["horizon-ctl wait 40"],
|
||||
cmd!("wait", World, "wait TICKS", "wait until TICKS game ticks have passed (20 per second)",
|
||||
"Keys and mouse buttons held with key ... down or mouse ... down stay held while it waits. Release them with key ... up, release or stop.",
|
||||
["horizon-ctl wait 40", "horizon-ctl key w down && horizon-ctl wait 40 && horizon-ctl key w up"],
|
||||
&[p("ticks", Ty::Int, "game ticks")], mcp: true, local: false),
|
||||
// Scripting
|
||||
cmd!("script", Scripting, "script [FILE] [--keep-going]", "run commands from FILE (or stdin), one per line",
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
//! Block entities carried by items, vanilla 26.3: Ctrl + Pick Block in creative
|
||||
//! (`ServerGamePacketListenerImpl.addBlockDataToItem`) and placing such an item
|
||||
//! (`BlockItem.updateCustomBlockEntityTag`, then
|
||||
//! `BlockEntity.applyComponentsFromItemStack`). Both work on the block entity's
|
||||
//! saved NBT (`block_entity_nbt::canonical`) and the item's component patch.
|
||||
//!
|
||||
//! What a type turns into item components (`removeComponentsFromTag`,
|
||||
//! `collectImplicitComponents`) leaves its NBT; the rest goes to
|
||||
//! `minecraft:block_entity_data`, tagged with the type's id.
|
||||
use crate::block_entity_nbt::{self, canonical};
|
||||
use horizon_world::nbt::{Compound, JavaString, Tag};
|
||||
|
||||
pub const BLOCK_ENTITY_DATA: &str = "minecraft:block_entity_data";
|
||||
|
||||
/// `BlockEntityTypes.OP_ONLY_CUSTOM_DATA`: the types whose data only a player
|
||||
/// who can use game master blocks places (`BlockEntityType.onlyOpCanSetNbt`).
|
||||
pub const OP_ONLY: [&str; 6] = [
|
||||
"minecraft:command_block",
|
||||
"minecraft:lectern",
|
||||
"minecraft:sign",
|
||||
"minecraft:hanging_sign",
|
||||
"minecraft:mob_spawner",
|
||||
"minecraft:trial_spawner",
|
||||
];
|
||||
|
||||
/// The `minecraft:block_entity_data` of `stack`: its type id and its data.
|
||||
pub fn data(stack: &crate::items::ItemStack) -> Option<(String, &Tag)> {
|
||||
let data = stack.data()?.other(BLOCK_ENTITY_DATA)?;
|
||||
Some((block_entity_nbt::id(data)?, data))
|
||||
}
|
||||
|
||||
/// `BlockItem.shouldPrintOpWarning` for a player with game master permission:
|
||||
/// the item carries data of an op-only type.
|
||||
pub fn op_warning(stack: &crate::items::ItemStack) -> bool {
|
||||
data(stack).is_some_and(|(id, _)| OP_ONLY.contains(&id.as_str()))
|
||||
}
|
||||
|
||||
/// `Spawner.getSpawnEntityDisplayName` (`SpawnData.entity.id` of the item's
|
||||
/// data, which `ItemStack.addDetailsToTooltip` reads for spawners and trial
|
||||
/// spawners alike): the entity type a spawner item spawns.
|
||||
pub fn spawner_entity(stack: &crate::items::ItemStack) -> Option<String> {
|
||||
let (_, data) = data(stack)?;
|
||||
match data.get("SpawnData")?.get("entity")?.get("id")? {
|
||||
Tag::String(id) => Some(id.to_string_lossy()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A part of a block entity's NBT that its item holds as a component.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Part {
|
||||
/// The NBT key and the component hold the same value.
|
||||
Same(&'static str, &'static str),
|
||||
/// `Items` as `minecraft:container`, in a container of this many slots.
|
||||
Items(usize),
|
||||
/// `LootTable` and `LootTableSeed` as `minecraft:container_loot`.
|
||||
Loot,
|
||||
/// `is_waxed` as `minecraft:waxed`.
|
||||
Waxed,
|
||||
/// A decorated pot's `item` as `minecraft:container`.
|
||||
PotItem,
|
||||
/// Left out of the item's data, with no component.
|
||||
Drop(&'static str),
|
||||
}
|
||||
const NAME: Part = Part::Same("CustomName", "minecraft:custom_name");
|
||||
const LOCK: Part = Part::Same("lock", "minecraft:lock");
|
||||
const CONTAINER: &str = "minecraft:container";
|
||||
|
||||
/// Each type's `removeComponentsFromTag`, `collectImplicitComponents` and
|
||||
/// `applyImplicitComponents`.
|
||||
fn parts(id: &str) -> &'static [Part] {
|
||||
match id {
|
||||
"minecraft:chest"
|
||||
| "minecraft:trapped_chest"
|
||||
| "minecraft:barrel"
|
||||
| "minecraft:shulker_box" => &[NAME, LOCK, Part::Items(27), Part::Loot],
|
||||
"minecraft:dispenser" | "minecraft:dropper" | "minecraft:crafter" => {
|
||||
&[NAME, LOCK, Part::Items(9), Part::Loot]
|
||||
}
|
||||
"minecraft:hopper" => &[NAME, LOCK, Part::Items(5), Part::Loot],
|
||||
"minecraft:furnace" | "minecraft:smoker" | "minecraft:blast_furnace" => {
|
||||
&[NAME, LOCK, Part::Items(3)]
|
||||
}
|
||||
"minecraft:brewing_stand" => &[NAME, LOCK, Part::Items(5)],
|
||||
"minecraft:beacon" => &[NAME, LOCK],
|
||||
"minecraft:enchanting_table" => &[NAME],
|
||||
"minecraft:command_block" => &[NAME, Part::Drop("conditionMet"), Part::Drop("powered")],
|
||||
"minecraft:campfire" => &[Part::Items(4)],
|
||||
"minecraft:chiseled_bookshelf" => &[Part::Items(6)],
|
||||
"minecraft:shelf" => &[Part::Items(3)],
|
||||
"minecraft:beehive" => &[Part::Same("bees", "minecraft:bees")],
|
||||
"minecraft:banner" => &[Part::Same("patterns", "minecraft:banner_patterns"), NAME],
|
||||
"minecraft:decorated_pot" => &[
|
||||
Part::Same("sherds", "minecraft:pot_decorations"),
|
||||
Part::PotItem,
|
||||
],
|
||||
"minecraft:sign" | "minecraft:hanging_sign" => &[
|
||||
Part::Same("front_text", "minecraft:sign_text_front"),
|
||||
Part::Same("back_text", "minecraft:sign_text_back"),
|
||||
Part::Waxed,
|
||||
],
|
||||
"minecraft:skull" => &[
|
||||
Part::Same("profile", "minecraft:profile"),
|
||||
Part::Same("note_block_sound", "minecraft:note_block_sound"),
|
||||
Part::Same("custom_name", "minecraft:custom_name"),
|
||||
],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The component a part reads, and what it holds for `tag` (None: unset).
|
||||
fn collect(part: Part, tag: &Tag) -> Option<(&'static str, Option<Tag>)> {
|
||||
Some(match part {
|
||||
Part::Same(key, component) => (
|
||||
component,
|
||||
tag.get(key).cloned().or_else(|| unset(component)),
|
||||
),
|
||||
Part::Items(_) => {
|
||||
let slots = match tag.get("Items") {
|
||||
Some(Tag::List(items)) => items.iter().filter_map(slot_entry).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
(CONTAINER, Some(Tag::List(slots)))
|
||||
}
|
||||
Part::Loot => {
|
||||
let table = tag.get("LootTable").cloned();
|
||||
let loot = table.map(|t| {
|
||||
let mut entries = vec![("loot_table", t)];
|
||||
if let Some(seed) = tag.get("LootTableSeed") {
|
||||
entries.push(("seed", seed.clone()));
|
||||
}
|
||||
Tag::compound(entries)
|
||||
});
|
||||
("minecraft:container_loot", loot)
|
||||
}
|
||||
Part::Waxed => (
|
||||
"minecraft:waxed",
|
||||
matches!(tag.get("is_waxed"), Some(Tag::Byte(1)))
|
||||
.then(|| Tag::Compound(Compound::new())),
|
||||
),
|
||||
Part::PotItem => {
|
||||
let slots = match tag.get("item") {
|
||||
Some(Tag::Compound(item)) => Some(container_slot(0, item.clone())),
|
||||
_ => None,
|
||||
};
|
||||
(CONTAINER, Some(Tag::List(slots.into_iter().collect())))
|
||||
}
|
||||
Part::Drop(_) => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// What a type that always collects `component` gives when its NBT leaves the
|
||||
/// value out (its empty value, which the NBT omits).
|
||||
fn unset(component: &str) -> Option<Tag> {
|
||||
Some(match component {
|
||||
"minecraft:bees" | "minecraft:banner_patterns" => Tag::List(Vec::new()),
|
||||
"minecraft:pot_decorations" => Tag::Compound(Compound::new()),
|
||||
"minecraft:sign_text_front" | "minecraft:sign_text_back" => {
|
||||
block_entity_nbt::sign_side(None)
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// An `Items` entry as a `minecraft:container` slot.
|
||||
fn slot_entry(entry: &Tag) -> Option<Tag> {
|
||||
let Tag::Compound(entry) = entry else {
|
||||
return None;
|
||||
};
|
||||
let mut item = entry.clone();
|
||||
let slot = match item.remove(&JavaString::from("Slot"))? {
|
||||
Tag::Byte(s) => i32::from(s),
|
||||
_ => return None,
|
||||
};
|
||||
Some(container_slot(slot, item))
|
||||
}
|
||||
|
||||
/// An `ItemContainerContents` slot: `ItemStackTemplate` leaves out a count of 1.
|
||||
fn container_slot(slot: i32, mut item: Compound) -> Tag {
|
||||
if item.get(&JavaString::from("count")) == Some(&Tag::Int(1)) {
|
||||
item.remove(&JavaString::from("count"));
|
||||
}
|
||||
Tag::compound([("slot", Tag::Int(slot)), ("item", Tag::Compound(item))])
|
||||
}
|
||||
|
||||
/// The value of `component` on the clone item of a type that collects it: a
|
||||
/// picked item leaves it out of its patch, and a placed item without it in its
|
||||
/// patch gives it to the block entity.
|
||||
fn item_default(component: &str) -> Option<Tag> {
|
||||
Some(match component {
|
||||
CONTAINER | "minecraft:bees" | "minecraft:banner_patterns" => Tag::List(Vec::new()),
|
||||
"minecraft:pot_decorations" => {
|
||||
let brick = || Tag::compound([("id", Tag::String("minecraft:brick".into()))]);
|
||||
Tag::compound([
|
||||
("back", brick()),
|
||||
("front", brick()),
|
||||
("left", brick()),
|
||||
("right", brick()),
|
||||
])
|
||||
}
|
||||
"minecraft:sign_text_front" | "minecraft:sign_text_back" => {
|
||||
block_entity_nbt::sign_side(None)
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
const METADATA: [&str; 6] = ["id", "x", "y", "z", "keepPacked", "components"];
|
||||
|
||||
/// `saveCustomOnly`: the saved NBT without its id, position and components.
|
||||
fn custom_only(saved: &Tag) -> Compound {
|
||||
match saved {
|
||||
Tag::Compound(c) => c
|
||||
.iter()
|
||||
.filter(|(k, _)| !METADATA.contains(&k.to_string_lossy().as_str()))
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect(),
|
||||
_ => Compound::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `addBlockDataToItem`: the component patch Ctrl + Pick Block gives the clone
|
||||
/// item of the block entity saved as `saved` (a full, canonical compound).
|
||||
pub fn pick(saved: &Tag) -> Compound {
|
||||
let id = block_entity_nbt::id(saved).unwrap_or_default();
|
||||
let mut patch = Compound::new();
|
||||
// BlockItem.setBlockEntityData after removeComponentsFromTag.
|
||||
let mut data = custom_only(saved);
|
||||
for part in parts(&id) {
|
||||
let key = match *part {
|
||||
Part::Same(key, _) | Part::Drop(key) => key,
|
||||
Part::Items(_) => "Items",
|
||||
Part::Loot => {
|
||||
data.remove(&JavaString::from("LootTableSeed"));
|
||||
"LootTable"
|
||||
}
|
||||
Part::Waxed => "is_waxed",
|
||||
Part::PotItem => "item",
|
||||
};
|
||||
data.remove(&JavaString::from(key));
|
||||
}
|
||||
if !data.is_empty() {
|
||||
data.insert("id".into(), Tag::String(id.as_str().into()));
|
||||
patch.insert(BLOCK_ENTITY_DATA.into(), Tag::Compound(data));
|
||||
}
|
||||
// ItemStack.applyComponents(collectComponents()): the block entity's own
|
||||
// components, then the implicit ones; a value equal to the item's own is
|
||||
// no change.
|
||||
let mut collected = match saved.get("components") {
|
||||
Some(Tag::Compound(c)) => c.clone(),
|
||||
_ => Compound::new(),
|
||||
};
|
||||
for part in parts(&id) {
|
||||
if let Some((component, value)) = collect(*part, saved) {
|
||||
match value {
|
||||
Some(v) => collected.insert(component.into(), v),
|
||||
None => collected.remove(&JavaString::from(component)),
|
||||
};
|
||||
}
|
||||
}
|
||||
for (component, value) in collected {
|
||||
if item_default(&component.to_string_lossy()).as_ref() == Some(&value) {
|
||||
patch.remove(&component);
|
||||
} else {
|
||||
patch.insert(component, value);
|
||||
}
|
||||
}
|
||||
patch
|
||||
}
|
||||
|
||||
/// The block entity saved as `saved` after an item with component patch
|
||||
/// `components` is placed as it: `BlockItem.updateCustomBlockEntityTag` (the
|
||||
/// item's data of the same type, op-only types only when `game_master`, the
|
||||
/// player can use game master blocks), then `applyComponentsFromItemStack`.
|
||||
pub fn place(saved: &Tag, components: &Compound, game_master: bool) -> Tag {
|
||||
let Some(id) = block_entity_nbt::id(saved) else {
|
||||
return saved.clone();
|
||||
};
|
||||
let Tag::Compound(mut tag) = saved.clone() else {
|
||||
return saved.clone();
|
||||
};
|
||||
// TypedEntityData.loadInto: saveCustomOnly, merge, loadCustomOnly when changed.
|
||||
if let Some(Tag::Compound(data)) = components.get(&JavaString::from(BLOCK_ENTITY_DATA))
|
||||
&& data
|
||||
.get(&JavaString::from("id"))
|
||||
.is_some_and(|t| matches!(t, Tag::String(s) if s.to_string_lossy() == id))
|
||||
&& (game_master || !OP_ONLY.contains(&id.as_str()))
|
||||
{
|
||||
let custom = custom_only(saved);
|
||||
let mut merged = custom.clone();
|
||||
let mut data = data.clone();
|
||||
data.remove(&JavaString::from("id"));
|
||||
crate::data_cmd::merge_compound(&mut merged, &data);
|
||||
if merged != custom {
|
||||
for key in METADATA {
|
||||
if let Some(v) = tag.get(&JavaString::from(key)) {
|
||||
merged.insert(key.into(), v.clone());
|
||||
}
|
||||
}
|
||||
if let Some(Tag::Compound(loaded)) = canonical(&Tag::Compound(merged)) {
|
||||
tag = loaded;
|
||||
}
|
||||
}
|
||||
}
|
||||
// applyImplicitComponents, then the rest of the patch as the block
|
||||
// entity's own components.
|
||||
let mut read = vec![BLOCK_ENTITY_DATA, "minecraft:block_state"];
|
||||
// The item's components: its patch over its own defaults.
|
||||
let get = |c: &str| match components.get(&JavaString::from(c)) {
|
||||
Some(v) => Some(v.clone()),
|
||||
None if components.contains_key(&JavaString::from(format!("!{c}").as_str())) => None,
|
||||
None => item_default(c),
|
||||
};
|
||||
let mut set = |key: &str, value: Option<Tag>| match value {
|
||||
Some(v) => tag.insert(key.into(), v),
|
||||
None => tag.remove(&JavaString::from(key)),
|
||||
};
|
||||
for part in parts(&id) {
|
||||
match *part {
|
||||
Part::Same(key, component) => {
|
||||
read.push(component);
|
||||
set(key, get(component));
|
||||
}
|
||||
Part::Items(size) => {
|
||||
read.push(CONTAINER);
|
||||
let items = container_slots(get(CONTAINER).as_ref())
|
||||
.filter(|(slot, _)| (*slot as usize) < size)
|
||||
.map(|(slot, mut item)| {
|
||||
item.insert("Slot".into(), Tag::Byte(slot as i8));
|
||||
Tag::Compound(item)
|
||||
})
|
||||
.collect();
|
||||
set("Items", Some(Tag::List(items)));
|
||||
}
|
||||
Part::Loot => {
|
||||
read.push("minecraft:container_loot");
|
||||
if let Some(loot) = get("minecraft:container_loot") {
|
||||
set("LootTable", loot.get("loot_table").cloned());
|
||||
set("LootTableSeed", loot.get("seed").cloned());
|
||||
}
|
||||
}
|
||||
Part::Waxed => {
|
||||
read.push("minecraft:waxed");
|
||||
set(
|
||||
"is_waxed",
|
||||
Some(Tag::Byte(i8::from(get("minecraft:waxed").is_some()))),
|
||||
);
|
||||
}
|
||||
Part::PotItem => {
|
||||
read.push(CONTAINER);
|
||||
let first = container_slots(get(CONTAINER).as_ref()).find(|(slot, _)| *slot == 0);
|
||||
set("item", first.map(|(_, item)| Tag::Compound(item)));
|
||||
}
|
||||
Part::Drop(_) => {}
|
||||
}
|
||||
}
|
||||
let own: Compound = components
|
||||
.iter()
|
||||
.filter(|(k, _)| {
|
||||
let k = k.to_string_lossy();
|
||||
!k.starts_with('!') && !read.contains(&k.as_str())
|
||||
})
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect();
|
||||
tag.insert("components".into(), Tag::Compound(own));
|
||||
let tag = Tag::Compound(tag);
|
||||
canonical(&tag).unwrap_or(tag)
|
||||
}
|
||||
|
||||
/// The slots of a `minecraft:container` component.
|
||||
fn container_slots(container: Option<&Tag>) -> impl Iterator<Item = (i32, Compound)> + '_ {
|
||||
let list = match container {
|
||||
Some(Tag::List(list)) => list.as_slice(),
|
||||
_ => &[],
|
||||
};
|
||||
list.iter().filter_map(|entry| {
|
||||
let slot = match entry.get("slot")? {
|
||||
Tag::Int(s) => *s,
|
||||
_ => return None,
|
||||
};
|
||||
match entry.get("item")? {
|
||||
Tag::Compound(item) => Some((slot, item.clone())),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn snbt(text: &str) -> Tag {
|
||||
horizon_world::nbt::parse_snbt(text).expect("snbt").0
|
||||
}
|
||||
fn saved(text: &str) -> Tag {
|
||||
canonical(&snbt(text)).expect("known type")
|
||||
}
|
||||
fn canon(patch: &Compound) -> String {
|
||||
crate::item_data::canon_tag(&Tag::Compound(patch.clone()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_filled_named_chest_gives_its_items_and_name_as_components() {
|
||||
let chest = saved(
|
||||
"{id:\"minecraft:chest\",x:1,y:2,z:3,CustomName:\"Loot\",\
|
||||
Items:[{Slot:4b,id:\"minecraft:stone\",count:5}]}",
|
||||
);
|
||||
let patch = pick(&chest);
|
||||
assert_eq!(
|
||||
canon(&patch),
|
||||
crate::item_data::canon_tag(&snbt(
|
||||
"{\"minecraft:container\":[{slot:4,item:{id:\"minecraft:stone\",count:5}}],\
|
||||
\"minecraft:custom_name\":\"Loot\"}"
|
||||
))
|
||||
);
|
||||
// Placing it back gives the same chest.
|
||||
let empty = saved("{id:\"minecraft:chest\",x:1,y:2,z:3}");
|
||||
assert_eq!(place(&empty, &patch, false), chest);
|
||||
// An empty chest has nothing to carry.
|
||||
assert!(pick(&empty).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_block_keeps_its_command_in_block_entity_data() {
|
||||
let block = saved(
|
||||
"{id:\"minecraft:command_block\",x:0,y:0,z:0,Command:\"say hi\",auto:1b,powered:1b}",
|
||||
);
|
||||
let patch = pick(&block);
|
||||
let data = patch
|
||||
.get(&JavaString::from(BLOCK_ENTITY_DATA))
|
||||
.expect("data");
|
||||
assert_eq!(data.get("Command"), Some(&Tag::String("say hi".into())));
|
||||
assert_eq!(data.get("powered"), None);
|
||||
assert_eq!(
|
||||
data.get("id"),
|
||||
Some(&Tag::String("minecraft:command_block".into()))
|
||||
);
|
||||
// Only a player who can use game master blocks places it.
|
||||
let fresh = saved("{id:\"minecraft:command_block\",x:0,y:0,z:0}");
|
||||
assert_eq!(place(&fresh, &patch, false), fresh);
|
||||
assert_eq!(
|
||||
place(&fresh, &patch, true).get("Command"),
|
||||
Some(&Tag::String("say hi".into()))
|
||||
);
|
||||
// Data of another type is ignored.
|
||||
let chest = saved("{id:\"minecraft:chest\",x:0,y:0,z:0}");
|
||||
assert_eq!(place(&chest, &patch, true), chest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_data_names_its_spawn_and_op_only_types() {
|
||||
let item = |text: &str| {
|
||||
let components = snbt(&format!("{{\"minecraft:block_entity_data\":{text}}}"));
|
||||
let (damage, extra) = crate::item_data::decode_components(Some(&components));
|
||||
crate::items::ItemStack {
|
||||
damage,
|
||||
extra,
|
||||
..crate::items::ItemStack::new("minecraft:spawner", 1)
|
||||
}
|
||||
};
|
||||
let spawner =
|
||||
item("{id:\"minecraft:mob_spawner\",SpawnData:{entity:{id:\"minecraft:zombie\"}}}");
|
||||
assert_eq!(
|
||||
spawner_entity(&spawner).as_deref(),
|
||||
Some("minecraft:zombie")
|
||||
);
|
||||
assert!(op_warning(&spawner));
|
||||
let chest = item("{id:\"minecraft:chest\",LootTable:\"x\"}");
|
||||
assert_eq!(spawner_entity(&chest), None);
|
||||
assert!(!op_warning(&chest));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_written_sign_gives_its_text_and_wax() {
|
||||
let sign = saved(
|
||||
"{id:\"minecraft:sign\",x:0,y:0,z:0,is_waxed:1b,\
|
||||
front_text:{messages:[\"a\",\"b\",\"\",\"\"]}}",
|
||||
);
|
||||
let patch = pick(&sign);
|
||||
let keys: Vec<String> = patch.keys().map(|k| k.to_string_lossy()).collect();
|
||||
assert_eq!(keys, ["minecraft:sign_text_front", "minecraft:waxed"]);
|
||||
let fresh = saved("{id:\"minecraft:sign\",x:0,y:0,z:0}");
|
||||
assert_eq!(place(&fresh, &patch, false), sign);
|
||||
}
|
||||
}
|
||||
@@ -259,7 +259,7 @@ fn is_pos(tag: &Tag) -> bool {
|
||||
matches!(tag, Tag::IntArray(v) if v.len() == 3)
|
||||
}
|
||||
/// The loaded `SignText` of one side, or the empty text.
|
||||
fn sign_side(tag: Option<&Tag>) -> Tag {
|
||||
pub(crate) fn sign_side(tag: Option<&Tag>) -> Tag {
|
||||
let empty = || string("");
|
||||
let mut messages: Vec<Tag> = match tag.and_then(|t| t.get("messages")) {
|
||||
Some(Tag::List(list)) => list.iter().filter_map(component).collect(),
|
||||
|
||||
@@ -876,7 +876,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, out);
|
||||
self.render_one(scene, *pos, kind, base, packed, lights, false, out);
|
||||
}
|
||||
}
|
||||
/// One block entity at `pos` drawn under `base` with the given packed light
|
||||
@@ -892,12 +892,33 @@ impl BlockEntityRenderer {
|
||||
) {
|
||||
let kind = self.kind(state);
|
||||
let lights = diffuse_lights(scene.nether);
|
||||
self.render_one(scene, pos, kind, base, packed, lights, out);
|
||||
self.render_one(scene, pos, kind, base, packed, lights, false, out);
|
||||
}
|
||||
/// Whether this block state has a block-entity renderer here.
|
||||
pub fn renders(&self, state: u32) -> bool {
|
||||
!matches!(self.kind(state), Kind::None)
|
||||
}
|
||||
/// ChestRenderer and BannerRenderer submit their animated models for crumbling.
|
||||
pub fn render_breaking(&self, scene: &Scene, pos: [i32; 3], state: u32, out: &mut Vec<Vertex>) {
|
||||
if !matches!(self.kind(state), Kind::Banner { .. } | Kind::Chest { .. }) {
|
||||
return;
|
||||
}
|
||||
let origin = (DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]))
|
||||
- scene.camera)
|
||||
.as_vec3();
|
||||
let start = out.len();
|
||||
self.render_one(
|
||||
scene,
|
||||
pos,
|
||||
self.kind(state),
|
||||
Mat4::from_translation(origin),
|
||||
0xff,
|
||||
diffuse_lights(scene.nether),
|
||||
true,
|
||||
out,
|
||||
);
|
||||
crate::breaking::entity_geometry(&mut out[start..], origin);
|
||||
}
|
||||
/// `ChestRenderer.extractRenderState`'s openness for the chest of `state`
|
||||
/// at `pos`: `ChestBlock.combine` (`DoubleBlockCombiner.combineWithNeigbour`)
|
||||
/// pairs a double half with its neighbour only when that is the same
|
||||
@@ -970,7 +991,7 @@ 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, out);
|
||||
self.render_one(scene, [i32::MAX; 3], kind, base, packed, lights, false, out);
|
||||
}
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -982,11 +1003,24 @@ impl BlockEntityRenderer {
|
||||
base: Mat4,
|
||||
packed: u8,
|
||||
lights: (Vec3, Vec3),
|
||||
crumbling: bool,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let sprite = |name: &str| scene.sprites.get(name).copied();
|
||||
let paint = |name: &str, tint: u32, packed: u8| {
|
||||
sprite(name).map(|s| Paint {
|
||||
let rect = if crumbling {
|
||||
Some(Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 16,
|
||||
h: 16,
|
||||
})
|
||||
} else {
|
||||
sprite(name)
|
||||
};
|
||||
rect.map(|s| Paint {
|
||||
sprite: s,
|
||||
tint,
|
||||
light: vertex_light(packed),
|
||||
@@ -1058,7 +1092,7 @@ impl BlockEntityRenderer {
|
||||
ChestPart::Left => "double_chest_left",
|
||||
ChestPart::Right => "double_chest_right",
|
||||
};
|
||||
let (Some(model), Some(_)) = (self.models.get(model), sprite(texture)) else {
|
||||
let Some(model) = self.models.get(model) else {
|
||||
return;
|
||||
};
|
||||
let (open, other) = self.chest_openness(
|
||||
@@ -1145,6 +1179,10 @@ impl BlockEntityRenderer {
|
||||
draw_part(main, "root", m, &none, paint, out);
|
||||
draw_part(flag, "root", m, &wave, paint, out);
|
||||
}
|
||||
// Pattern passes must not multiply the crumbling decal.
|
||||
if crumbling {
|
||||
return;
|
||||
}
|
||||
// BannerRenderer.submitPatterns: the base pattern layer in the dye colour,
|
||||
// then up to 16 pattern layers in order.
|
||||
if let Some(paint) = paint("entity/banner/base", *color, packed) {
|
||||
@@ -1673,6 +1711,138 @@ fn portal_cube(m: Mat4, faces: &[Direction], layer: u32, out: &mut Vec<Vertex>)
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
#[ignore = "requires prepared models/textures in HORIZON_TEST_RESOURCES"]
|
||||
fn entity_block_particles_and_breaking_use_exported_models() -> crate::Result<()> {
|
||||
use crate::{models::catalog::StateModels, sprites::Sprites};
|
||||
use horizon_world::{DATA_VERSION, chunk::BlockState, nbt::Tag};
|
||||
let root = std::path::PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES").expect("HORIZON_TEST_RESOURCES"),
|
||||
);
|
||||
let names = [
|
||||
"white_banner",
|
||||
"white_wall_banner",
|
||||
"chest",
|
||||
"trapped_chest",
|
||||
"ender_chest",
|
||||
"copper_chest",
|
||||
];
|
||||
let mut facts: std::collections::BTreeMap<String, Value> =
|
||||
serde_json::from_slice(&std::fs::read(root.join("model-states.json"))?)?;
|
||||
let states = names.map(|name| {
|
||||
let name = format!("minecraft:{name}");
|
||||
let mut first = facts[&name]["states"][0].clone();
|
||||
if first.get("type").is_some() {
|
||||
first["type"] = serde_json::json!("single");
|
||||
}
|
||||
facts.get_mut(&name).unwrap()["states"] = serde_json::json!([first.clone()]);
|
||||
BlockState {
|
||||
name,
|
||||
properties: serde_json::from_value(first).unwrap(),
|
||||
}
|
||||
.to_nbt()
|
||||
});
|
||||
let registry = BlockRegistry::from_nbt(&Tag::compound([
|
||||
("DataVersion", Tag::Int(DATA_VERSION)),
|
||||
("states", Tag::List(states.into())),
|
||||
(
|
||||
"defaults",
|
||||
Tag::compound(names.iter().enumerate().map(|(id, name)| {
|
||||
(
|
||||
horizon_world::nbt::JavaString::from(format!("minecraft:{name}").as_str()),
|
||||
Tag::Int(id as i32),
|
||||
)
|
||||
})),
|
||||
),
|
||||
]))?;
|
||||
let mut sprites = Sprites::default();
|
||||
let catalog = StateModels::from_facts(&root, ®istry, &names, &mut sprites, &facts)?;
|
||||
for (id, name) in names.iter().enumerate() {
|
||||
let models = catalog.collect(
|
||||
BlockStateId(id as u32),
|
||||
horizon_core::position::BlockPos::default(),
|
||||
)?;
|
||||
assert!(models.iter().all(|(_, model)| model.faces.is_empty()));
|
||||
let particle = match *name {
|
||||
"ender_chest" => "minecraft:block/obsidian",
|
||||
"copper_chest" => "minecraft:block/copper_block",
|
||||
_ => "minecraft:block/oak_planks",
|
||||
};
|
||||
assert_eq!(models[0].1.particle, Some(sprites.layers[particle]));
|
||||
}
|
||||
let all: Value = serde_json::from_slice(&std::fs::read(root.join("entity-models.json"))?)?;
|
||||
let renderer = BlockEntityRenderer::new(
|
||||
MODELS
|
||||
.iter()
|
||||
.filter_map(|(key, layer)| all.get(*layer).map(|v| (*key, ModelPart::parse(v))))
|
||||
.collect(),
|
||||
®istry,
|
||||
);
|
||||
let world = World::new(BlockStateId(0), 1);
|
||||
let base_sprites = [
|
||||
"entity/banner/banner_base",
|
||||
"entity/chest/normal",
|
||||
"entity/chest/trapped",
|
||||
"entity/chest/ender",
|
||||
"entity/chest/copper",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
(
|
||||
name.to_owned(),
|
||||
Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 16,
|
||||
h: 16,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut scene = Scene {
|
||||
camera: DVec3::ZERO,
|
||||
orientation: Quat::IDENTITY,
|
||||
anim: &|_| BlockAnim::default(),
|
||||
texts: None,
|
||||
partial: 0.5,
|
||||
game_time: 0,
|
||||
max_y: 320,
|
||||
world: &world,
|
||||
sprites: &base_sprites,
|
||||
nether: false,
|
||||
lid: &|_| 0.0,
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
glyph: &|_| (None, 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);
|
||||
let mut cracks = Vec::new();
|
||||
renderer.render_breaking(&scene, [0; 3], state, &mut cracks);
|
||||
assert!(!cracks.is_empty());
|
||||
assert_eq!(
|
||||
base.iter().map(|v| v.pos).collect::<Vec<_>>(),
|
||||
cracks.iter().map(|v| v.pos).collect::<Vec<_>>()
|
||||
);
|
||||
assert!(cracks.iter().all(|v| v.layer == 11
|
||||
&& v.color == u32::MAX
|
||||
&& v.uv.iter().all(|u| u.is_finite())));
|
||||
scene.game_time = 20;
|
||||
if state >= 2 {
|
||||
scene.lid = &|_| 1.0;
|
||||
}
|
||||
let mut animated = Vec::new();
|
||||
renderer.render_breaking(&scene, [0; 3], state, &mut animated);
|
||||
assert!(cracks.iter().zip(&animated).any(|(a, b)| a.pos != b.pos));
|
||||
scene.game_time = 0;
|
||||
scene.lid = &|_| 0.0;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_vanilla_block_names() {
|
||||
let props = |pairs: &'static [(&'static str, &'static str)]| {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
//! Per-state gameplay facts exported from vanilla's own block-state accessors
|
||||
//! (`gameplay-facts.json`), indexed by registry state ID.
|
||||
mod decode;
|
||||
use crate::Result;
|
||||
use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
use horizon_world::{
|
||||
level::{Behaviours, GameplayStateSource},
|
||||
registry::{BlockRegistry, BlockStateId},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
@@ -106,23 +110,190 @@ impl StateFacts {
|
||||
self.classes.iter().any(|c| &**c == name)
|
||||
}
|
||||
}
|
||||
/// Compact, owned behaviour inputs retained until the normal behaviour startup stage.
|
||||
/// No borrowed gameplay JSON or process-wide cache survives the fact loader.
|
||||
pub struct GameplayProjection {
|
||||
flags: u16,
|
||||
emission: u64,
|
||||
dampening: u64,
|
||||
hardness: f64,
|
||||
push: Arc<str>,
|
||||
volume: f64,
|
||||
pitch: f64,
|
||||
place: Arc<str>,
|
||||
break_sound: Arc<str>,
|
||||
fall: Arc<str>,
|
||||
}
|
||||
|
||||
impl GameplayProjection {
|
||||
fn from_entry(entry: &decode::Entry<'_>, strings: &mut HashMap<String, Arc<str>>) -> Self {
|
||||
fn intern(strings: &mut HashMap<String, Arc<str>>, value: &str) -> Arc<str> {
|
||||
if let Some(existing) = strings.get(value) {
|
||||
return Arc::clone(existing);
|
||||
}
|
||||
let value_arc: Arc<str> = Arc::from(value);
|
||||
strings.insert(value.to_owned(), Arc::clone(&value_arc));
|
||||
value_arc
|
||||
}
|
||||
const FLAGS: [&str; 11] = [
|
||||
"replaceable",
|
||||
"random_ticks",
|
||||
"solid_render",
|
||||
"liquid",
|
||||
"block_entity",
|
||||
"ignited_by_lava",
|
||||
"can_occlude",
|
||||
"full_collision",
|
||||
"skylight_down",
|
||||
"requires_tool",
|
||||
"shape_occlusion",
|
||||
];
|
||||
Self {
|
||||
flags: FLAGS.iter().enumerate().fold(0, |flags, (i, name)| {
|
||||
flags | ((u16::from(entry.flag(name))) << i)
|
||||
}),
|
||||
emission: entry.unsigned("emission"),
|
||||
dampening: entry.unsigned("dampening"),
|
||||
hardness: entry.number("hardness"),
|
||||
push: intern(strings, entry.text("push")),
|
||||
volume: entry.sound_number("volume"),
|
||||
pitch: entry.sound_number("pitch"),
|
||||
place: intern(strings, entry.sound_id("place")),
|
||||
break_sound: intern(strings, entry.sound_id("break")),
|
||||
fall: intern(strings, entry.sound_id("fall")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GameplayStateSource for GameplayProjection {
|
||||
fn flag(&self, name: &str) -> bool {
|
||||
const FLAGS: [&str; 11] = [
|
||||
"replaceable",
|
||||
"random_ticks",
|
||||
"solid_render",
|
||||
"liquid",
|
||||
"block_entity",
|
||||
"ignited_by_lava",
|
||||
"can_occlude",
|
||||
"full_collision",
|
||||
"skylight_down",
|
||||
"requires_tool",
|
||||
"shape_occlusion",
|
||||
];
|
||||
FLAGS
|
||||
.iter()
|
||||
.position(|field| *field == name)
|
||||
.is_some_and(|index| self.flags & (1 << index) != 0)
|
||||
}
|
||||
fn unsigned(&self, name: &str) -> u64 {
|
||||
match name {
|
||||
"emission" => self.emission,
|
||||
"dampening" => self.dampening,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
fn number(&self, name: &str) -> f64 {
|
||||
if name == "hardness" {
|
||||
self.hardness
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
fn text(&self, name: &str) -> &str {
|
||||
if name == "push" { &self.push } else { "" }
|
||||
}
|
||||
fn sound_number(&self, name: &str) -> f64 {
|
||||
match name {
|
||||
"volume" => self.volume,
|
||||
"pitch" => self.pitch,
|
||||
_ => 1.0,
|
||||
}
|
||||
}
|
||||
fn sound_id(&self, name: &str) -> &str {
|
||||
match name {
|
||||
"place" => &self.place,
|
||||
"break" => &self.break_sound,
|
||||
"fall" => &self.fall,
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct BlockFacts {
|
||||
pub states: Vec<StateFacts>,
|
||||
pub air: BlockStateId,
|
||||
}
|
||||
impl BlockFacts {
|
||||
pub fn load(root: &Path, registry: &BlockRegistry) -> Result<Self> {
|
||||
let raw: Value = serde_json::from_slice(&fs::read(root.join("gameplay-facts.json"))?)?;
|
||||
let entries = raw["states"]
|
||||
.as_array()
|
||||
let behaviours: Value = serde_json::from_reader(BufReader::new(File::open(
|
||||
root.join("block-behaviours.json"),
|
||||
)?))?;
|
||||
Self::load_with_behaviour_json(root, registry, &behaviours)
|
||||
}
|
||||
|
||||
/// Load both tables from one resource root, sharing each JSON decode.
|
||||
/// No data is cached across calls: changes to prepared packs or mods are read again.
|
||||
pub fn load_pair(root: &Path, registry: &BlockRegistry) -> Result<(Self, Behaviours)> {
|
||||
let behaviours: Value = serde_json::from_reader(BufReader::new(File::open(
|
||||
root.join("block-behaviours.json"),
|
||||
)?))?;
|
||||
Self::load_with_behaviours(root, registry, &behaviours)
|
||||
}
|
||||
|
||||
/// Decode state facts once, retaining only the small owned fields needed later.
|
||||
pub fn load_with_projection(
|
||||
root: &Path,
|
||||
registry: &BlockRegistry,
|
||||
behaviours: &Value,
|
||||
) -> Result<(Self, Vec<GameplayProjection>)> {
|
||||
Self::load_with_callback(root, registry, behaviours, |entries| {
|
||||
let mut strings = HashMap::new();
|
||||
Ok(entries
|
||||
.iter()
|
||||
.map(|entry| GameplayProjection::from_entry(entry, &mut strings))
|
||||
.collect())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load_with_behaviour_json(
|
||||
root: &Path,
|
||||
registry: &BlockRegistry,
|
||||
behaviours: &Value,
|
||||
) -> Result<Self> {
|
||||
Ok(Self::load_with_callback(root, registry, behaviours, |_| Ok(()))?.0)
|
||||
}
|
||||
|
||||
/// Build both tables from the same behaviour document and gameplay decoder.
|
||||
pub fn load_with_behaviours(
|
||||
root: &Path,
|
||||
registry: &BlockRegistry,
|
||||
behaviours: &Value,
|
||||
) -> Result<(Self, Behaviours)> {
|
||||
Self::load_with_callback(root, registry, behaviours, |entries| {
|
||||
Ok(Behaviours::load_with_gameplay(
|
||||
registry, root, behaviours, entries,
|
||||
)?)
|
||||
})
|
||||
}
|
||||
|
||||
fn load_with_callback<T>(
|
||||
root: &Path,
|
||||
registry: &BlockRegistry,
|
||||
behaviours: &Value,
|
||||
callback: impl FnOnce(&[decode::Entry<'_>]) -> Result<T>,
|
||||
) -> Result<(Self, T)> {
|
||||
let bytes = fs::read(root.join("gameplay-facts.json"))?;
|
||||
serde_json::from_slice::<decode::Validate>(&bytes)?;
|
||||
let raw: decode::Gameplay<'_> = serde_json::from_slice(&bytes)?;
|
||||
let entries = raw
|
||||
.states
|
||||
.0
|
||||
.as_ref()
|
||||
.ok_or("missing gameplay state facts")?;
|
||||
if entries.len() != registry.len() {
|
||||
return Err("gameplay facts do not match the block registry".into());
|
||||
}
|
||||
// Class chains and per-state behaviour flags exported from the actual block objects.
|
||||
let behaviours: Value = serde_json::from_reader(BufReader::new(File::open(
|
||||
root.join("block-behaviours.json"),
|
||||
)?))?;
|
||||
let behaviour_states = behaviours["states"]
|
||||
.as_array()
|
||||
.filter(|s| s.len() == registry.len())
|
||||
@@ -148,40 +319,22 @@ impl BlockFacts {
|
||||
.collect();
|
||||
let mut names: HashMap<String, Arc<str>> = HashMap::new();
|
||||
let mut intern = |s: &str| -> Arc<str> {
|
||||
names
|
||||
.entry(s.to_owned())
|
||||
.or_insert_with(|| Arc::from(s))
|
||||
.clone()
|
||||
};
|
||||
let mut shapes: HashMap<String, Arc<[[f64; 6]]>> = HashMap::new();
|
||||
let mut shape = |v: &Value, fallback: &dyn Fn() -> Vec<[f64; 6]>| -> Arc<[[f64; 6]]> {
|
||||
let Some(list) = v.as_array() else {
|
||||
return fallback().into();
|
||||
};
|
||||
shapes
|
||||
.entry(v.to_string())
|
||||
.or_insert_with(|| {
|
||||
list.iter()
|
||||
.filter_map(|b| {
|
||||
let b = b.as_array()?;
|
||||
let n = |i: usize| b.get(i).and_then(Value::as_f64).unwrap_or(0.0);
|
||||
Some([n(0), n(1), n(2), n(3), n(4), n(5)])
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.clone()
|
||||
if let Some(name) = names.get(s) {
|
||||
return name.clone();
|
||||
}
|
||||
let name: Arc<str> = Arc::from(s);
|
||||
names.insert(s.to_owned(), name.clone());
|
||||
name
|
||||
};
|
||||
let mut shapes = decode::Shapes::default();
|
||||
let mut states = Vec::with_capacity(entries.len());
|
||||
for (index, entry) in entries.iter().enumerate() {
|
||||
let id = BlockStateId(index as u32);
|
||||
if entry["id"].as_u64() != Some(index as u64) {
|
||||
if entry.id != Some(index as u64) {
|
||||
return Err("gameplay facts are not in registry order".into());
|
||||
}
|
||||
let state = registry.state(id).ok_or("missing registry state")?;
|
||||
let num = |k: &str| entry[k].as_f64().unwrap_or(0.0);
|
||||
let flag = |k: &str| entry[k].as_bool().unwrap_or(false);
|
||||
let sound = &entry["sound"];
|
||||
let sound_id = |k: &str| sound[k]["id"].as_str().unwrap_or("").to_owned();
|
||||
let sound = &entry.sound;
|
||||
let prop = |k: &str| state.properties.get(k).map(String::as_str);
|
||||
let (fluid, fluid_level, fluid_falling) = match state.name.as_str() {
|
||||
"minecraft:water" | "minecraft:lava" => {
|
||||
@@ -220,10 +373,10 @@ impl BlockFacts {
|
||||
}
|
||||
};
|
||||
let edges = move || if air { Vec::new() } else { cube_edges() };
|
||||
let outline = shape(&entry["outline"], &cube);
|
||||
let outline_edges = shape(&entry["outline_edges"], &edges);
|
||||
let outline = shapes.intern(entry.outline, cube)?;
|
||||
let outline_edges = shapes.intern(entry.outline_edges, edges)?;
|
||||
// The export holds MapColor.col; ids are positions in MAP_COLORS.
|
||||
let col = entry["map_color"].as_u64().unwrap_or(0) as u32;
|
||||
let col = entry.map_color.unwrap_or(0) as u32;
|
||||
let map_color = if col == 0 {
|
||||
0
|
||||
} else {
|
||||
@@ -234,43 +387,41 @@ impl BlockFacts {
|
||||
outline,
|
||||
outline_edges,
|
||||
name: intern(&state.name),
|
||||
emission: num("emission") as u8,
|
||||
dampening: num("dampening") as u8,
|
||||
skylight_down: flag("skylight_down"),
|
||||
shape_occlusion: flag("shape_occlusion"),
|
||||
can_occlude: flag("can_occlude"),
|
||||
solid_render: flag("solid_render"),
|
||||
hardness: num("hardness") as f32,
|
||||
requires_tool: flag("requires_tool"),
|
||||
replaceable: flag("replaceable"),
|
||||
random_ticks: flag("random_ticks"),
|
||||
block_entity: flag("block_entity"),
|
||||
render_shape: match entry["render_shape"].as_str() {
|
||||
emission: entry.emission.as_f64().unwrap_or(0.0) as u8,
|
||||
dampening: entry.dampening.as_f64().unwrap_or(0.0) as u8,
|
||||
skylight_down: entry.skylight_down,
|
||||
shape_occlusion: entry.shape_occlusion,
|
||||
can_occlude: entry.can_occlude,
|
||||
solid_render: entry.solid_render,
|
||||
hardness: entry.hardness.as_f64().unwrap_or(0.0) as f32,
|
||||
requires_tool: entry.requires_tool,
|
||||
replaceable: entry.replaceable,
|
||||
random_ticks: entry.random_ticks,
|
||||
block_entity: entry.block_entity,
|
||||
render_shape: match entry.render_shape.as_deref() {
|
||||
Some("INVISIBLE") => RenderShape::Invisible,
|
||||
Some("ENTITYBLOCK_ANIMATED") => RenderShape::Animated,
|
||||
_ => RenderShape::Model,
|
||||
},
|
||||
full_collision: flag("full_collision"),
|
||||
friction: num("friction") as f32,
|
||||
explosion_resistance: num("explosion_resistance") as f32,
|
||||
item: intern(entry["item"].as_str().unwrap_or("minecraft:air")),
|
||||
full_collision: entry.full_collision,
|
||||
friction: entry.friction as f32,
|
||||
explosion_resistance: entry.explosion_resistance as f32,
|
||||
item: intern(entry.item.as_deref().unwrap_or("minecraft:air")),
|
||||
sounds: Sounds {
|
||||
volume: sound["volume"].as_f64().unwrap_or(1.0) as f32,
|
||||
pitch: sound["pitch"].as_f64().unwrap_or(1.0) as f32,
|
||||
break_sound: intern(&sound_id("break")),
|
||||
step: intern(&sound_id("step")),
|
||||
place: intern(&sound_id("place")),
|
||||
hit: intern(&sound_id("hit")),
|
||||
fall: intern(&sound_id("fall")),
|
||||
volume: sound.volume as f32,
|
||||
pitch: sound.pitch as f32,
|
||||
break_sound: intern(sound.r#break.id.as_deref().unwrap_or("")),
|
||||
step: intern(sound.step.id.as_deref().unwrap_or("")),
|
||||
place: intern(sound.place.id.as_deref().unwrap_or("")),
|
||||
hit: intern(sound.hit.id.as_deref().unwrap_or("")),
|
||||
fall: intern(sound.fall.id.as_deref().unwrap_or("")),
|
||||
},
|
||||
fluid,
|
||||
fluid_level,
|
||||
fluid_falling,
|
||||
waterlogged: prop("waterlogged") == Some("true"),
|
||||
random_offset: flag("offset"),
|
||||
outline_offset: entry["outline_offset"]
|
||||
.as_bool()
|
||||
.unwrap_or_else(|| flag("offset")),
|
||||
random_offset: entry.offset,
|
||||
outline_offset: entry.outline_offset.unwrap_or(entry.offset),
|
||||
legacy_solid: behaviour_states[index][1].as_u64().unwrap_or(0) & 1 != 0,
|
||||
sturdy_full: (behaviour_states[index][0].as_u64().unwrap_or(0) & 63) as u8,
|
||||
classes: classes
|
||||
@@ -279,12 +430,16 @@ impl BlockFacts {
|
||||
.ok_or("missing block behaviour class chain")?,
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
states,
|
||||
air: registry
|
||||
.default_state("minecraft:air")
|
||||
.ok_or("missing air")?,
|
||||
})
|
||||
let extra = callback(entries)?;
|
||||
Ok((
|
||||
Self {
|
||||
states,
|
||||
air: registry
|
||||
.default_state("minecraft:air")
|
||||
.ok_or("missing air")?,
|
||||
},
|
||||
extra,
|
||||
))
|
||||
}
|
||||
pub fn get(&self, id: BlockStateId) -> &StateFacts {
|
||||
&self.states[id.0 as usize]
|
||||
@@ -296,3 +451,56 @@ impl BlockFacts {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl GameplayStateSource for decode::Entry<'_> {
|
||||
fn flag(&self, name: &str) -> bool {
|
||||
match name {
|
||||
"replaceable" => self.replaceable,
|
||||
"random_ticks" => self.random_ticks,
|
||||
"solid_render" => self.solid_render,
|
||||
"liquid" => self.liquid,
|
||||
"block_entity" => self.block_entity,
|
||||
"ignited_by_lava" => self.ignited_by_lava,
|
||||
"can_occlude" => self.can_occlude,
|
||||
"full_collision" => self.full_collision,
|
||||
"skylight_down" => self.skylight_down,
|
||||
"requires_tool" => self.requires_tool,
|
||||
"shape_occlusion" => self.shape_occlusion,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
fn unsigned(&self, name: &str) -> u64 {
|
||||
match name {
|
||||
"emission" => self.emission.as_u64().unwrap_or(0),
|
||||
"dampening" => self.dampening.as_u64().unwrap_or(0),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
fn number(&self, name: &str) -> f64 {
|
||||
match name {
|
||||
"hardness" => self.hardness.as_f64().unwrap_or(0.0),
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
fn text(&self, name: &str) -> &str {
|
||||
match name {
|
||||
"push" => self.push.as_deref().unwrap_or(""),
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
fn sound_number(&self, name: &str) -> f64 {
|
||||
match name {
|
||||
"volume" => self.sound.volume,
|
||||
"pitch" => self.sound.pitch,
|
||||
_ => 1.0,
|
||||
}
|
||||
}
|
||||
fn sound_id(&self, name: &str) -> &str {
|
||||
match name {
|
||||
"place" => self.sound.place.id.as_deref().unwrap_or(""),
|
||||
"break" => self.sound.r#break.id.as_deref().unwrap_or(""),
|
||||
"fall" => self.sound.fall.id.as_deref().unwrap_or(""),
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
//! Typed projection of gameplay facts. Scalar conversions deliberately retain Value's
|
||||
//! permissive defaults; maps retain its last-key-wins behavior. Repeated geometry stays
|
||||
//! borrowed until it is interned, rather than allocating a JSON tree for every state.
|
||||
use serde::{
|
||||
Deserialize, Deserializer,
|
||||
de::{MapAccess, SeqAccess, Visitor},
|
||||
};
|
||||
use serde_json::{Value, value::RawValue};
|
||||
use std::{
|
||||
collections::{HashMap, hash_map::Entry as MapEntry},
|
||||
fmt,
|
||||
hash::{Hash, Hasher},
|
||||
marker::PhantomData,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
// RawValue skips numeric range and depth checks. A non-allocating validation pass
|
||||
// keeps the original Value loader's JSON errors, even in overwritten/unused fields.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Validate;
|
||||
impl<'de> Container<'de> for Validate {}
|
||||
impl<'de> Deserialize<'de> for Validate {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
container(d)
|
||||
}
|
||||
}
|
||||
|
||||
trait Container<'de>: Default {
|
||||
fn map<A: MapAccess<'de>>(mut map: A) -> Result<Self, A::Error> {
|
||||
while map.next_key::<serde::de::IgnoredAny>()?.is_some() {
|
||||
map.next_value::<Validate>()?;
|
||||
}
|
||||
Ok(Self::default())
|
||||
}
|
||||
fn seq<A: SeqAccess<'de>>(mut seq: A) -> Result<Self, A::Error> {
|
||||
while seq.next_element::<Validate>()?.is_some() {}
|
||||
Ok(Self::default())
|
||||
}
|
||||
}
|
||||
struct ContainerVisitor<T>(PhantomData<T>);
|
||||
impl<'de, T: Container<'de>> Visitor<'de> for ContainerVisitor<T> {
|
||||
type Value = T;
|
||||
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
f.write_str("JSON facts")
|
||||
}
|
||||
fn visit_map<A: MapAccess<'de>>(self, map: A) -> Result<T, A::Error> {
|
||||
T::map(map)
|
||||
}
|
||||
fn visit_seq<A: SeqAccess<'de>>(self, seq: A) -> Result<T, A::Error> {
|
||||
T::seq(seq)
|
||||
}
|
||||
fn visit_unit<E: serde::de::Error>(self) -> Result<T, E> {
|
||||
Ok(T::default())
|
||||
}
|
||||
fn visit_bool<E: serde::de::Error>(self, _: bool) -> Result<T, E> {
|
||||
Ok(T::default())
|
||||
}
|
||||
fn visit_i64<E: serde::de::Error>(self, _: i64) -> Result<T, E> {
|
||||
Ok(T::default())
|
||||
}
|
||||
fn visit_u64<E: serde::de::Error>(self, _: u64) -> Result<T, E> {
|
||||
Ok(T::default())
|
||||
}
|
||||
fn visit_f64<E: serde::de::Error>(self, _: f64) -> Result<T, E> {
|
||||
Ok(T::default())
|
||||
}
|
||||
fn visit_str<E: serde::de::Error>(self, _: &str) -> Result<T, E> {
|
||||
Ok(T::default())
|
||||
}
|
||||
}
|
||||
fn container<'de, D: Deserializer<'de>, T: Container<'de>>(d: D) -> Result<T, D::Error> {
|
||||
d.deserialize_any(ContainerVisitor(PhantomData))
|
||||
}
|
||||
macro_rules! read_field {
|
||||
($map:ident, typed) => {
|
||||
$map.next_value()?
|
||||
};
|
||||
($map:ident, value($convert:expr)) => {
|
||||
($convert)($map.next_value::<Value>()?)
|
||||
};
|
||||
}
|
||||
macro_rules! object {
|
||||
($name:ident<$life:lifetime> { $($field:ident: $ty:ty = $default:expr => $kind:ident $(($convert:expr))?),* $(,)? }) => {
|
||||
pub(super) struct $name<$life> {
|
||||
$(pub $field: $ty,)*
|
||||
marker: PhantomData<&$life ()>,
|
||||
}
|
||||
impl Default for $name<'_> {
|
||||
fn default() -> Self { Self { $($field: $default,)* marker: PhantomData } }
|
||||
}
|
||||
impl<$life> Container<$life> for $name<$life> {
|
||||
fn map<A: MapAccess<$life>>(mut map: A) -> Result<Self, A::Error> {
|
||||
#[derive(Deserialize)]
|
||||
#[allow(non_camel_case_types)]
|
||||
#[serde(field_identifier)]
|
||||
enum Field { $($field,)* #[serde(other)] Unknown }
|
||||
let mut result = Self::default();
|
||||
while let Some(field) = map.next_key::<Field>()? {
|
||||
match field {
|
||||
$(Field::$field => result.$field = read_field!(map, $kind $(($convert))?),)*
|
||||
Field::Unknown => { map.next_value::<Validate>()?; }
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
impl<$life> Deserialize<$life> for $name<$life> {
|
||||
fn deserialize<D: Deserializer<$life>>(d: D) -> Result<Self, D::Error> { container(d) }
|
||||
}
|
||||
};
|
||||
}
|
||||
fn number(v: Value) -> f64 {
|
||||
v.as_f64().unwrap_or(0.0)
|
||||
}
|
||||
fn flag(v: Value) -> bool {
|
||||
v.as_bool().unwrap_or(false)
|
||||
}
|
||||
fn unsigned(v: Value) -> Option<u64> {
|
||||
v.as_u64()
|
||||
}
|
||||
fn text(v: Value) -> Option<String> {
|
||||
if let Value::String(s) = v {
|
||||
Some(s)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Entries<'a>(pub Option<Vec<Entry<'a>>>);
|
||||
impl<'de> Container<'de> for Entries<'de> {
|
||||
fn seq<A: SeqAccess<'de>>(mut seq: A) -> Result<Self, A::Error> {
|
||||
let mut entries = Vec::new();
|
||||
while let Some(entry) = seq.next_element()? {
|
||||
entries.push(entry);
|
||||
}
|
||||
Ok(Self(Some(entries)))
|
||||
}
|
||||
}
|
||||
impl<'de> Deserialize<'de> for Entries<'de> {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
container(d)
|
||||
}
|
||||
}
|
||||
object!(Gameplay<'a> { states: Entries<'a> = Entries::default() => typed });
|
||||
object!(SoundId<'a> { id: Option<String> = None => value(text) });
|
||||
object!(Sound<'a> {
|
||||
volume: f64 = 1.0 => value(|v: Value| v.as_f64().unwrap_or(1.0)),
|
||||
pitch: f64 = 1.0 => value(|v: Value| v.as_f64().unwrap_or(1.0)),
|
||||
r#break: SoundId<'a> = SoundId::default() => typed,
|
||||
step: SoundId<'a> = SoundId::default() => typed,
|
||||
place: SoundId<'a> = SoundId::default() => typed,
|
||||
hit: SoundId<'a> = SoundId::default() => typed,
|
||||
fall: SoundId<'a> = SoundId::default() => typed,
|
||||
});
|
||||
object!(Entry<'a> {
|
||||
id: Option<u64> = None => value(unsigned),
|
||||
emission: Value = Value::Null => typed,
|
||||
dampening: Value = Value::Null => typed,
|
||||
skylight_down: bool = false => value(flag),
|
||||
shape_occlusion: bool = false => value(flag),
|
||||
can_occlude: bool = false => value(flag),
|
||||
solid_render: bool = false => value(flag),
|
||||
hardness: Value = Value::Null => typed,
|
||||
requires_tool: bool = false => value(flag),
|
||||
replaceable: bool = false => value(flag),
|
||||
random_ticks: bool = false => value(flag),
|
||||
block_entity: bool = false => value(flag),
|
||||
liquid: bool = false => value(flag),
|
||||
ignited_by_lava: bool = false => value(flag),
|
||||
push: Option<String> = None => value(text),
|
||||
render_shape: Option<String> = None => value(text),
|
||||
full_collision: bool = false => value(flag),
|
||||
friction: f64 = 0.0 => value(number),
|
||||
explosion_resistance: f64 = 0.0 => value(number),
|
||||
item: Option<String> = None => value(text),
|
||||
sound: Sound<'a> = Sound::default() => typed,
|
||||
offset: bool = false => value(flag),
|
||||
outline_offset: Option<bool> = None => value(|v: Value| v.as_bool()),
|
||||
map_color: Option<u64> = None => value(unsigned),
|
||||
outline: Option<&'a RawValue> = None => typed,
|
||||
outline_edges: Option<&'a RawValue> = None => typed,
|
||||
});
|
||||
|
||||
// Match Value::to_string identity without serializing: integer/float spelling is
|
||||
// distinct, as is the sign of floating zero. Object keys are already sorted by Value.
|
||||
struct ShapeKey(Value);
|
||||
fn same(a: &Value, b: &Value) -> bool {
|
||||
match (a, b) {
|
||||
(Value::Number(a), Value::Number(b)) => {
|
||||
a == b && a.as_f64().map(f64::to_bits) == b.as_f64().map(f64::to_bits)
|
||||
}
|
||||
(Value::Array(a), Value::Array(b)) => {
|
||||
a.len() == b.len() && a.iter().zip(b).all(|(a, b)| same(a, b))
|
||||
}
|
||||
(Value::Object(a), Value::Object(b)) => {
|
||||
a.len() == b.len()
|
||||
&& a.iter()
|
||||
.zip(b)
|
||||
.all(|((ak, av), (bk, bv))| ak == bk && same(av, bv))
|
||||
}
|
||||
_ => a == b,
|
||||
}
|
||||
}
|
||||
impl PartialEq for ShapeKey {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
same(&self.0, &other.0)
|
||||
}
|
||||
}
|
||||
impl Eq for ShapeKey {}
|
||||
fn shape_hash<H: Hasher>(v: &Value, h: &mut H) {
|
||||
std::mem::discriminant(v).hash(h);
|
||||
match v {
|
||||
Value::Null => {}
|
||||
Value::Bool(b) => b.hash(h),
|
||||
Value::String(s) => s.hash(h),
|
||||
Value::Number(n) => {
|
||||
n.is_f64().hash(h);
|
||||
n.hash(h);
|
||||
n.as_f64().map(f64::to_bits).hash(h);
|
||||
}
|
||||
Value::Array(a) => {
|
||||
a.len().hash(h);
|
||||
for v in a {
|
||||
shape_hash(v, h);
|
||||
}
|
||||
}
|
||||
Value::Object(o) => {
|
||||
o.len().hash(h);
|
||||
for (k, v) in o {
|
||||
k.hash(h);
|
||||
shape_hash(v, h);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Hash for ShapeKey {
|
||||
fn hash<H: Hasher>(&self, h: &mut H) {
|
||||
shape_hash(&self.0, h);
|
||||
}
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub(super) struct Shapes<'a> {
|
||||
raw: HashMap<&'a str, usize>,
|
||||
canonical: HashMap<ShapeKey, usize>,
|
||||
values: Vec<Arc<[[f64; 6]]>>,
|
||||
}
|
||||
impl<'a> Shapes<'a> {
|
||||
pub fn intern(
|
||||
&mut self,
|
||||
raw: Option<&'a RawValue>,
|
||||
fallback: impl FnOnce() -> Vec<[f64; 6]>,
|
||||
) -> crate::Result<Arc<[[f64; 6]]>> {
|
||||
let Some(raw) = raw else {
|
||||
return Ok(fallback().into());
|
||||
};
|
||||
if let Some(&id) = self.raw.get(raw.get()) {
|
||||
return Ok(self.values[id].clone());
|
||||
}
|
||||
let value: Value = serde_json::from_str(raw.get())?;
|
||||
if !value.is_array() {
|
||||
return Ok(fallback().into());
|
||||
}
|
||||
let id = match self.canonical.entry(ShapeKey(value)) {
|
||||
MapEntry::Occupied(entry) => *entry.get(),
|
||||
MapEntry::Vacant(entry) => {
|
||||
let shape = entry
|
||||
.key()
|
||||
.0
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|b| {
|
||||
let b = b.as_array()?;
|
||||
Some(std::array::from_fn(|i| {
|
||||
b.get(i).and_then(Value::as_f64).unwrap_or(0.0)
|
||||
}))
|
||||
})
|
||||
.collect::<Arc<[[f64; 6]]>>();
|
||||
let id = self.values.len();
|
||||
self.values.push(shape);
|
||||
entry.insert(id);
|
||||
id
|
||||
}
|
||||
};
|
||||
self.raw.insert(raw.get(), id);
|
||||
Ok(self.values[id].clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "decode_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,137 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shape_identity_and_geometry_match_the_value_loader() -> crate::Result<()> {
|
||||
let inputs = [
|
||||
"[]",
|
||||
"[ ]",
|
||||
"[[0,0,0,1,1,1]]",
|
||||
"[ [0, 0, 0, 1, 1, 1] ]",
|
||||
"[[0.0,0,0,1,1,1]]",
|
||||
"[[0e0,0,0,1,1,1]]",
|
||||
"[[-0.0,0,0,1,1,1]]",
|
||||
"[[0,0,0,1,1,1,7]]",
|
||||
"[[0,0,0,1,1,1,8]]",
|
||||
"[[1,null,\"2\"],true,{}]",
|
||||
"[[1,null,\"\\u0032\"],true,{}]",
|
||||
"[{\"z\":0,\"a\":1}]",
|
||||
"[{\"a\":1,\"z\":0}]",
|
||||
"[[18446744073709551615,-9223372036854775808,1e20]]",
|
||||
"[{\"a\":2,\"a\":1,\"z\":0}]",
|
||||
];
|
||||
let values = inputs
|
||||
.iter()
|
||||
.map(|s| serde_json::from_str::<Value>(s))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
// Value equality merges signed zero; the legacy string keys do not.
|
||||
let keys: Vec<_> = values.iter().map(Value::to_string).collect();
|
||||
let mut shapes = Shapes::default();
|
||||
let actual = inputs
|
||||
.iter()
|
||||
.map(|s| {
|
||||
shapes.intern(Some(serde_json::from_str::<&RawValue>(s)?), || {
|
||||
panic!("array fallback")
|
||||
})
|
||||
})
|
||||
.collect::<crate::Result<Vec<_>>>()?;
|
||||
for (i, value) in values.iter().enumerate() {
|
||||
let expected: Vec<[f64; 6]> = value
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|b| {
|
||||
let b = b.as_array()?;
|
||||
Some(std::array::from_fn(|i| {
|
||||
b.get(i).and_then(Value::as_f64).unwrap_or(0.0)
|
||||
}))
|
||||
})
|
||||
.collect();
|
||||
// Bit comparisons include negative zero.
|
||||
assert_eq!(
|
||||
actual[i]
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|v| v.to_bits())
|
||||
.collect::<Vec<_>>(),
|
||||
expected
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|v| v.to_bits())
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
for j in 0..values.len() {
|
||||
assert_eq!(
|
||||
Arc::ptr_eq(&actual[i], &actual[j]),
|
||||
keys[i] == keys[j],
|
||||
"{i}, {j}"
|
||||
);
|
||||
}
|
||||
}
|
||||
for input in ["null", "false", "7", "{}", "\"shape\""] {
|
||||
let raw = serde_json::from_str(input)?;
|
||||
let a = shapes.intern(Some(raw), || vec![[7.0; 6]])?;
|
||||
let b = shapes.intern(Some(raw), || vec![[7.0; 6]])?;
|
||||
assert_eq!(&*a, &[[7.0; 6]]);
|
||||
assert!(!Arc::ptr_eq(&a, &b)); // Fallbacks were never interned.
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_fields_keep_defaults_duplicates_and_wrong_type_behavior() -> crate::Result<()> {
|
||||
let input = r#"{"states":[null,[],{"id":0,"id":2,"emission":4.5,"dampening":"15",
|
||||
"hardness":-0.0,"skylight_down":true,"offset":true,"outline_offset":"true",
|
||||
"item":42,"render_shape":false,"sound":{"volume":null,"pitch":2,"break":{"id":"a\u0062"},
|
||||
"step":false,"hit":{"id":2}},"outline":[[0,1]],"unknown":{"nested":true}}]}"#;
|
||||
let parsed: Gameplay<'_> = serde_json::from_str(input)?;
|
||||
let entries = parsed.states.0.unwrap();
|
||||
assert_eq!(entries[0].id, None);
|
||||
assert_eq!(entries[1].id, None);
|
||||
let e = &entries[2];
|
||||
assert_eq!(e.id, Some(2));
|
||||
assert_eq!(e.emission.as_f64(), Some(4.5));
|
||||
assert_eq!(e.dampening.as_f64().unwrap_or(0.0), 0.0);
|
||||
assert_eq!(e.hardness.as_f64().unwrap().to_bits(), (-0.0f64).to_bits());
|
||||
assert!(e.skylight_down && e.outline_offset.unwrap_or(e.offset));
|
||||
assert_eq!(e.item, None);
|
||||
assert_eq!(e.render_shape, None);
|
||||
assert_eq!((e.sound.volume, e.sound.pitch), (1.0, 2.0));
|
||||
assert_eq!(e.sound.r#break.id.as_deref(), Some("ab"));
|
||||
assert_eq!(e.sound.step.id, None);
|
||||
assert_eq!(e.sound.hit.id, None);
|
||||
assert_eq!(e.outline.unwrap().get(), "[[0,1]]");
|
||||
for input in ["null", "[]", "{}", r#"{"states":null}"#, r#"{"states":{}}"#] {
|
||||
let parsed: Gameplay<'_> = serde_json::from_str(input)?;
|
||||
assert!(parsed.states.0.is_none());
|
||||
}
|
||||
for input in [
|
||||
r#"{"states":["#,
|
||||
r#"{"states":[{"id":1e400}]}"#,
|
||||
"{} trailing",
|
||||
r#"{"unknown":1e400}"#,
|
||||
] {
|
||||
let before = serde_json::from_str::<Value>(input).err().unwrap();
|
||||
let after = serde_json::from_str::<Gameplay<'_>>(input).err().unwrap();
|
||||
assert_eq!(before.classify(), after.classify());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_shape_capture_keeps_original_json_errors() {
|
||||
let deep = format!("{}0{}", "[".repeat(130), "]".repeat(130));
|
||||
let inputs = [
|
||||
br#"{"states":[{"outline":[[1e400]],"outline":[]}]}"#.to_vec(),
|
||||
format!("{{\"states\":[{{\"outline\":{deep}}}]}}").into_bytes(),
|
||||
br#"{"states":[{"outline":[[0]]}]} trailing"#.to_vec(),
|
||||
b"{\"states\":[{\"outline\":[[\"\xff\"]]}]}".to_vec(),
|
||||
];
|
||||
for input in inputs {
|
||||
let old = serde_json::from_slice::<Value>(&input).err().unwrap();
|
||||
let new = serde_json::from_slice::<Validate>(&input).err().unwrap();
|
||||
assert_eq!(
|
||||
(old.classify(), old.line(), old.column()),
|
||||
(new.classify(), new.line(), new.column())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,35 @@ pub fn geometry(model: &BakedModel, origin: glam::Vec3, translucent: bool, out:
|
||||
}
|
||||
}
|
||||
|
||||
/// Project a block entity's transformed quads onto the same destroy-stage pass.
|
||||
pub fn entity_geometry(vertices: &mut [Vertex], origin: glam::Vec3) {
|
||||
for quad in vertices.as_chunks_mut::<4>().0 {
|
||||
let positions = [0, 1, 2].map(|i| glam::Vec3::from(quad[i].pos));
|
||||
let normal = (positions[1] - positions[0]).cross(positions[2] - positions[0]);
|
||||
let mut facing = Direction::North;
|
||||
let mut best = f32::from_bits(1);
|
||||
for (direction, dot) in [
|
||||
(Direction::Down, -normal.y),
|
||||
(Direction::Up, normal.y),
|
||||
(Direction::North, -normal.z),
|
||||
(Direction::South, normal.z),
|
||||
(Direction::West, -normal.x),
|
||||
(Direction::East, normal.x),
|
||||
] {
|
||||
if dot > best {
|
||||
facing = direction;
|
||||
best = dot;
|
||||
}
|
||||
}
|
||||
for vertex in quad {
|
||||
vertex.uv = decal_uv((glam::Vec3::from(vertex.pos) - origin).to_array(), facing);
|
||||
vertex.layer = 11;
|
||||
vertex.color = 0xffff_ffff;
|
||||
vertex.light = 240 | (240 << 8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -49,6 +78,7 @@ mod tests {
|
||||
fn slab_top() -> BakedModel {
|
||||
BakedModel {
|
||||
ambient_occlusion: true,
|
||||
particle: None,
|
||||
cube: None,
|
||||
faces: vec![BakedFace {
|
||||
layer: 0,
|
||||
@@ -86,6 +116,24 @@ mod tests {
|
||||
assert_eq!(v.color, 0xffff_ffff);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn entity_overlay_uses_block_local_projected_uvs() {
|
||||
let origin = glam::Vec3::new(12.0, -3.0, 8.0);
|
||||
let mut expected = Vec::new();
|
||||
geometry(&slab_top(), origin, false, &mut expected);
|
||||
let mut actual = expected.clone();
|
||||
for vertex in &mut actual {
|
||||
vertex.uv = [0.0; 2];
|
||||
vertex.layer = 0;
|
||||
vertex.color = 0;
|
||||
}
|
||||
entity_geometry(&mut actual, origin);
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<_, u8>(&actual),
|
||||
bytemuck::cast_slice::<_, u8>(&expected)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_faces_use_projected_not_stretched_uvs() {
|
||||
let a = decal_uv([0.0, 0.0, 0.0], Direction::North);
|
||||
|
||||
@@ -99,6 +99,8 @@ pub struct Chat {
|
||||
pub options: ChatOptions,
|
||||
/// Messages printed to stdout as `[CHAT]` (vanilla logs them).
|
||||
pub log: bool,
|
||||
/// Messages added since the start, for the control socket's events.
|
||||
pub received: u64,
|
||||
}
|
||||
impl Chat {
|
||||
pub fn lines_per_page(&self, focused: bool) -> usize {
|
||||
@@ -130,6 +132,7 @@ impl Chat {
|
||||
self.add_lines(atlas, &message, focused);
|
||||
self.messages.insert(0, message);
|
||||
self.messages.truncate(100);
|
||||
self.received += 1;
|
||||
}
|
||||
fn add_lines(&mut self, atlas: &HudAtlas, message: &Message, focused: bool) {
|
||||
let width = self.max_width();
|
||||
@@ -515,7 +518,9 @@ 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.suggestions(parse, cursor_b, c.registries);
|
||||
let (range, list) = c
|
||||
.dispatcher
|
||||
.client_suggestions(parse, cursor_b, c.registries);
|
||||
self.pending = Some((range.0, list));
|
||||
self.update_usage_info(m, c);
|
||||
}
|
||||
|
||||
@@ -163,3 +163,70 @@ fn locate_coordinates_fill_chat_with_teleport_without_submitting() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_arrows_recall_messages_and_restore_the_draft() {
|
||||
use winit::keyboard::KeyCode::{ArrowDown, ArrowUp};
|
||||
|
||||
let font = font();
|
||||
let m = Measure(&font);
|
||||
let registries = Registries::default();
|
||||
let commands = Commands {
|
||||
dispatcher: crate::command::vanilla::game_dispatcher(),
|
||||
registries: ®istries,
|
||||
};
|
||||
let history = vec!["first message".to_owned(), "latest message".to_owned()];
|
||||
let mut screen = ChatScreen::new(&m, &commands, "unfinished draft", history.len(), 640, 360);
|
||||
for (key, expected) in [
|
||||
(ArrowUp, "latest message"),
|
||||
(ArrowUp, "first message"),
|
||||
(ArrowUp, "first message"),
|
||||
(ArrowDown, "latest message"),
|
||||
(ArrowDown, "unfinished draft"),
|
||||
(ArrowDown, "unfinished draft"),
|
||||
] {
|
||||
assert_eq!(
|
||||
screen.key(&m, &commands, &history, key, false, false),
|
||||
ChatAction::None
|
||||
);
|
||||
assert_eq!(screen.value(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mod_commands_are_coloured_and_complete_like_vanilla_ones() {
|
||||
let font = font();
|
||||
let m = Measure(&font);
|
||||
// Allow Commands off: a mod's command needs no level.
|
||||
let registries = Registries::default();
|
||||
let vanilla = crate::command::vanilla::game_dispatcher();
|
||||
let modded = vanilla.with_mod_commands(["where", "coords"]);
|
||||
let red = |screen: &ChatScreen| {
|
||||
screen
|
||||
.format(screen.value(), 0)
|
||||
.spans
|
||||
.iter()
|
||||
.any(|s| s.style.color == Some(text::RED))
|
||||
};
|
||||
let typed = |dispatcher: &crate::command::Dispatcher, line: &str| {
|
||||
let c = Commands {
|
||||
dispatcher,
|
||||
registries: ®istries,
|
||||
};
|
||||
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360);
|
||||
screen.type_text(&m, &c, line);
|
||||
screen
|
||||
};
|
||||
// Unknown without the mod's tree (#456).
|
||||
assert!(red(&typed(vanilla, "/coords")));
|
||||
for line in ["/coords", "/where", "/where me and you"] {
|
||||
assert!(!red(&typed(&modded, line)), "{line}");
|
||||
}
|
||||
let c = Commands {
|
||||
dispatcher: &modded,
|
||||
registries: ®istries,
|
||||
};
|
||||
let mut screen = typed(&modded, "/coo");
|
||||
screen.key(&m, &c, &[], winit::keyboard::KeyCode::Tab, false, false);
|
||||
assert_eq!(screen.value(), "/coords");
|
||||
}
|
||||
@@ -158,6 +158,19 @@ impl Chunk {
|
||||
pub fn block(&self, x: i32, y: i32, z: i32) -> BlockStateId {
|
||||
BlockStateId(u32::from(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> {
|
||||
self.sections
|
||||
.iter()
|
||||
.enumerate()
|
||||
.rev()
|
||||
.filter(|(_, section)| !section.is_uniform(skip))
|
||||
.find_map(|(s, _)| {
|
||||
let base = MIN_Y + s as i32 * 16;
|
||||
(base..base + 16).rev().find(|&y| matches(y))
|
||||
})
|
||||
}
|
||||
pub fn set(&mut self, x: i32, y: i32, z: i32, v: u16) -> u16 {
|
||||
let s = (y - MIN_Y) >> 4;
|
||||
if !(0..SECTIONS as i32).contains(&s) {
|
||||
@@ -198,3 +211,40 @@ impl Chunk {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn highest_skips_only_sections_that_cannot_match() {
|
||||
let (air, cave_air, stone) = (0, 7, 1);
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
// Stone low down, cave air (uniform in its section) above it, and a
|
||||
// section of air with one stone block higher up.
|
||||
for y in MIN_Y..MIN_Y + 40 {
|
||||
chunk.set(3, y, 4, stone);
|
||||
}
|
||||
for i in 0..4096 {
|
||||
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| {
|
||||
(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,
|
||||
] {
|
||||
let fast = chunk.highest(air, |y| matches(chunk.get(3, y, 4)));
|
||||
assert_eq!(fast, full(&chunk, matches));
|
||||
}
|
||||
assert_eq!(
|
||||
chunk.highest(air, |y| chunk.get(3, y, 4) == stone),
|
||||
Some(150)
|
||||
);
|
||||
assert_eq!(Chunk::empty(0, 0).highest(air, |_| unreachable!()), None);
|
||||
}
|
||||
}
|
||||
@@ -223,6 +223,10 @@ impl AppendSelection {
|
||||
pub fn covers(&self, id: FieldId, value: &str) -> bool {
|
||||
self.0.as_ref().is_some_and(|(i, v)| *i == id && v == value)
|
||||
}
|
||||
/// Select the whole `value` of field `id`, as Select All does.
|
||||
pub fn select(&mut self, id: FieldId, value: &str) {
|
||||
self.0 = Some((id, value.to_owned()));
|
||||
}
|
||||
/// Before typing or a backspace: an all-selected value is cleared (true).
|
||||
pub fn take(&mut self, id: FieldId, value: &mut String) -> bool {
|
||||
let covered = self.covers(id, value);
|
||||
@@ -247,7 +251,7 @@ impl AppendSelection {
|
||||
let covered = self.covers(id, value);
|
||||
match shortcut {
|
||||
EditShortcut::SelectAll => {
|
||||
self.0 = Some((id, value.clone()));
|
||||
self.select(id, value);
|
||||
false
|
||||
}
|
||||
EditShortcut::Copy => {
|
||||
|
||||
@@ -298,6 +298,9 @@ pub struct FixedStep {
|
||||
tick_times: [i64; SAMPLES],
|
||||
aggregated: i64,
|
||||
tick_count: i64,
|
||||
/// A server's client: its level's tick rate only slows the timer down
|
||||
/// (`Minecraft.getTickTargetMillis`), since the server runs the ticks.
|
||||
client: bool,
|
||||
}
|
||||
|
||||
impl Default for FixedStep {
|
||||
@@ -312,11 +315,20 @@ impl Default for FixedStep {
|
||||
tick_times: [0; SAMPLES],
|
||||
aggregated: 0,
|
||||
tick_count: 0,
|
||||
client: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FixedStep {
|
||||
/// The clock of a client connected to a server: the `ClientLevel`'s
|
||||
/// `TickRateManager`, set by the server's ticking packets.
|
||||
pub fn client() -> Self {
|
||||
Self {
|
||||
client: true,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
fn now_ms(&self) -> i64 {
|
||||
self.now.as_millis() as i64
|
||||
}
|
||||
@@ -341,9 +353,14 @@ impl FixedStep {
|
||||
}
|
||||
let runs = self.manager.base.runs_normally();
|
||||
let mspt = self.manager.base.milliseconds_per_tick();
|
||||
let client = self.client;
|
||||
let ticks = self
|
||||
.timer
|
||||
.advance_game_time(now_ms, |default| if runs { mspt } else { default });
|
||||
.advance_game_time(now_ms, |default| match (runs, client) {
|
||||
(true, true) => default.max(mspt),
|
||||
(true, false) => mspt,
|
||||
(false, _) => default,
|
||||
});
|
||||
ticks.clamp(0, 10) as u32
|
||||
}
|
||||
/// Restart pacing from now (after loading, so no backlog replays).
|
||||
@@ -393,9 +410,17 @@ impl FixedStep {
|
||||
pub fn frozen(&self) -> bool {
|
||||
self.manager.base.is_frozen()
|
||||
}
|
||||
/// `runsNormally` as of the last `world_runs`.
|
||||
pub fn runs_normally(&self) -> bool {
|
||||
self.manager.base.runs_normally()
|
||||
}
|
||||
pub fn set_frozen(&mut self, frozen: bool) {
|
||||
self.manager.base.set_frozen(frozen);
|
||||
}
|
||||
/// `TickRateManager.setFrozenTicksToRun` (ClientboundTickingStepPacket).
|
||||
pub fn set_frozen_ticks_to_run(&mut self, ticks: i32) {
|
||||
self.manager.base.set_frozen_ticks_to_run(ticks);
|
||||
}
|
||||
pub fn sprinting(&self) -> bool {
|
||||
self.manager.is_sprinting()
|
||||
}
|
||||
@@ -480,4 +505,21 @@ mod tests {
|
||||
m.base.tick();
|
||||
assert!(!m.base.runs_normally());
|
||||
}
|
||||
#[test]
|
||||
fn a_servers_tick_rate_only_slows_its_client_down() {
|
||||
let quarter = std::time::Duration::from_millis(250);
|
||||
let mut client = FixedStep::client();
|
||||
client.set_rate(40.0);
|
||||
assert_eq!(client.advance(quarter), 5, "still 20 a second");
|
||||
let mut slow = FixedStep::client();
|
||||
slow.set_rate(10.0);
|
||||
assert_eq!(slow.advance(quarter * 2), 5, "10 a second");
|
||||
let mut local = FixedStep::default();
|
||||
local.set_rate(40.0);
|
||||
assert_eq!(
|
||||
local.advance(quarter),
|
||||
10,
|
||||
"the integrated loop runs at the rate"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -68,9 +68,226 @@ pub fn cells(radius: i32) -> impl Iterator<Item = (i32, i32)> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Per-frame cloud inputs. Resources are immutable for the renderer's lifetime:
|
||||
/// resource-pack reloads replace the renderer and its cache together.
|
||||
pub struct Input<'a> {
|
||||
pub texture: &'a (u32, u32, Vec<bool>),
|
||||
pub uv: [f32; 2],
|
||||
pub camera: glam::DVec3,
|
||||
pub time: i64,
|
||||
pub partial_tick: f64,
|
||||
pub range: i32,
|
||||
pub fast: bool,
|
||||
pub color: [f32; 4],
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct MeshKey {
|
||||
cell_x: i64,
|
||||
cell_z: i64,
|
||||
radius: i32,
|
||||
fast: bool,
|
||||
// The two vertical face tests include both surfaces at the boundaries.
|
||||
top: bool,
|
||||
bottom: bool,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub(crate) struct Uniform {
|
||||
offset: [f32; 4],
|
||||
colors: [[f32; 4]; 4],
|
||||
}
|
||||
|
||||
impl Input<'_> {
|
||||
fn state(&self) -> (MeshKey, Uniform) {
|
||||
let (w, h, _) = self.texture;
|
||||
let offset = self.time.rem_euclid(i64::from(*w) * 400) as f64 + self.partial_tick;
|
||||
let mut x = self.camera.x + offset * 0.030_000_001;
|
||||
let mut z = self.camera.z + 3.96;
|
||||
let (tw, th) = (f64::from(*w) * 12.0, f64::from(*h) * 12.0);
|
||||
x -= (x / tw).floor() * tw;
|
||||
z -= (z / th).floor() * th;
|
||||
let cell_x = (x / 12.0).floor() as i64;
|
||||
let cell_z = (z / 12.0).floor() as i64;
|
||||
let bottom = (192.33 - self.camera.y) as f32;
|
||||
let key = MeshKey {
|
||||
cell_x,
|
||||
cell_z,
|
||||
radius: radius_cells(self.range),
|
||||
fast: self.fast,
|
||||
top: bottom <= 0.0,
|
||||
bottom: bottom + 4.0 >= 0.0,
|
||||
};
|
||||
let colors = [1.0, 0.7, 0.8, 0.9].map(|shade| {
|
||||
let [r, g, b, a] = self.color;
|
||||
[r * shade, g * shade, b * shade, a]
|
||||
.map(|v| (v.clamp(0.0, 1.0) * 255.0) as u8 as f32 / 255.0)
|
||||
});
|
||||
(
|
||||
key,
|
||||
Uniform {
|
||||
offset: [
|
||||
-(x - cell_x as f64 * 12.0) as f32,
|
||||
bottom,
|
||||
-(z - cell_z as f64 * 12.0) as f32,
|
||||
0.0,
|
||||
],
|
||||
colors,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Mesh {
|
||||
key: Option<MeshKey>,
|
||||
pub vertices: Vec<crate::mesher::Vertex>,
|
||||
}
|
||||
|
||||
impl Mesh {
|
||||
/// Returns true only when vertices must be uploaded. Sub-cell motion and
|
||||
/// color changes update only the uniform, as in CloudRenderer.prepare.
|
||||
pub fn prepare(&mut self, input: &Input<'_>) -> (bool, Uniform) {
|
||||
let (key, uniform) = input.state();
|
||||
if self.key.as_ref() == Some(&key) {
|
||||
return (false, uniform);
|
||||
}
|
||||
self.vertices.clear();
|
||||
let (w, h, cells) = input.texture;
|
||||
let (w, h) = (i64::from(*w), i64::from(*h));
|
||||
let (cell_x, cell_z, radius) = (key.cell_x, key.cell_z, key.radius);
|
||||
let bottom = uniform.offset[1];
|
||||
let uv = input.uv;
|
||||
let filled = |cx: i64, cz: i64| cells[(cz.rem_euclid(h) * w + cx.rem_euclid(w)) as usize];
|
||||
for (dx, dz) in self::cells(radius) {
|
||||
let (dx, dz) = (i64::from(dx), i64::from(dz));
|
||||
let (cx, cz) = (cell_x + dx, cell_z + dz);
|
||||
if !filled(cx, cz) {
|
||||
continue;
|
||||
}
|
||||
let x0 = dx as f32 * 12.0;
|
||||
let z0 = dz as f32 * 12.0;
|
||||
let (x1, z1, y0, y1) = (x0 + 12.0, z0 + 12.0, 0.0, 4.0);
|
||||
let mut quad = |p: [[f32; 3]; 4], shade: u32| {
|
||||
for v in p {
|
||||
// Overlay 4: clouds (unlit, faded by the cloud fog end).
|
||||
self.vertices.push(crate::mesher::Vertex {
|
||||
normal: [0; 4],
|
||||
pos: v,
|
||||
uv,
|
||||
layer: 4,
|
||||
color: u32::MAX,
|
||||
light: shade,
|
||||
});
|
||||
}
|
||||
};
|
||||
if input.fast {
|
||||
for p in flat_cell(x0, z0, 0.0) {
|
||||
quad(p, 0);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let quads = [
|
||||
([[x1, y0, z0], [x1, y0, z1], [x0, y0, z1], [x0, y0, z0]], 1),
|
||||
([[x0, y1, z0], [x0, y1, z1], [x1, y1, z1], [x1, y1, z0]], 0),
|
||||
([[x0, y0, z0], [x0, y1, z0], [x1, y1, z0], [x1, y0, z0]], 2),
|
||||
([[x1, y0, z1], [x1, y1, z1], [x0, y1, z1], [x0, y0, z1]], 2),
|
||||
([[x0, y0, z1], [x0, y1, z1], [x0, y1, z0], [x0, y0, z0]], 3),
|
||||
([[x1, y0, z0], [x1, y1, z0], [x1, y1, z1], [x1, y0, z1]], 3),
|
||||
];
|
||||
let mask = face_mask(
|
||||
dx as i32,
|
||||
dz as i32,
|
||||
bottom,
|
||||
[
|
||||
!filled(cx, cz - 1),
|
||||
!filled(cx, cz + 1),
|
||||
!filled(cx - 1, cz),
|
||||
!filled(cx + 1, cz),
|
||||
],
|
||||
);
|
||||
for (direction, inside) in faces(mask) {
|
||||
let (p, shade) = quads[usize::from(direction)];
|
||||
quad(if inside { [p[3], p[2], p[1], p[0]] } else { p }, shade);
|
||||
}
|
||||
}
|
||||
self.key = Some(key);
|
||||
(true, uniform)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn cached_mesh_tracks_geometry_but_not_subcell_motion_or_color() {
|
||||
let texture = (8, 8, (0..64).map(|i| i % 3 != 0).collect());
|
||||
let mut input = Input {
|
||||
texture: &texture,
|
||||
uv: [0.5; 2],
|
||||
camera: glam::DVec3::new(1.0, 100.0, 1.0),
|
||||
time: 0,
|
||||
partial_tick: 0.0,
|
||||
range: 2,
|
||||
fast: false,
|
||||
color: [1.0; 4],
|
||||
};
|
||||
let mut mesh = Mesh::default();
|
||||
assert!(mesh.prepare(&input).0);
|
||||
let vertices = bytemuck::cast_slice::<_, u8>(&mesh.vertices).to_vec();
|
||||
let pointer = mesh.vertices.as_ptr();
|
||||
for tick in 0..100 {
|
||||
input.time = tick;
|
||||
input.partial_tick = 0.5;
|
||||
input.camera.x = 2.0;
|
||||
input.camera.y = 101.0;
|
||||
input.color = [0.8, 0.7, 0.6, 0.5];
|
||||
let (rebuilt, uniform) = mesh.prepare(&input);
|
||||
assert!(!rebuilt);
|
||||
assert_eq!(mesh.vertices.as_ptr(), pointer);
|
||||
assert_eq!(bytemuck::cast_slice::<_, u8>(&mesh.vertices), vertices);
|
||||
assert_eq!(uniform.offset[1], (192.33 - input.camera.y) as f32);
|
||||
assert_eq!(
|
||||
uniform.colors[1],
|
||||
[0.8 * 0.7, 0.7 * 0.7, 0.6 * 0.7, 0.5].map(|v| (v * 255.0) as u8 as f32 / 255.0)
|
||||
);
|
||||
for color in uniform.colors {
|
||||
assert_eq!(color[3], 127.0 / 255.0);
|
||||
}
|
||||
}
|
||||
for (x, y, z, range, fast) in [
|
||||
(13.0, 101.0, 1.0, 2, false),
|
||||
(13.0, 101.0, -5.0, 2, false),
|
||||
(13.0, 192.33, -5.0, 2, false),
|
||||
(13.0, 196.34, -5.0, 2, false),
|
||||
(13.0, 196.34, -5.0, 3, false),
|
||||
(13.0, 196.34, -5.0, 3, true),
|
||||
] {
|
||||
input.camera = glam::DVec3::new(x, y, z);
|
||||
input.range = range;
|
||||
input.fast = fast;
|
||||
assert!(mesh.prepare(&input).0);
|
||||
let mut fresh = Mesh::default();
|
||||
fresh.prepare(&input);
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<_, u8>(&mesh.vertices),
|
||||
bytemuck::cast_slice::<_, u8>(&fresh.vertices)
|
||||
);
|
||||
assert!(!mesh.prepare(&input).0);
|
||||
}
|
||||
// Drift alone crosses a cell, even with a stationary camera.
|
||||
input.time += 400;
|
||||
assert!(mesh.prepare(&input).0);
|
||||
// Resource reload constructs a new renderer and cache, including empty packs.
|
||||
let mut reloaded = Mesh::default();
|
||||
let empty = (1, 1, vec![false]);
|
||||
input.texture = ∅
|
||||
assert!(reloaded.prepare(&input).0);
|
||||
assert!(reloaded.vertices.is_empty());
|
||||
assert!(!reloaded.prepare(&input).0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_and_packed_alpha_gate_clouds() {
|
||||
for mode in ["false", "fast", "true"] {
|
||||
|
||||
@@ -28,7 +28,7 @@ use std::{fs, 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::default(),
|
||||
Limits::tables(),
|
||||
)?
|
||||
.0
|
||||
.tag;
|
||||
@@ -65,6 +65,68 @@ pub struct SceneCollisions<'w, 'r> {
|
||||
/// The entity colliding, for powder snow; None: no fall, no boots.
|
||||
pub subject: Option<PowderSnowSubject>,
|
||||
}
|
||||
/// Already selected entity boxes alongside the resident block collision world.
|
||||
pub struct EntityCollisions<'a, W> {
|
||||
pub blocks: W,
|
||||
pub entities: &'a [Aabb],
|
||||
}
|
||||
impl<W: BlockCollisionWorld> BlockCollisionWorld for EntityCollisions<'_, W> {
|
||||
type Error = W::Error;
|
||||
type Chunk = W::Chunk;
|
||||
fn collision_chunk(
|
||||
&mut self,
|
||||
p: ChunkPos,
|
||||
) -> std::result::Result<Option<Self::Chunk>, Self::Error> {
|
||||
self.blocks.collision_chunk(p)
|
||||
}
|
||||
fn collision_block(
|
||||
&mut self,
|
||||
chunk: Self::Chunk,
|
||||
p: BlockPos,
|
||||
) -> std::result::Result<CollisionBlock, Self::Error> {
|
||||
self.blocks.collision_block(chunk, p)
|
||||
}
|
||||
fn is_suffocating(
|
||||
&mut self,
|
||||
chunk: Self::Chunk,
|
||||
block: CollisionBlock,
|
||||
p: BlockPos,
|
||||
) -> std::result::Result<bool, Self::Error> {
|
||||
self.blocks.is_suffocating(chunk, block, p)
|
||||
}
|
||||
fn context_shape(
|
||||
&mut self,
|
||||
block: CollisionBlock,
|
||||
p: BlockPos,
|
||||
context: &CollisionContext<'_>,
|
||||
) -> std::result::Result<VoxelShape, Self::Error> {
|
||||
self.blocks.context_shape(block, p, context)
|
||||
}
|
||||
}
|
||||
impl<W: EntityMovementWorld<Error = io::Error>> EntityMovementWorld for EntityCollisions<'_, W> {
|
||||
type Error = io::Error;
|
||||
fn entity_collisions(
|
||||
&mut self,
|
||||
entity: EntityIdentity,
|
||||
bounds: Aabb,
|
||||
) -> io::Result<Vec<VoxelShape>> {
|
||||
let mut shapes = self.blocks.entity_collisions(entity, bounds)?;
|
||||
for &aabb in self.entities {
|
||||
// EntityGetter.getEntityCollisions expands its entity query by 1e-7.
|
||||
if aabb.intersects(bounds.inflate(Vec3::new(1.0e-7, 1.0e-7, 1.0e-7))) {
|
||||
shapes.push(VoxelShape::from_box(aabb).map_err(io::Error::other)?);
|
||||
}
|
||||
}
|
||||
Ok(shapes)
|
||||
}
|
||||
fn movement_border(
|
||||
&mut self,
|
||||
entity: EntityIdentity,
|
||||
bounds: Aabb,
|
||||
) -> io::Result<Option<VoxelShape>> {
|
||||
self.blocks.movement_border(entity, bounds)
|
||||
}
|
||||
}
|
||||
impl SceneCollisions<'_, '_> {
|
||||
/// Position contexts permit geometric movement probes without inventing a
|
||||
/// player, inventory, effects or fluid-standing behavior; a lava walker
|
||||
@@ -147,21 +209,22 @@ where
|
||||
)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
impl BlockCollisionWorld for SceneCollisions<'_, '_> {
|
||||
impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
|
||||
type Error = io::Error;
|
||||
type Chunk = ChunkPos;
|
||||
fn collision_chunk(&mut self, p: ChunkPos) -> io::Result<Option<ChunkPos>> {
|
||||
Ok(self.world.chunks.contains_key(&(p.x, p.z)).then_some(p))
|
||||
/// The resident column, looked up once per chunk the scan enters.
|
||||
type Chunk = &'w crate::chunk::Chunk;
|
||||
fn collision_chunk(&mut self, p: ChunkPos) -> io::Result<Option<Self::Chunk>> {
|
||||
Ok(self.world.chunks.get(&(p.x, p.z)).map(|chunk| &**chunk))
|
||||
}
|
||||
fn collision_block(&mut self, chunk: ChunkPos, p: BlockPos) -> io::Result<CollisionBlock> {
|
||||
let chunk = self
|
||||
.world
|
||||
.chunks
|
||||
.get(&(chunk.x, chunk.z))
|
||||
.ok_or_else(|| io::Error::other("resident collision chunk disappeared"))?;
|
||||
fn collision_block(&mut self, chunk: Self::Chunk, p: BlockPos) -> io::Result<CollisionBlock> {
|
||||
self.binding.collision_block(chunk.block(p.x, p.y, p.z))
|
||||
}
|
||||
fn is_suffocating(&mut self, _: ChunkPos, _: CollisionBlock, _: BlockPos) -> io::Result<bool> {
|
||||
fn is_suffocating(
|
||||
&mut self,
|
||||
_: Self::Chunk,
|
||||
_: CollisionBlock,
|
||||
_: BlockPos,
|
||||
) -> io::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
fn context_shape(
|
||||
|
||||
@@ -61,6 +61,33 @@ pub fn decode_component(id: &str, t: &Tag) -> Result<(), String> {
|
||||
_ => Err(not_map(t)),
|
||||
},
|
||||
"minecraft:custom_name" | "minecraft:item_name" => super::component::decode(t),
|
||||
// TypedEntityData.codec: the type is the compound's id.
|
||||
"minecraft:block_entity_data" => match t {
|
||||
Tag::Compound(c) => match c.get(&"id".into()) {
|
||||
None => Err(format!("Expected 'id' field in {}", snbt(t))),
|
||||
Some(Tag::String(id)) => {
|
||||
let id = id.to_string_lossy();
|
||||
let id = if id.contains(':') {
|
||||
id
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
};
|
||||
let known = crate::block_entity_nbt::TYPES
|
||||
.iter()
|
||||
.chain(&crate::block_entity_nbt::NEWEST)
|
||||
.any(|k| *k == id);
|
||||
if known {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"Unknown registry key in ResourceKey[minecraft:root / minecraft:block_entity_type]: {id}"
|
||||
))
|
||||
}
|
||||
}
|
||||
Some(_) => Ok(()),
|
||||
},
|
||||
_ => Ok(()),
|
||||
},
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
@@ -609,3 +636,26 @@ pub fn suggest_predicate(builder: &mut SuggestionsBuilder, reg: &Registries) {
|
||||
fn _unused(t: &Tag) -> String {
|
||||
snbt(t)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::decode_component;
|
||||
|
||||
fn check(snbt: &str) -> Result<(), String> {
|
||||
let tag = horizon_world::nbt::parse_snbt(snbt).expect("snbt").0;
|
||||
decode_component("minecraft:block_entity_data", &tag)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_entity_data_needs_a_known_type() {
|
||||
assert_eq!(check("{id:\"chest\",Lock:1}"), Ok(()));
|
||||
assert_eq!(
|
||||
check("{Command:\"say\"}"),
|
||||
Err("Expected 'id' field in {Command:\"say\"}".into())
|
||||
);
|
||||
assert_eq!(
|
||||
check("{id:\"minecraft:stone\"}"),
|
||||
Err("Unknown registry key in ResourceKey[minecraft:root / minecraft:block_entity_type]: minecraft:stone".into())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ pub mod block;
|
||||
pub mod codec;
|
||||
pub mod component;
|
||||
pub mod item;
|
||||
pub mod network;
|
||||
pub mod packrat;
|
||||
pub mod selector;
|
||||
pub mod snbt;
|
||||
@@ -23,6 +24,9 @@ mod vanilla_order;
|
||||
use crate::text::Text;
|
||||
pub use args::{Arg, ArgType, Registries};
|
||||
|
||||
/// The command id of a mod's command (`Dispatcher::with_mod_commands`).
|
||||
pub const MOD_COMMAND: &str = "mod";
|
||||
|
||||
/// A command error (CommandSyntaxException): the message and, for parse
|
||||
/// errors, the input and cursor it occurred at.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -770,6 +774,19 @@ impl Dispatcher {
|
||||
self.nodes[node].permission = level;
|
||||
node
|
||||
}
|
||||
/// This tree plus the commands mods own (`docs/mods.md`), so the chat
|
||||
/// colours and completes them like the others. Every player may use one,
|
||||
/// and the text after its name goes to the mod as it was typed.
|
||||
pub fn with_mod_commands<'a>(&self, names: impl IntoIterator<Item = &'a str>) -> Dispatcher {
|
||||
let mut d = self.clone();
|
||||
for name in names {
|
||||
let command = d.literal(0, name);
|
||||
d.executes(command, MOD_COMMAND);
|
||||
let args = d.argument(command, "args", ArgType::Greedy);
|
||||
d.executes(args, MOD_COMMAND);
|
||||
}
|
||||
d
|
||||
}
|
||||
/// A root literal by name.
|
||||
pub fn find(&self, name: &str) -> Option<usize> {
|
||||
self.nodes[0]
|
||||
@@ -930,19 +947,41 @@ impl Dispatcher {
|
||||
let context = Context::new(0, start);
|
||||
self.parse_nodes(0, input, start, &context, reg, reg.permission)
|
||||
}
|
||||
/// CommandDispatcher.getCompletionSuggestions at `cursor`.
|
||||
/// CommandDispatcher.getCompletionSuggestions at `cursor`: brigadier
|
||||
/// lists every child, whatever its requirement.
|
||||
pub fn suggestions(
|
||||
&self,
|
||||
parse: &ParseResults,
|
||||
cursor: usize,
|
||||
reg: &Registries,
|
||||
) -> (Range, Vec<Suggestion>) {
|
||||
self.suggestions_within(parse, cursor, reg, u8::MAX)
|
||||
}
|
||||
/// The suggestions the player's chat shows: the client's tree only holds
|
||||
/// the nodes the player may use (Commands.sendCommands prunes by canUse).
|
||||
pub fn client_suggestions(
|
||||
&self,
|
||||
parse: &ParseResults,
|
||||
cursor: usize,
|
||||
reg: &Registries,
|
||||
) -> (Range, Vec<Suggestion>) {
|
||||
self.suggestions_within(parse, cursor, reg, reg.permission)
|
||||
}
|
||||
fn suggestions_within(
|
||||
&self,
|
||||
parse: &ParseResults,
|
||||
cursor: usize,
|
||||
reg: &Registries,
|
||||
permission: u8,
|
||||
) -> (Range, Vec<Suggestion>) {
|
||||
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();
|
||||
// Brigadier lists every child, whatever its requirement.
|
||||
for &child in &self.nodes[parent].children {
|
||||
if self.nodes[child].permission > permission {
|
||||
continue;
|
||||
}
|
||||
let mut builder = SuggestionsBuilder::new(truncated, start);
|
||||
match &self.nodes[child].kind {
|
||||
NodeKind::Literal(l) => {
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
//! The command tree a server sends (ClientboundCommandsPacket) as a
|
||||
//! dispatcher the chat parses, colours and completes with
|
||||
//! (ClientPacketListener.handleCommands).
|
||||
//!
|
||||
//! The packet holds each argument's parser and its network properties. A
|
||||
//! node the client's own vanilla tree has at the same path takes that node's
|
||||
//! argument type and suggestion provider (the parsers' suggestion lists and
|
||||
//! the custom providers are the same code on both sides); any other node
|
||||
//! (another server's or a mod's) gets the type its parser and properties
|
||||
//! describe.
|
||||
use super::{ArgType, Dispatcher, Node, NodeKind, Suggests};
|
||||
use horizon_protocol::packets::play::{ARGUMENT_TYPES, ArgumentProperties, Commands};
|
||||
use std::{
|
||||
collections::{HashSet, VecDeque},
|
||||
sync::{Mutex, OnceLock},
|
||||
};
|
||||
|
||||
/// The command id of every executable node of a server's tree: the chat
|
||||
/// sends the command, the server runs it.
|
||||
pub const SERVER_COMMAND: &str = "server";
|
||||
|
||||
/// The dispatcher for a server's tree. `local` is the client's own tree
|
||||
/// (vanilla's), whose matching nodes lend their types and providers.
|
||||
pub fn from_packet(packet: &Commands, local: &Dispatcher) -> Dispatcher {
|
||||
let count = packet.nodes.len();
|
||||
let root = usize::try_from(packet.root)
|
||||
.ok()
|
||||
.filter(|&r| r < count)
|
||||
.unwrap_or(0);
|
||||
// The packet's root becomes node 0, the others follow in packet order.
|
||||
let index = |i: usize| -> usize {
|
||||
if i == root {
|
||||
0
|
||||
} else if i < root {
|
||||
i + 1
|
||||
} else {
|
||||
i
|
||||
}
|
||||
};
|
||||
let valid = |c: &i32| usize::try_from(*c).ok().filter(|&c| c < count);
|
||||
// The local node at the same path, found breadth first from the roots
|
||||
// along children (redirects are not paths of their own).
|
||||
let mut matched: Vec<Option<usize>> = vec![None; count];
|
||||
matched[root] = Some(0);
|
||||
let mut queue = VecDeque::from([root]);
|
||||
let mut seen = HashSet::from([root]);
|
||||
while let Some(n) = queue.pop_front() {
|
||||
for c in packet.nodes[n].children.iter().filter_map(valid) {
|
||||
if !seen.insert(c) {
|
||||
continue;
|
||||
}
|
||||
let node = &packet.nodes[c];
|
||||
matched[c] = matched[n].and_then(|l| {
|
||||
local.nodes[l].children.iter().copied().find(|&lc| {
|
||||
let ln = &local.nodes[lc];
|
||||
ln.name() == node.name
|
||||
&& matches!(
|
||||
(&ln.kind, node.kind),
|
||||
(NodeKind::Literal(_), 1) | (NodeKind::Argument { .. }, 2)
|
||||
)
|
||||
})
|
||||
});
|
||||
queue.push_back(c);
|
||||
}
|
||||
}
|
||||
let mut nodes = vec![None; count];
|
||||
for (i, node) in packet.nodes.iter().enumerate() {
|
||||
let local_node = matched[i].map(|l| &local.nodes[l]);
|
||||
let kind = match node.kind {
|
||||
1 => NodeKind::Literal(node.name.clone()),
|
||||
2 => {
|
||||
let ty = match local_node.map(|l| &l.kind) {
|
||||
Some(NodeKind::Argument { ty, .. }) => ty.clone(),
|
||||
_ => node
|
||||
.parser
|
||||
.as_ref()
|
||||
.map_or(ArgType::Word, |(id, props)| argument_type(*id, props)),
|
||||
};
|
||||
NodeKind::Argument {
|
||||
name: node.name.clone(),
|
||||
ty,
|
||||
}
|
||||
}
|
||||
_ => NodeKind::Root,
|
||||
};
|
||||
let suggests = match local_node {
|
||||
Some(l) => l.suggests.clone(),
|
||||
None => 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(),
|
||||
command: node.executable.then_some(SERVER_COMMAND),
|
||||
redirect: node.redirect.as_ref().and_then(valid).map(index),
|
||||
// The server sent only what the player may use.
|
||||
permission: 0,
|
||||
suggests,
|
||||
});
|
||||
}
|
||||
let mut d = Dispatcher::new();
|
||||
if count > 0 {
|
||||
d.nodes = nodes.into_iter().flatten().collect();
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// SuggestionProviders by id (`ask_server` asks the server; the client's
|
||||
/// own argument suggestions stand in for it).
|
||||
fn provider(id: &str) -> Option<Suggests> {
|
||||
match id {
|
||||
"minecraft:summonable_entities" => Some(Suggests::SummonableEntities),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A registry name kept for the life of the game (argument types hold
|
||||
/// `&'static str`): each distinct name is stored once.
|
||||
fn intern(name: &str) -> &'static str {
|
||||
static NAMES: OnceLock<Mutex<HashSet<&'static str>>> = OnceLock::new();
|
||||
let mut names = NAMES
|
||||
.get_or_init(Default::default)
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(&n) = names.get(name) {
|
||||
return n;
|
||||
}
|
||||
let n: &'static str = Box::leak(name.to_owned().into_boxed_str());
|
||||
names.insert(n);
|
||||
n
|
||||
}
|
||||
|
||||
/// The argument type a parser and its properties describe
|
||||
/// (ArgumentTypeInfo.Template.instantiate).
|
||||
pub fn argument_type(id: i32, props: &[u8]) -> ArgType {
|
||||
let Some(name) = usize::try_from(id).ok().and_then(|i| ARGUMENT_TYPES.get(i)) else {
|
||||
return ArgType::Word;
|
||||
};
|
||||
let p = ArgumentProperties::decode(id, props).unwrap_or(ArgumentProperties::None);
|
||||
let registry = || match &p {
|
||||
ArgumentProperties::Registry(r) => intern(r),
|
||||
_ => "",
|
||||
};
|
||||
match *name {
|
||||
"brigadier:bool" => ArgType::Bool,
|
||||
"brigadier:integer" => match p {
|
||||
ArgumentProperties::Integer { min, max } => ArgType::Integer {
|
||||
min: min.unwrap_or(i32::MIN),
|
||||
max: max.unwrap_or(i32::MAX),
|
||||
},
|
||||
_ => ArgType::Integer {
|
||||
min: i32::MIN,
|
||||
max: i32::MAX,
|
||||
},
|
||||
},
|
||||
// No long type of its own: a double over the same bounds reads
|
||||
// the same digits.
|
||||
"brigadier:long" => match p {
|
||||
ArgumentProperties::Long { min, max } => ArgType::Double {
|
||||
min: min.map_or(f64::MIN, |v| v as f64),
|
||||
max: max.map_or(f64::MAX, |v| v as f64),
|
||||
},
|
||||
_ => ArgType::Double {
|
||||
min: f64::MIN,
|
||||
max: f64::MAX,
|
||||
},
|
||||
},
|
||||
"brigadier:float" => match p {
|
||||
ArgumentProperties::Float { min, max } => ArgType::Float {
|
||||
min: min.unwrap_or(f32::MIN),
|
||||
max: max.unwrap_or(f32::MAX),
|
||||
},
|
||||
_ => ArgType::Float {
|
||||
min: f32::MIN,
|
||||
max: f32::MAX,
|
||||
},
|
||||
},
|
||||
"brigadier:double" => match p {
|
||||
ArgumentProperties::Double { min, max } => ArgType::Double {
|
||||
min: min.unwrap_or(f64::MIN),
|
||||
max: max.unwrap_or(f64::MAX),
|
||||
},
|
||||
_ => ArgType::Double {
|
||||
min: f64::MIN,
|
||||
max: f64::MAX,
|
||||
},
|
||||
},
|
||||
"brigadier:string" => match p {
|
||||
ArgumentProperties::String(1) => ArgType::Str,
|
||||
ArgumentProperties::String(2) => ArgType::Greedy,
|
||||
_ => ArgType::Word,
|
||||
},
|
||||
"minecraft:entity" => match p {
|
||||
ArgumentProperties::Entity {
|
||||
single,
|
||||
players_only,
|
||||
} => ArgType::Entity {
|
||||
single,
|
||||
players: players_only,
|
||||
},
|
||||
_ => ArgType::Entity {
|
||||
single: false,
|
||||
players: false,
|
||||
},
|
||||
},
|
||||
"minecraft:game_profile" => ArgType::GameProfile,
|
||||
"minecraft:block_pos" => ArgType::BlockPos,
|
||||
"minecraft:column_pos" => ArgType::ColumnPos,
|
||||
"minecraft:vec3" => ArgType::Vec3 { center: true },
|
||||
"minecraft:vec2" => ArgType::Vec2,
|
||||
"minecraft:block_state" => ArgType::BlockState,
|
||||
"minecraft:block_predicate" => ArgType::BlockPredicate,
|
||||
"minecraft:item_stack" => ArgType::Item,
|
||||
"minecraft:item_predicate" => ArgType::ItemPredicate,
|
||||
"minecraft:team_color" => ArgType::TeamColor,
|
||||
"minecraft:hex_color" => ArgType::HexColor,
|
||||
"minecraft:component" => ArgType::Component,
|
||||
"minecraft:style" => ArgType::Style,
|
||||
"minecraft:message" => ArgType::Message,
|
||||
"minecraft:nbt_compound_tag" => ArgType::Nbt,
|
||||
"minecraft:nbt_tag" => ArgType::NbtTag,
|
||||
"minecraft:nbt_path" => ArgType::NbtPath,
|
||||
"minecraft:objective" => ArgType::Objective,
|
||||
"minecraft:objective_criteria" => ArgType::Criteria,
|
||||
"minecraft:operation" => ArgType::Operation,
|
||||
"minecraft:particle" => ArgType::Particle,
|
||||
"minecraft:angle" => ArgType::Angle,
|
||||
"minecraft:rotation" => ArgType::Rotation,
|
||||
"minecraft:scoreboard_slot" => ArgType::ScoreboardSlot,
|
||||
"minecraft:score_holder" => ArgType::ScoreHolder {
|
||||
multiple: matches!(p, ArgumentProperties::ScoreHolder { multiple: true }),
|
||||
},
|
||||
"minecraft:swizzle" => ArgType::Swizzle,
|
||||
"minecraft:team" => ArgType::Team,
|
||||
"minecraft:item_slot" => ArgType::Slot,
|
||||
"minecraft:item_slots" => ArgType::Slots,
|
||||
"minecraft:resource_location" => ArgType::Id("none"),
|
||||
"minecraft:function" => ArgType::Function,
|
||||
"minecraft:entity_anchor" => ArgType::Anchor,
|
||||
"minecraft:int_range" => ArgType::IntRange,
|
||||
"minecraft:float_range" => ArgType::FloatRange,
|
||||
"minecraft:dimension" => ArgType::Dimension,
|
||||
"minecraft:gamemode" => ArgType::GameMode,
|
||||
"minecraft:time" => ArgType::Time {
|
||||
min: match p {
|
||||
ArgumentProperties::Time { min } => min,
|
||||
_ => 0,
|
||||
},
|
||||
},
|
||||
"minecraft:resource_or_tag" => ArgType::ResOrTag(registry()),
|
||||
"minecraft:resource_or_tag_key" => ArgType::ResOrTagKey(registry()),
|
||||
"minecraft:resource" => ArgType::Res(registry()),
|
||||
"minecraft:resource_key" => ArgType::ResKey(registry()),
|
||||
"minecraft:resource_selector" => ArgType::ResourceSelector(registry()),
|
||||
"minecraft:template_mirror" => ArgType::Enum(&["none", "front_back", "left_right"]),
|
||||
"minecraft:template_rotation" => {
|
||||
ArgType::Enum(&["none", "clockwise_90", "180", "counterclockwise_90"])
|
||||
}
|
||||
"minecraft:heightmap" => ArgType::Enum(&[
|
||||
"world_surface",
|
||||
"motion_blocking",
|
||||
"motion_blocking_no_leaves",
|
||||
"ocean_floor",
|
||||
]),
|
||||
"minecraft:swing_animation" => ArgType::Enum(&["none", "whack", "stab"]),
|
||||
"minecraft:loot_table" => ArgType::ResourceOrId("minecraft:loot_table"),
|
||||
"minecraft:loot_predicate" => ArgType::ResourceOrId("minecraft:predicate"),
|
||||
"minecraft:loot_modifier" => ArgType::ResourceOrId("minecraft:item_modifier"),
|
||||
"minecraft:context_float_provider" => {
|
||||
ArgType::ResourceOrId("minecraft:context_float_provider")
|
||||
}
|
||||
"minecraft:context_int_provider" => ArgType::ResourceOrId("minecraft:context_int_provider"),
|
||||
"minecraft:slot_source" => ArgType::SlotSource,
|
||||
"minecraft:dialog" => ArgType::ResourceOrId("minecraft:dialog"),
|
||||
"minecraft:feature" => ArgType::ResourceOrId("minecraft:worldgen/feature"),
|
||||
"minecraft:uuid" => ArgType::Uuid,
|
||||
_ => ArgType::Word,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_protocol::{
|
||||
buf::{Codec, Reader, Writer},
|
||||
packets::play::CommandNode,
|
||||
};
|
||||
|
||||
fn literal(name: &str, children: Vec<i32>, executable: bool) -> CommandNode {
|
||||
CommandNode {
|
||||
kind: 1,
|
||||
executable,
|
||||
restricted: false,
|
||||
children,
|
||||
redirect: None,
|
||||
name: name.into(),
|
||||
parser: None,
|
||||
suggestions: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn argument(name: &str, parser: &str, props: Vec<u8>, executable: bool) -> CommandNode {
|
||||
let id = ARGUMENT_TYPES.iter().position(|p| *p == parser).unwrap() as i32;
|
||||
CommandNode {
|
||||
kind: 2,
|
||||
executable,
|
||||
restricted: false,
|
||||
children: Vec::new(),
|
||||
redirect: None,
|
||||
name: name.into(),
|
||||
parser: Some((id, props)),
|
||||
suggestions: Some("minecraft:ask_server".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A tree with the root last (as vanilla's numbering need not put it
|
||||
/// first), a vanilla command and one the client does not know.
|
||||
fn packet() -> Commands {
|
||||
let mut range = Writer::new();
|
||||
range.u8(3);
|
||||
range.i32(1);
|
||||
range.i32(64);
|
||||
Commands {
|
||||
nodes: vec![
|
||||
literal("give", vec![2], false),
|
||||
literal("warp", vec![3], false),
|
||||
argument("targets", "minecraft:entity", vec![2], false),
|
||||
argument("count", "brigadier:integer", range.data, true),
|
||||
CommandNode {
|
||||
kind: 0,
|
||||
executable: false,
|
||||
restricted: false,
|
||||
children: vec![0, 1],
|
||||
redirect: None,
|
||||
name: String::new(),
|
||||
parser: None,
|
||||
suggestions: None,
|
||||
},
|
||||
],
|
||||
root: 4,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn argument_nodes_round_trip() {
|
||||
let p = packet();
|
||||
let mut w = Writer::new();
|
||||
Commands::write(&p, &mut w);
|
||||
let back = Commands::read(&mut Reader::new(&w.data)).unwrap();
|
||||
assert_eq!(back, p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_tree_becomes_a_dispatcher() {
|
||||
let local = super::super::vanilla::dispatcher();
|
||||
let d = from_packet(&packet(), local);
|
||||
assert!(matches!(d.nodes[0].kind, NodeKind::Root));
|
||||
let warp = d.find("warp").expect("warp");
|
||||
let count = d.nodes[warp].children[0];
|
||||
match &d.nodes[count].kind {
|
||||
NodeKind::Argument {
|
||||
ty: ArgType::Integer { min, max },
|
||||
..
|
||||
} => assert_eq!((*min, *max), (1, 64)),
|
||||
other => panic!("count is {other:?}"),
|
||||
}
|
||||
assert_eq!(d.nodes[count].command, Some(SERVER_COMMAND));
|
||||
// `give <targets>` is vanilla's: it keeps the client's type.
|
||||
let give = d.find("give").expect("give");
|
||||
let targets = d.nodes[give].children[0];
|
||||
assert!(matches!(
|
||||
d.nodes[targets].kind,
|
||||
NodeKind::Argument {
|
||||
ty: ArgType::Entity {
|
||||
single: false,
|
||||
players: true
|
||||
},
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_argument_type_decodes() {
|
||||
for (id, name) in ARGUMENT_TYPES.iter().enumerate() {
|
||||
let mut w = Writer::new();
|
||||
match *name {
|
||||
"brigadier:integer" | "brigadier:long" | "brigadier:float" | "brigadier:double" => {
|
||||
w.u8(0)
|
||||
}
|
||||
"brigadier:string" => w.varint(2),
|
||||
"minecraft:entity" | "minecraft:score_holder" => w.u8(1),
|
||||
"minecraft:time" => w.i32(0),
|
||||
n if n.starts_with("minecraft:resource") && n != "minecraft:resource_location" => {
|
||||
w.string("minecraft:item")
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let mut r = Reader::new(&w.data);
|
||||
ArgumentProperties::read(&mut r, id as i32).unwrap();
|
||||
assert_eq!(r.remaining(), 0, "{name}");
|
||||
let _ = argument_type(id as i32, &w.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,6 +185,42 @@ fn vanilla_error_messages() {
|
||||
assert_eq!(e.message.plain(), "argument.block.property.invalid");
|
||||
}
|
||||
#[test]
|
||||
fn commands_need_allow_commands() {
|
||||
// IntegratedServer.getProfilePermissions: the owner of a world without
|
||||
// Allow Commands is at LevelBasedPermissionSet.ALL.
|
||||
let reg = Registries {
|
||||
permission: 0,
|
||||
..reg()
|
||||
};
|
||||
let d = vanilla::game_dispatcher();
|
||||
let e = d.parse("gamemode creative", ®).executable().unwrap_err();
|
||||
assert_eq!(e.message.plain(), "command.unknown.command");
|
||||
assert!(d.parse("me waves", ®).executable().is_ok());
|
||||
let parse = d.parse("", ®);
|
||||
// Brigadier itself lists every literal; the chat's (client tree) only
|
||||
// the ones the player may use.
|
||||
assert!(
|
||||
d.suggestions(&parse, 0, ®)
|
||||
.1
|
||||
.iter()
|
||||
.any(|s| s.text == "gamemode")
|
||||
);
|
||||
let root: Vec<String> = d
|
||||
.client_suggestions(&parse, 0, ®)
|
||||
.1
|
||||
.into_iter()
|
||||
.map(|s| s.text)
|
||||
.collect();
|
||||
// Commands.java with CommandSelection.INTEGRATED: seed and version need no level.
|
||||
assert_eq!(
|
||||
root,
|
||||
[
|
||||
"help", "list", "me", "msg", "random", "seed", "teammsg", "tell", "tm", "trigger",
|
||||
"version", "w"
|
||||
]
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn suggestions_like_vanilla() {
|
||||
let root = suggestions("");
|
||||
assert!(root.contains(&"gamemode".to_owned()) && root.contains(&"tp".to_owned()));
|
||||
@@ -534,3 +570,25 @@ fn place_template_tree() {
|
||||
assert!(run("place template minecraft:x ~ ~ ~ sideways").is_err());
|
||||
assert!(run("place template minecraft:x ~ ~ ~ none none 2").is_err());
|
||||
}
|
||||
#[test]
|
||||
fn mod_commands_join_the_tree_for_every_player() {
|
||||
let reg = Registries {
|
||||
permission: 0,
|
||||
..reg()
|
||||
};
|
||||
let d = vanilla::game_dispatcher().with_mod_commands(["where"]);
|
||||
for line in ["where", "where north 12"] {
|
||||
let parse = d.parse(line, ®);
|
||||
assert_eq!(parse.executable().unwrap().1, MOD_COMMAND, "{line}");
|
||||
}
|
||||
let parse = d.parse("w", ®);
|
||||
let names: Vec<String> = d
|
||||
.client_suggestions(&parse, 1, ®)
|
||||
.1
|
||||
.into_iter()
|
||||
.map(|s| s.text)
|
||||
.collect();
|
||||
assert!(names.contains(&"where".to_owned()), "{names:?}");
|
||||
// The game's own tree is left as it was.
|
||||
assert!(vanilla::game_dispatcher().find("where").is_none());
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
//! `CommandBlockEntity` with its `BaseCommandBlock`: the command, the success
|
||||
//! count comparators read, the output it keeps, and the powered, automatic
|
||||
//! and condition flags `CommandBlock.tick` runs on.
|
||||
use crate::{
|
||||
save::{number, text},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
/// `CommandBlockEntity.Mode`, in declaration order (the edit screen's mode
|
||||
/// button cycles through it).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Mode {
|
||||
Sequence,
|
||||
Auto,
|
||||
Redstone,
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
/// `CommandBlockEntity.getMode`: the block the entity sits in.
|
||||
pub fn of_block(name: &str) -> Option<Self> {
|
||||
match name {
|
||||
"minecraft:command_block" => Some(Self::Redstone),
|
||||
"minecraft:repeating_command_block" => Some(Self::Auto),
|
||||
"minecraft:chain_command_block" => Some(Self::Sequence),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The block `ServerGamePacketListenerImpl.handleSetCommandBlock` puts
|
||||
/// down for a mode.
|
||||
pub fn block(self) -> &'static str {
|
||||
match self {
|
||||
Self::Sequence => "minecraft:chain_command_block",
|
||||
Self::Auto => "minecraft:repeating_command_block",
|
||||
Self::Redstone => "minecraft:command_block",
|
||||
}
|
||||
}
|
||||
/// `CycleButton`: the next value, wrapping round.
|
||||
pub fn next(self) -> Self {
|
||||
match self {
|
||||
Self::Sequence => Self::Auto,
|
||||
Self::Auto => Self::Redstone,
|
||||
Self::Redstone => Self::Sequence,
|
||||
}
|
||||
}
|
||||
/// `CommandBlockEditScreen`'s label for the mode.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Sequence => "advMode.mode.sequence",
|
||||
Self::Auto => "advMode.mode.auto",
|
||||
Self::Redstone => "advMode.mode.redstone",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `automatic` flag `CommandBlock` gives its entity: chain command blocks
|
||||
/// are always active when placed.
|
||||
pub fn automatic_block(name: &str) -> bool {
|
||||
name == "minecraft:chain_command_block"
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CommandBlock {
|
||||
pub command: String,
|
||||
pub success_count: i32,
|
||||
/// `CustomName` as saved (a text component).
|
||||
pub custom_name: Option<Tag>,
|
||||
pub track_output: bool,
|
||||
pub last_output: Option<Text>,
|
||||
pub update_last_execution: bool,
|
||||
/// The game time of the last run, -1 for none.
|
||||
pub last_execution: i64,
|
||||
pub powered: bool,
|
||||
pub auto: bool,
|
||||
pub condition_met: bool,
|
||||
}
|
||||
|
||||
impl Default for CommandBlock {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
command: String::new(),
|
||||
success_count: 0,
|
||||
custom_name: None,
|
||||
track_output: true,
|
||||
last_output: None,
|
||||
update_last_execution: true,
|
||||
last_execution: -1,
|
||||
powered: false,
|
||||
auto: false,
|
||||
condition_met: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CommandBlock {
|
||||
/// `CommandBlockEntity.loadAdditional` with `BaseCommandBlock.load`.
|
||||
pub fn load(tag: &Tag) -> Self {
|
||||
let flag = |key: &str, default: bool| number(tag.get(key)).map_or(default, |v| v != 0);
|
||||
let track_output = flag("TrackOutput", true);
|
||||
let update_last_execution = flag("UpdateLastExecution", true);
|
||||
Self {
|
||||
command: text(tag.get("Command")).unwrap_or_default(),
|
||||
success_count: number(tag.get("SuccessCount")).unwrap_or(0) as i32,
|
||||
custom_name: tag.get("CustomName").cloned(),
|
||||
track_output,
|
||||
last_output: track_output
|
||||
.then(|| tag.get("LastOutput"))
|
||||
.flatten()
|
||||
.map(|t| crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t))),
|
||||
update_last_execution,
|
||||
last_execution: if update_last_execution {
|
||||
number(tag.get("LastExecution")).unwrap_or(-1)
|
||||
} else {
|
||||
-1
|
||||
},
|
||||
powered: flag("powered", false),
|
||||
auto: flag("auto", false),
|
||||
condition_met: flag("conditionMet", false),
|
||||
}
|
||||
}
|
||||
/// `CommandBlockEntity.saveAdditional`. The last output is written as a
|
||||
/// plain string component.
|
||||
pub fn save(&self) -> Vec<(&'static str, Tag)> {
|
||||
let mut out = vec![
|
||||
("Command", Tag::String(self.command.as_str().into())),
|
||||
("SuccessCount", Tag::Int(self.success_count)),
|
||||
];
|
||||
if let Some(name) = &self.custom_name {
|
||||
out.push(("CustomName", name.clone()));
|
||||
}
|
||||
out.push(("TrackOutput", Tag::Byte(i8::from(self.track_output))));
|
||||
if self.track_output
|
||||
&& let Some(last) = &self.last_output
|
||||
{
|
||||
out.push(("LastOutput", Tag::String(last.plain().as_str().into())));
|
||||
}
|
||||
out.push((
|
||||
"UpdateLastExecution",
|
||||
Tag::Byte(i8::from(self.update_last_execution)),
|
||||
));
|
||||
if self.update_last_execution && self.last_execution != -1 {
|
||||
out.push(("LastExecution", Tag::Long(self.last_execution)));
|
||||
}
|
||||
out.push(("powered", Tag::Byte(i8::from(self.powered))));
|
||||
out.push(("conditionMet", Tag::Byte(i8::from(self.condition_met))));
|
||||
out.push(("auto", Tag::Byte(i8::from(self.auto))));
|
||||
out
|
||||
}
|
||||
/// `BaseCommandBlock.setCommand`: a new command starts at no successes.
|
||||
pub fn set_command(&mut self, command: &str) {
|
||||
self.command = command.to_owned();
|
||||
self.success_count = 0;
|
||||
}
|
||||
/// `getLastOutput().getString()`: what the edit screen shows.
|
||||
pub fn last_output_string(&self) -> String {
|
||||
self.last_output
|
||||
.as_ref()
|
||||
.map(Text::plain)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
/// `getName`: the custom name, else `@`.
|
||||
pub fn name(&self) -> Text {
|
||||
self.custom_name.as_ref().map_or_else(
|
||||
|| Text::literal("@"),
|
||||
|t| crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// What `CommandBlock.tick` does with a block of `mode` whose condition was
|
||||
/// `met` when last checked: run its command (`Some(true)`), clear the success
|
||||
/// count of a conditional block (`Some(false)`), or nothing. Chain blocks
|
||||
/// only run from the block before them (`executeChain`).
|
||||
pub fn tick_action(mode: Mode, met: bool, conditional: bool) -> Option<bool> {
|
||||
match mode {
|
||||
Mode::Sequence => None,
|
||||
_ if met => Some(true),
|
||||
_ if conditional => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `CommandBlock.tick`: a repeating block ticks again next tick while powered
|
||||
/// or always active.
|
||||
pub fn repeats(mode: Mode, powered: bool, auto: bool) -> bool {
|
||||
mode == Mode::Auto && (powered || auto)
|
||||
}
|
||||
|
||||
/// `CommandBlock.setPoweredAndUpdate`: a rising signal schedules the impulse
|
||||
/// or repeating block's tick unless it is always active anyway.
|
||||
pub fn power_schedules(mode: Mode, was_powered: bool, powered: bool, auto: bool) -> bool {
|
||||
powered && !was_powered && !auto && mode != Mode::Sequence
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn modes_cycle_and_map_to_blocks() {
|
||||
for mode in [Mode::Sequence, Mode::Auto, Mode::Redstone] {
|
||||
assert_eq!(Mode::of_block(mode.block()), Some(mode));
|
||||
}
|
||||
assert_eq!(Mode::Redstone.next(), Mode::Sequence);
|
||||
assert_eq!(Mode::Sequence.next(), Mode::Auto);
|
||||
assert!(automatic_block("minecraft:chain_command_block"));
|
||||
assert!(!automatic_block("minecraft:repeating_command_block"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tick_follows_the_condition() {
|
||||
assert_eq!(tick_action(Mode::Redstone, true, false), Some(true));
|
||||
assert_eq!(tick_action(Mode::Redstone, false, true), Some(false));
|
||||
assert_eq!(tick_action(Mode::Auto, false, false), None);
|
||||
assert_eq!(tick_action(Mode::Sequence, true, false), None);
|
||||
assert!(repeats(Mode::Auto, false, true));
|
||||
assert!(!repeats(Mode::Auto, false, false));
|
||||
assert!(!repeats(Mode::Redstone, true, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_rising_edge_schedules() {
|
||||
assert!(power_schedules(Mode::Redstone, false, true, false));
|
||||
assert!(!power_schedules(Mode::Redstone, true, true, false));
|
||||
assert!(!power_schedules(Mode::Redstone, false, true, true));
|
||||
assert!(!power_schedules(Mode::Sequence, false, true, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saves_like_base_command_block() {
|
||||
let block = CommandBlock {
|
||||
command: "say hi".into(),
|
||||
success_count: 1,
|
||||
last_output: Some(Text::literal("[12:00:00] hi")),
|
||||
last_execution: 42,
|
||||
powered: true,
|
||||
..CommandBlock::default()
|
||||
};
|
||||
let tag = Tag::compound(block.save());
|
||||
let back = CommandBlock::load(&tag);
|
||||
assert_eq!(back.command, "say hi");
|
||||
assert_eq!(back.success_count, 1);
|
||||
assert_eq!(back.last_output_string(), "[12:00:00] hi");
|
||||
assert_eq!(back.last_execution, 42);
|
||||
assert!(back.powered && !back.auto && back.track_output);
|
||||
// Not tracking output drops it, and no execution time is kept at -1.
|
||||
let quiet = CommandBlock {
|
||||
track_output: false,
|
||||
last_output: Some(Text::literal("x")),
|
||||
..CommandBlock::default()
|
||||
};
|
||||
let tag = Tag::compound(quiet.save());
|
||||
assert!(tag.get("LastOutput").is_none() && tag.get("LastExecution").is_none());
|
||||
assert_eq!(CommandBlock::load(&tag).last_output, None);
|
||||
}
|
||||
}
|
||||
@@ -70,8 +70,12 @@ pub enum BlockEntity {
|
||||
Spawner(Box<crate::mobs::spawner::Spawner>),
|
||||
/// `TrialSpawnerBlockEntity` (`crate::mobs::trial_spawner`).
|
||||
TrialSpawner(Box<crate::mobs::trial_spawner::TrialSpawner>),
|
||||
/// `VaultBlockEntity` (`crate::vault`).
|
||||
Vault(Box<crate::vault::Vault>),
|
||||
/// `BeehiveBlockEntity` of beehives and bee nests (`crate::beehive`).
|
||||
Beehive(Box<crate::beehive::Beehive>),
|
||||
/// `CommandBlockEntity` of the three command blocks (`crate::command_block`).
|
||||
CommandBlock(Box<crate::command_block::CommandBlock>),
|
||||
}
|
||||
/// `CrafterBlockEntity`: nine slots that can be disabled, the powered flag
|
||||
/// and the ticks left in the crafting animation.
|
||||
@@ -128,6 +132,8 @@ impl Crafter {
|
||||
pub struct Banner {
|
||||
pub layers: Vec<crate::banner::BannerLayer>,
|
||||
pub name: Option<String>,
|
||||
/// Components not consumed by BannerBlockEntity's implicit fields.
|
||||
pub components: horizon_world::nbt::Compound,
|
||||
}
|
||||
/// `CreakingHeartBlockEntity` (the logic is in `game/creaking.rs`).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -192,12 +198,24 @@ impl BlockEntity {
|
||||
if name == "minecraft:trial_spawner" {
|
||||
return Some(Self::TrialSpawner(Box::default()));
|
||||
}
|
||||
if name == "minecraft:vault" {
|
||||
return Some(Self::Vault(Box::default()));
|
||||
}
|
||||
if name == "minecraft:beehive" || name == "minecraft:bee_nest" {
|
||||
return Some(Self::Beehive(Box::default()));
|
||||
}
|
||||
if name == "minecraft:lectern" {
|
||||
return Some(Self::Lectern(Lectern::default()));
|
||||
}
|
||||
if crate::command_block::Mode::of_block(name).is_some() {
|
||||
// CommandBlock.newBlockEntity: setAutomatic(this.automatic).
|
||||
return Some(Self::CommandBlock(Box::new(
|
||||
crate::command_block::CommandBlock {
|
||||
auto: crate::command_block::automatic_block(name),
|
||||
..Default::default()
|
||||
},
|
||||
)));
|
||||
}
|
||||
if let Some(sculk) = crate::sculk::SculkEntity::for_block(name) {
|
||||
return Some(Self::Sculk(Box::new(sculk)));
|
||||
}
|
||||
@@ -272,7 +290,9 @@ impl BlockEntity {
|
||||
Self::CreakingHeart(_) => "minecraft:creaking_heart",
|
||||
Self::Spawner(_) => "minecraft:mob_spawner",
|
||||
Self::TrialSpawner(_) => "minecraft:trial_spawner",
|
||||
Self::Vault(_) => "minecraft:vault",
|
||||
Self::Beehive(_) => "minecraft:beehive",
|
||||
Self::CommandBlock(_) => "minecraft:command_block",
|
||||
}
|
||||
}
|
||||
pub fn contents(&self) -> Vec<ItemStack> {
|
||||
@@ -286,7 +306,9 @@ impl BlockEntity {
|
||||
| Self::CreakingHeart(_)
|
||||
| Self::Spawner(_)
|
||||
| Self::TrialSpawner(_)
|
||||
| Self::Beehive(_) => Vec::new(),
|
||||
| Self::Vault(_)
|
||||
| Self::Beehive(_)
|
||||
| Self::CommandBlock(_) => Vec::new(),
|
||||
Self::Lectern(l) => l.book.iter().cloned().collect(),
|
||||
Self::Sculk(_) => Vec::new(),
|
||||
Self::Banner(_) => Vec::new(),
|
||||
@@ -308,7 +330,9 @@ impl BlockEntity {
|
||||
Self::CreakingHeart(_)
|
||||
| Self::Spawner(_)
|
||||
| Self::TrialSpawner(_)
|
||||
| Self::Beehive(_) => &[],
|
||||
| Self::Vault(_)
|
||||
| Self::Beehive(_)
|
||||
| Self::CommandBlock(_) => &[],
|
||||
}
|
||||
}
|
||||
pub fn slots_mut(&mut self) -> &mut [Option<ItemStack>] {
|
||||
@@ -326,7 +350,9 @@ impl BlockEntity {
|
||||
Self::CreakingHeart(_)
|
||||
| Self::Spawner(_)
|
||||
| Self::TrialSpawner(_)
|
||||
| Self::Beehive(_) => &mut [],
|
||||
| Self::Vault(_)
|
||||
| Self::Beehive(_)
|
||||
| Self::CommandBlock(_) => &mut [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -467,13 +467,16 @@ impl FrameStats {
|
||||
}
|
||||
let total: f64 = sorted.iter().map(|&m| f64::from(m)).sum();
|
||||
let percentile = |p: f64| sorted[((n as f64 * p).ceil() as usize).clamp(1, n) - 1];
|
||||
// Average frame time of the slowest 1% of frames, as frames per second.
|
||||
let slowest = n.div_ceil(100);
|
||||
let slow: f64 = sorted[n - slowest..]
|
||||
.iter()
|
||||
.map(|&m| f64::from(m))
|
||||
.sum::<f64>()
|
||||
/ slowest as f64;
|
||||
// Average frame time of the slowest 1% (0.1%) of frames, as frames per second.
|
||||
let low = |per: usize| {
|
||||
let slowest = n.div_ceil(per);
|
||||
let slow: f64 = sorted[n - slowest..]
|
||||
.iter()
|
||||
.map(|&m| f64::from(m))
|
||||
.sum::<f64>()
|
||||
/ slowest as f64;
|
||||
1000.0 / slow
|
||||
};
|
||||
// Where the slowest 1% of frames by CPU time go.
|
||||
let slow_phases = {
|
||||
let mut cpu = self.cpu.clone();
|
||||
@@ -487,7 +490,8 @@ impl FrameStats {
|
||||
"frames": n,
|
||||
"seconds": total / 1000.0,
|
||||
"avg_fps": n as f64 * 1000.0 / total,
|
||||
"low_1pct_fps": 1000.0 / slow,
|
||||
"low_1pct_fps": low(100),
|
||||
"low_01pct_fps": low(1000),
|
||||
"p50_ms": percentile(0.5),
|
||||
"p95_ms": percentile(0.95),
|
||||
"p99_ms": percentile(0.99),
|
||||
|
||||
@@ -14,7 +14,7 @@ use serde_json::Value;
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fs,
|
||||
path::Path,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
@@ -1182,18 +1182,33 @@ impl Recipes {
|
||||
let mut crafting = Vec::new();
|
||||
let mut cooking = Vec::new();
|
||||
let mut meta = HashMap::new();
|
||||
let mut files: Vec<_> = fs::read_dir(root.join("data/minecraft/recipe"))?
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().is_some_and(|e| e == "json"))
|
||||
let json_files = |dir: &Path| -> Result<Vec<PathBuf>> {
|
||||
let mut files: Vec<_> = fs::read_dir(dir)?
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().is_some_and(|e| e == "json"))
|
||||
.collect();
|
||||
files.sort();
|
||||
Ok(files)
|
||||
};
|
||||
// Vanilla's recipes by bare name, then content mods' as `<ns>:<name>`.
|
||||
let mut files: Vec<(String, PathBuf)> = json_files(&root.join("data/minecraft/recipe"))?
|
||||
.into_iter()
|
||||
.map(|p| (String::new(), p))
|
||||
.collect();
|
||||
files.sort();
|
||||
for path in files {
|
||||
let name = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("")
|
||||
.to_owned();
|
||||
for namespace in crate::mod_content::namespaces(root) {
|
||||
let dir = root.join(format!("data/{namespace}/recipe"));
|
||||
if dir.is_dir() {
|
||||
files.extend(
|
||||
json_files(&dir)?
|
||||
.into_iter()
|
||||
.map(|p| (format!("{namespace}:"), p)),
|
||||
);
|
||||
}
|
||||
}
|
||||
for (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)?)?;
|
||||
let text = |k: &str| v[k].as_str().unwrap_or("").to_owned();
|
||||
meta.insert(name.clone(), (text("category"), text("group")));
|
||||
|
||||
@@ -164,7 +164,8 @@ fn operating_system() -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn cpu() -> String {
|
||||
/// Logical processors and the CPU's name (`12x AMD Ryzen 5 5600X ...`).
|
||||
pub fn cpu() -> String {
|
||||
let threads = std::thread::available_parallelism().map_or(0, |n| n.get());
|
||||
let name = if cfg!(target_os = "linux") {
|
||||
read("/proc/cpuinfo").and_then(|t| {
|
||||
|
||||
@@ -36,6 +36,9 @@ pub struct Creative {
|
||||
/// The scrollbar is held (scrolling): dragging moves it.
|
||||
pub scrolling: bool,
|
||||
pub search: String,
|
||||
/// When the search box took the focus (`EditBox.setFocused`): its
|
||||
/// cursor blinks from then.
|
||||
pub search_focused: std::time::Instant,
|
||||
/// The search tab's stacks, in its display order.
|
||||
search_items: Vec<ItemStack>,
|
||||
search_index: Vec<SearchEntry>,
|
||||
@@ -46,17 +49,24 @@ pub struct Names {
|
||||
}
|
||||
impl Names {
|
||||
pub fn load(root: &Path) -> Self {
|
||||
let map = fs::read(root.join("assets/minecraft/lang/en_us.json"))
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice::<HashMap<String, String>>(&b).ok())
|
||||
.unwrap_or_default();
|
||||
let read = |namespace: &str| {
|
||||
fs::read(root.join(format!("assets/{namespace}/lang/en_us.json")))
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice::<HashMap<String, String>>(&b).ok())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let mut map = read("minecraft");
|
||||
// Content mods' names (docs/modpacks.md); none in a vanilla root.
|
||||
for namespace in crate::mod_content::namespaces(root) {
|
||||
map.extend(read(&namespace));
|
||||
}
|
||||
Self { map }
|
||||
}
|
||||
pub fn item(&self, id: &str) -> String {
|
||||
let path = id.trim_start_matches("minecraft:");
|
||||
let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", id));
|
||||
self.map
|
||||
.get(&format!("item.minecraft.{path}"))
|
||||
.or_else(|| self.map.get(&format!("block.minecraft.{path}")))
|
||||
.get(&format!("item.{namespace}.{path}"))
|
||||
.or_else(|| self.map.get(&format!("block.{namespace}.{path}")))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| path.replace('_', " "))
|
||||
}
|
||||
@@ -93,6 +103,26 @@ impl Creative {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Game slot id of an item tab's first display slot (ItemPickerMenu's
|
||||
/// `CustomCreativeSlot`s, 9 by 5); its hotbar row keeps ids 0..9.
|
||||
pub const DISPLAY: usize = 900;
|
||||
|
||||
/// Visible slots of an item tab (game slot ids).
|
||||
pub fn tab_slots() -> impl Iterator<Item = usize> {
|
||||
(Self::DISPLAY..Self::DISPLAY + 45).chain(0..9)
|
||||
}
|
||||
|
||||
/// ItemPickerMenu's slot positions.
|
||||
pub fn tab_slot_position(slot: usize) -> Option<(f32, f32)> {
|
||||
match slot {
|
||||
0..9 => Some((9.0 + slot as f32 * 18.0, 112.0)),
|
||||
_ if (Self::DISPLAY..Self::DISPLAY + 45).contains(&slot) => {
|
||||
let i = slot - Self::DISPLAY;
|
||||
Some((9.0 + (i % 9) as f32 * 18.0, 18.0 + (i / 9) as f32 * 18.0))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The scrollbar track (insideScrollbar), relative to the screen origin.
|
||||
pub const SCROLL_X: f32 = 175.0;
|
||||
pub const SCROLL_Y: f32 = 18.0;
|
||||
@@ -212,10 +242,21 @@ impl Creative {
|
||||
scroll: 0.0,
|
||||
scrolling: false,
|
||||
search: String::new(),
|
||||
search_focused: std::time::Instant::now(),
|
||||
search_items,
|
||||
search_index,
|
||||
}
|
||||
}
|
||||
/// Where a stack comes in the creative tabs (the search tab lists them
|
||||
/// all, in tab order): the same stack's place, else its item's first.
|
||||
/// None for items the tabs don't show.
|
||||
pub fn order_of(&self, stack: &ItemStack) -> Option<usize> {
|
||||
let items = &self.search_items;
|
||||
items
|
||||
.iter()
|
||||
.position(|s| s.same_item_same_components(stack))
|
||||
.or_else(|| items.iter().position(|s| s.item == stack.item))
|
||||
}
|
||||
pub fn tab(&self) -> &Tab {
|
||||
&self.tabs[self.selected.min(self.tabs.len() - 1)]
|
||||
}
|
||||
@@ -226,6 +267,9 @@ impl Creative {
|
||||
}
|
||||
self.selected = tab;
|
||||
self.scroll = 0.0;
|
||||
if self.tab().search {
|
||||
self.search_focused = std::time::Instant::now();
|
||||
}
|
||||
}
|
||||
/// The search tab's stacks.
|
||||
pub fn search_items(&self) -> &[ItemStack] {
|
||||
@@ -380,10 +424,172 @@ fn split_id(id: &str) -> (String, String) {
|
||||
None => ("minecraft".to_owned(), id.to_owned()),
|
||||
}
|
||||
}
|
||||
/// What a click on an item tab's display slot does besides changing the
|
||||
/// carried stack.
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub enum DisplayClick {
|
||||
Nothing,
|
||||
/// SWAP: the hotbar slot gets a full stack of the item.
|
||||
Hotbar(usize, ItemStack),
|
||||
/// THROW: a copy of the item is dropped.
|
||||
Drop(ItemStack),
|
||||
}
|
||||
|
||||
/// `CreativeModeInventoryScreen.slotClicked` on a display slot
|
||||
/// (`slot.container == CONTAINER`): `shown` is the slot's item (None past
|
||||
/// the tab's end) and `max` gives an item's max stack size. The tab's items
|
||||
/// are never changed: clicks make copies.
|
||||
pub fn click_display(
|
||||
carried: &mut Option<ItemStack>,
|
||||
shown: Option<&ItemStack>,
|
||||
max: impl Fn(&ItemStack) -> u32,
|
||||
button: i32,
|
||||
input: crate::menu::Input,
|
||||
) -> DisplayClick {
|
||||
use crate::menu::Input;
|
||||
let copy = |s: &ItemStack, count: u32| ItemStack { count, ..s.clone() };
|
||||
let quick_key = input == Input::QuickMove;
|
||||
match input {
|
||||
Input::Swap => {
|
||||
return match shown {
|
||||
Some(s) => DisplayClick::Hotbar(button as usize, copy(s, max(s))),
|
||||
None => DisplayClick::Nothing,
|
||||
};
|
||||
}
|
||||
Input::Clone => {
|
||||
if carried.is_none()
|
||||
&& let Some(s) = shown
|
||||
{
|
||||
*carried = Some(copy(s, max(s)));
|
||||
}
|
||||
return DisplayClick::Nothing;
|
||||
}
|
||||
Input::Throw => {
|
||||
return match shown {
|
||||
Some(s) => DisplayClick::Drop(copy(s, if button == 0 { 1 } else { max(s) })),
|
||||
None => DisplayClick::Nothing,
|
||||
};
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
match (carried.as_mut(), shown) {
|
||||
(Some(c), Some(s)) if c.item == s.item && c.extra == s.extra && c.damage == s.damage => {
|
||||
// isSameItemSameComponents: grow, fill or shrink what is carried.
|
||||
if button == 0 {
|
||||
if quick_key {
|
||||
c.count = max(c);
|
||||
} else if c.count < max(c) {
|
||||
c.count += 1;
|
||||
}
|
||||
} else {
|
||||
c.count -= 1;
|
||||
}
|
||||
}
|
||||
(None, Some(s)) => {
|
||||
*carried = Some(copy(s, if quick_key { max(s) } else { s.count }));
|
||||
}
|
||||
(Some(c), _) => {
|
||||
if button == 0 {
|
||||
*carried = None;
|
||||
} else {
|
||||
c.count -= 1;
|
||||
}
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
if carried.as_ref().is_some_and(|c| c.count == 0) {
|
||||
*carried = None;
|
||||
}
|
||||
DisplayClick::Nothing
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn stack(item: &str, count: u32) -> ItemStack {
|
||||
ItemStack {
|
||||
item: format!("minecraft:{item}").into(),
|
||||
count,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// One click on a display slot showing one stone (max 64; pearls 16).
|
||||
fn display(
|
||||
carried: Option<ItemStack>,
|
||||
button: i32,
|
||||
input: crate::menu::Input,
|
||||
) -> (Option<ItemStack>, DisplayClick) {
|
||||
let mut carried = carried;
|
||||
let max = |s: &ItemStack| if s.item.ends_with("pearl") { 16 } else { 64 };
|
||||
let effect = click_display(&mut carried, Some(&stack("stone", 1)), max, button, input);
|
||||
(carried, effect)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_slot_clicks_follow_slot_clicked() {
|
||||
use crate::menu::Input::*;
|
||||
// An empty hand takes the shown stack, or a full one with Shift.
|
||||
assert_eq!(display(None, 0, Pickup).0, Some(stack("stone", 1)));
|
||||
assert_eq!(display(None, 1, Pickup).0, Some(stack("stone", 1)));
|
||||
assert_eq!(display(None, 0, QuickMove).0, Some(stack("stone", 64)));
|
||||
// The same item grows by one (left), fills (Shift) or shrinks (right).
|
||||
assert_eq!(
|
||||
display(Some(stack("stone", 5)), 0, Pickup).0,
|
||||
Some(stack("stone", 6))
|
||||
);
|
||||
assert_eq!(
|
||||
display(Some(stack("stone", 64)), 0, Pickup).0,
|
||||
Some(stack("stone", 64))
|
||||
);
|
||||
assert_eq!(
|
||||
display(Some(stack("stone", 5)), 0, QuickMove).0,
|
||||
Some(stack("stone", 64))
|
||||
);
|
||||
assert_eq!(
|
||||
display(Some(stack("stone", 5)), 1, Pickup).0,
|
||||
Some(stack("stone", 4))
|
||||
);
|
||||
assert_eq!(display(Some(stack("stone", 1)), 1, QuickMove).0, None);
|
||||
// Another item is deleted (left) or loses one (right).
|
||||
assert_eq!(display(Some(stack("ender_pearl", 9)), 0, Pickup).0, None);
|
||||
assert_eq!(display(Some(stack("ender_pearl", 9)), 0, QuickMove).0, None);
|
||||
assert_eq!(
|
||||
display(Some(stack("ender_pearl", 9)), 1, Pickup).0,
|
||||
Some(stack("ender_pearl", 8))
|
||||
);
|
||||
// Number keys fill a hotbar slot, Q drops copies, pick block clones.
|
||||
assert_eq!(
|
||||
display(None, 3, Swap).1,
|
||||
DisplayClick::Hotbar(3, stack("stone", 64))
|
||||
);
|
||||
assert_eq!(
|
||||
display(None, 0, Throw).1,
|
||||
DisplayClick::Drop(stack("stone", 1))
|
||||
);
|
||||
assert_eq!(
|
||||
display(None, 1, Throw).1,
|
||||
DisplayClick::Drop(stack("stone", 64))
|
||||
);
|
||||
assert_eq!(display(None, 2, Clone).0, Some(stack("stone", 64)));
|
||||
assert_eq!(
|
||||
display(Some(stack("dirt", 2)), 2, Clone).0,
|
||||
Some(stack("dirt", 2))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_display_slot_deletes_or_shrinks_the_carried_stack() {
|
||||
use crate::menu::Input::Pickup;
|
||||
let mut carried = Some(stack("dirt", 3));
|
||||
click_display(&mut carried, None, |_| 64, 1, Pickup);
|
||||
assert_eq!(carried, Some(stack("dirt", 2)));
|
||||
click_display(&mut carried, None, |_| 64, 0, Pickup);
|
||||
assert_eq!(carried, None);
|
||||
}
|
||||
|
||||
fn creative(items: usize) -> Creative {
|
||||
let mut stacks: Vec<String> = ["anvil", "crafting_table", "wooden_hoe"]
|
||||
.iter()
|
||||
|
||||
@@ -34,6 +34,27 @@ impl DataPack {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The files of `data/<namespace>/<kind>/` for each kind, in the order
|
||||
/// `list("data/")` gives them. A folder pack walks only those folders:
|
||||
/// the built-in pack holds about 10,000 other data files.
|
||||
fn list_kinds(&self, kinds: &[&str]) -> Vec<String> {
|
||||
debug_assert!(kinds.is_sorted(), "kinds in walk order");
|
||||
let Source::Dir(root) = &self.source else {
|
||||
return self.list("data/");
|
||||
};
|
||||
let Ok(entries) = std::fs::read_dir(root.join("data")) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut namespaces: Vec<_> = entries.flatten().map(|e| e.path()).collect();
|
||||
namespaces.sort();
|
||||
let mut out = Vec::new();
|
||||
for ns in namespaces {
|
||||
for kind in kinds {
|
||||
walk(root, &ns.join(kind), &mut out);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
match &self.source {
|
||||
Source::Zip(z) => z.read(path).ok(),
|
||||
@@ -308,7 +329,7 @@ impl DataStore {
|
||||
pub fn load(packs: &[&DataPack]) -> Self {
|
||||
let mut s = Self::default();
|
||||
for pack in packs {
|
||||
for f in pack.list("data/") {
|
||||
for f in pack.list_kinds(&["function", "item_modifier", "predicate", "tags/function"]) {
|
||||
if let Some(id) = resource_id(&f, "function", ".mcfunction")
|
||||
&& let Some(bytes) = pack.read(&f)
|
||||
{
|
||||
@@ -505,4 +526,44 @@ mod tests {
|
||||
assert!(store.predicates.contains_key("mine:p"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_packs_list_the_needed_kinds_in_walk_order() {
|
||||
let dir = std::env::temp_dir().join(format!("hz-datapack-kinds-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let pack = dir.join("datapacks/p");
|
||||
let files = [
|
||||
"data/b/function/z.mcfunction",
|
||||
"data/b/function/a/y.mcfunction",
|
||||
"data/b/loot_table/l.json",
|
||||
"data/b/tags/block/t.json",
|
||||
"data/b/tags/function/load.json",
|
||||
"data/b/predicate/p.json",
|
||||
"data/a/item_modifier/m.json",
|
||||
"data/a/function/f.mcfunction",
|
||||
"data/a/functions/old.mcfunction",
|
||||
"data/a/recipe/r.json",
|
||||
"data/a/tags/function/tick.json",
|
||||
"data/c/worldgen/noise/n.json",
|
||||
];
|
||||
for f in files {
|
||||
let path = pack.join(f);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, "{}").unwrap();
|
||||
}
|
||||
std::fs::write(pack.join("pack.mcmeta"), "{}").unwrap();
|
||||
let p = DataPack::builtin(&pack);
|
||||
let kinds = ["function", "item_modifier", "predicate", "tags/function"];
|
||||
let wanted: Vec<String> = p
|
||||
.list("data/")
|
||||
.into_iter()
|
||||
.filter(|f| {
|
||||
let rest = f.splitn(3, '/').nth(2).unwrap_or_default();
|
||||
kinds.iter().any(|k| rest.starts_with(&format!("{k}/")))
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(wanted.len(), 7);
|
||||
assert_eq!(p.list_kinds(&kinds), wanted);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,12 @@ pub struct DebugCharts {
|
||||
pub ping: SampleLogger,
|
||||
/// `bandwidthLogger`: bytes received per tick.
|
||||
pub bandwidth: SampleLogger,
|
||||
/// `MinecraftServer.smoothedTickTimeMillis`.
|
||||
pub smoothed_tick_ms: f32,
|
||||
/// Connection's smoothed packets per second, sent then received.
|
||||
pub packet_rates: [f32; 2],
|
||||
packet_totals: [i64; 2],
|
||||
packet_tick: u8,
|
||||
previous_tick: Option<Instant>,
|
||||
bytes_seen: Option<i64>,
|
||||
}
|
||||
@@ -110,6 +116,10 @@ impl DebugCharts {
|
||||
self.bandwidth.reset();
|
||||
self.previous_tick = None;
|
||||
self.bytes_seen = None;
|
||||
self.smoothed_tick_ms = 0.0;
|
||||
self.packet_rates = [0.0; 2];
|
||||
self.packet_totals = [0; 2];
|
||||
self.packet_tick = 0;
|
||||
}
|
||||
/// One integrated server tick that started at `started` and ran its
|
||||
/// tick method for `method` (`MinecraftServer.logFullTickTime` and
|
||||
@@ -123,6 +133,7 @@ impl DebugCharts {
|
||||
.max(method);
|
||||
self.previous_tick = Some(started);
|
||||
self.tick_times.log_full([full, method, 0, full - method]);
|
||||
self.smoothed_tick_ms = self.smoothed_tick_ms * 0.8 + method as f32 / 1e6 * 0.199_999_99;
|
||||
}
|
||||
/// `BandwidthDebugMonitor.tick`: the bytes received since the last
|
||||
/// tick, from the connection's running total.
|
||||
@@ -132,6 +143,17 @@ impl DebugCharts {
|
||||
}
|
||||
self.bytes_seen = Some(total);
|
||||
}
|
||||
/// `Connection.tickSecond`: sampled on the first tick, then every 20 ticks.
|
||||
pub fn log_packets(&mut self, totals: [i64; 2]) {
|
||||
if self.packet_tick == 0 {
|
||||
for (i, total) in totals.into_iter().enumerate() {
|
||||
let count = (total - self.packet_totals[i]).max(0) as f32;
|
||||
self.packet_rates[i] = count + 0.75 * (self.packet_rates[i] - count);
|
||||
}
|
||||
self.packet_totals = totals;
|
||||
}
|
||||
self.packet_tick = (self.packet_tick + 1) % 20;
|
||||
}
|
||||
/// The charts along the bottom of the screen (`extractRenderState`):
|
||||
/// frame times left and tick times right, or bandwidth left (remote
|
||||
/// servers only) and ping right. `mspt` is the level's milliseconds
|
||||
@@ -520,6 +542,27 @@ mod tests {
|
||||
assert!(draws.contains(&fill(361, 174, 365, 178, 0xFF0A_141E)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packet_rates_follow_connection_smoothing_and_reset_between_worlds() {
|
||||
let mut charts = DebugCharts::default();
|
||||
charts.log_packets([80, 400]);
|
||||
assert_eq!(charts.packet_rates, [20.0, 100.0]);
|
||||
for _ in 1..20 {
|
||||
charts.log_packets([180, 800]);
|
||||
}
|
||||
assert_eq!(charts.packet_rates, [20.0, 100.0]);
|
||||
charts.log_packets([180, 800]);
|
||||
assert_eq!(charts.packet_rates, [40.0, 175.0]);
|
||||
for _ in 0..20 {
|
||||
charts.log_packets([180, 800]);
|
||||
}
|
||||
assert_eq!(charts.packet_rates, [30.0, 131.25]);
|
||||
charts.reset();
|
||||
assert_eq!(charts.packet_rates, [0.0, 0.0]);
|
||||
charts.log_packets([4, 8]);
|
||||
assert_eq!(charts.packet_rates, [1.0, 2.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticks_and_bandwidth_log_their_differences() {
|
||||
let mut c = DebugCharts::default();
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
//! `DebugScreenOverlay`: the F3 text. The entries of the default profile
|
||||
//! (`DebugScreenEntries.PROFILES`) report priority lines, regular lines and
|
||||
//! groups; the overlay splits them into a left and a right column and draws
|
||||
//! them last, over any screen, at the debug GUI scale.
|
||||
use crate::{
|
||||
font::Font,
|
||||
hud_layout::{GuiDraw, PoseOp, fill},
|
||||
text::Text,
|
||||
};
|
||||
|
||||
/// `extractLines`'s background: ARGB 0x90505050.
|
||||
const BACKGROUND: u32 = 0x9050_5050;
|
||||
/// `extractLines`'s text colour: ARGB 0xFFE0E0E0.
|
||||
const TEXT: u32 = 0xFFE0_E0E0;
|
||||
/// `Font.lineHeight`.
|
||||
const LINE_HEIGHT: i32 = 9;
|
||||
|
||||
/// What the enabled entries reported (`DebugScreenDisplayer`).
|
||||
#[derive(Default, Debug)]
|
||||
pub struct Displayer {
|
||||
left: Vec<String>,
|
||||
right: Vec<String>,
|
||||
lines: Vec<String>,
|
||||
groups: Vec<(&'static str, Vec<String>)>,
|
||||
}
|
||||
|
||||
impl Displayer {
|
||||
/// `addPriorityLine`: to the shorter column, the left one first.
|
||||
pub fn priority(&mut self, line: impl Into<String>) {
|
||||
if self.left.len() > self.right.len() {
|
||||
self.right.push(line.into());
|
||||
} else {
|
||||
self.left.push(line.into());
|
||||
}
|
||||
}
|
||||
/// `addLine`: split between the columns after the priority lines.
|
||||
pub fn line(&mut self, line: impl Into<String>) {
|
||||
self.lines.push(line.into());
|
||||
}
|
||||
/// `addToGroup`: lines kept together, groups in the order first added.
|
||||
pub fn group(&mut self, group: &'static str, lines: impl IntoIterator<Item = String>) {
|
||||
let index = match self.groups.iter().position(|(g, _)| *g == group) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
self.groups.push((group, Vec::new()));
|
||||
self.groups.len() - 1
|
||||
}
|
||||
};
|
||||
self.groups[index].1.extend(lines);
|
||||
}
|
||||
/// `extractRenderState`: the left and right columns, then `footer` (the
|
||||
/// chart keys, shown while the overlay is open) under the left one.
|
||||
pub fn columns(self, footer: &[String]) -> (Vec<String>, Vec<String>) {
|
||||
let (mut left, mut right) = (self.left, self.right);
|
||||
if !left.is_empty() {
|
||||
left.push(String::new());
|
||||
}
|
||||
if !right.is_empty() {
|
||||
right.push(String::new());
|
||||
}
|
||||
if !self.lines.is_empty() {
|
||||
let mid = self.lines.len().div_ceil(2);
|
||||
left.extend_from_slice(&self.lines[..mid]);
|
||||
right.extend_from_slice(&self.lines[mid..]);
|
||||
left.push(String::new());
|
||||
if mid < self.lines.len() {
|
||||
right.push(String::new());
|
||||
}
|
||||
}
|
||||
let mid = self.groups.len().div_ceil(2);
|
||||
for (i, (_, lines)) in self.groups.into_iter().enumerate() {
|
||||
if lines.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let column = if i < mid { &mut left } else { &mut right };
|
||||
column.extend(lines);
|
||||
column.push(String::new());
|
||||
}
|
||||
if !footer.is_empty() {
|
||||
left.push(String::new());
|
||||
left.extend_from_slice(footer);
|
||||
}
|
||||
(left, right)
|
||||
}
|
||||
}
|
||||
|
||||
/// `Mth.wrapDegrees`.
|
||||
fn wrap_degrees(angle: f32) -> f32 {
|
||||
let wrapped = angle % 360.0;
|
||||
if wrapped >= 180.0 {
|
||||
wrapped - 360.0
|
||||
} else if wrapped < -180.0 {
|
||||
wrapped + 360.0
|
||||
} else {
|
||||
wrapped
|
||||
}
|
||||
}
|
||||
|
||||
/// `DebugEntryPosition` and `DebugEntrySectionPosition` for a camera at
|
||||
/// `pos` looking `yaw`/`pitch` in `dimension` with `forced` force-loaded chunks.
|
||||
pub fn position(
|
||||
pos: [f64; 3],
|
||||
yaw: f32,
|
||||
pitch: f32,
|
||||
dimension: &str,
|
||||
forced: usize,
|
||||
) -> Vec<String> {
|
||||
let block = pos.map(|v| v.floor() as i32);
|
||||
let (cx, sy, cz) = (block[0] >> 4, block[1] >> 4, block[2] >> 4);
|
||||
// Direction.fromYRot: floor(yRot / 90 + 0.5) & 3 as a 2D data value.
|
||||
let (direction, towards) = match ((f64::from(yaw) / 90.0 + 0.5).floor() as i32) & 3 {
|
||||
0 => ("south", "Towards positive Z"),
|
||||
1 => ("west", "Towards negative X"),
|
||||
2 => ("north", "Towards negative Z"),
|
||||
_ => ("east", "Towards positive X"),
|
||||
};
|
||||
vec![
|
||||
format!("XYZ: {:.3} / {:.5} / {:.3}", pos[0], pos[1], pos[2]),
|
||||
format!("Block: {} {} {}", block[0], block[1], block[2]),
|
||||
format!(
|
||||
"Chunk: {cx} {sy} {cz} [{} {} in r.{}.{}.mca]",
|
||||
cx & 31,
|
||||
cz & 31,
|
||||
cx >> 5,
|
||||
cz >> 5
|
||||
),
|
||||
format!(
|
||||
"Facing: {direction} ({towards}) ({:.1} / {:.1})",
|
||||
wrap_degrees(yaw),
|
||||
wrap_degrees(pitch)
|
||||
),
|
||||
format!("{dimension} FC: {forced}"),
|
||||
format!(
|
||||
"Section-relative: {:02} {:02} {:02}",
|
||||
block[0] & 15,
|
||||
block[1] & 15,
|
||||
block[2] & 15
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// `VideoMode.refreshRateLabel`: whole rates without decimals.
|
||||
pub fn refresh_rate_label(hz: f64) -> String {
|
||||
if hz == hz.round() {
|
||||
format!("{}", hz as i64)
|
||||
} else {
|
||||
format!("{hz:.2}")
|
||||
}
|
||||
}
|
||||
|
||||
/// `DeviceInfo.vendorName` for a PCI vendor id.
|
||||
pub fn vendor_name(id: u32) -> String {
|
||||
match id {
|
||||
0x10de => "NVIDIA Corporation".into(),
|
||||
0x1002 | 0x1022 => "AMD".into(),
|
||||
0x8086 => "Intel".into(),
|
||||
0x106b => "Apple".into(),
|
||||
0x5143 => "Qualcomm".into(),
|
||||
0x13b5 => "ARM".into(),
|
||||
0x10005 => "Mesa".into(),
|
||||
0 => "unknown".into(),
|
||||
other => format!("{other:#06x}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The overlay's scale against the GUI scale and the scaled screen size it
|
||||
/// lays out in. `debugGuiScale`: -1 keeps the GUI scale, 0 (the default) is
|
||||
/// half the largest automatic scale, n is `calculateScale(n)`; only a scale
|
||||
/// below the GUI scale applies.
|
||||
pub fn scale(
|
||||
framebuffer: [i32; 2],
|
||||
gui_scale: i32,
|
||||
option: i32,
|
||||
unicode: bool,
|
||||
gui_size: [i32; 2],
|
||||
) -> (f32, [i32; 2]) {
|
||||
let scale = match option {
|
||||
-1 => gui_scale,
|
||||
0 => crate::window::calculate_scale(framebuffer, 0, unicode) / 2,
|
||||
n => crate::window::calculate_scale(framebuffer, n, unicode),
|
||||
};
|
||||
if scale < gui_scale && scale > 0 {
|
||||
(
|
||||
scale as f32 / gui_scale as f32,
|
||||
[framebuffer[0] / scale, framebuffer[1] / scale],
|
||||
)
|
||||
} else {
|
||||
(1.0, gui_size)
|
||||
}
|
||||
}
|
||||
|
||||
/// `extractLines` for both columns under the overlay's pose: each line's
|
||||
/// background first, then the text, right-aligned in the right column.
|
||||
pub fn draw(font: &Font, columns: [&[String]; 2], scale: f32, width: i32) -> Vec<GuiDraw> {
|
||||
let mut out = vec![GuiDraw::Push(vec![PoseOp::Scale(scale, scale)])];
|
||||
for (lines, align_left) in [(columns[0], true), (columns[1], false)] {
|
||||
let placed: Vec<(&String, i32, i32)> = lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, l)| !l.is_empty())
|
||||
.map(|(i, l)| {
|
||||
let w = font.width(l) as i32;
|
||||
let left = if align_left { 2 } else { width - 2 - w };
|
||||
(l, left, 2 + LINE_HEIGHT * i as i32)
|
||||
})
|
||||
.collect();
|
||||
for (line, left, top) in &placed {
|
||||
let w = font.width(line) as i32;
|
||||
out.push(fill(
|
||||
left - 1,
|
||||
top - 1,
|
||||
left + w + 1,
|
||||
top + LINE_HEIGHT - 1,
|
||||
BACKGROUND,
|
||||
));
|
||||
}
|
||||
for (line, left, top) in placed {
|
||||
out.push(GuiDraw::Text {
|
||||
text: Text::literal(line.clone()),
|
||||
x: left,
|
||||
y: top,
|
||||
color: TEXT,
|
||||
shadow: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
out.push(GuiDraw::Pop);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn lines(v: &[&str]) -> Vec<String> {
|
||||
v.iter().map(|s| (*s).to_owned()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn columns_follow_the_vanilla_split() {
|
||||
// The default profile's entries, sorted by identifier as
|
||||
// DebugScreenEntryList.rebuildCurrentList does: fps, game_version,
|
||||
// memory, player_position, player_section_position,
|
||||
// simple_performance_impactors, system_specs, tps.
|
||||
let mut d = Displayer::default();
|
||||
d.priority("112 fps T: 120 (fifo) @165Hz");
|
||||
d.priority("Minecraft 26.3 (26.3/vanilla)");
|
||||
d.group("memory", lines(&["Mem: 25% 1046/4096MiB"]));
|
||||
d.group("position", lines(&["XYZ: 1 / 2 / 3", "Block: 1 2 3"]));
|
||||
d.group("position", lines(&["Section-relative: 01 02 03"]));
|
||||
d.line("fancy-clouds B: 2");
|
||||
d.line("Filtering: RGSS");
|
||||
d.line("Terrain Rendering: multidrawindirect");
|
||||
d.group(
|
||||
"system",
|
||||
lines(&["CPU: 12x Ryzen", "Display: 854x709 (NVIDIA)"]),
|
||||
);
|
||||
d.line("Integrated server @ 6.6/50.0 ms, 28 tx, 492 rx");
|
||||
let footer = lines(&["Debug charts: ...", "To edit: press [F3+F6]"]);
|
||||
let (left, right) = d.columns(&footer);
|
||||
// As the issue's vanilla screenshot (#376) shows them.
|
||||
assert_eq!(
|
||||
left,
|
||||
lines(&[
|
||||
"112 fps T: 120 (fifo) @165Hz",
|
||||
"",
|
||||
"fancy-clouds B: 2",
|
||||
"Filtering: RGSS",
|
||||
"",
|
||||
"Mem: 25% 1046/4096MiB",
|
||||
"",
|
||||
"XYZ: 1 / 2 / 3",
|
||||
"Block: 1 2 3",
|
||||
"Section-relative: 01 02 03",
|
||||
"",
|
||||
"",
|
||||
"Debug charts: ...",
|
||||
"To edit: press [F3+F6]",
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
right,
|
||||
lines(&[
|
||||
"Minecraft 26.3 (26.3/vanilla)",
|
||||
"",
|
||||
"Terrain Rendering: multidrawindirect",
|
||||
"Integrated server @ 6.6/50.0 ms, 28 tx, 492 rx",
|
||||
"",
|
||||
"CPU: 12x Ryzen",
|
||||
"Display: 854x709 (NVIDIA)",
|
||||
"",
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn position_lines_match_vanilla() {
|
||||
// The issue's vanilla screenshot (#376).
|
||||
assert_eq!(
|
||||
position([108.5, 104.0, -51.5], -1.0, 7.1, "minecraft:overworld", 0),
|
||||
lines(&[
|
||||
"XYZ: 108.500 / 104.00000 / -51.500",
|
||||
"Block: 108 104 -52",
|
||||
"Chunk: 6 6 -4 [6 28 in r.0.-1.mca]",
|
||||
"Facing: south (Towards positive Z) (-1.0 / 7.1)",
|
||||
"minecraft:overworld FC: 0",
|
||||
"Section-relative: 12 08 12",
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
position([0.0, 64.0, 0.0], 450.0, 0.0, "minecraft:the_nether", 2)[3],
|
||||
"Facing: west (Towards negative X) (90.0 / 0.0)"
|
||||
);
|
||||
assert_eq!(refresh_rate_label(165.0), "165");
|
||||
assert_eq!(refresh_rate_label(59.94), "59.94");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_scale_is_half_the_automatic_scale() {
|
||||
// The issue's window: 854x709 at GUI scale 2 draws F3 at scale 1.
|
||||
assert_eq!(
|
||||
scale([854, 709], 2, 0, false, [427, 355]),
|
||||
(0.5, [854, 709])
|
||||
);
|
||||
// 1920x1080 at GUI scale 4: auto is 4, so F3 is at 2.
|
||||
assert_eq!(
|
||||
scale([1920, 1080], 4, 0, false, [480, 270]),
|
||||
(0.5, [960, 540])
|
||||
);
|
||||
// A GUI scale already at or under the debug scale is kept.
|
||||
assert_eq!(
|
||||
scale([1920, 1080], 2, 0, false, [960, 540]),
|
||||
(1.0, [960, 540])
|
||||
);
|
||||
assert_eq!(
|
||||
scale([1920, 1080], 3, -1, false, [640, 360]),
|
||||
(1.0, [640, 360])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,14 @@ impl EffectInstance {
|
||||
}
|
||||
changed
|
||||
}
|
||||
/// `MobEffectInstance.tickClient`: a client counts its effects down
|
||||
/// (the server removes them).
|
||||
pub fn tick_client(&mut self) {
|
||||
if self.infinite() || self.duration > 0 {
|
||||
self.tick_down();
|
||||
self.downgrade();
|
||||
}
|
||||
}
|
||||
fn tick_down(&mut self) {
|
||||
if let Some(h) = self.hidden.as_mut() {
|
||||
h.tick_down();
|
||||
@@ -170,6 +178,39 @@ impl PlayerEffects {
|
||||
pub fn clear(&mut self) {
|
||||
self.list.clear();
|
||||
}
|
||||
/// The effects of a saved player (`Player.active_effects`: id,
|
||||
/// duration, amplifier, ambient, visible).
|
||||
pub fn from_saved(saved: &[(String, i32, i32, bool, bool)]) -> Self {
|
||||
let mut effects = Self::default();
|
||||
for (id, duration, amplifier, ambient, _) in saved {
|
||||
if let Some(def) = brewing::effect(id) {
|
||||
let mut e = EffectInstance::new(def.id, *duration, *amplifier);
|
||||
e.ambient = *ambient;
|
||||
effects.add(e);
|
||||
}
|
||||
}
|
||||
effects
|
||||
}
|
||||
/// `Player.active_effects` for the save.
|
||||
pub fn saved(&self) -> Vec<(String, i32, i32, bool, bool)> {
|
||||
self.list
|
||||
.iter()
|
||||
.map(|e| {
|
||||
(
|
||||
format!("minecraft:{}", e.id),
|
||||
e.duration,
|
||||
e.amplifier,
|
||||
e.ambient,
|
||||
e.visible,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// ClientboundUpdateMobEffectPacket's flags without blending: ambient,
|
||||
/// particles visible and the icon shown (always, for these effects).
|
||||
pub fn flags(e: &EffectInstance) -> i8 {
|
||||
i8::from(e.ambient) | i8::from(e.visible) << 1 | 4
|
||||
}
|
||||
/// `LivingEntity.tickEffects` (server part) with the omens' effect ticks:
|
||||
/// `omen` answers `BadOmenMobEffect` (every tick) and `RaidOmenMobEffect`
|
||||
/// (on its last tick). Returns the actions to apply, in order, and
|
||||
|
||||
@@ -185,6 +185,8 @@ impl Enchantments {
|
||||
.into_iter()
|
||||
.filter(|(k, _)| {
|
||||
k.starts_with("enchantment.")
|
||||
|| k.starts_with("attribute.")
|
||||
|| k.starts_with("item.modifiers.")
|
||||
|| k.starts_with("effect.")
|
||||
|| k.starts_with("item.minecraft.potion")
|
||||
|| k.starts_with("item.minecraft.splash_potion")
|
||||
|
||||
@@ -301,6 +301,20 @@ pub struct Experience {
|
||||
pub total: i32,
|
||||
}
|
||||
impl Experience {
|
||||
/// `Player.getBaseExperienceReward`: keep-inventory and spectators drop none.
|
||||
pub fn death_reward(&self, keep: bool) -> u32 {
|
||||
if keep {
|
||||
0
|
||||
} else {
|
||||
self.level.wrapping_mul(7).clamp(0, 100) as u32
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.restoreFrom`: only keep-inventory and spectators retain XP.
|
||||
pub fn respawn(&mut self, keep: bool) {
|
||||
if !keep {
|
||||
*self = Self::default();
|
||||
}
|
||||
}
|
||||
pub fn needed(level: i32) -> i32 {
|
||||
if level >= 30 {
|
||||
112 + (level - 30) * 9
|
||||
@@ -540,6 +554,37 @@ impl ArmorStand {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod experience_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn death_reward_uses_levels_and_caps_the_recoverable_points() {
|
||||
for (level, reward) in [(0, 0), (1, 7), (14, 98), (15, 100), (30, 100)] {
|
||||
let xp = Experience {
|
||||
level,
|
||||
progress: 0.75,
|
||||
total: 10_000,
|
||||
};
|
||||
assert_eq!(xp.death_reward(false), reward);
|
||||
assert_eq!(xp.death_reward(true), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn respawn_clears_all_xp_unless_it_is_kept() {
|
||||
let mut xp = Experience::default();
|
||||
xp.give(155);
|
||||
let before = (xp.level, xp.progress, xp.total);
|
||||
xp.respawn(true);
|
||||
assert_eq!((xp.level, xp.progress, xp.total), before);
|
||||
xp.respawn(false);
|
||||
assert_eq!((xp.level, xp.progress, xp.total), (0, 0.0, 0));
|
||||
xp.give(7);
|
||||
assert_eq!((xp.level, xp.progress, xp.total), (1, 0.0, 7));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod fire_environment_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -58,8 +58,8 @@ fn overlay_texel(overlay: u32) -> vec4<f32> {
|
||||
if overlay >= 16u && overlay < 32u { return vec4(1.0, 1.0, 1.0, WHITE_OVERLAY_ALPHA[overlay - 16u]); }
|
||||
return vec4(1.0);
|
||||
}
|
||||
@vertex fn vs_entity(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
|
||||
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>, @location(5) normal: vec4<f32>) -> Out {
|
||||
fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
|
||||
overlay: u32, color: vec4<f32>, light: vec4<u32>, normal: vec4<f32>) -> Out {
|
||||
var out: Out;
|
||||
out.clip = globals.view_proj * vec4(position, 1.0);
|
||||
out.proj = vec4(out.clip.xy * 0.5 + out.clip.w * 0.5, out.clip.zw);
|
||||
@@ -90,6 +90,19 @@ fn overlay_texel(overlay: u32) -> vec4<f32> {
|
||||
if overlay == 4u { out.spherical = length(position); }
|
||||
return out;
|
||||
}
|
||||
@vertex fn vs_entity(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
|
||||
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>, @location(5) normal: vec4<f32>) -> Out {
|
||||
return entity_vertex(position, uv, overlay, color, light, normal);
|
||||
}
|
||||
struct CloudInfo {
|
||||
offset: vec4<f32>,
|
||||
colors: array<vec4<f32>, 4>,
|
||||
}
|
||||
@group(1) @binding(0) var<uniform> cloud: CloudInfo;
|
||||
@vertex fn vs_cloud(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
|
||||
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>, @location(5) normal: vec4<f32>) -> Out {
|
||||
return entity_vertex(position + cloud.offset.xyz, uv, overlay, cloud.colors[light.x], light, normal);
|
||||
}
|
||||
// rendertype_end_portal.fsh: the layer colours.
|
||||
const PORTAL_COLORS = array<vec3<f32>, 16>(
|
||||
vec3(0.022087, 0.098399, 0.110818),
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
//! Conservative, budgeted occlusion against full opaque block cross-sections.
|
||||
//!
|
||||
//! A covered projection rectangle proves that an entire bounding box is hidden.
|
||||
//! Testing a few rays would miss openings between those rays.
|
||||
use std::cell::Cell;
|
||||
|
||||
const EPSILON: f64 = 1.0e-5;
|
||||
|
||||
pub struct Culler {
|
||||
pub camera: [f64; 3],
|
||||
remaining: Cell<usize>,
|
||||
}
|
||||
|
||||
impl Culler {
|
||||
pub fn new(camera: [f64; 3], budget: usize) -> Self {
|
||||
Self {
|
||||
camera,
|
||||
remaining: Cell::new(budget),
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove only this entity's completed camera-relative mesh when hidden.
|
||||
pub fn cull_vertices(
|
||||
&self,
|
||||
vertices: &mut Vec<crate::mesher::Vertex>,
|
||||
start: usize,
|
||||
opaque: impl FnMut([i32; 3]) -> bool,
|
||||
) -> bool {
|
||||
if start >= vertices.len() || self.remaining.get() == 0 {
|
||||
return false;
|
||||
}
|
||||
let mut min = [f64::INFINITY; 3];
|
||||
let mut max = [f64::NEG_INFINITY; 3];
|
||||
for vertex in &vertices[start..] {
|
||||
for a in 0..3 {
|
||||
let value = f64::from(vertex.pos[a]) + self.camera[a];
|
||||
if !value.is_finite() {
|
||||
return false;
|
||||
}
|
||||
min[a] = min[a].min(value);
|
||||
max[a] = max[a].max(value);
|
||||
}
|
||||
}
|
||||
// Cover floating-point and rasterization edges as well as the vertices.
|
||||
if self.occluded(min.map(|v| v - 0.05), max.map(|v| v + 0.05), opaque) {
|
||||
vertices.truncate(start);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Returns true only when a complete opaque block plane covers the bounds.
|
||||
/// Unknown blocks, invalid bounds and an exhausted lookup budget stay visible.
|
||||
pub fn occluded(
|
||||
&self,
|
||||
min: [f64; 3],
|
||||
max: [f64; 3],
|
||||
mut opaque: impl FnMut([i32; 3]) -> bool,
|
||||
) -> bool {
|
||||
if (0..3).any(|a| {
|
||||
!self.camera[a].is_finite()
|
||||
|| !min[a].is_finite()
|
||||
|| !max[a].is_finite()
|
||||
|| min[a] > max[a]
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
let mut best = (0, 0.0, 0.0);
|
||||
for a in 0..3 {
|
||||
let (gap, sign) = if self.camera[a] < min[a] {
|
||||
(min[a] - self.camera[a], 1.0)
|
||||
} else {
|
||||
(self.camera[a] - max[a], -1.0)
|
||||
};
|
||||
if gap > best.1 {
|
||||
best = (a, gap, sign);
|
||||
}
|
||||
}
|
||||
let (axis, gap, sign) = best;
|
||||
if !gap.is_finite() || gap <= EPSILON {
|
||||
return false;
|
||||
}
|
||||
let across = [(axis + 1) % 3, (axis + 2) % 3];
|
||||
let Some(mut cell) = floor_cell(self.camera[axis]) else {
|
||||
return false;
|
||||
};
|
||||
let step = sign as i32;
|
||||
if (f64::from(cell) + 0.5 - self.camera[axis]) * sign <= EPSILON {
|
||||
let Some(next) = cell.checked_add(step) else {
|
||||
return false;
|
||||
};
|
||||
cell = next;
|
||||
}
|
||||
loop {
|
||||
let depth = (f64::from(cell) + 0.5 - self.camera[axis]) * sign;
|
||||
if depth >= gap - EPSILON || self.remaining.get() == 0 {
|
||||
return false;
|
||||
}
|
||||
let mut lo = [f64::INFINITY; 2];
|
||||
let mut hi = [f64::NEG_INFINITY; 2];
|
||||
for corner in 0..8 {
|
||||
let point: [f64; 3] = std::array::from_fn(|a| {
|
||||
if corner & (1 << a) == 0 {
|
||||
min[a]
|
||||
} else {
|
||||
max[a]
|
||||
}
|
||||
});
|
||||
let t = depth / ((point[axis] - self.camera[axis]) * sign);
|
||||
for (i, &a) in across.iter().enumerate() {
|
||||
let p = self.camera[a] + t * (point[a] - self.camera[a]);
|
||||
if !p.is_finite() {
|
||||
return false;
|
||||
}
|
||||
lo[i] = lo[i].min(p);
|
||||
hi[i] = hi[i].max(p);
|
||||
}
|
||||
}
|
||||
let bounds = [
|
||||
floor_cell(lo[0] - EPSILON),
|
||||
floor_cell(hi[0] + EPSILON),
|
||||
floor_cell(lo[1] - EPSILON),
|
||||
floor_cell(hi[1] + EPSILON),
|
||||
];
|
||||
let [Some(x0), Some(x1), Some(y0), Some(y1)] = bounds else {
|
||||
return false;
|
||||
};
|
||||
let mut pos = [0; 3];
|
||||
pos[axis] = cell;
|
||||
pos[across[0]] = x0 + ((i64::from(x1) - i64::from(x0)) / 2) as i32;
|
||||
pos[across[1]] = y0 + ((i64::from(y1) - i64::from(y0)) / 2) as i32;
|
||||
self.remaining.set(self.remaining.get() - 1);
|
||||
if opaque(pos) {
|
||||
let area = (i64::from(x1) - i64::from(x0) + 1)
|
||||
.checked_mul(i64::from(y1) - i64::from(y0) + 1)
|
||||
.and_then(|n| usize::try_from(n).ok());
|
||||
let Some(area) = area.filter(|&n| n <= self.remaining.get()) else {
|
||||
return false;
|
||||
};
|
||||
// Reserve the worst-case lookups before walking the rectangle.
|
||||
self.remaining.set(self.remaining.get() - area);
|
||||
let covered = (x0..=x1).all(|x| {
|
||||
(y0..=y1).all(|y| {
|
||||
pos[across[0]] = x;
|
||||
pos[across[1]] = y;
|
||||
opaque(pos)
|
||||
})
|
||||
});
|
||||
if covered {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
let Some(next) = cell.checked_add(step) else {
|
||||
return false;
|
||||
};
|
||||
cell = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn floor_cell(value: f64) -> Option<i32> {
|
||||
let n = value.floor();
|
||||
(n.is_finite() && n >= f64::from(i32::MIN) && n <= f64::from(i32::MAX)).then_some(n as i32)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn completed_mesh_culling_preserves_prior_geometry_and_protruding_parts() {
|
||||
let vertex = |pos| crate::mesher::Vertex {
|
||||
pos,
|
||||
uv: [0.0; 2],
|
||||
layer: 0,
|
||||
color: u32::MAX,
|
||||
light: 0,
|
||||
normal: [0; 4],
|
||||
};
|
||||
let prior = vertex([0.0; 3]);
|
||||
let body = [vertex([8.0, -1.0, -1.0]), vertex([10.0, 1.0, 1.0])];
|
||||
let wall = |p: [i32; 3]| p[0] == 4 && (-1..=0).contains(&p[1]);
|
||||
let mut hidden = vec![prior];
|
||||
hidden.extend(body);
|
||||
assert!(Culler::new([0.0; 3], 1_000).cull_vertices(&mut hidden, 1, wall));
|
||||
assert_eq!(hidden.len(), 1);
|
||||
assert_eq!(hidden[0].pos, prior.pos);
|
||||
|
||||
// A held item or animated part beyond the wall keeps the whole model.
|
||||
let mut exposed = vec![prior];
|
||||
exposed.extend(body);
|
||||
exposed.push(vertex([8.0, 5.0, 0.0]));
|
||||
let before = exposed.clone();
|
||||
assert!(!Culler::new([0.0; 3], 1_000).cull_vertices(&mut exposed, 1, wall));
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<_, u8>(&exposed),
|
||||
bytemuck::cast_slice::<_, u8>(&before)
|
||||
);
|
||||
assert!(!Culler::new([0.0; 3], 0).cull_vertices(&mut exposed, 1, |_| panic!()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opaque_walls_hide_bounds_in_all_directions() {
|
||||
for axis in 0..3 {
|
||||
for sign in [-1.0, 1.0] {
|
||||
let mut min = [-2.0; 3];
|
||||
let mut max = [2.0; 3];
|
||||
min[axis] = if sign > 0.0 { 8.0 } else { -10.0 };
|
||||
max[axis] = if sign > 0.0 { 10.0 } else { -8.0 };
|
||||
let wall = if sign > 0.0 { 4 } else { -5 };
|
||||
assert!(Culler::new([0.0; 3], 1_000).occluded(min, max, |p| p[axis] == wall));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_slits_and_partial_walls_stay_visible() {
|
||||
let min = [8.0, -4.0, -4.0];
|
||||
let max = [10.0, 4.0, 4.0];
|
||||
for opening in 0..3 {
|
||||
assert!(!Culler::new([0.0; 3], 1_000).occluded(min, max, |p| {
|
||||
p[0] == 4
|
||||
&& match opening {
|
||||
0 => !(p[1] == 1 && p[2] == 1),
|
||||
1 => p[1] != 1,
|
||||
_ => p[1] < 0,
|
||||
}
|
||||
}));
|
||||
}
|
||||
assert!(!Culler::new([0.0; 3], 1_000).occluded(min, max, |_| false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budgets_are_shared_and_exhaustion_fails_open() {
|
||||
let culler = Culler::new([0.0; 3], 3);
|
||||
let calls = Cell::new(0);
|
||||
for _ in 0..2 {
|
||||
assert!(!culler.occluded([8.0; 3], [10.0; 3], |_| {
|
||||
calls.set(calls.get() + 1);
|
||||
false
|
||||
}));
|
||||
}
|
||||
assert_eq!(calls.get(), 3);
|
||||
assert!(!Culler::new([0.0; 3], 0).occluded([8.0; 3], [10.0; 3], |_| panic!()));
|
||||
assert!(!Culler::new([0.0; 3], 1).occluded([8.0; 3], [10.0; 3], |_| true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_inside_invalid_and_target_crossing_wall_stay_visible() {
|
||||
let culler = Culler::new([0.0; 3], 1_000);
|
||||
assert!(!culler.occluded([-1.0; 3], [1.0; 3], |_| panic!()));
|
||||
assert!(!culler.occluded([f64::NAN; 3], [1.0; 3], |_| panic!()));
|
||||
assert!(!culler.occluded([2.0; 3], [1.0; 3], |_| panic!()));
|
||||
assert!(!culler.occluded([4.0, -1.0, -1.0], [8.0, 1.0, 1.0], |p| p[0] == 4));
|
||||
assert!(!Culler::new([f64::INFINITY; 3], 1_000).occluded(
|
||||
[8.0; 3],
|
||||
[10.0; 3],
|
||||
|_| panic!()
|
||||
));
|
||||
assert!(!Culler::new([f64::MAX; 3], 1_000).occluded(
|
||||
[-f64::MAX; 3],
|
||||
[-1.0; 3],
|
||||
|_| panic!()
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -435,8 +435,8 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
});
|
||||
}
|
||||
"minecraft:zombie" => {
|
||||
map.insert("IsBaby".into(), Tag::Byte(i8::from(m.is_baby())));
|
||||
for (key, value) in [
|
||||
("IsBaby", Tag::Byte(0)),
|
||||
("CanBreakDoors", Tag::Byte(0)),
|
||||
("DrownedConversionTime", Tag::Int(-1)),
|
||||
("InWaterTime", Tag::Int(-1)),
|
||||
@@ -845,6 +845,11 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.health = health;
|
||||
m.extra.flag2 = flag(tag, "wasOnGround");
|
||||
}
|
||||
// Zombie, Piglin and Zoglin.readAdditionalSaveData: IsBaby (the baby
|
||||
// zombies of trial spawners).
|
||||
if !crate::mobs::finalize::ageable(m.species()) && flag(tag, "IsBaby") {
|
||||
crate::mobs::finalize::set_baby(&mut m, true);
|
||||
}
|
||||
crate::mobs::raider::read_nbt(&mut m, tag);
|
||||
crate::mobs::riders::read_nbt(&mut m, tag);
|
||||
// A mob saved mid-death finishes dying; one saved dead is dropped.
|
||||
@@ -1041,12 +1046,15 @@ pub fn arrow_from_nbt(
|
||||
}
|
||||
|
||||
/// `FallingBlockEntity`: BlockState, Time, DropItem, HurtEntities.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn falling_block_to_nbt(
|
||||
state: Tag,
|
||||
position: Vec3,
|
||||
motion: Vec3,
|
||||
time: i32,
|
||||
drop_item: bool,
|
||||
damage: crate::falling_block::Damage,
|
||||
fall_distance: f64,
|
||||
uuid: [i32; 4],
|
||||
) -> Tag {
|
||||
let base = Base {
|
||||
@@ -1056,29 +1064,27 @@ pub fn falling_block_to_nbt(
|
||||
motion,
|
||||
rotation: [0.0, 0.0],
|
||||
on_ground: false,
|
||||
fall_distance: 0.0,
|
||||
fall_distance,
|
||||
};
|
||||
let mut tag = base.tag(None);
|
||||
for (key, value) in [
|
||||
("BlockState", state),
|
||||
("Time", Tag::Int(time)),
|
||||
("DropItem", Tag::Byte(i8::from(drop_item))),
|
||||
("HurtEntities", Tag::Byte(0)),
|
||||
("CancelDrop", Tag::Byte(0)),
|
||||
("FallHurtAmount", Tag::Float(0)),
|
||||
("FallHurtMax", Tag::Int(40)),
|
||||
] {
|
||||
put(&mut tag, key, value);
|
||||
}
|
||||
damage.save(&mut tag);
|
||||
tag
|
||||
}
|
||||
/// (block state tag, position, motion, time)
|
||||
pub fn falling_block_from_nbt(tag: &Tag) -> Option<(Tag, Vec3, Vec3, i32)> {
|
||||
/// (block state tag, position, motion, time, fall distance)
|
||||
pub fn falling_block_from_nbt(tag: &Tag) -> Option<(Tag, Vec3, Vec3, i32, f64)> {
|
||||
Some((
|
||||
tag.get("BlockState")?.clone(),
|
||||
position(tag)?,
|
||||
motion(tag),
|
||||
number(tag.get("Time")).unwrap_or(0) as i32,
|
||||
float(tag.get("fall_distance")).unwrap_or(0.0),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -1488,6 +1494,36 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::items::ItemStack;
|
||||
|
||||
#[test]
|
||||
fn falling_block_round_trip_keeps_impact_distance_and_settings() {
|
||||
let state = Tag::compound([("Name", string("minecraft:damaged_anvil"))]);
|
||||
let position = Vec3::new(3.5, 100.0, -7.5);
|
||||
let velocity = Vec3::new(0.0, -1.25, 0.0);
|
||||
let damage = crate::falling_block::Damage {
|
||||
cancel_drop: true,
|
||||
..crate::falling_block::Damage::anvil()
|
||||
};
|
||||
let tag = falling_block_to_nbt(
|
||||
state.clone(),
|
||||
position,
|
||||
velocity,
|
||||
30,
|
||||
false,
|
||||
damage,
|
||||
19.75,
|
||||
[1, 2, 3, 4],
|
||||
);
|
||||
let (loaded_state, pos, motion, age, distance) = falling_block_from_nbt(&tag).unwrap();
|
||||
assert_eq!(loaded_state, state);
|
||||
assert_eq!(
|
||||
(pos, motion, age, distance),
|
||||
(position, velocity, 30, 19.75)
|
||||
);
|
||||
assert_eq!(falling_block_flags(&tag), (false, false));
|
||||
assert_eq!(crate::falling_block::Damage::from_nbt(&tag, true), damage);
|
||||
assert_eq!(uuid(&tag), Some([1, 2, 3, 4]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_entities_round_trip() {
|
||||
let e = ItemEntity {
|
||||
|
||||
@@ -7,6 +7,8 @@ use std::{collections::VecDeque, fs, path::Path};
|
||||
pub const FILE: &str = "horizon-extras.txt";
|
||||
/// The name the options screen uses while the zoom key waits for a key.
|
||||
pub const ZOOM_KEY: &str = "horizon.zoom";
|
||||
/// The same for the sort key.
|
||||
pub const SORT_KEY: &str = "horizon.sort";
|
||||
/// Zoom levels the default cycles through, and the wheel's limits.
|
||||
pub const ZOOM_LEVELS: [u8; 6] = [2, 3, 4, 6, 8, 10];
|
||||
pub const ZOOM_MIN: f32 = 1.25;
|
||||
@@ -66,6 +68,7 @@ impl Corner {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Extras {
|
||||
pub fullbright: bool,
|
||||
pub zoom: bool,
|
||||
/// A vanilla key name (`key.keyboard.z`).
|
||||
pub zoom_key: String,
|
||||
@@ -94,10 +97,19 @@ pub struct Extras {
|
||||
pub minimap_rotate: bool,
|
||||
/// Retry lost multiplayer connections (`reconnect`).
|
||||
pub auto_reconnect: bool,
|
||||
/// Mouse Tweaks-style drags in the inventory and storage containers.
|
||||
pub mouse_tweaks: bool,
|
||||
/// With them, the wheel over a slot moves its items one at a time.
|
||||
pub mouse_tweaks_wheel: bool,
|
||||
/// Sort buttons on the inventory and chest-like containers.
|
||||
pub sort: bool,
|
||||
/// A vanilla key name: sorts the part under the cursor.
|
||||
pub sort_key: String,
|
||||
}
|
||||
impl Default for Extras {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
fullbright: false,
|
||||
zoom: false,
|
||||
zoom_key: "key.keyboard.z".into(),
|
||||
zoom_level: 4,
|
||||
@@ -116,6 +128,10 @@ impl Default for Extras {
|
||||
minimap_zoom: 1.0,
|
||||
minimap_rotate: false,
|
||||
auto_reconnect: false,
|
||||
mouse_tweaks: false,
|
||||
mouse_tweaks_wheel: true,
|
||||
sort: false,
|
||||
sort_key: "key.keyboard.unknown".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,6 +157,7 @@ impl Extras {
|
||||
_ => d,
|
||||
};
|
||||
match key.trim() {
|
||||
"fullbright" => e.fullbright = flag(e.fullbright),
|
||||
"zoom" => e.zoom = flag(e.zoom),
|
||||
"zoomKey" if value.starts_with("key.") => e.zoom_key = value.to_owned(),
|
||||
"zoomLevel" => {
|
||||
@@ -173,6 +190,10 @@ impl Extras {
|
||||
}
|
||||
"minimapRotate" => e.minimap_rotate = flag(e.minimap_rotate),
|
||||
"autoReconnect" => e.auto_reconnect = flag(e.auto_reconnect),
|
||||
"mouseTweaks" => e.mouse_tweaks = flag(e.mouse_tweaks),
|
||||
"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(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -180,7 +201,8 @@ impl Extras {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"zoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\n",
|
||||
"fullbright:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n",
|
||||
bool_text(self.fullbright),
|
||||
bool_text(self.zoom),
|
||||
self.zoom_key,
|
||||
self.zoom_level,
|
||||
@@ -203,8 +225,22 @@ impl Extras {
|
||||
self.minimap_zoom,
|
||||
bool_text(self.minimap_rotate),
|
||||
bool_text(self.auto_reconnect),
|
||||
bool_text(self.mouse_tweaks),
|
||||
bool_text(self.mouse_tweaks_wheel),
|
||||
bool_text(self.sort),
|
||||
self.sort_key,
|
||||
)
|
||||
}
|
||||
/// Binds the extra key `name` (`ZOOM_KEY`, `SORT_KEY`); false for
|
||||
/// another name (a vanilla mapping).
|
||||
pub fn set_key(&mut self, name: &str, value: String) -> bool {
|
||||
match name {
|
||||
ZOOM_KEY => self.zoom_key = value,
|
||||
SORT_KEY => self.sort_key = value,
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
/// Written only once something differs from the defaults, so a vanilla
|
||||
/// setup gains no file.
|
||||
pub fn save(&self, dir: &Path) {
|
||||
@@ -386,6 +422,7 @@ impl Cps {
|
||||
/// The settings the Extras page shows, one per row.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Item {
|
||||
Fullbright,
|
||||
Zoom,
|
||||
ZoomKey,
|
||||
ZoomLevel,
|
||||
@@ -404,11 +441,17 @@ pub enum Item {
|
||||
MinimapZoom,
|
||||
MinimapRotate,
|
||||
AutoReconnect,
|
||||
MouseTweaks,
|
||||
MouseTweaksWheel,
|
||||
Sort,
|
||||
SortKey,
|
||||
}
|
||||
/// The Extras page: headers and items.
|
||||
pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
use Item::*;
|
||||
vec![
|
||||
Err("Lighting"),
|
||||
Ok(Fullbright),
|
||||
Err("Zoom"),
|
||||
Ok(Zoom),
|
||||
Ok(ZoomKey),
|
||||
@@ -430,6 +473,11 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
Ok(Keystrokes),
|
||||
Ok(KeystrokesCorner),
|
||||
Ok(KeystrokesCps),
|
||||
Err("Inventory"),
|
||||
Ok(MouseTweaks),
|
||||
Ok(MouseTweaksWheel),
|
||||
Ok(Sort),
|
||||
Ok(SortKey),
|
||||
Err("Multiplayer"),
|
||||
Ok(AutoReconnect),
|
||||
]
|
||||
@@ -452,6 +500,7 @@ fn next<T: Copy + PartialEq>(all: &[T], current: T, back: bool) -> T {
|
||||
impl Item {
|
||||
pub fn caption(self) -> &'static str {
|
||||
match self {
|
||||
Item::Fullbright => "Fullbright",
|
||||
Item::Zoom => "Zoom",
|
||||
Item::ZoomKey => "Zoom key",
|
||||
Item::ZoomLevel => "Starting zoom",
|
||||
@@ -465,6 +514,10 @@ impl Item {
|
||||
Item::MinimapZoom => "Zoom",
|
||||
Item::MinimapRotate => "Turn with the player",
|
||||
Item::AutoReconnect => "Auto-reconnect",
|
||||
Item::MouseTweaks => "Mouse Tweaks controls",
|
||||
Item::MouseTweaksWheel => "Scroll wheel moves items",
|
||||
Item::Sort => "Sort buttons",
|
||||
Item::SortKey => "Sort key",
|
||||
Item::FpsLows => "Minimum and 1% low",
|
||||
Item::Keystrokes => "Keystrokes",
|
||||
Item::KeystrokesCps => "Clicks per second",
|
||||
@@ -473,6 +526,7 @@ impl Item {
|
||||
/// What the row says under its caption.
|
||||
pub fn description(self) -> &'static str {
|
||||
match self {
|
||||
Item::Fullbright => "Show blocks and entities at full light, even in dark areas.",
|
||||
Item::Zoom => "Hold the zoom key to zoom; the wheel changes it while held.",
|
||||
Item::ZoomKey => "Click, then press a key. Escape unbinds it.",
|
||||
Item::ZoomLevel => "The zoom the key starts at.",
|
||||
@@ -491,6 +545,18 @@ impl Item {
|
||||
"Rejoins a server after a lost connection or a restart, never after a kick or ban."
|
||||
}
|
||||
Item::FpsLows => "The slowest frame and the 1% low of the last 5 seconds.",
|
||||
Item::MouseTweaks => {
|
||||
"Right-drag places one per slot, left-drag gathers the held item, Shift + left-drag moves every slot."
|
||||
}
|
||||
Item::MouseTweaksWheel => {
|
||||
"Down sends one item of the slot to the other side, up brings one back."
|
||||
}
|
||||
Item::Sort => {
|
||||
"Sorts the inventory, chests, barrels, ender chests and shulker boxes in creative tab order, merging stacks."
|
||||
}
|
||||
Item::SortKey => {
|
||||
"Sorts the part under the cursor. Click, then press a key; Escape unbinds it."
|
||||
}
|
||||
Item::Keystrokes => "Movement keys, jump and mouse buttons as you press them.",
|
||||
Item::KeystrokesCps => "Clicks in the last second under each mouse button.",
|
||||
}
|
||||
@@ -499,15 +565,16 @@ impl Item {
|
||||
pub fn cost(self) -> Option<crate::graphics_style::Cost> {
|
||||
use crate::graphics_style::Cost;
|
||||
match self {
|
||||
Item::Zoom | Item::Fps | Item::Keystrokes => Some(Cost::None),
|
||||
Item::Fullbright | Item::Zoom | Item::Fps | Item::Keystrokes => Some(Cost::None),
|
||||
Item::Minimap => Some(Cost::Low),
|
||||
Item::AutoReconnect => Some(Cost::None),
|
||||
Item::AutoReconnect | Item::MouseTweaks | Item::Sort => Some(Cost::None),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// On/off items and their value.
|
||||
pub fn switch(self, e: &Extras) -> Option<bool> {
|
||||
Some(match self {
|
||||
Item::Fullbright => e.fullbright,
|
||||
Item::Zoom => e.zoom,
|
||||
Item::ZoomSmooth => e.zoom_smooth,
|
||||
Item::ZoomSensitivity => e.zoom_sensitivity,
|
||||
@@ -518,6 +585,9 @@ impl Item {
|
||||
Item::Minimap => e.minimap,
|
||||
Item::MinimapRotate => e.minimap_rotate,
|
||||
Item::AutoReconnect => e.auto_reconnect,
|
||||
Item::MouseTweaks => e.mouse_tweaks,
|
||||
Item::MouseTweaksWheel => e.mouse_tweaks_wheel,
|
||||
Item::Sort => e.sort,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -525,6 +595,7 @@ impl Item {
|
||||
pub fn value(self, e: &Extras) -> String {
|
||||
match self {
|
||||
Item::ZoomKey => crate::keybinds::display_name(&e.zoom_key).plain(),
|
||||
Item::SortKey => crate::keybinds::display_name(&e.sort_key).plain(),
|
||||
Item::ZoomLevel => format!("{}x", e.zoom_level),
|
||||
Item::FpsCorner => e.fps_corner.label().into(),
|
||||
Item::KeystrokesCorner => e.keystrokes_corner.label().into(),
|
||||
@@ -545,6 +616,7 @@ impl Item {
|
||||
/// A click (`back` for the right button). The zoom key binds instead.
|
||||
pub fn cycle(self, e: &mut Extras, back: bool) {
|
||||
match self {
|
||||
Item::Fullbright => e.fullbright = !e.fullbright,
|
||||
Item::Zoom => e.zoom = !e.zoom,
|
||||
Item::ZoomSmooth => e.zoom_smooth = !e.zoom_smooth,
|
||||
Item::ZoomSensitivity => e.zoom_sensitivity = !e.zoom_sensitivity,
|
||||
@@ -560,6 +632,9 @@ impl Item {
|
||||
Item::Minimap => e.minimap = !e.minimap,
|
||||
Item::MinimapRotate => e.minimap_rotate = !e.minimap_rotate,
|
||||
Item::AutoReconnect => e.auto_reconnect = !e.auto_reconnect,
|
||||
Item::MouseTweaks => e.mouse_tweaks = !e.mouse_tweaks,
|
||||
Item::MouseTweaksWheel => e.mouse_tweaks_wheel = !e.mouse_tweaks_wheel,
|
||||
Item::Sort => e.sort = !e.sort,
|
||||
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();
|
||||
@@ -567,7 +642,15 @@ impl Item {
|
||||
}
|
||||
Item::MinimapShape => e.minimap_round = !e.minimap_round,
|
||||
Item::MinimapZoom => e.minimap_zoom = next(&MINIMAP_ZOOMS, e.minimap_zoom, back),
|
||||
Item::ZoomKey => {}
|
||||
Item::ZoomKey | Item::SortKey => {}
|
||||
}
|
||||
}
|
||||
/// The key name the options screen waits on for a key item.
|
||||
pub fn key_name(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Item::ZoomKey => Some(ZOOM_KEY),
|
||||
Item::SortKey => Some(SORT_KEY),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -579,10 +662,14 @@ mod tests {
|
||||
#[test]
|
||||
fn everything_is_off_by_default_and_round_trips() {
|
||||
let d = Extras::default();
|
||||
assert!(!d.fullbright);
|
||||
assert!(!d.zoom && !d.fps && !d.keystrokes && !d.minimap && !d.auto_reconnect);
|
||||
assert!(!d.mouse_tweaks && !d.sort);
|
||||
assert_eq!(Extras::parse(""), d);
|
||||
assert_eq!(Extras::parse(&d.serialize()), d);
|
||||
let mut e = d.clone();
|
||||
Item::Fullbright.cycle(&mut e, false);
|
||||
assert_eq!(Item::Fullbright.switch(&e), Some(true));
|
||||
e.zoom = true;
|
||||
e.zoom_key = "key.keyboard.c".into();
|
||||
e.zoom_level = 6;
|
||||
@@ -596,6 +683,10 @@ mod tests {
|
||||
e.minimap_rotate = true;
|
||||
e.minimap_corner = Corner::BottomLeft;
|
||||
e.auto_reconnect = true;
|
||||
e.mouse_tweaks = true;
|
||||
e.mouse_tweaks_wheel = false;
|
||||
e.sort = true;
|
||||
e.sort_key = "key.keyboard.r".into();
|
||||
assert_eq!(Extras::parse(&e.serialize()), e);
|
||||
}
|
||||
|
||||
|
||||
@@ -129,6 +129,32 @@ pub fn draw_fps(
|
||||
);
|
||||
}
|
||||
|
||||
/// A box of text lines (a mod's HUD panel): `(text, 0xRRGGBB)` each.
|
||||
pub fn draw_lines(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
corners: &mut Corners,
|
||||
corner: Corner,
|
||||
lines: &[(&str, u32)],
|
||||
) {
|
||||
if lines.is_empty() {
|
||||
return;
|
||||
}
|
||||
let w = lines
|
||||
.iter()
|
||||
.map(|(text, _)| HudBatch::text_width(atlas, text))
|
||||
.fold(0.0, f32::max)
|
||||
+ 4.0;
|
||||
let h = 10.0 * lines.len() as f32 + 2.0;
|
||||
let (x, y) = corners.place(corner, w, h);
|
||||
batch.fill(atlas, x, y, w, h, BACKGROUND);
|
||||
for (i, (text, color)) in lines.iter().enumerate() {
|
||||
let [_, r, g, b] = color.to_be_bytes();
|
||||
let y = y + 2.0 + 10.0 * i as f32;
|
||||
batch.text(atlas, text, x + 2.0, y, rgba(r, g, b, 255), true);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the keystroke overlay shows.
|
||||
pub struct Keystrokes {
|
||||
/// Forward, left, back, right: held, and the bound key's name.
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
//! FallingBlockEntity's impact damage, anvil wear and saved impact settings.
|
||||
use crate::{
|
||||
mobs::{Damage as MobDamage, DamageKind, Mob},
|
||||
save::{float, number, put},
|
||||
};
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
use horizon_world::{chunk::BlockState, nbt::Tag};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Damage {
|
||||
pub hurt_entities: bool,
|
||||
pub cancel_drop: bool,
|
||||
pub per_distance: f32,
|
||||
pub max: i32,
|
||||
}
|
||||
impl Default for Damage {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
hurt_entities: false,
|
||||
cancel_drop: false,
|
||||
per_distance: 0.0,
|
||||
max: 40,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Damage {
|
||||
pub fn anvil() -> Self {
|
||||
Self {
|
||||
hurt_entities: true,
|
||||
per_distance: 2.0,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
pub fn from_nbt(tag: &Tag, anvil: bool) -> Self {
|
||||
Self {
|
||||
hurt_entities: number(tag.get("HurtEntities")).map_or(anvil, |v| v != 0),
|
||||
cancel_drop: number(tag.get("CancelDrop")).is_some_and(|v| v != 0),
|
||||
per_distance: float(tag.get("FallHurtAmount")).unwrap_or(0.0) as f32,
|
||||
max: number(tag.get("FallHurtMax")).unwrap_or(40) as i32,
|
||||
}
|
||||
}
|
||||
pub fn save(self, tag: &mut Tag) {
|
||||
for (key, value) in [
|
||||
("HurtEntities", Tag::Byte(i8::from(self.hurt_entities))),
|
||||
("CancelDrop", Tag::Byte(i8::from(self.cancel_drop))),
|
||||
("FallHurtAmount", Tag::Float(self.per_distance.to_bits())),
|
||||
("FallHurtMax", Tag::Int(self.max)),
|
||||
] {
|
||||
put(tag, key, value);
|
||||
}
|
||||
}
|
||||
pub fn impact(self, fall_distance: f64) -> Option<Impact> {
|
||||
if !self.hurt_entities {
|
||||
return None;
|
||||
}
|
||||
let steps = (fall_distance - 1.0).ceil() as i32;
|
||||
if steps < 0 {
|
||||
return None;
|
||||
}
|
||||
let amount = ((steps as f32 * self.per_distance).floor() as i32).min(self.max) as f32;
|
||||
Some(Impact {
|
||||
amount,
|
||||
wear_chance: if amount > 0.0 {
|
||||
0.05 + steps as f32 * 0.05
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Impact {
|
||||
pub amount: f32,
|
||||
pub wear_chance: f32,
|
||||
}
|
||||
|
||||
/// AnvilBlock.damage: advance wear without changing facing; false means broken.
|
||||
pub fn wear_anvil(state: &mut BlockState) -> bool {
|
||||
state.name = match state.name.as_str() {
|
||||
"minecraft:anvil" => "minecraft:chipped_anvil",
|
||||
"minecraft:chipped_anvil" => "minecraft:damaged_anvil",
|
||||
_ => return false,
|
||||
}
|
||||
.into();
|
||||
true
|
||||
}
|
||||
|
||||
pub fn bounds(position: Vec3) -> Aabb {
|
||||
let size = f64::from(0.98_f32);
|
||||
Aabb::new(
|
||||
Vec3::new(position.x - size / 2.0, position.y, position.z - size / 2.0),
|
||||
Vec3::new(
|
||||
position.x + size / 2.0,
|
||||
position.y + size,
|
||||
position.z + size / 2.0,
|
||||
),
|
||||
)
|
||||
}
|
||||
pub fn hurt_mobs(mobs: &mut [Mob], position: Vec3, amount: f32) {
|
||||
if amount <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let bounds = bounds(position);
|
||||
let source = MobDamage {
|
||||
kind: DamageKind::FallingBlock,
|
||||
attacker: None,
|
||||
from: Some([position.x, position.y, position.z]),
|
||||
};
|
||||
for mob in mobs
|
||||
.iter_mut()
|
||||
.filter(|m| m.alive() && bounds.intersects(m.bounds()))
|
||||
{
|
||||
mob.damage(amount, source);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn impact_uses_ceil_distance_capped_damage_and_positive_damage_wear() {
|
||||
let damage = Damage::anvil();
|
||||
assert!(Damage::default().impact(50.0).is_none());
|
||||
assert!(damage.impact(0.0).is_none());
|
||||
let short = damage.impact(1.0).unwrap();
|
||||
assert_eq!((short.amount, short.wear_chance), (0.0, 0.0));
|
||||
assert_eq!(damage.impact(1.01).unwrap().amount, 2.0);
|
||||
assert_eq!(damage.impact(5.0).unwrap().amount, 8.0);
|
||||
assert_eq!(damage.impact(100.0).unwrap().amount, 40.0);
|
||||
assert!(damage.impact(21.0).unwrap().wear_chance > 1.0);
|
||||
}
|
||||
#[test]
|
||||
fn damage_settings_round_trip_and_keep_vanilla_load_defaults() {
|
||||
let empty = Tag::Compound(Default::default());
|
||||
assert_eq!(Damage::from_nbt(&empty, false), Damage::default());
|
||||
let loaded_anvil = Damage::from_nbt(&empty, true);
|
||||
assert!(loaded_anvil.hurt_entities);
|
||||
assert_eq!(loaded_anvil.per_distance, 0.0);
|
||||
let damage = Damage {
|
||||
hurt_entities: true,
|
||||
cancel_drop: true,
|
||||
per_distance: 3.5,
|
||||
max: 17,
|
||||
};
|
||||
let mut tag = empty;
|
||||
damage.save(&mut tag);
|
||||
assert_eq!(Damage::from_nbt(&tag, false), damage);
|
||||
}
|
||||
#[test]
|
||||
fn anvil_wears_twice_then_breaks_preserving_facing() {
|
||||
for facing in ["north", "south", "east", "west"] {
|
||||
let mut state = BlockState {
|
||||
name: "minecraft:anvil".into(),
|
||||
properties: [("facing".into(), facing.into())].into(),
|
||||
};
|
||||
for name in ["minecraft:chipped_anvil", "minecraft:damaged_anvil"] {
|
||||
assert!(wear_anvil(&mut state));
|
||||
assert_eq!(state.name, name);
|
||||
assert_eq!(state.properties["facing"], facing);
|
||||
}
|
||||
assert!(!wear_anvil(&mut state));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "requires prepared entity models/data in HORIZON_TEST_RESOURCES"]
|
||||
fn impact_hurts_only_living_intersecting_noninvulnerable_mobs() {
|
||||
use crate::mobs::{Mob, MobTypes};
|
||||
use std::{path::Path, sync::Arc};
|
||||
let root = std::env::var_os("HORIZON_TEST_RESOURCES").expect("HORIZON_TEST_RESOURCES");
|
||||
let types = MobTypes::load(Path::new(&root)).unwrap();
|
||||
let cow = Arc::new(types.types["minecraft:cow"].clone());
|
||||
let mut mobs = vec![
|
||||
Mob::new(1, cow.clone(), Vec3::ZERO, 0.0),
|
||||
Mob::new(2, cow.clone(), Vec3::new(3.0, 0.0, 0.0), 0.0),
|
||||
Mob::new(3, cow.clone(), Vec3::ZERO, 0.0),
|
||||
Mob::new(4, cow, Vec3::ZERO, 0.0),
|
||||
];
|
||||
mobs[2].invulnerable_flag = true;
|
||||
mobs[3].extra.head = Some("minecraft:carved_pumpkin".into());
|
||||
let initial = mobs.iter().map(|m| m.health).collect::<Vec<_>>();
|
||||
hurt_mobs(
|
||||
&mut mobs,
|
||||
Vec3::ZERO,
|
||||
Damage::anvil().impact(5.0).unwrap().amount,
|
||||
);
|
||||
assert_eq!(mobs[0].health, initial[0] - 8.0);
|
||||
assert_eq!(mobs[1].health, initial[1]);
|
||||
assert_eq!(mobs[2].health, initial[2]);
|
||||
assert_eq!(mobs[3].health, initial[3] - 6.0);
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,9 @@ pub enum ItemPose {
|
||||
},
|
||||
}
|
||||
|
||||
/// `submitArmWithItem`: the main-hand item pose, before the model transform.
|
||||
/// `submitArmWithItem`: the pose of an item in the hand on the `left` or
|
||||
/// right arm, before the model transform. Only the main hand's charged
|
||||
/// crossbow is turned aside.
|
||||
pub fn item_pose(left: bool, inverse_height: f32, attack: f32, pose: ItemPose) -> glam::Mat4 {
|
||||
use glam::{Mat4, Vec3};
|
||||
use horizon_core::geometry::trig::Mth;
|
||||
@@ -127,6 +129,25 @@ pub fn item_pose(left: bool, inverse_height: f32, attack: f32, pose: ItemPose) -
|
||||
m
|
||||
}
|
||||
|
||||
/// `FirstPersonHandsAndItems.evaluateWhichHandsToRender`: whether the main
|
||||
/// and the off hand show, from what each holds (item and whether it is a
|
||||
/// charged crossbow) and the item in use with its hand. A bow or crossbow
|
||||
/// being drawn hides the other hand, and a charged crossbow in the main hand
|
||||
/// hides the off hand unless the off hand is the one in use.
|
||||
pub fn hands_to_render(held: [Option<(&str, bool)>; 2], using: Option<(Hand, &str)>) -> [bool; 2] {
|
||||
let bow_like = |id: &str| matches!(id, "minecraft:bow" | "minecraft:crossbow");
|
||||
let charged_crossbow =
|
||||
|h: Option<(&str, bool)>| h.is_some_and(|(id, c)| c && id == "minecraft:crossbow");
|
||||
if !held.iter().flatten().any(|&(id, _)| bow_like(id)) {
|
||||
return [true, true];
|
||||
}
|
||||
match using {
|
||||
None => [true, !charged_crossbow(held[0])],
|
||||
Some((hand, id)) if bow_like(id) => [hand == Hand::Main, hand == Hand::Off],
|
||||
Some((hand, _)) => [true, !(hand == Hand::Main && charged_crossbow(held[1]))],
|
||||
}
|
||||
}
|
||||
|
||||
/// `InteractionHand`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Hand {
|
||||
@@ -719,6 +740,36 @@ impl Clicks {
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn drawing_a_bow_or_holding_a_charged_crossbow_hides_a_hand() {
|
||||
let (bow, apple) = (
|
||||
Some(("minecraft:bow", false)),
|
||||
Some(("minecraft:apple", false)),
|
||||
);
|
||||
let charged = Some(("minecraft:crossbow", true));
|
||||
assert_eq!(hands_to_render([apple, apple], None), [true, true]);
|
||||
assert_eq!(hands_to_render([bow, apple], None), [true, true]);
|
||||
assert_eq!(hands_to_render([charged, apple], None), [true, false]);
|
||||
assert_eq!(hands_to_render([apple, charged], None), [true, true]);
|
||||
let drawing = |hand| Some((hand, "minecraft:bow"));
|
||||
assert_eq!(
|
||||
hands_to_render([bow, apple], drawing(Hand::Main)),
|
||||
[true, false]
|
||||
);
|
||||
assert_eq!(
|
||||
hands_to_render([apple, bow], drawing(Hand::Off)),
|
||||
[false, true]
|
||||
);
|
||||
let eating = |hand| Some((hand, "minecraft:apple"));
|
||||
assert_eq!(
|
||||
hands_to_render([apple, charged], eating(Hand::Main)),
|
||||
[true, false]
|
||||
);
|
||||
assert_eq!(
|
||||
hands_to_render([charged, apple], eating(Hand::Off)),
|
||||
[true, true]
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn a_swing_lasts_its_duration_and_restarts_after_half() {
|
||||
let mut s = SwingState::default();
|
||||
assert!(s.start_if_able(Hand::Main, SwingAnimation::DEFAULT, 6));
|
||||
|
||||
@@ -119,22 +119,6 @@ impl FogState {
|
||||
}
|
||||
}
|
||||
|
||||
/// The fog tables and evolving state kept by the game.
|
||||
pub struct FogRenderer {
|
||||
pub tables: FogTables,
|
||||
pub state: FogState,
|
||||
}
|
||||
impl FogRenderer {
|
||||
pub fn load(root: &Path, biomes: &Biomes) -> Self {
|
||||
Self {
|
||||
tables: FogTables::load(root, biomes),
|
||||
state: FogState::default(),
|
||||
}
|
||||
}
|
||||
pub fn setup(&self, input: &FogInputs) -> FogData {
|
||||
setup_fog(&self.tables, &self.state, input)
|
||||
}
|
||||
}
|
||||
/// `FluidState.getHeightForCamera`.
|
||||
fn camera_fluid_height(world: &World, facts: &BlockFacts, b: [i32; 3]) -> Option<(Fluid, f64)> {
|
||||
let s = facts.get(world.block(b[0], b[1], b[2]));
|
||||
|
||||
@@ -318,6 +318,7 @@ impl Title {
|
||||
x: f32,
|
||||
y: f32,
|
||||
) -> Action {
|
||||
self.remember_direct_address();
|
||||
if self.skip_opening() {
|
||||
return Action::None;
|
||||
}
|
||||
@@ -441,7 +442,9 @@ impl Title {
|
||||
Screen::Direct { .. } => 53,
|
||||
Screen::Friend { .. } => 57,
|
||||
Screen::Disconnected { .. } => 54,
|
||||
Screen::ConfirmDelete { .. } => 59,
|
||||
}),
|
||||
CONFIRM_DELETE => Some(58),
|
||||
CANCEL_RETRY => Some(55),
|
||||
DONE => Some(match screen {
|
||||
Screen::Direct { .. } => 52,
|
||||
@@ -470,7 +473,7 @@ impl Title {
|
||||
_ => None,
|
||||
};
|
||||
if let Some(id) = id {
|
||||
match screen.click(Some(id), 0.0, 0.0, (0.0, 0.0), &saves) {
|
||||
match self.multiplayer_click(Some(id), 0.0, 0.0, (0.0, 0.0)) {
|
||||
Outcome::Back => self.mode = Mode::Main,
|
||||
Outcome::Connect(address) => return Action::Connect { address },
|
||||
Outcome::None => {}
|
||||
@@ -840,8 +843,14 @@ fn multiplayer_page(ui: &mut Ui, x: f32, w: f32, screen: &Screen, caret: bool) {
|
||||
Some(Ping::Ok {
|
||||
motd, players, ms, ..
|
||||
}) => (
|
||||
motd.lines().next().unwrap_or("").trim().to_owned(),
|
||||
format!("{players} · {ms} ms"),
|
||||
motd.resolve_legacy()
|
||||
.plain()
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_owned(),
|
||||
format!("{} · {ms} ms", Ping::player_count(*players).plain()),
|
||||
color::ACCENT,
|
||||
),
|
||||
Some(Ping::Failed(_)) => (
|
||||
@@ -1035,6 +1044,40 @@ fn multiplayer_page(ui: &mut Ui, x: f32, w: f32, screen: &Screen, caret: bool) {
|
||||
ui.text(Face::Regular, SMALL, x, ry + 12.0, color::MUTED, ¬e);
|
||||
}
|
||||
}
|
||||
Screen::ConfirmDelete { name, .. } => {
|
||||
let mut y = header(
|
||||
ui,
|
||||
x,
|
||||
"Remove this server?",
|
||||
"It leaves your list. You can add it again later.",
|
||||
);
|
||||
ui.divider(x, y, w);
|
||||
y += 16.0;
|
||||
y += notice(
|
||||
ui,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
Some(name),
|
||||
"Its address and icon will be forgotten.",
|
||||
) + 16.0;
|
||||
let dw = ui.button_width("Delete server", false);
|
||||
ui.button(
|
||||
CONFIRM_DELETE,
|
||||
[x, y, dw, CONTROL],
|
||||
"Delete server",
|
||||
Kind::Danger,
|
||||
true,
|
||||
);
|
||||
let cw = ui.button_width("Cancel", false);
|
||||
ui.button(
|
||||
BACK,
|
||||
[x + dw + 8.0, y, cw, CONTROL],
|
||||
"Cancel",
|
||||
Kind::Ghost,
|
||||
true,
|
||||
);
|
||||
}
|
||||
Screen::Disconnected { reason, retry } => {
|
||||
let mut y = header(
|
||||
ui,
|
||||
@@ -1130,6 +1173,7 @@ pub mod pause {
|
||||
quit_label: &str,
|
||||
opened: Instant,
|
||||
friends: &FriendsView,
|
||||
remote: bool,
|
||||
) {
|
||||
let (w, h) = (ui.w, ui.h);
|
||||
let p = t::ease_out_quint(opened.elapsed().as_secs_f32() / t::move_());
|
||||
@@ -1164,11 +1208,11 @@ pub mod pause {
|
||||
Kind::Primary,
|
||||
true,
|
||||
);
|
||||
let lw = ui.button_width("Open to LAN", true);
|
||||
let lw = ui.button_width("World Options", true);
|
||||
ui.button(
|
||||
act(9),
|
||||
[x + bw + 8.0, y, lw, CONTROL_LARGE],
|
||||
"Open to LAN",
|
||||
"World Options",
|
||||
Kind::Secondary,
|
||||
true,
|
||||
);
|
||||
@@ -1192,7 +1236,11 @@ pub mod pause {
|
||||
x,
|
||||
y,
|
||||
"Leave",
|
||||
"Saves the world, then returns to the title.",
|
||||
if remote {
|
||||
"Disconnects from the server, then returns to the title."
|
||||
} else {
|
||||
"Saves the world, then returns to the title."
|
||||
},
|
||||
cw - t::ROW_LABEL - 220.0,
|
||||
);
|
||||
let qw = ui.button_width(quit_label, false);
|
||||
@@ -1320,6 +1368,7 @@ pub mod pause {
|
||||
world: &str,
|
||||
quit_label: &str,
|
||||
friends: &FriendsView,
|
||||
remote: bool,
|
||||
) -> Option<u8> {
|
||||
let mut ui = Ui::dry(atlas, batch, (x, y));
|
||||
draw(
|
||||
@@ -1328,6 +1377,7 @@ pub mod pause {
|
||||
quit_label,
|
||||
Instant::now() - std::time::Duration::from_secs(5),
|
||||
friends,
|
||||
remote,
|
||||
);
|
||||
let hit = ui.hit((x, y))?;
|
||||
let nav = id("pause.nav", 0);
|
||||
@@ -1348,6 +1398,47 @@ mod tests {
|
||||
use super::*;
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
#[test]
|
||||
fn pause_world_options_and_disconnect_are_clickable_on_servers() {
|
||||
let atlas =
|
||||
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap();
|
||||
let friends = FriendsView::default();
|
||||
for remote in [false, true] {
|
||||
let quit = if remote {
|
||||
"Disconnect"
|
||||
} else {
|
||||
"Save and quit"
|
||||
};
|
||||
for action in [9u8, 2] {
|
||||
let mut batch = HudBatch::new([1280, 720]);
|
||||
let (rect, density) = {
|
||||
let mut ui = Ui::dry(&atlas, &mut batch, (0.0, 0.0));
|
||||
pause::draw(
|
||||
&mut ui,
|
||||
"World",
|
||||
quit,
|
||||
Instant::now() - std::time::Duration::from_secs(5),
|
||||
&friends,
|
||||
remote,
|
||||
);
|
||||
let hit = ui
|
||||
.hits
|
||||
.iter()
|
||||
.find(|h| h.id == id("pause", u64::from(action)))
|
||||
.expect("pause action available");
|
||||
(hit.rect, ui.density)
|
||||
};
|
||||
let k = density / batch.scale;
|
||||
let x = (rect[0] + rect[2]) / 2.0 * k;
|
||||
let y = (rect[1] + rect[3]) / 2.0 * k;
|
||||
assert_eq!(
|
||||
pause::click(&atlas, &mut batch, x, y, "World", quit, &friends, remote),
|
||||
Some(action)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn world(saves: &std::path::Path, folder: &str, last_played: i64) {
|
||||
crate::save::write_dat(
|
||||
&saves.join(folder).join("level.dat"),
|
||||
@@ -1390,6 +1481,28 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizon_direct_connection_remembers_the_address_after_leaving() {
|
||||
let atlas =
|
||||
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap();
|
||||
let saves =
|
||||
std::env::temp_dir().join(format!("horizon-front-direct-{}", std::process::id()));
|
||||
let mut title = Title::new(saves.clone(), &saves);
|
||||
title.last_server = "localhost:25566".into();
|
||||
title.mode = Mode::Multiplayer(Screen::list(&saves));
|
||||
press(&mut title, &atlas, DIRECT);
|
||||
assert_eq!(title.focused_field().unwrap().1, "localhost:25566");
|
||||
*title.focused_field().unwrap().1 = "example.org:25567".into();
|
||||
press(&mut title, &atlas, BACK);
|
||||
press(&mut title, &atlas, DIRECT);
|
||||
assert_eq!(title.focused_field().unwrap().1, "example.org:25567");
|
||||
*title.focused_field().unwrap().1 = "localhost:25568".into();
|
||||
press(&mut title, &atlas, NAV);
|
||||
press(&mut title, &atlas, NAV + 2);
|
||||
press(&mut title, &atlas, DIRECT);
|
||||
assert_eq!(title.focused_field().unwrap().1, "localhost:25568");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizon_world_delete_confirms_and_keeps_the_selection_in_place() {
|
||||
let atlas =
|
||||
|
||||
+1973
-1191
File diff suppressed because it is too large.
Load diff
@@ -7,10 +7,11 @@ use horizon_client::{
|
||||
hud::HudBatch,
|
||||
items::ItemStack,
|
||||
text::{self, Style, Text},
|
||||
toasts::Slide,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::{BTreeMap, VecDeque},
|
||||
collections::{BTreeMap, BTreeSet, VecDeque},
|
||||
sync::Arc,
|
||||
time::Instant,
|
||||
};
|
||||
@@ -68,61 +69,25 @@ impl Toast {
|
||||
(fully_for as f64 / (5000.0 * multiplier / n).max(1.0) % n) as usize
|
||||
}
|
||||
}
|
||||
/// `ToastManager.ToastInstance`: slides a toast in and out over 600 ms,
|
||||
/// eased by the square, and counts how long it has been fully shown.
|
||||
#[derive(Default)]
|
||||
struct Slide {
|
||||
/// When it first updated; the times below are milliseconds since.
|
||||
origin: Option<Instant>,
|
||||
begun: bool,
|
||||
/// `animationStartTime`.
|
||||
start: i64,
|
||||
/// `becameFullyVisibleAt`.
|
||||
fully_at: i64,
|
||||
show: bool,
|
||||
/// `visiblePortion`: 0 off screen, 1 fully in.
|
||||
portion: f32,
|
||||
/// `fullyVisibleFor`.
|
||||
fully_for: i64,
|
||||
}
|
||||
impl Slide {
|
||||
const MS: i64 = 600;
|
||||
/// `ToastInstance.update` at `now`; `wants_show` is the toast's own
|
||||
/// update. The visibility it switched to (the sound to play), and whether
|
||||
/// it has finished sliding out.
|
||||
fn update(&mut self, now: i64, wants_show: impl FnOnce(i64) -> bool) -> (Option<bool>, bool) {
|
||||
let was = self.show;
|
||||
if !self.begun {
|
||||
self.begun = true;
|
||||
self.start = now;
|
||||
self.show = true;
|
||||
}
|
||||
if self.show && now - self.start <= Self::MS {
|
||||
self.fully_at = now;
|
||||
}
|
||||
self.fully_for = now - self.fully_at;
|
||||
let progress = ((now - self.start) as f32 / Self::MS as f32).clamp(0.0, 1.0);
|
||||
let progress = progress * progress;
|
||||
self.portion = if self.show { progress } else { 1.0 - progress };
|
||||
let wanted = wants_show(self.fully_for);
|
||||
if wanted != self.show {
|
||||
self.start = now - ((1.0 - self.portion) * Self::MS as f32) as i64;
|
||||
self.show = wanted;
|
||||
}
|
||||
let finished = !self.show && now - self.start > Self::MS;
|
||||
((self.show != was).then_some(self.show), finished)
|
||||
}
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub(super) struct AdvState {
|
||||
pub(super) tree: Arc<Tree>,
|
||||
/// `ClientAdvancements`: a server's tree carries each advancement's
|
||||
/// place on its tab (`DisplayInfo` x, y), in tree order.
|
||||
pub(super) positions: Option<Vec<(f32, f32)>>,
|
||||
pub(super) progress: Progress,
|
||||
pub(super) loaded: bool,
|
||||
pub(super) toasts: VecDeque<Toast>,
|
||||
/// Awards skip the toast (`/advancement grant ... everything`).
|
||||
pub(super) quiet: bool,
|
||||
/// The inventory last seen by `inventory_changed`.
|
||||
last_inventory: u64,
|
||||
pub(super) last_inventory: u64,
|
||||
/// While the game acts for a network player (`as_guest`): the
|
||||
/// advancements whose progress changed (`progressChanged`), the recipes
|
||||
/// unlocked and the announcements, for their client and everyone's.
|
||||
pub(super) changed: BTreeSet<usize>,
|
||||
pub(super) unlocked: Vec<String>,
|
||||
pub(super) announced: Vec<Text>,
|
||||
}
|
||||
/// The game's world for predicates.
|
||||
struct View<'a, 'g>(&'a Game<'g>);
|
||||
@@ -153,7 +118,7 @@ impl adv::World for View<'_, '_> {
|
||||
let name = self.0.res.facts.get(state).name.to_string();
|
||||
let props = self
|
||||
.0
|
||||
.res
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
@@ -215,29 +180,10 @@ impl adv::World for View<'_, '_> {
|
||||
}
|
||||
}
|
||||
fn now_text() -> String {
|
||||
// "yyyy-MM-dd HH:mm:ss Z" in UTC.
|
||||
let secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs()) as i64;
|
||||
let days = secs.div_euclid(86400);
|
||||
let rem = secs.rem_euclid(86400);
|
||||
// Civil from days (Howard Hinnant's algorithm).
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if m <= 2 { y + 1 } else { y };
|
||||
format!(
|
||||
"{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02} +0000",
|
||||
rem / 3600,
|
||||
rem % 3600 / 60,
|
||||
rem % 60
|
||||
)
|
||||
adv::date_text(secs)
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `ItemPredicate.test` (the `item_filter` of a filtered item modifier).
|
||||
@@ -261,30 +207,30 @@ pub(super) fn item_view(s: &ItemStack) -> ItemView {
|
||||
}
|
||||
impl Game<'_> {
|
||||
fn adv_path(&self) -> Option<std::path::PathBuf> {
|
||||
let uuid = self.level.player_uuid?;
|
||||
let hex: String = uuid.iter().map(|v| format!("{:08x}", *v as u32)).collect();
|
||||
let text = format!(
|
||||
"{}-{}-{}-{}-{}",
|
||||
&hex[0..8],
|
||||
&hex[8..12],
|
||||
&hex[12..16],
|
||||
&hex[16..20],
|
||||
&hex[20..32]
|
||||
);
|
||||
Some(
|
||||
self.world_root
|
||||
.join("players/advancements")
|
||||
.join(format!("{text}.json")),
|
||||
)
|
||||
Some(super::stats::player_json(
|
||||
&self.world_root,
|
||||
"advancements",
|
||||
self.level.player_uuid?,
|
||||
))
|
||||
}
|
||||
pub(super) fn load_advancements(&mut self) {
|
||||
if self.adv.loaded || self.remote.is_some() {
|
||||
return;
|
||||
}
|
||||
self.adv.loaded = true;
|
||||
/// A server with no player of its own and nobody it acts for: there is
|
||||
/// no player for advancements and statistics.
|
||||
pub(super) fn no_player_here(&self) -> bool {
|
||||
self.options.dedicated.is_some() && self.lan_acting.is_none()
|
||||
}
|
||||
/// 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")));
|
||||
}
|
||||
self.adv.tree.clone()
|
||||
}
|
||||
pub(super) fn load_advancements(&mut self) {
|
||||
if self.adv.loaded || self.remote.is_some() || self.no_player_here() {
|
||||
return;
|
||||
}
|
||||
self.adv.loaded = true;
|
||||
self.advancement_tree();
|
||||
let progress = self
|
||||
.adv_path()
|
||||
.and_then(|p| std::fs::read_to_string(p).ok())
|
||||
@@ -298,7 +244,7 @@ impl Game<'_> {
|
||||
self.ui.registries.advancements = tree.list.iter().map(|a| a.id.clone()).collect();
|
||||
}
|
||||
pub(super) fn save_advancements(&self) {
|
||||
if !self.adv.loaded || self.remote.is_some() {
|
||||
if !self.adv.loaded || self.remote.is_some() || self.no_player_here() {
|
||||
return;
|
||||
}
|
||||
let Some(path) = self.adv_path() else {
|
||||
@@ -420,7 +366,7 @@ impl Game<'_> {
|
||||
/// `SimpleCriterionTrigger.trigger`: test each open criterion of the
|
||||
/// trigger and award what it completes.
|
||||
pub(super) fn trigger(&mut self, name: &str, test: impl Fn(&Value, &dyn adv::World) -> bool) {
|
||||
if self.remote.is_some() {
|
||||
if self.remote.is_some() || self.no_player_here() {
|
||||
return;
|
||||
}
|
||||
self.load_advancements();
|
||||
@@ -447,15 +393,27 @@ impl Game<'_> {
|
||||
let a = &tree.list[ai];
|
||||
let was = self.adv.progress.done(a);
|
||||
self.adv.progress.grant(&a.id, &a.criteria[ci].name, &date);
|
||||
self.progress_changed(ai);
|
||||
if !was && self.adv.progress.done(a) {
|
||||
self.award(ai);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `PlayerAdvancements.progressChanged`, kept for a network player's client.
|
||||
fn progress_changed(&mut self, index: usize) {
|
||||
if self.lan_acting.is_some() {
|
||||
self.adv.changed.insert(index);
|
||||
}
|
||||
}
|
||||
/// After `ServerRecipeBook.addRecipes`: the toast and the
|
||||
/// `recipe_unlocked` trigger of each recipe now known.
|
||||
pub(super) fn recipe_unlocked(&mut self, unlocked: &[String]) {
|
||||
self.recipe_toast(unlocked);
|
||||
if self.lan_acting.is_some() {
|
||||
// A network player's client gets them in ClientboundRecipeBookAddPacket.
|
||||
self.adv.unlocked.extend_from_slice(unlocked);
|
||||
} else {
|
||||
self.recipe_toast(unlocked);
|
||||
}
|
||||
for r in unlocked {
|
||||
let r = r.clone();
|
||||
self.trigger("minecraft:recipe_unlocked", move |c, _| {
|
||||
@@ -497,7 +455,8 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
if let Some(d) = &a.display {
|
||||
if d.show_toast && !self.adv.quiet {
|
||||
// A network player's client shows its own toast.
|
||||
if d.show_toast && !self.adv.quiet && self.lan_acting.is_none() {
|
||||
self.adv.toasts.push_back(Toast {
|
||||
icon: d.icon.clone(),
|
||||
frame: d.frame,
|
||||
@@ -515,10 +474,18 @@ impl Game<'_> {
|
||||
Frame::Challenge => "chat.type.advancement.challenge",
|
||||
};
|
||||
let name = adv_name(a);
|
||||
self.system_message(Text::translate(
|
||||
key,
|
||||
&[Text::literal(super::chat::PLAYER_NAME), name],
|
||||
));
|
||||
// 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])),
|
||||
None => self.system_message(Text::translate(
|
||||
key,
|
||||
&[Text::literal(super::chat::PLAYER_NAME), name],
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
self.recipe_unlocked(&unlocked);
|
||||
@@ -657,7 +624,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// `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.res.assets.registry.state(state).cloned() else {
|
||||
let Some(definition) = self.pack.assets.registry.state(state).cloned() else {
|
||||
return;
|
||||
};
|
||||
let me = self.player_entity();
|
||||
@@ -790,11 +757,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.res.atlas;
|
||||
let atlas = &self.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.res.atlas;
|
||||
let atlas = &self.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();
|
||||
@@ -803,7 +770,7 @@ impl Game<'_> {
|
||||
self.draw_item_model(batch, &item, x + 8.0, 8.0);
|
||||
return;
|
||||
}
|
||||
let atlas = &self.res.atlas;
|
||||
let atlas = &self.atlas;
|
||||
batch.sprite(atlas, "gui/sprites/toast/advancement", x, 0.0);
|
||||
let (key, color) = match frame {
|
||||
Frame::Task => ("advancements.toast.task", 0xFF00_FFFF),
|
||||
@@ -864,6 +831,7 @@ impl Game<'_> {
|
||||
let was = self.adv.progress.done(a);
|
||||
if self.adv.progress.grant(&a.id, c, &date) {
|
||||
changed = true;
|
||||
self.progress_changed(index);
|
||||
if !was && self.adv.progress.done(a) {
|
||||
self.award(index);
|
||||
}
|
||||
@@ -875,6 +843,7 @@ impl Game<'_> {
|
||||
pub(super) fn revoke_advancement(&mut self, index: usize, criterion: Option<&str>) -> bool {
|
||||
let tree = self.adv.tree.clone();
|
||||
let a = &tree.list[index];
|
||||
self.progress_changed(index);
|
||||
match criterion {
|
||||
Some(c) => self.adv.progress.revoke(&a.id, c),
|
||||
None => {
|
||||
@@ -887,6 +856,110 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `ClientAdvancements.update` (ClientboundUpdateAdvancementsPacket):
|
||||
/// the server's advancements replace or join the tree, each changed one
|
||||
/// takes the progress sent, and one just done shows its toast. `icons`
|
||||
/// are the added advancements' icon items by name.
|
||||
pub(super) fn remote_advancements(
|
||||
&mut self,
|
||||
p: horizon_protocol::packets::play::UpdateAdvancements,
|
||||
icons: Vec<String>,
|
||||
) {
|
||||
use horizon_client::scoreboard::tag_to_json;
|
||||
let mut list: Vec<adv::Advancement> = Vec::new();
|
||||
let mut positions: Vec<(f32, f32)> = Vec::new();
|
||||
if !p.reset {
|
||||
let old = self.adv.positions.take().unwrap_or_default();
|
||||
for (i, a) in self.adv.tree.list.iter().enumerate() {
|
||||
if !p.removed.contains(&a.id) {
|
||||
list.push(a.clone());
|
||||
positions.push(old.get(i).copied().unwrap_or((0.0, 0.0)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.adv.progress = Progress::default();
|
||||
}
|
||||
for id in &p.removed {
|
||||
self.adv.progress.criteria.remove(id);
|
||||
}
|
||||
for (a, icon) in p.added.into_iter().zip(icons) {
|
||||
list.retain(|o| o.id != a.id);
|
||||
let display = a.display.map(|d| adv::Display {
|
||||
icon,
|
||||
title: tag_to_json(&d.title),
|
||||
description: tag_to_json(&d.description),
|
||||
frame: match d.frame {
|
||||
1 => Frame::Challenge,
|
||||
2 => Frame::Goal,
|
||||
_ => Frame::Task,
|
||||
},
|
||||
background: d.background,
|
||||
show_toast: d.show_toast,
|
||||
announce: false,
|
||||
hidden: d.hidden,
|
||||
});
|
||||
let mut criteria: Vec<adv::Criterion> = Vec::new();
|
||||
for name in a.requirements.iter().flatten() {
|
||||
if !criteria.iter().any(|c| &c.name == name) {
|
||||
criteria.push(adv::Criterion {
|
||||
name: name.clone(),
|
||||
trigger: String::new(),
|
||||
conditions: Value::Null,
|
||||
});
|
||||
}
|
||||
}
|
||||
list.push(adv::Advancement {
|
||||
id: a.id,
|
||||
parent: a.parent,
|
||||
display,
|
||||
criteria,
|
||||
requirements: a.requirements,
|
||||
rewards: Default::default(),
|
||||
});
|
||||
positions.push((a.x, a.y));
|
||||
}
|
||||
let mut tree = Tree {
|
||||
list,
|
||||
..Tree::default()
|
||||
};
|
||||
for (i, a) in tree.list.iter().enumerate() {
|
||||
tree.by_id.insert(a.id.clone(), i);
|
||||
}
|
||||
self.ui.registries.advancements = tree.list.iter().map(|a| a.id.clone()).collect();
|
||||
self.adv.tree = Arc::new(tree);
|
||||
self.adv.positions = Some(positions);
|
||||
let tree = self.adv.tree.clone();
|
||||
for (id, criteria) in p.progress {
|
||||
let Some(&i) = tree.by_id.get(&id) else {
|
||||
continue;
|
||||
};
|
||||
// The client only shows whether a criterion is obtained, not when.
|
||||
let obtained: BTreeMap<String, String> = criteria
|
||||
.into_iter()
|
||||
.filter(|(_, at)| at.is_some())
|
||||
.map(|(name, _)| (name, String::new()))
|
||||
.collect();
|
||||
self.adv.progress.criteria.insert(id, obtained);
|
||||
let a = &tree.list[i];
|
||||
if !p.reset
|
||||
&& p.show
|
||||
&& self.adv.progress.done(a)
|
||||
&& let Some(d) = a.display.as_ref().filter(|d| d.show_toast)
|
||||
{
|
||||
self.adv.toasts.push_back(Toast {
|
||||
icon: d.icon.clone(),
|
||||
frame: d.frame,
|
||||
title: text_of(&d.title),
|
||||
slide: Slide::default(),
|
||||
recipes: Vec::new(),
|
||||
changed: false,
|
||||
last_changed: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A text component from advancement JSON (`translate` or `text`).
|
||||
pub(super) fn text_of(v: &Value) -> Text {
|
||||
if let Some(key) = v["translate"].as_str() {
|
||||
|
||||
@@ -163,11 +163,18 @@ impl Game<'_> {
|
||||
}
|
||||
let tree = self.adv.tree.clone();
|
||||
let kids = tree.children();
|
||||
let pos = tree
|
||||
.layout()
|
||||
.into_iter()
|
||||
.map(|p| p.map(|(x, y)| (x * 28, (y * 27.0).floor() as i32)))
|
||||
.collect();
|
||||
// A server's advancements come placed (`DisplayInfo` x, y).
|
||||
let pos = match &self.adv.positions {
|
||||
Some(server) => server
|
||||
.iter()
|
||||
.map(|&(x, y)| Some(((x * 28.0).floor() as i32, (y * 27.0).floor() as i32)))
|
||||
.collect(),
|
||||
None => tree
|
||||
.layout()
|
||||
.into_iter()
|
||||
.map(|p| p.map(|(x, y)| (x * 28, (y * 27.0).floor() as i32)))
|
||||
.collect(),
|
||||
};
|
||||
self.adv_ui.cache = Some(Cache { tree, kids, pos });
|
||||
}
|
||||
fn adv_done(&self, i: usize) -> bool {
|
||||
@@ -184,7 +191,12 @@ impl Game<'_> {
|
||||
};
|
||||
};
|
||||
let tree = &c.tree;
|
||||
let visible = tree.visible(&c.kids, |i| self.adv.progress.done(&tree.list[i]));
|
||||
// A server sends only what the player may see.
|
||||
let visible = if self.adv.positions.is_some() {
|
||||
vec![true; tree.list.len()]
|
||||
} else {
|
||||
tree.visible(&c.kids, |i| self.adv.progress.done(&tree.list[i]))
|
||||
};
|
||||
// AdvancementTab.create: a displayed root with a background.
|
||||
let mut tabs: Vec<usize> = (0..tree.list.len())
|
||||
.filter(|&i| {
|
||||
@@ -257,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.res.atlas.font;
|
||||
let font = &self.atlas.font;
|
||||
let a = &self.adv.tree.list[i];
|
||||
let Some(d) = &a.display else {
|
||||
return (Vec::new(), Vec::new(), 0.0);
|
||||
@@ -320,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.res.atlas;
|
||||
let atlas = &self.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);
|
||||
@@ -432,7 +444,7 @@ impl Game<'_> {
|
||||
}
|
||||
let (x, y) = pos(i);
|
||||
let (x, y) = ((ox + x) as f32, (oy + y) as f32);
|
||||
let atlas = &self.res.atlas;
|
||||
let atlas = &self.atlas;
|
||||
batch.sprite_scaled(
|
||||
atlas,
|
||||
frame_sprite(d.frame, done),
|
||||
@@ -449,7 +461,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
// The window, tabs and title.
|
||||
let atlas = &self.res.atlas;
|
||||
let atlas = &self.atlas;
|
||||
batch.sprite_region(
|
||||
atlas,
|
||||
"gui/advancements/window",
|
||||
@@ -463,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.res.atlas,
|
||||
&self.atlas,
|
||||
&sprite,
|
||||
left + x,
|
||||
top + y,
|
||||
@@ -482,7 +494,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let atlas = &self.res.atlas;
|
||||
let atlas = &self.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),
|
||||
@@ -497,7 +509,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
// Done.
|
||||
let atlas = &self.res.atlas;
|
||||
let atlas = &self.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(
|
||||
@@ -520,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.res.atlas, &[text_of(&d.title)], self.cursor);
|
||||
ui::tooltip(batch, &self.atlas, &[text_of(&d.title)], self.cursor);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -604,7 +616,7 @@ impl Game<'_> {
|
||||
],
|
||||
)
|
||||
});
|
||||
let atlas = &self.res.atlas;
|
||||
let atlas = &self.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 586 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user