mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Compare commits
200
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 | ||
|
|
3230b6b88d | ||
|
|
cf837118e9 | ||
|
|
5d614f1cf6 | ||
|
|
78791b79ce | ||
|
|
a01d47f28f | ||
|
|
f03990f663 | ||
|
|
a024409a1a | ||
|
|
34d1ef6510 | ||
|
|
7e430564a6 | ||
|
|
ac2cc43aa6 | ||
|
|
dc42425c01 | ||
|
|
ae22d5dd96 | ||
|
|
4f2674275d | ||
|
|
e7003e7d49 | ||
|
|
7384db307d | ||
|
|
7f4636fcb5 | ||
|
|
cf4f95f3d8 | ||
|
|
3c28f55ce3 | ||
|
|
e5fcdaaec6 | ||
|
|
361c994d98 | ||
|
|
a71d148ca5 | ||
|
|
30d11d6a2e | ||
|
|
bd0dd979a0 | ||
|
|
0560bbf876 | ||
|
|
32f5f01e22 | ||
|
|
886def4360 | ||
|
|
ddc115e969 | ||
|
|
53955abf99 | ||
|
|
86ccd5d299 | ||
|
|
8f86098a02 | ||
|
|
a2cce7486d | ||
|
|
3db9a250be | ||
|
|
4218a496b2 | ||
|
|
1ee64ded55 |
No files matched your search
@@ -83,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
|
||||
|
||||
@@ -28,5 +28,6 @@
|
||||
/horizon-player.txt
|
||||
/servers.dat
|
||||
/screenshots/
|
||||
/.horizon-cache/
|
||||
/crash-reports/
|
||||
/debug/
|
||||
Generated
+4
@@ -1573,6 +1573,7 @@ dependencies = [
|
||||
"horizon-protocol",
|
||||
"horizon-world",
|
||||
"log",
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1608,7 +1609,10 @@ dependencies = [
|
||||
"horizon-core",
|
||||
"libm",
|
||||
"lz4_flex",
|
||||
"postcard",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"xxhash-rust",
|
||||
]
|
||||
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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,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(())
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -245,7 +245,7 @@ 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,
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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)]| {
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
//! (`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,
|
||||
@@ -107,12 +110,178 @@ 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 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)?;
|
||||
@@ -125,9 +294,6 @@ impl BlockFacts {
|
||||
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())
|
||||
@@ -221,13 +387,13 @@ impl BlockFacts {
|
||||
outline,
|
||||
outline_edges,
|
||||
name: intern(&state.name),
|
||||
emission: entry.emission as u8,
|
||||
dampening: entry.dampening as u8,
|
||||
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 f32,
|
||||
hardness: entry.hardness.as_f64().unwrap_or(0.0) as f32,
|
||||
requires_tool: entry.requires_tool,
|
||||
replaceable: entry.replaceable,
|
||||
random_ticks: entry.random_ticks,
|
||||
@@ -264,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]
|
||||
@@ -281,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(""),
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,17 +155,20 @@ object!(Sound<'a> {
|
||||
});
|
||||
object!(Entry<'a> {
|
||||
id: Option<u64> = None => value(unsigned),
|
||||
emission: f64 = 0.0 => value(number),
|
||||
dampening: f64 = 0.0 => value(number),
|
||||
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: f64 = 0.0 => value(number),
|
||||
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),
|
||||
|
||||
@@ -89,9 +89,9 @@ fn typed_fields_keep_defaults_duplicates_and_wrong_type_behavior() -> crate::Res
|
||||
assert_eq!(entries[1].id, None);
|
||||
let e = &entries[2];
|
||||
assert_eq!(e.id, Some(2));
|
||||
assert_eq!(e.emission, 4.5);
|
||||
assert_eq!(e.dampening, 0.0);
|
||||
assert_eq!(e.hardness.to_bits(), (-0.0f64).to_bits());
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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).
|
||||
@@ -400,6 +417,10 @@ impl FixedStep {
|
||||
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()
|
||||
}
|
||||
@@ -484,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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,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)]
|
||||
|
||||
@@ -74,40 +74,13 @@ pub struct DebugCharts {
|
||||
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>,
|
||||
}
|
||||
/// `Connection.getAverageSentPackets` / `getAverageReceivedPackets`: once a second
|
||||
/// (`Connection.tick`, every 20 ticks from the first) each average moves a quarter of
|
||||
/// the way to the packets counted since the last time.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct PacketRates {
|
||||
/// The connection's totals at the last update.
|
||||
seen: Option<(i64, i64)>,
|
||||
ticks: u32,
|
||||
pub sent: f32,
|
||||
pub received: f32,
|
||||
}
|
||||
impl PacketRates {
|
||||
/// One client tick, with the connection's sent and received packet totals.
|
||||
pub fn tick(&mut self, sent: i64, received: i64) {
|
||||
let tick = self.ticks;
|
||||
self.ticks = self.ticks.wrapping_add(1);
|
||||
if !tick.is_multiple_of(20) {
|
||||
return;
|
||||
}
|
||||
let (last_sent, last_received) = self.seen.unwrap_or((0, 0));
|
||||
// Mth.lerp(0.75, count, average).
|
||||
let lerp = |count: i64, average: f32| {
|
||||
let count = (count.max(0)) as f32;
|
||||
count + 0.75 * (average - count)
|
||||
};
|
||||
self.sent = lerp(sent - last_sent, self.sent);
|
||||
self.received = lerp(received - last_received, self.received);
|
||||
self.seen = Some((sent, received));
|
||||
}
|
||||
}
|
||||
|
||||
impl DebugCharts {
|
||||
/// `toggleFpsCharts`; `overlay` is the debug overlay's visibility.
|
||||
pub fn toggle_fps(&mut self, overlay: &mut bool) {
|
||||
@@ -144,6 +117,9 @@ impl DebugCharts {
|
||||
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
|
||||
@@ -167,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
|
||||
@@ -511,19 +498,6 @@ fn shaded(out: &mut Vec<GuiDraw>, font: &Font, s: &str, x: i32, y: i32) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn packet_rates_move_a_quarter_of_the_way_each_second() {
|
||||
let mut rates = super::PacketRates::default();
|
||||
// The first tick already counts (Connection.tick's tickCount++ % 20 == 0).
|
||||
rates.tick(40, 400);
|
||||
assert_eq!((rates.sent, rates.received), (10.0, 100.0));
|
||||
for _ in 1..20 {
|
||||
rates.tick(1000, 1000);
|
||||
assert_eq!((rates.sent, rates.received), (10.0, 100.0));
|
||||
}
|
||||
rates.tick(80, 400);
|
||||
assert_eq!((rates.sent, rates.received), (17.5, 75.0));
|
||||
}
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
@@ -568,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();
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -68,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,
|
||||
@@ -108,6 +109,7 @@ pub struct Extras {
|
||||
impl Default for Extras {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
fullbright: false,
|
||||
zoom: false,
|
||||
zoom_key: "key.keyboard.z".into(),
|
||||
zoom_level: 4,
|
||||
@@ -155,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" => {
|
||||
@@ -198,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:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n",
|
||||
"fullbright:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n",
|
||||
bool_text(self.fullbright),
|
||||
bool_text(self.zoom),
|
||||
self.zoom_key,
|
||||
self.zoom_level,
|
||||
@@ -418,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,
|
||||
@@ -445,6 +450,8 @@ pub enum Item {
|
||||
pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
use Item::*;
|
||||
vec![
|
||||
Err("Lighting"),
|
||||
Ok(Fullbright),
|
||||
Err("Zoom"),
|
||||
Ok(Zoom),
|
||||
Ok(ZoomKey),
|
||||
@@ -493,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",
|
||||
@@ -518,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.",
|
||||
@@ -556,7 +565,7 @@ 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 | Item::MouseTweaks | Item::Sort => Some(Cost::None),
|
||||
_ => None,
|
||||
@@ -565,6 +574,7 @@ impl Item {
|
||||
/// 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,
|
||||
@@ -606,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,
|
||||
@@ -651,11 +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;
|
||||
|
||||
@@ -1173,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_());
|
||||
@@ -1207,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,
|
||||
);
|
||||
@@ -1235,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);
|
||||
@@ -1363,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(
|
||||
@@ -1371,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);
|
||||
@@ -1391,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"),
|
||||
|
||||
+353
-105
@@ -68,8 +68,10 @@ mod lan_mobs;
|
||||
mod lan_pvp;
|
||||
mod lan_screen;
|
||||
mod lan_trading;
|
||||
mod lan_waypoints;
|
||||
mod leads;
|
||||
mod level_particles;
|
||||
mod levels;
|
||||
mod lightning;
|
||||
mod llamas;
|
||||
mod loom;
|
||||
@@ -112,6 +114,7 @@ mod scoreboard;
|
||||
mod screens;
|
||||
mod sculk;
|
||||
mod sculk_fixture;
|
||||
mod server_travel;
|
||||
mod shader_packs;
|
||||
mod shield;
|
||||
mod sign_edit;
|
||||
@@ -245,8 +248,9 @@ struct Options {
|
||||
benchmark: Option<String>,
|
||||
/// `--dev-mod PATH` (repeatable): mods always on and reloaded when rebuilt.
|
||||
dev_mods: Vec<PathBuf>,
|
||||
/// `--server-thread` or `HORIZON_SERVER_THREAD=1`: single player runs on
|
||||
/// an integrated server thread (game/integrated.rs).
|
||||
/// Single player runs on an integrated server thread (game/integrated.rs),
|
||||
/// as vanilla's does: the default. `--no-server-thread` or
|
||||
/// `HORIZON_SERVER_THREAD=0` runs the world in the frame instead.
|
||||
server_thread: bool,
|
||||
/// Set on the integrated server's own options: it draws nothing, so it skips meshing.
|
||||
integrated: bool,
|
||||
@@ -301,7 +305,10 @@ impl Options {
|
||||
dedicated: None,
|
||||
benchmark: None,
|
||||
dev_mods: Vec::new(),
|
||||
server_thread: std::env::var("HORIZON_SERVER_THREAD").is_ok_and(|v| v == "1"),
|
||||
// The command harness (HORIZON_COMMAND_SCRIPT) drives the world
|
||||
// itself, without the event loop: it stays in the frame.
|
||||
server_thread: std::env::var("HORIZON_SERVER_THREAD").map_or(true, |v| v != "0")
|
||||
&& std::env::var_os("HORIZON_COMMAND_SCRIPT").is_none(),
|
||||
integrated: false,
|
||||
};
|
||||
let mut session_file = std::env::var("HORIZON_SESSION_FILE").ok();
|
||||
@@ -310,7 +317,7 @@ impl Options {
|
||||
match arg.as_str() {
|
||||
"--help" | "-h" => {
|
||||
println!(
|
||||
"Horizon — Minecraft Java Edition 26.3 in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..64 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A Horizon mod (.hmod or folder) to develop: always on, reloaded when rebuilt\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause."
|
||||
"Horizon — Minecraft Java Edition 26.3 in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..64 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A Horizon mod (.hmod or folder) to develop: always on, reloaded when rebuilt\n --no-server-thread Run single player's world in the frame, not on its server thread\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause."
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -324,6 +331,7 @@ impl Options {
|
||||
"--offscreen" => o.offscreen = true,
|
||||
"--control" => o.control = Some(horizon_client::control::default_path()),
|
||||
"--server-thread" => o.server_thread = true,
|
||||
"--no-server-thread" => o.server_thread = false,
|
||||
_ => {
|
||||
let value = args
|
||||
.next()
|
||||
@@ -966,12 +974,21 @@ struct Game<'a> {
|
||||
/// `ClientAvatarState`: walked distance and view bob.
|
||||
avatar: horizon_client::camera::AvatarBob,
|
||||
flash: u32,
|
||||
/// Sleeping in a bed: (bed head position, ticks asleep).
|
||||
sleeping: Option<([i32; 3], u32)>,
|
||||
/// The head of the bed while sleeping; the visual timer also survives waking.
|
||||
sleeping: Option<[i32; 3]>,
|
||||
sleep_timer: u32,
|
||||
dim: Dim,
|
||||
world_root: PathBuf,
|
||||
/// A player carried through a portal, re-placed once the destination loads.
|
||||
travelling: Option<(PlayerMotion, Vec3)>,
|
||||
/// A server's players on their way to another level, each waiting for
|
||||
/// the destination to load around them.
|
||||
server_trips: Vec<server_travel::Trip>,
|
||||
/// A server's levels other than the one in place (game/levels.rs).
|
||||
levels: levels::Levels,
|
||||
/// Which pass of a server tick runs (`tick_server`): the first one reads
|
||||
/// the network, the last one ends the players' tick.
|
||||
level_pass: levels::Pass,
|
||||
/// Nether portals: the player's, items' and mobs' (game/nether_portal.rs).
|
||||
portals: nether_portal::Portals,
|
||||
/// The pending travel goes through an End portal (EndPortalBlock destinations).
|
||||
@@ -1087,6 +1104,8 @@ struct Game<'a> {
|
||||
lan: Option<lan::LanHost>,
|
||||
/// Single player's server thread, which `remote` is connected to.
|
||||
integrated: Option<integrated::Integrated>,
|
||||
/// This game is that server thread's (game/integrated.rs).
|
||||
serving: Option<integrated::Serving>,
|
||||
/// The open world is shared behind an invite code (game/friends.rs).
|
||||
friends: Option<friends::Sharing>,
|
||||
world_options: Option<lan_screen::WorldOptions>,
|
||||
@@ -1159,7 +1178,7 @@ struct Game<'a> {
|
||||
/// The local player as a model (third person, the inventory).
|
||||
player_model: Option<Mob>,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
enum Dim {
|
||||
Overworld,
|
||||
Nether,
|
||||
@@ -1447,9 +1466,13 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.flat_map(|(_, m)| m.faces.iter())
|
||||
.find(|f| f.quad.facing == horizon_assets::model::Direction::Up)
|
||||
.or_else(|| models.iter().flat_map(|(_, m)| m.faces.iter()).next())?;
|
||||
let name = self.pack.layer_names.get(&face.layer)?.clone();
|
||||
let tint = if face.quad.tint_index >= 0 {
|
||||
.or_else(|| models.iter().flat_map(|(_, m)| m.faces.iter()).next());
|
||||
// Entity-rendered blocks may have a particle texture without any faces.
|
||||
let layer = face
|
||||
.map(|face| face.layer)
|
||||
.or_else(|| models.iter().find_map(|(_, model)| model.particle))?;
|
||||
let name = self.pack.layer_names.get(&layer)?.clone();
|
||||
let tint = if face.is_some_and(|face| face.quad.tint_index >= 0) {
|
||||
let n = &self.res.facts.get(state).name;
|
||||
if n.contains("leaves") || n.contains("vine") {
|
||||
0x48B518
|
||||
@@ -1777,6 +1800,12 @@ impl Game<'_> {
|
||||
});
|
||||
self.process_level_events()
|
||||
}
|
||||
fn attack_block(&mut self, pos: [i32; 3]) -> Result<()> {
|
||||
self.with_level(|b, level, ticks| {
|
||||
b.attack_block(level, ticks, BlockPos::new(pos[0], pos[1], pos[2]));
|
||||
});
|
||||
self.process_level_events()
|
||||
}
|
||||
/// Track which chunks are ticking (Ticks needs chunk load/unload).
|
||||
fn sync_tick_chunks(&mut self) {
|
||||
if !self.tick_chunks.synchronize(&self.world.chunks) {
|
||||
@@ -2885,6 +2914,7 @@ impl Game<'_> {
|
||||
return Ok(());
|
||||
}
|
||||
if self.mining.as_ref().is_none_or(|m| m.pos != hit.pos) {
|
||||
self.attack_block(hit.pos)?;
|
||||
self.mining = Some(Mining {
|
||||
pos: hit.pos,
|
||||
progress: 0.0,
|
||||
@@ -3091,6 +3121,7 @@ impl Game<'_> {
|
||||
self.eye = 1.62;
|
||||
self.previous_eye = 1.62;
|
||||
self.clock.reset();
|
||||
self.capture_loaded_world();
|
||||
Ok(())
|
||||
}
|
||||
fn tick(&mut self) -> Result<()> {
|
||||
@@ -3165,7 +3196,7 @@ impl Game<'_> {
|
||||
if !self.streaming.is_lit(cx, cz) {
|
||||
return Ok(());
|
||||
}
|
||||
let playing = self.screen == Screen::Playing;
|
||||
let playing = self.screen == Screen::Playing && self.sleeping.is_none();
|
||||
let held = |k| playing && self.keys.contains(&k);
|
||||
let keys = Keys {
|
||||
forward: held(KeyCode::KeyW),
|
||||
@@ -3259,7 +3290,12 @@ impl Game<'_> {
|
||||
let feet = [player.position.x, player.position.y, player.position.z];
|
||||
let player_velocity = [player.velocity.x, player.velocity.y, player.velocity.z];
|
||||
self.previous_eye = self.eye;
|
||||
self.eye += (camera_eye.unwrap_or(player.dimensions.eye_height) - self.eye) * 0.5;
|
||||
let eye_height = if self.sleeping.is_some() {
|
||||
0.2
|
||||
} else {
|
||||
player.dimensions.eye_height
|
||||
};
|
||||
self.eye += (camera_eye.unwrap_or(eye_height) - self.eye) * 0.5;
|
||||
let crouching = player.pose == horizon_client::pose::Pose::Crouching;
|
||||
self.tick_avatar(before, riding);
|
||||
self.record_combat(&events);
|
||||
@@ -3406,11 +3442,8 @@ impl Game<'_> {
|
||||
}
|
||||
if player.health.dead_or_dying() && self.screen != Screen::Dead {
|
||||
// Inventory.dropAll: every stack thrown in a random direction.
|
||||
let at = [
|
||||
player.position.x,
|
||||
player.position.y + 1.32,
|
||||
player.position.z,
|
||||
];
|
||||
let feet = [player.position.x, player.position.y, player.position.z];
|
||||
let at = [feet[0], feet[1] + 1.32, feet[2]];
|
||||
let killer = self.combat_killer_kind();
|
||||
self.death_message();
|
||||
// ServerPlayer.die: deaths, and the time-since counters restart.
|
||||
@@ -3426,6 +3459,10 @@ impl Game<'_> {
|
||||
}
|
||||
self.stats.reset("custom", "minecraft:time_since_death");
|
||||
self.stats.reset("custom", "minecraft:time_since_rest");
|
||||
let reward = self
|
||||
.experience
|
||||
.death_reward(self.ui.rules.bool("keep_inventory") || self.spectator());
|
||||
self.spawn_orbs(feet, reward);
|
||||
if !self.creative && !self.ui.rules.bool("keep_inventory") {
|
||||
// Inventory.dropAll: slots 0..36, armor feet to head (36..39), off hand (40).
|
||||
let mut stacks: Vec<ItemStack> =
|
||||
@@ -3521,7 +3558,11 @@ impl Game<'_> {
|
||||
});
|
||||
horizon_client::spans::time("tick_particles", || self.tick_particles());
|
||||
}
|
||||
if self.right && self.use_delay == 0 && self.screen == Screen::Playing {
|
||||
if self.right
|
||||
&& self.use_delay == 0
|
||||
&& self.screen == Screen::Playing
|
||||
&& self.sleeping.is_none()
|
||||
{
|
||||
self.use_with_hand()?;
|
||||
self.use_delay = 4;
|
||||
}
|
||||
@@ -3634,8 +3675,8 @@ impl Game<'_> {
|
||||
}
|
||||
/// `Minecraft.pause`: a single-player game with a pausing screen open.
|
||||
fn pauses_game(&self) -> bool {
|
||||
(self.remote.is_none() || self.integrated.is_some())
|
||||
&& self.lan.is_none()
|
||||
self.has_singleplayer_server()
|
||||
&& !self.is_published()
|
||||
&& self.title.is_none()
|
||||
&& (matches!(
|
||||
self.screen,
|
||||
@@ -3648,6 +3689,15 @@ impl Game<'_> {
|
||||
) || self.book.is_some())
|
||||
}
|
||||
fn capture(&mut self, capture: bool) {
|
||||
// A guest's action (`as_guest`) opens and closes screens on the
|
||||
// guest's own client: the window's pointer stays the host's (#687).
|
||||
if self.lan_acting.is_some() {
|
||||
return;
|
||||
}
|
||||
// MouseHandler.grabMouse never captures an inactive window.
|
||||
if capture && !self.focused {
|
||||
return;
|
||||
}
|
||||
if !capture {
|
||||
self.stop_mining();
|
||||
}
|
||||
@@ -3666,6 +3716,13 @@ impl Game<'_> {
|
||||
}
|
||||
window.set_cursor_visible(!self.captured);
|
||||
}
|
||||
/// LevelLoadingScreen.onClose -> Gui.setScreen(null) -> grabMouse.
|
||||
fn capture_loaded_world(&mut self) {
|
||||
// Loading may finish after focus was lost or another screen opened.
|
||||
if self.title.is_none() && self.screen == Screen::Playing {
|
||||
self.capture(true);
|
||||
}
|
||||
}
|
||||
fn draw_item(&mut self, batch: &mut HudBatch, stack: &ItemStack, x: f32, y: f32) {
|
||||
// `GuiGraphics.renderItem`: the resolved item model (tints, foil and
|
||||
// special renderers included), then the decorations.
|
||||
@@ -3843,7 +3900,9 @@ impl Game<'_> {
|
||||
let spectator = self.spectator();
|
||||
let (wi, hi) = (w as i32, h as i32);
|
||||
// Horizon's pause menu sits over the blurred world alone.
|
||||
if !self.ui.hide_gui && !self.horizon_pause() {
|
||||
let horizon_pause = self.horizon_pause();
|
||||
let show_hud = !self.ui.hide_gui && !horizon_pause;
|
||||
if show_hud {
|
||||
self.draw_spyglass(&mut batch);
|
||||
// Hud.extractCrosshair / extractItemHotbar / extractPlayerHealth /
|
||||
// extractVehicleHealth and the experience bar, laid out by hud_layout.
|
||||
@@ -3888,15 +3947,7 @@ impl Game<'_> {
|
||||
self.draw_boss_bars(&mut batch);
|
||||
self.draw_extras(&mut batch);
|
||||
}
|
||||
// Hud.extractSleepOverlay, under the sidebar, titles and chat.
|
||||
if let Some((_, ticks)) = self.sleeping {
|
||||
let draws = hud_overlays::sleep_overlay(wi, hi, ticks as i32);
|
||||
hud_layout::paint(&mut batch, &self.atlas, &draws);
|
||||
}
|
||||
if !self.ui.hide_gui {
|
||||
self.draw_titles(&mut batch);
|
||||
self.draw_scoreboard(&mut batch);
|
||||
self.draw_subtitles(&mut batch);
|
||||
if show_hud {
|
||||
// The mount's hearts (and the jump bar that replaces the experience bar).
|
||||
let (vehicle_hearts, jump_bar) = if spectator {
|
||||
(0, false)
|
||||
@@ -3968,16 +4019,26 @@ impl Game<'_> {
|
||||
self.draw_selected_name(&mut batch, !self.creative);
|
||||
}
|
||||
}
|
||||
// Hud.extractSleepOverlay: over all health/food bars, under titles and chat.
|
||||
let draws = hud_overlays::sleep_overlay(wi, hi, self.sleep_timer as i32);
|
||||
hud_layout::paint(&mut batch, &self.atlas, &draws);
|
||||
if show_hud {
|
||||
self.draw_titles(&mut batch);
|
||||
self.draw_scoreboard(&mut batch);
|
||||
self.draw_subtitles(&mut batch);
|
||||
}
|
||||
let atlas = &self.atlas;
|
||||
if !self.ui.hide_gui
|
||||
if show_hud
|
||||
&& let Some((text, at)) = &self.message
|
||||
&& at.elapsed() < Duration::from_secs(3)
|
||||
{
|
||||
let tw = HudBatch::text_width(atlas, text);
|
||||
batch.text(atlas, text, (w - tw) / 2.0, h - 59.0, u32::MAX, true);
|
||||
}
|
||||
self.draw_chat(&mut batch);
|
||||
self.draw_tab_list(&mut batch);
|
||||
if !horizon_pause {
|
||||
self.draw_chat(&mut batch);
|
||||
self.draw_tab_list(&mut batch);
|
||||
}
|
||||
match self.screen {
|
||||
Screen::Paused if self.pause_menu => self.draw_pause_menu(&mut batch),
|
||||
// PauseScreen(false): only its title, at the top.
|
||||
@@ -4336,14 +4397,7 @@ impl Game<'_> {
|
||||
} else {
|
||||
("Crafting", 97.0)
|
||||
};
|
||||
batch.text(
|
||||
atlas,
|
||||
title,
|
||||
ox + tx,
|
||||
oy + if self.craft_size == 3 { 6.0 } else { 8.0 },
|
||||
grey,
|
||||
false,
|
||||
);
|
||||
batch.text(atlas, title, ox + tx, oy + 6.0, grey, false);
|
||||
if self.craft_size == 3 {
|
||||
batch.text(atlas, "Inventory", ox + 8.0, oy + 72.0, grey, false);
|
||||
}
|
||||
@@ -5579,6 +5633,20 @@ impl Game<'_> {
|
||||
.block_entity_render
|
||||
.render(&scene, visible, &mut out);
|
||||
}
|
||||
if let Some(mining) = &self.mining
|
||||
&& mining.progress > 0.0
|
||||
&& self.atlas.sprites.contains_key(&format!(
|
||||
"block/destroy_stage_{}",
|
||||
((mining.progress * 10.0) as u32).min(9)
|
||||
))
|
||||
{
|
||||
let state = self
|
||||
.world
|
||||
.block(mining.pos[0], mining.pos[1], mining.pos[2]);
|
||||
self.res
|
||||
.block_entity_render
|
||||
.render_breaking(&scene, mining.pos, state.0, &mut out);
|
||||
}
|
||||
}
|
||||
// Experience orbs (ExperienceOrbRenderer): pulsing billboards from a 4x4 icon sheet.
|
||||
if let Some(r) = self
|
||||
@@ -5816,6 +5884,38 @@ impl Game<'_> {
|
||||
let delay = 20.0 / (4.0 + speed).max(0.1);
|
||||
((self.attack_ticks as f32 + 0.5) / delay).clamp(0.0, 1.0)
|
||||
}
|
||||
/// `Player.isSweepAttack`: a charged, grounded sword hit below the movement limit.
|
||||
pub(super) fn sweep_attack_position(&self, scale: f32, crit: bool) -> Option<[f64; 3]> {
|
||||
let p = self.player.as_ref()?;
|
||||
let sword = self.held().is_some_and(|s| {
|
||||
self.res
|
||||
.items
|
||||
.item_tags
|
||||
.get("minecraft:swords")
|
||||
.is_some_and(|tag| tag.contains(&*s.item))
|
||||
});
|
||||
if !p.is_sweep_attack(scale, crit, sword) {
|
||||
return None;
|
||||
}
|
||||
let yaw = f64::from(self.yaw.to_radians());
|
||||
Some([
|
||||
p.position.x - yaw.sin(),
|
||||
p.position.y + f64::from(p.dimensions.height) * 0.5,
|
||||
p.position.z + yaw.cos(),
|
||||
])
|
||||
}
|
||||
/// The visual and sound part of `Player.doSweepAttack` after a successful hit.
|
||||
pub(super) fn sweep_attack(&mut self, at: [f64; 3], from: Option<[f64; 3]>) {
|
||||
self.particles.add(
|
||||
&horizon_client::particles::ParticleOptions::simple(
|
||||
horizon_client::particles::ParticleType::SweepAttack,
|
||||
),
|
||||
at,
|
||||
[0.0; 3],
|
||||
);
|
||||
self.audio
|
||||
.play("minecraft:entity.player.attack.sweep", from, 1.0, 1.0);
|
||||
}
|
||||
/// `Player.attack`'s sounds when a vehicle takes the hit (it always does,
|
||||
/// and can't be hit critically): knockback when sprinting at full
|
||||
/// strength, then strong or weak. Before `swing_arm` resets the charge.
|
||||
@@ -5832,7 +5932,11 @@ impl Game<'_> {
|
||||
} else {
|
||||
"minecraft:entity.player.attack.weak"
|
||||
};
|
||||
self.audio.play(sound, from, 1.0, 1.0);
|
||||
if let Some(at) = self.sweep_attack_position(self.attack_strength_scale(), false) {
|
||||
self.sweep_attack(at, from);
|
||||
} else {
|
||||
self.audio.play(sound, from, 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
/// Player.attack with the attack-strength charge, crits and knockback.
|
||||
fn attack(&mut self) -> bool {
|
||||
@@ -5884,6 +5988,7 @@ impl Game<'_> {
|
||||
let target_kind = self.mobs[index].kind.id.clone();
|
||||
let target_kind = format!("minecraft:{}", target_kind.trim_start_matches("minecraft:"));
|
||||
let (amount, scale, crit) = self.melee_damage(&target_kind);
|
||||
let sweep = self.sweep_attack_position(scale, crit);
|
||||
self.attack_ticks = 0;
|
||||
if let Some(p) = self.player.as_mut()
|
||||
&& !self.creative
|
||||
@@ -5943,7 +6048,11 @@ impl Game<'_> {
|
||||
"minecraft:entity.player.attack.weak"
|
||||
};
|
||||
// Player.playServerSideSound: at the player.
|
||||
self.audio.play(sound, from, 1.0, 1.0);
|
||||
if let Some(at) = sweep {
|
||||
self.sweep_attack(at, from);
|
||||
} else {
|
||||
self.audio.play(sound, from, 1.0, 1.0);
|
||||
}
|
||||
// The hurt or death sound the mob raised (getHurtSound / getDeathSound,
|
||||
// its voice pitch from its own random).
|
||||
let queued = std::mem::take(&mut self.mobs[index].outbox);
|
||||
@@ -6284,6 +6393,8 @@ impl Game<'_> {
|
||||
self.clear_entities();
|
||||
self.travelling = None;
|
||||
self.player = None;
|
||||
self.sleeping = None;
|
||||
self.sleep_timer = 0;
|
||||
self.spawn = None;
|
||||
self.respawn_travel = None;
|
||||
self.respawn_config =
|
||||
@@ -6410,6 +6521,17 @@ impl Game<'_> {
|
||||
let Some(player) = self.player.take() else {
|
||||
return Ok(());
|
||||
};
|
||||
let from = self.dim;
|
||||
let destination = self.switch_level(target, player.position)?;
|
||||
self.trigger_dimension(from, target);
|
||||
self.travelling = Some((player, destination));
|
||||
self.end_travel = (target == Dim::End || from == Dim::End) && self.end_portal_travel;
|
||||
Ok(())
|
||||
}
|
||||
/// The level swap under a dimension change: save the departing level,
|
||||
/// open `target`'s, and return where a player leaving from `from` goes
|
||||
/// (scaled coordinates, or the End portal's fixed destination).
|
||||
pub(super) fn switch_level(&mut self, target: Dim, from: Vec3) -> Result<Vec3> {
|
||||
// Save the departing dimension's changed chunks.
|
||||
self.streaming.save(&mut self.world, true);
|
||||
let keys: Vec<_> = self.world.modified.drain().collect();
|
||||
@@ -6425,11 +6547,10 @@ impl Game<'_> {
|
||||
_ => 1.0,
|
||||
};
|
||||
let destination = self.end_destination(target).unwrap_or(Vec3::new(
|
||||
player.position.x * scale,
|
||||
player.position.y,
|
||||
player.position.z * scale,
|
||||
from.x * scale,
|
||||
from.y,
|
||||
from.z * scale,
|
||||
));
|
||||
let from_end = self.dim == Dim::End;
|
||||
let (streaming, save) =
|
||||
self.make_streaming(&self.world_root.clone(), target, self.level.seed)?;
|
||||
let mut world = World::new(self.res.facts.air, self.level.seed);
|
||||
@@ -6437,12 +6558,14 @@ impl Game<'_> {
|
||||
world.game_time = self.world.game_time;
|
||||
world.day_time = self.world.day_time;
|
||||
world.weather = self.world.weather;
|
||||
// An open hub keeps hearing the new level's block changes.
|
||||
world.block_log = self.world.block_log.as_ref().map(|_| Vec::new());
|
||||
// The sculk tables' watched blocks are built once per game.
|
||||
world.watched = std::mem::take(&mut self.world.watched);
|
||||
self.streaming = streaming;
|
||||
self.world = world;
|
||||
self.save = save;
|
||||
let from = self.dim;
|
||||
self.dim = target;
|
||||
self.trigger_dimension(from, target);
|
||||
self.level.dimension = target.id().to_owned();
|
||||
self.block_ticks = Ticks::new(self.level.seed as i32);
|
||||
self.tick_chunks.clear();
|
||||
@@ -6455,13 +6578,11 @@ impl Game<'_> {
|
||||
self.pickups.clear();
|
||||
self.orbs.clear();
|
||||
self.mining = None;
|
||||
self.travelling = Some((player, destination));
|
||||
self.end_travel = (target == Dim::End || from_end) && self.end_portal_travel;
|
||||
if let Some(r) = self.renderer.as_mut() {
|
||||
r.clear_sections();
|
||||
}
|
||||
self.apply_settings();
|
||||
Ok(())
|
||||
Ok(destination)
|
||||
}
|
||||
/// PortalShape: an obsidian frame around 2..=21 by 3..=21 empty blocks.
|
||||
fn portal_shape(&self, start: [i32; 3], x_axis: bool) -> Option<([i32; 3], i32, i32)> {
|
||||
@@ -6631,6 +6752,8 @@ impl Game<'_> {
|
||||
self.streaming.paused = true;
|
||||
self.world = World::new(self.res.facts.air, 0);
|
||||
self.player = None;
|
||||
self.sleeping = None;
|
||||
self.sleep_timer = 0;
|
||||
self.clear_entities();
|
||||
self.screen = Screen::Playing;
|
||||
if let Some(r) = self.renderer.as_mut() {
|
||||
@@ -7460,6 +7583,7 @@ impl Game<'_> {
|
||||
// GameRenderer.renderItemInHand: F1 hides the hand with the HUD.
|
||||
if self.spectator()
|
||||
|| self.ui.hide_gui
|
||||
|| self.sleeping.is_some()
|
||||
|| !self.camera_type.first_person()
|
||||
|| self
|
||||
.player
|
||||
@@ -7648,7 +7772,15 @@ impl Game<'_> {
|
||||
/// The renderer for the window, with the current block sprites, shared
|
||||
/// atlas and world border texture.
|
||||
fn new_renderer(&self, window: Arc<Window>) -> Result<Renderer> {
|
||||
let assets = &self.pack.assets;
|
||||
self.renderer_for(window, &self.pack, &self.atlas)
|
||||
}
|
||||
fn renderer_for(
|
||||
&self,
|
||||
window: Arc<Window>,
|
||||
pack: &PackResources,
|
||||
atlas: &HudAtlas,
|
||||
) -> Result<Renderer> {
|
||||
let assets = &pack.assets;
|
||||
let mut renderer = pollster::block_on(Renderer::new(
|
||||
window,
|
||||
self.options.offscreen,
|
||||
@@ -7658,12 +7790,12 @@ impl Game<'_> {
|
||||
assets.texture_layers,
|
||||
&assets.sprite_mips,
|
||||
&assets.animations,
|
||||
(&self.atlas.pixels, self.atlas.width, self.atlas.height),
|
||||
(&self.pack.forcefield.2, self.pack.forcefield.0),
|
||||
&self.pack.panorama,
|
||||
(&atlas.pixels, atlas.width, atlas.height),
|
||||
(&pack.forcefield.2, pack.forcefield.0),
|
||||
&pack.panorama,
|
||||
self.renderer.as_ref(),
|
||||
))?;
|
||||
renderer.hud.fire_animations = self
|
||||
.atlas
|
||||
renderer.hud.fire_animations = atlas
|
||||
.fire_animations
|
||||
.iter()
|
||||
.map(|(rect, animation)| (*rect, animation.clone(), usize::MAX))
|
||||
@@ -7927,13 +8059,23 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
self.message = Some(("Respawn point set".into(), Instant::now()));
|
||||
self.sleeping = Some((head, 0));
|
||||
self.stop_mining();
|
||||
self.left = false;
|
||||
self.right = false;
|
||||
self.using = None;
|
||||
self.sleeping = Some(head);
|
||||
self.sleep_timer = 0;
|
||||
self.stats.custom("sleep_in_bed", 1);
|
||||
self.trigger_player("minecraft:slept_in_bed");
|
||||
self.stats.reset("custom", "minecraft:time_since_rest");
|
||||
}
|
||||
fn wake_up(&mut self) {
|
||||
self.sleeping = None;
|
||||
self.sleep_timer = 100;
|
||||
}
|
||||
fn tick_sleep(&mut self) {
|
||||
let Some((head, ticks)) = self.sleeping else {
|
||||
self.sleep_timer = tick_sleep_timer(self.sleep_timer, self.sleeping.is_some());
|
||||
let Some(head) = self.sleeping else {
|
||||
return;
|
||||
};
|
||||
if self.keys.contains(&KeyCode::ShiftLeft)
|
||||
@@ -7942,7 +8084,7 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.is_none_or(|p| p.health.dead_or_dying())
|
||||
{
|
||||
self.sleeping = None;
|
||||
self.wake_up();
|
||||
return;
|
||||
}
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
@@ -7953,18 +8095,15 @@ impl Game<'_> {
|
||||
);
|
||||
p.velocity = Vec3::ZERO;
|
||||
}
|
||||
let ticks = ticks + 1;
|
||||
// ServerLevel: once everyone has slept 100 ticks, skip to morning and clear weather.
|
||||
if ticks >= 100 {
|
||||
if self.sleep_timer >= 100 {
|
||||
let t = self.world.day_time;
|
||||
self.world.day_time = t + 24000 - t.rem_euclid(24000);
|
||||
self.world.weather.raining = false;
|
||||
self.world.weather.thundering = false;
|
||||
self.world.weather.rain_time = 0;
|
||||
self.world.weather.thunder_time = 0;
|
||||
self.sleeping = None;
|
||||
} else {
|
||||
self.sleeping = Some((head, ticks));
|
||||
self.wake_up();
|
||||
}
|
||||
}
|
||||
fn creative_origin(batch: &HudBatch) -> (f32, f32) {
|
||||
@@ -8064,7 +8203,7 @@ impl Game<'_> {
|
||||
self.creative_screen.search_focused.elapsed().as_millis(),
|
||||
);
|
||||
}
|
||||
let mut hovered: Option<String> = None;
|
||||
let mut hovered: Option<ItemStack> = None;
|
||||
if tab.inventory {
|
||||
self.draw_player_preview(
|
||||
batch,
|
||||
@@ -8114,7 +8253,7 @@ impl Game<'_> {
|
||||
&& self.cursor.1 < y + 16.0
|
||||
{
|
||||
batch.fill(&self.atlas, x, y, 16.0, 16.0, rgba(255, 255, 255, 128));
|
||||
hovered = Some(stack.item.to_string());
|
||||
hovered = Some(stack.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8129,7 +8268,7 @@ impl Game<'_> {
|
||||
&& self.cursor.1 >= y
|
||||
&& self.cursor.1 < y + 16.0
|
||||
{
|
||||
hovered = Some(stack.item.to_string());
|
||||
hovered = Some(stack);
|
||||
}
|
||||
}
|
||||
let scrollable = self.creative_screen.rows(&self.pack.names) > 5;
|
||||
@@ -8165,8 +8304,8 @@ impl Game<'_> {
|
||||
} else if self.carried.is_none()
|
||||
&& let Some(item) = hovered
|
||||
{
|
||||
let name = self.pack.names.item(&item);
|
||||
self.tooltip(batch, &name, self.cursor.0, self.cursor.1);
|
||||
let lines = self.stack_tooltip(&item);
|
||||
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
|
||||
}
|
||||
self.draw_inventory_effects(batch, (ox, oy), Creative::WIDTH as i32);
|
||||
}
|
||||
@@ -8310,21 +8449,7 @@ impl Game<'_> {
|
||||
self.ender_items = items.clone();
|
||||
}
|
||||
level.ender_items = self.ender_items.clone();
|
||||
level.active_effects = self
|
||||
.potions
|
||||
.effects
|
||||
.list
|
||||
.iter()
|
||||
.map(|e| {
|
||||
(
|
||||
format!("minecraft:{}", e.id),
|
||||
e.duration,
|
||||
e.amplifier,
|
||||
e.ambient,
|
||||
e.visible,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
level.active_effects = self.potions.effects.saved();
|
||||
}
|
||||
fn save_game(&mut self) -> Result<()> {
|
||||
self.save_game_with(true)
|
||||
@@ -8336,18 +8461,10 @@ impl Game<'_> {
|
||||
if self.remote.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
self.streaming.save(&mut self.world, wait);
|
||||
let keys: Vec<_> = self.world.modified.drain().collect();
|
||||
let chunks = self.with_entities(keys.iter().filter_map(|k| self.world.chunks.get(k)));
|
||||
self.save
|
||||
.save_later(&chunks, self.pack.assets.registry, &self.res.biomes)?;
|
||||
if wait {
|
||||
self.save.flush();
|
||||
}
|
||||
self.save_entities(wait)?;
|
||||
self.save_level(wait)?;
|
||||
self.save_parked_levels(wait)?;
|
||||
self.save_maps();
|
||||
self.save_random_sequences();
|
||||
self.save_raids();
|
||||
self.save_fight();
|
||||
self.save_wandering_trader();
|
||||
self.save_portal_arrivals();
|
||||
@@ -8364,6 +8481,20 @@ impl Game<'_> {
|
||||
self.message = Some(("Saved the game".into(), Instant::now()));
|
||||
Ok(())
|
||||
}
|
||||
/// Save the level in place: its changed chunks, its entities and its raids.
|
||||
fn save_level(&mut self, wait: bool) -> Result<()> {
|
||||
self.streaming.save(&mut self.world, wait);
|
||||
let keys: Vec<_> = self.world.modified.drain().collect();
|
||||
let chunks = self.with_entities(keys.iter().filter_map(|k| self.world.chunks.get(k)));
|
||||
self.save
|
||||
.save_later(&chunks, self.pack.assets.registry, &self.res.biomes)?;
|
||||
if wait {
|
||||
self.save.flush();
|
||||
}
|
||||
self.save_entities(wait)?;
|
||||
self.save_raids();
|
||||
Ok(())
|
||||
}
|
||||
fn render(&mut self) -> Result<bool> {
|
||||
self.poll_shader_pack();
|
||||
let Some(size) = self
|
||||
@@ -8442,12 +8573,23 @@ impl Game<'_> {
|
||||
let debug_lines = horizon_client::gizmos::camera_relative(&lines, at, view);
|
||||
let debug_lines_on_top = horizon_client::gizmos::camera_relative(&on_top, at, view);
|
||||
let debug_crosshair = horizon_client::gizmos::camera_relative(&crosshair.on_top, at, view);
|
||||
// LevelRenderer.renderHitOutline: the outline shape's edges.
|
||||
// LevelExtractor.extractBlockOutline: model translucency controls draw order.
|
||||
let mut outline_after_terrain = false;
|
||||
let outline = if self.screen == Screen::Playing && !self.spectator() {
|
||||
self.pick()
|
||||
.map(|h| {
|
||||
let state = self.world.block(h.pos[0], h.pos[1], h.pos[2]);
|
||||
let f = self.res.facts.get(state);
|
||||
outline_after_terrain = self
|
||||
.pack
|
||||
.assets
|
||||
.model_catalog
|
||||
.collect(state, BlockPos::new(h.pos[0], h.pos[1], h.pos[2]))
|
||||
.is_ok_and(|models| {
|
||||
models
|
||||
.iter()
|
||||
.any(|(_, model)| model.faces.iter().any(|f| f.translucent))
|
||||
});
|
||||
let o = if f.outline_offset {
|
||||
self.pack
|
||||
.context
|
||||
@@ -8597,6 +8739,7 @@ impl Game<'_> {
|
||||
}),
|
||||
dark_disc: self.dark_disc(camera),
|
||||
outline,
|
||||
outline_after_terrain,
|
||||
debug_lines: &debug_lines,
|
||||
debug_lines_on_top: &debug_lines_on_top,
|
||||
debug_crosshair: &debug_crosshair,
|
||||
@@ -8774,6 +8917,18 @@ impl Game<'_> {
|
||||
}
|
||||
/// A key press while a world is open (keyboard or control socket).
|
||||
fn key_pressed(&mut self, key: KeyCode) -> Result<()> {
|
||||
if self.sleeping.is_some()
|
||||
&& self.screen == Screen::Playing
|
||||
&& !matches!(
|
||||
key,
|
||||
KeyCode::F1 | KeyCode::F2 | KeyCode::F11 | KeyCode::KeyT | KeyCode::Slash
|
||||
)
|
||||
{
|
||||
if matches!(key, KeyCode::Escape | KeyCode::ShiftLeft) {
|
||||
self.wake_up();
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if self.sort_key_press(key) {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -8860,7 +9015,13 @@ impl Game<'_> {
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
// key.swapOffhand: ServerboundPlayerActionPacket SWAP_ITEM_WITH_OFFHAND.
|
||||
// key.swapOffhand: on a server, ServerboundPlayerActionPacket SWAP_ITEM_WITH_OFFHAND
|
||||
// (the server swaps the hands and sends both slots back; spectators can't).
|
||||
KeyCode::KeyF if self.screen == Screen::Playing && self.remote.is_some() => {
|
||||
if !self.spectator() {
|
||||
self.remote_swap_hands();
|
||||
}
|
||||
}
|
||||
KeyCode::KeyF if self.screen == Screen::Playing => {
|
||||
std::mem::swap(&mut self.offhand, &mut self.inventory[self.selected]);
|
||||
self.using = None;
|
||||
@@ -8898,6 +9059,9 @@ impl Game<'_> {
|
||||
}
|
||||
/// A mouse button while playing (window or control socket).
|
||||
fn play_mouse(&mut self, button: MouseButton, pressed: bool) -> Result<()> {
|
||||
if self.sleeping.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
if self.remote.is_some() {
|
||||
return self.remote_mouse(button, pressed);
|
||||
}
|
||||
@@ -9227,6 +9391,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
title.scroll_worlds(d, self.cursor, (batch.width, batch.height));
|
||||
}
|
||||
if self.screen == Screen::Playing
|
||||
&& self.sleeping.is_none()
|
||||
&& self.player.is_some()
|
||||
&& !self.extras_scroll(d)
|
||||
&& let Some((wheel, _)) =
|
||||
@@ -9363,6 +9528,21 @@ impl ApplicationHandler for Game<'_> {
|
||||
}
|
||||
WindowEvent::MouseInput { state, button, .. } => {
|
||||
let pressed = state == ElementState::Pressed;
|
||||
if matches!(self.screen, Screen::Inventory | Screen::Creative)
|
||||
&& let Some(key) = self
|
||||
.settings
|
||||
.keys
|
||||
.container_mouse_key(button, self.creative)
|
||||
{
|
||||
if pressed && self.carried.is_none() {
|
||||
self.inventory_hotkey(key);
|
||||
}
|
||||
// Neither edge of a bound button is an ordinary slot click.
|
||||
return Ok(());
|
||||
}
|
||||
if pressed && self.sort_mouse_press(button) {
|
||||
return Ok(());
|
||||
}
|
||||
match self.screen {
|
||||
Screen::Playing => {
|
||||
if pressed && !self.captured {
|
||||
@@ -9485,9 +9665,6 @@ impl ApplicationHandler for Game<'_> {
|
||||
self.station_text(event.text.as_deref(), backspace);
|
||||
}
|
||||
}
|
||||
WindowEvent::KeyboardInput { event, .. } if self.remote_chat_open() => {
|
||||
self.remote_chat_key(&event)
|
||||
}
|
||||
WindowEvent::KeyboardInput { event, .. }
|
||||
if self.screen == Screen::WorldOptions
|
||||
&& !matches!(event.physical_key, PhysicalKey::Code(KeyCode::Escape)) =>
|
||||
@@ -9594,7 +9771,6 @@ impl ApplicationHandler for Game<'_> {
|
||||
&& self.title.is_none()
|
||||
&& self.sign_edit.is_none()
|
||||
&& self.book.is_none()
|
||||
&& !self.remote_chat_open()
|
||||
{
|
||||
let turn = |k| f32::from(u8::from(self.keys.contains(&k)));
|
||||
let secs = elapsed.as_secs_f32().min(0.1);
|
||||
@@ -10003,9 +10179,13 @@ impl<'a> Game<'a> {
|
||||
avatar: Default::default(),
|
||||
flash: 0,
|
||||
sleeping: None,
|
||||
sleep_timer: 0,
|
||||
dim: Dim::Overworld,
|
||||
world_root: PathBuf::new(),
|
||||
travelling: None,
|
||||
server_trips: Vec::new(),
|
||||
levels: Default::default(),
|
||||
level_pass: Default::default(),
|
||||
portals: Default::default(),
|
||||
end_travel: false,
|
||||
respawn_config: None,
|
||||
@@ -10086,6 +10266,7 @@ impl<'a> Game<'a> {
|
||||
remote: None,
|
||||
lan: None,
|
||||
integrated: None,
|
||||
serving: None,
|
||||
world_options: None,
|
||||
options_to_world_options: false,
|
||||
command_block_screen: None,
|
||||
@@ -10147,7 +10328,16 @@ pub fn run() -> Result<()> {
|
||||
horizon_client::spans::enable(options.spikes.is_some());
|
||||
let vanilla_root = options.resources.clone();
|
||||
let early_settings = Settings::load(&std::env::current_dir()?.join("options.txt"));
|
||||
options.resources = packs::startup_root(&vanilla_root, &early_settings.resource_packs);
|
||||
// Snapshots of derived tables live in the game folder, never beside the
|
||||
// shared resources.
|
||||
Behaviours::set_cache_dir(std::env::current_dir()?.join(".horizon-cache"));
|
||||
let mut pack_error = None;
|
||||
options.resources = packs::startup_root(&vanilla_root, &early_settings.resource_packs)
|
||||
.unwrap_or_else(|e| {
|
||||
log::warn!("Horizon: resource packs not applied: {e}");
|
||||
pack_error = Some(e.to_string());
|
||||
vanilla_root.clone()
|
||||
});
|
||||
options.resources = mods::content_root(
|
||||
&options.resources,
|
||||
&early_settings.mods.enabled,
|
||||
@@ -10159,6 +10349,7 @@ pub fn run() -> Result<()> {
|
||||
// with the game's own resources.
|
||||
Err(e) if options.resources != vanilla_root => {
|
||||
log::warn!("Horizon: resource packs failed to load ({e}); using the default resources");
|
||||
pack_error = Some(e.to_string());
|
||||
options.resources = vanilla_root.clone();
|
||||
Assets::load(&vanilla_root)?
|
||||
}
|
||||
@@ -10172,7 +10363,11 @@ pub fn run() -> Result<()> {
|
||||
let root = options.resources.clone();
|
||||
let assets = Arc::new(assets);
|
||||
log::info!("load assets: {:.2}s", started.elapsed().as_secs_f32());
|
||||
let facts = BlockFacts::load(&root, assets.registry)?;
|
||||
let behaviour_json: serde_json::Value = serde_json::from_reader(std::io::BufReader::new(
|
||||
std::fs::File::open(root.join("block-behaviours.json"))?,
|
||||
))?;
|
||||
let (facts, gameplay_projection) =
|
||||
BlockFacts::load_with_projection(&root, assets.registry, &behaviour_json)?;
|
||||
let mob_data = horizon_client::mobs::MobData::load(&root, &facts);
|
||||
horizon_client::mobs::trial_spawner::load_registry(&root);
|
||||
log::info!("load facts: {:.2}s", started.elapsed().as_secs_f32());
|
||||
@@ -10239,7 +10434,12 @@ pub fn run() -> Result<()> {
|
||||
ambient: horizon_client::ambience::AmbientTables::load(&root),
|
||||
nether_environment: Environment::load_dimension(&root, "the_nether", false)?,
|
||||
end_environment: Environment::load_dimension(&root, "the_end", false)?,
|
||||
behaviours: Behaviours::load(registry_owner.registry, &root)?,
|
||||
behaviours: Behaviours::load_with_gameplay(
|
||||
registry_owner.registry,
|
||||
&root,
|
||||
&behaviour_json,
|
||||
&gameplay_projection,
|
||||
)?,
|
||||
mob_types: MobTypes::load(&root)?
|
||||
.types
|
||||
.into_iter()
|
||||
@@ -10251,6 +10451,10 @@ pub fn run() -> Result<()> {
|
||||
player_arm: horizon_client::mobs::load_model(&root, "minecraft:player#main")
|
||||
.and_then(|m| m.children.into_iter().find(|c| c.name == "right_arm")),
|
||||
});
|
||||
// Both inputs are needed only while constructing the behaviour tables.
|
||||
// Release their owned JSON/Arc buffers before entering the long-lived game loop.
|
||||
drop(gameplay_projection);
|
||||
drop(behaviour_json);
|
||||
let mut settings = Settings::load(&std::env::current_dir()?.join("options.txt"));
|
||||
horizon_client::ui_theme::load_preferences(&std::env::current_dir()?);
|
||||
// Scripted and frame-limited runs (the parity harnesses) see vanilla's
|
||||
@@ -10265,6 +10469,10 @@ pub fn run() -> Result<()> {
|
||||
}
|
||||
let mut game = Game::new(&resources, pack, atlas, options, settings, audio)?;
|
||||
game.ui.vanilla_root = vanilla_root;
|
||||
if let Some(error) = pack_error {
|
||||
game.open_options_at(true, horizon_client::options_ui::Page::Packs);
|
||||
game.ui.options.resource_error = Some(error);
|
||||
}
|
||||
if game.options.benchmark.is_some() {
|
||||
// The same conditions on every machine: no VSync, no frame cap, no
|
||||
// pause or idle limit without focus. In memory only, never saved.
|
||||
@@ -10372,6 +10580,17 @@ pub fn run() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Player.tick: cap the sleeping fade at 100, then fade out over 10 waking ticks.
|
||||
fn tick_sleep_timer(timer: u32, sleeping: bool) -> u32 {
|
||||
if sleeping {
|
||||
(timer + 1).min(100)
|
||||
} else if (1..109).contains(&timer) {
|
||||
timer + 1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
fn reset_world_progress(adv: &mut advancements::AdvState, stats: &mut stats::Stats) {
|
||||
*adv = Default::default();
|
||||
*stats = Default::default();
|
||||
@@ -10406,6 +10625,35 @@ mod tests {
|
||||
use horizon_client::container_screen::{Button, Clicks, Release, Target};
|
||||
use horizon_client::keymapping::{KeyAction, SPRINT, ToggleKeys};
|
||||
|
||||
#[test]
|
||||
fn sleep_fade_lasts_ten_ticks_after_waking() {
|
||||
let alpha = |timer| match hud_overlays::sleep_overlay(320, 240, timer).first() {
|
||||
Some(hud_layout::GuiDraw::Fill { color, .. }) => color >> 24,
|
||||
None => 0,
|
||||
_ => panic!("sleep overlay must be a full-screen fill"),
|
||||
};
|
||||
assert_eq!(tick_sleep_timer(0, false), 0);
|
||||
let mut timer = 0;
|
||||
for _ in 0..50 {
|
||||
timer = tick_sleep_timer(timer, true);
|
||||
}
|
||||
assert_eq!(alpha(timer as i32), 110);
|
||||
for _ in 0..100 {
|
||||
timer = tick_sleep_timer(timer, true);
|
||||
}
|
||||
assert_eq!(timer, 100);
|
||||
assert_eq!(alpha(timer as i32), 220);
|
||||
for _ in 0..5 {
|
||||
timer = tick_sleep_timer(timer, false);
|
||||
}
|
||||
assert_eq!(alpha(timer as i32), 110);
|
||||
for _ in 0..5 {
|
||||
timer = tick_sleep_timer(timer, false);
|
||||
}
|
||||
assert_eq!(timer, 0);
|
||||
assert!(hud_overlays::sleep_overlay(320, 240, timer as i32).is_empty());
|
||||
}
|
||||
|
||||
/// An integrated server thread shares the loaded resources with the client.
|
||||
#[test]
|
||||
fn shared_resources_can_cross_threads() {
|
||||
|
||||
@@ -11,7 +11,7 @@ use horizon_client::{
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::{BTreeMap, VecDeque},
|
||||
collections::{BTreeMap, BTreeSet, VecDeque},
|
||||
sync::Arc,
|
||||
time::Instant,
|
||||
};
|
||||
@@ -72,13 +72,22 @@ impl Toast {
|
||||
#[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>);
|
||||
@@ -171,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).
|
||||
@@ -217,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())
|
||||
@@ -254,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 {
|
||||
@@ -376,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();
|
||||
@@ -403,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, _| {
|
||||
@@ -453,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,
|
||||
@@ -471,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);
|
||||
@@ -820,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);
|
||||
}
|
||||
@@ -831,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 => {
|
||||
@@ -843,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| {
|
||||
|
||||
@@ -460,12 +460,18 @@ impl Game<'_> {
|
||||
json!({
|
||||
"screen": self.screen_label(),
|
||||
"menu": self.title.as_ref().map(|_| self.title_value()),
|
||||
"world": (self.title.is_none()).then(|| self.level.name.clone()),
|
||||
"world": (self.title.is_none()).then(|| match &self.integrated {
|
||||
Some(server) => server.world_name.clone(),
|
||||
None => self.level.name.clone(),
|
||||
}),
|
||||
"dimension": self.dim.id(),
|
||||
"gamemode": format!("{:?}", self.mode).to_lowercase(),
|
||||
"time": { "day_time": self.world.day_time, "day": self.world.day_time.div_euclid(24000), "time_of_day": self.world.day_time.rem_euclid(24000) },
|
||||
"weather": { "raining": self.world.weather.raining, "thundering": self.world.weather.thundering },
|
||||
"paused": self.pause,
|
||||
// Mouse movement turns the camera (`apply_mouse_look`): the real
|
||||
// pointer is grabbed, or an agent's `mouse-move` stands in for it.
|
||||
"pointer_grabbed": self.captured || self.agent_pointer,
|
||||
"player": player,
|
||||
"selected": self.selected + 1,
|
||||
"held": self.held().map(|s| self.stack_value(s)),
|
||||
@@ -610,6 +616,9 @@ impl Game<'_> {
|
||||
"screen": self.screen_label(),
|
||||
"buttons": buttons,
|
||||
});
|
||||
if let Some(chat) = &self.ui.chat_screen {
|
||||
v["chat_input"] = json!(chat.input.value);
|
||||
}
|
||||
if self.title.is_some() {
|
||||
v["menu"] = self.title_value();
|
||||
}
|
||||
@@ -628,6 +637,15 @@ impl Game<'_> {
|
||||
v["craft_size"] = json!(self.craft_size);
|
||||
v["station"] = json!(self.station.as_ref().map(|s| format!("{:?}", s.kind)));
|
||||
v["container"] = json!(self.container);
|
||||
// The recipe book's buttons (GUI pixels): the book button, then
|
||||
// the recipe buttons of the page, page arrows, filter and tabs.
|
||||
let book = self.recipe_book_targets();
|
||||
if !book.is_empty() {
|
||||
v["recipe_book"] = json!({
|
||||
"targets": book.iter().map(|t| t.rect).collect::<Vec<_>>(),
|
||||
"known": self.recipe_book_known(),
|
||||
});
|
||||
}
|
||||
v["slot_ids"] = json!(
|
||||
"0-8 hotbar, 9-35 inventory, 100+ crafting grid (row-major), 200 crafting result, 300+ container, 500-503 armor, 504 offhand, 600+ station"
|
||||
);
|
||||
@@ -1476,6 +1494,13 @@ impl Game<'_> {
|
||||
return Err("command TEXT".into());
|
||||
}
|
||||
let command = format!("/{text}");
|
||||
// Single player's console is its server thread's.
|
||||
if let Some(server) = &self.integrated {
|
||||
let chat = server
|
||||
.call(move |g| g.owner_console_command(&command))
|
||||
.ok_or("the integrated server stopped")?;
|
||||
return ok(json!({ "ok": true, "chat": chat }));
|
||||
}
|
||||
if self.remote.is_some() {
|
||||
self.send_chat(&command);
|
||||
return ok(json!({ "ok": true }));
|
||||
@@ -1761,11 +1786,21 @@ impl Game<'_> {
|
||||
if self.ticks < *report_tick && !timed_out {
|
||||
return None;
|
||||
}
|
||||
let after = pos.map(|p| self.block_json(p));
|
||||
let changed = match (*pos, *before) {
|
||||
(Some(p), Some(b)) => self.world.block(p[0], p[1], p[2]) != b,
|
||||
_ => false,
|
||||
};
|
||||
// A client of a server (single player's server thread, a LAN
|
||||
// guest) sees the block once the server's answer comes.
|
||||
if *place
|
||||
&& !changed
|
||||
&& self.remote.is_some()
|
||||
&& self.ticks < *report_tick + 20
|
||||
&& !timed_out
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let after = pos.map(|p| self.block_json(p));
|
||||
let used = delta(inventory, &self.inventory_counts());
|
||||
let mut v = json!({
|
||||
"ok": !*place || changed,
|
||||
|
||||
@@ -220,7 +220,11 @@ impl Game<'_> {
|
||||
levels
|
||||
}
|
||||
/// `applyEffects`: the powers for players within `levels * 10 + 10` (any height above).
|
||||
/// `BeaconBlockEntity.applyEffects`: every player in range.
|
||||
fn beacon_effects(&mut self, p: [i32; 3], b: &Beacon) {
|
||||
self.for_each_player(|g| g.beacon_effects_on_player(p, b));
|
||||
}
|
||||
fn beacon_effects_on_player(&mut self, p: [i32; 3], b: &Beacon) {
|
||||
let Some(primary) = b.primary.as_deref().and_then(effect_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -10,6 +10,18 @@ use horizon_client::{
|
||||
text::{ClickEvent, Style, Text},
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// The tree the chat parses and completes with: a server's, once it has
|
||||
/// sent one (ClientPacketListener.commands), else the game's own.
|
||||
fn chat_tree<'a>(
|
||||
remote: &'a Option<super::remote::Remote>,
|
||||
mods: &'a super::mods::ModsState,
|
||||
) -> &'a horizon_client::command::Dispatcher {
|
||||
match remote.as_ref().and_then(|r| r.commands.as_ref()) {
|
||||
Some(tree) => tree,
|
||||
None => mods.dispatcher(),
|
||||
}
|
||||
}
|
||||
use winit::{
|
||||
event::KeyEvent,
|
||||
keyboard::{KeyCode, PhysicalKey},
|
||||
@@ -378,6 +390,10 @@ impl Game<'_> {
|
||||
/// The block the player is looking at, for coordinate suggestions.
|
||||
fn refresh_dynamic_registries(&mut self) {
|
||||
self.ui.registries.target_block = self.pick().map(|h| h.pos);
|
||||
// ClientPacketListener.getOnlinePlayerNames on a server.
|
||||
if let Some(names) = self.remote_online_names() {
|
||||
self.ui.registries.players = names;
|
||||
}
|
||||
}
|
||||
pub(super) fn open_chat(&mut self, initial: &str) {
|
||||
if self.player.is_none() {
|
||||
@@ -391,7 +407,7 @@ impl Game<'_> {
|
||||
let b = HudBatch::new(size);
|
||||
let m = Measure(&self.atlas.font);
|
||||
let c = Commands {
|
||||
dispatcher: self.mods.dispatcher(),
|
||||
dispatcher: chat_tree(&self.remote, &self.mods),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
let mut screen = ChatScreen::new(
|
||||
@@ -425,6 +441,12 @@ impl Game<'_> {
|
||||
capture.push(text);
|
||||
return;
|
||||
}
|
||||
// Connected to a server (single player's own included), the chat on
|
||||
// screen is the connection's: the game's own messages join it.
|
||||
if self.remote.is_some() {
|
||||
self.chat_line(text.plain());
|
||||
return;
|
||||
}
|
||||
let focused = self.screen == Screen::Chat;
|
||||
let heard = self.mods.mods.is_running().then(|| text.plain());
|
||||
self.ui.chat.add(
|
||||
@@ -443,7 +465,7 @@ impl Game<'_> {
|
||||
if let Some(shortcut) = self.edit_shortcut(event) {
|
||||
let m = Measure(&self.atlas.font);
|
||||
let c = Commands {
|
||||
dispatcher: self.mods.dispatcher(),
|
||||
dispatcher: chat_tree(&self.remote, &self.mods),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
if let Some(screen) = self.ui.chat_screen.as_mut() {
|
||||
@@ -489,7 +511,7 @@ impl Game<'_> {
|
||||
};
|
||||
let m = Measure(&self.atlas.font);
|
||||
let c = Commands {
|
||||
dispatcher: self.mods.dispatcher(),
|
||||
dispatcher: chat_tree(&self.remote, &self.mods),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
match screen.key(&m, &c, &self.ui.chat.recent, key, shift, ctrl) {
|
||||
@@ -515,7 +537,7 @@ impl Game<'_> {
|
||||
pub(super) fn chat_type(&mut self, text: &str) {
|
||||
let m = Measure(&self.atlas.font);
|
||||
let c = Commands {
|
||||
dispatcher: self.mods.dispatcher(),
|
||||
dispatcher: chat_tree(&self.remote, &self.mods),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
if let Some(screen) = self.ui.chat_screen.as_mut() {
|
||||
@@ -525,6 +547,11 @@ impl Game<'_> {
|
||||
/// ChatScreen.handleChatInput: history, then a command or a chat line.
|
||||
pub(super) fn handle_chat_input(&mut self, message: &str) {
|
||||
self.ui.chat.add_recent(message);
|
||||
// On a server (single player's own included) the server runs it.
|
||||
if self.remote.is_some() {
|
||||
self.send_chat(message);
|
||||
return;
|
||||
}
|
||||
if let Some(command) = message.strip_prefix('/') {
|
||||
self.run_command(command);
|
||||
} else {
|
||||
@@ -566,7 +593,7 @@ impl Game<'_> {
|
||||
});
|
||||
let m = Measure(&self.atlas.font);
|
||||
let c = Commands {
|
||||
dispatcher: self.mods.dispatcher(),
|
||||
dispatcher: chat_tree(&self.remote, &self.mods),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
if let Some(screen) = self.ui.chat_screen.as_mut() {
|
||||
@@ -611,7 +638,7 @@ impl Game<'_> {
|
||||
};
|
||||
let m = Measure(&self.atlas.font);
|
||||
let c = Commands {
|
||||
dispatcher: self.mods.dispatcher(),
|
||||
dispatcher: chat_tree(&self.remote, &self.mods),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
screen.resize(&m, &c, batch.width as i32, batch.height as i32);
|
||||
|
||||
@@ -758,18 +758,25 @@ impl Game<'_> {
|
||||
.and_then(|l| l.local_addr())
|
||||
.map_or(25564, |a| i32::from(a.port())),
|
||||
};
|
||||
if let Some(lan) = &self.lan {
|
||||
return Err(err(
|
||||
"commands.publish.alreadyPublished.lan",
|
||||
&[&lan.settings.port],
|
||||
));
|
||||
let published = if self.serving() {
|
||||
self.published_port()
|
||||
} else {
|
||||
self.lan.as_ref().map(|l| l.settings.port)
|
||||
};
|
||||
if let Some(port) = published {
|
||||
return Err(err("commands.publish.alreadyPublished.lan", &[&port]));
|
||||
}
|
||||
let settings = super::super::lan::LanSettings {
|
||||
port: port as u16,
|
||||
guest_commands: allow,
|
||||
force_game_mode: false,
|
||||
};
|
||||
if self.lan_start(settings).is_err() {
|
||||
let started = if self.serving() {
|
||||
self.publish(settings).is_ok()
|
||||
} else {
|
||||
self.lan_start(settings).is_ok()
|
||||
};
|
||||
if !started {
|
||||
return Err(err("commands.publish.failed", &[]));
|
||||
}
|
||||
let port_text = port.to_string();
|
||||
@@ -785,6 +792,13 @@ impl Game<'_> {
|
||||
Ok(port)
|
||||
}
|
||||
pub(super) fn cmd_unpublish(&mut self) -> R<i32> {
|
||||
if self.serving() {
|
||||
if !self.unpublish() {
|
||||
return Err(err("commands.unpublish.notPublished", &[]));
|
||||
}
|
||||
self.feedback(Text::translate("commands.unpublish.success", &[]));
|
||||
return Ok(1);
|
||||
}
|
||||
if self.lan.is_none() {
|
||||
return Err(err("commands.unpublish.notPublished", &[]));
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ const HELP: &[&str] = &[
|
||||
"chat TEXT send a chat line or /command as if typed",
|
||||
"type TEXT type into the open chat box (key enter tap sends)",
|
||||
"chat-log [N] the newest N chat messages (default 10)",
|
||||
"options [PAGE] open the options (video sound controls keybinds mouse chat accessibility language font packs graphics graphics-advanced)",
|
||||
"options [PAGE] open the options (video sound controls keybinds mouse chat accessibility language font packs mods graphics graphics-advanced)",
|
||||
"stats [reset] frame-time summary since the last reset",
|
||||
"bench SECONDS reset stats, wait, then reply with the summary",
|
||||
"fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH [raw]] fly the camera along a line at constant speed, then reply with the frame summary (raw: every frame time too)",
|
||||
@@ -54,12 +54,23 @@ pub struct Flight {
|
||||
/// Reply with every frame time too (`fly ... PITCH raw`).
|
||||
raw: bool,
|
||||
reply: Sender<String>,
|
||||
/// The world's smoothed tick time each frame (the F3 server line):
|
||||
/// the server thread's when single player runs on one.
|
||||
tick_ms: Vec<f32>,
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Move the camera along the running flight; reply when it ends.
|
||||
pub(super) fn advance_flight(&mut self) {
|
||||
let Some(flight) = &self.flight else { return };
|
||||
let tick_ms = self
|
||||
.integrated
|
||||
.as_ref()
|
||||
.map_or(self.debug_charts.smoothed_tick_ms, |s| s.smoothed_tick_ms());
|
||||
let Some(flight) = self.flight.as_mut() else {
|
||||
return;
|
||||
};
|
||||
flight.tick_ms.push(tick_ms);
|
||||
let flight = &*flight;
|
||||
let t = (flight.started.elapsed().as_secs_f64() / flight.seconds).min(1.0);
|
||||
let (a, b) = (flight.from, flight.to);
|
||||
let d = [b.x - a.x, b.y - a.y, b.z - a.z];
|
||||
@@ -79,6 +90,9 @@ impl Game<'_> {
|
||||
&& let Some(flight) = self.flight.take()
|
||||
{
|
||||
let mut summary = self.frame_stats.summary();
|
||||
let n = flight.tick_ms.len().max(1) as f32;
|
||||
summary["tick_ms"] = (flight.tick_ms.iter().sum::<f32>() / n).into();
|
||||
summary["tick_ms_max"] = flight.tick_ms.iter().copied().fold(0.0, f32::max).into();
|
||||
if flight.raw {
|
||||
summary["frame_ms"] = self.frame_stats.frame_millis().into();
|
||||
summary["cpu_ms"] = self.frame_stats.cpu_millis().into();
|
||||
@@ -339,6 +353,14 @@ impl Game<'_> {
|
||||
.get(&item)
|
||||
.ok_or_else(|| format!("unknown item {item}"))?;
|
||||
let count: u32 = words.get(2).map_or(Ok(1), |c| c.parse())?;
|
||||
// Single player's inventory is its server thread's player's.
|
||||
if let Some(server) = &self.integrated {
|
||||
let command = format!("give @s {item} {count}");
|
||||
server
|
||||
.call(move |g| g.owner_console_command(&command))
|
||||
.ok_or("the integrated server stopped")?;
|
||||
return Ok(Some(json!({ "ok": true, "given": count })));
|
||||
}
|
||||
let max = info.max_stack;
|
||||
let mut given = 0;
|
||||
// Stop once the inventory is full.
|
||||
@@ -369,7 +391,14 @@ impl Game<'_> {
|
||||
ok()
|
||||
}
|
||||
"time" => {
|
||||
self.world.day_time = arg(1)?.parse()?;
|
||||
let time: i64 = arg(1)?.parse()?;
|
||||
self.world.day_time = time;
|
||||
// Single player's clock is its server thread's.
|
||||
if let Some(server) = &self.integrated {
|
||||
server
|
||||
.call(move |g| g.world.day_time = time)
|
||||
.ok_or("the integrated server stopped")?;
|
||||
}
|
||||
ok()
|
||||
}
|
||||
"summon" => {
|
||||
@@ -462,8 +491,10 @@ impl Game<'_> {
|
||||
"language" => Page::Language,
|
||||
"font" => Page::Font,
|
||||
"packs" => Page::Packs,
|
||||
"mods" => Page::Mods,
|
||||
"graphics" => Page::Graphics,
|
||||
"graphics-advanced" => Page::GraphicsAdvanced,
|
||||
"extras" => Page::Extras,
|
||||
other => return Err(format!("unknown options page {other}").into()),
|
||||
};
|
||||
if self.screen == Screen::Playing && self.title.is_none() {
|
||||
@@ -475,7 +506,10 @@ impl Game<'_> {
|
||||
}
|
||||
"chat-log" => {
|
||||
let n = words.get(1).map_or(Ok(10), |n| n.parse())?;
|
||||
Ok(Some(json!({ "chat": self.ui.chat.recent_lines(n) })))
|
||||
let lines = self
|
||||
.remote_chat(n)
|
||||
.unwrap_or_else(|| self.ui.chat.recent_lines(n));
|
||||
Ok(Some(json!({ "chat": lines })))
|
||||
}
|
||||
"stats" => {
|
||||
let summary = self.frame_stats.summary();
|
||||
@@ -512,6 +546,7 @@ impl Game<'_> {
|
||||
seconds: n(7)?.max(0.1),
|
||||
raw: words.get(9) == Some(&"raw"),
|
||||
reply: reply.clone(),
|
||||
tick_ms: Vec::new(),
|
||||
});
|
||||
Ok(None)
|
||||
}
|
||||
@@ -549,18 +584,20 @@ impl Game<'_> {
|
||||
self.leave_server(None)?;
|
||||
ok()
|
||||
}
|
||||
"save" => {
|
||||
"save" | "save-all" => {
|
||||
self.require_world()?;
|
||||
self.save_game()?;
|
||||
ok()
|
||||
}
|
||||
"save-all" => {
|
||||
// Like `save-all flush`: also rewrite chunks that were not modified
|
||||
// (the save round-trip harness compares them with vanilla's files).
|
||||
self.require_world()?;
|
||||
let keys: Vec<_> = self.world.chunks.keys().copied().collect();
|
||||
self.world.modified.extend(keys);
|
||||
self.save_game()?;
|
||||
// `save-all` is like `save-all flush`: it also rewrites chunks
|
||||
// that were not modified (the save round-trip harness compares
|
||||
// them with vanilla's files).
|
||||
let all = words[0] == "save-all";
|
||||
// Single player's world is its server thread's.
|
||||
if let Some(server) = &self.integrated {
|
||||
server
|
||||
.call(move |g| g.control_save(all).map_err(|e| e.to_string()))
|
||||
.ok_or("the integrated server stopped")??;
|
||||
} else {
|
||||
self.control_save(all)?;
|
||||
}
|
||||
ok()
|
||||
}
|
||||
"dragon-hurt" => {
|
||||
@@ -581,6 +618,15 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `horizon-ctl save` and `save-all`.
|
||||
fn control_save(&mut self, all: bool) -> Result<()> {
|
||||
if all {
|
||||
let keys: Vec<_> = self.world.chunks.keys().copied().collect();
|
||||
self.world.modified.extend(keys);
|
||||
}
|
||||
self.save_game()
|
||||
}
|
||||
|
||||
fn require_world(&self) -> Result<()> {
|
||||
if self.title.is_some() {
|
||||
return Err("no world open; use: open WORLD [SEED] [creative]".into());
|
||||
@@ -590,18 +636,6 @@ impl Game<'_> {
|
||||
|
||||
fn control_key_down(&mut self, key: KeyCode) -> Result<()> {
|
||||
self.physical_keys.insert(key);
|
||||
// A server's chat input takes the keys first, as remote_chat_key does.
|
||||
if self.remote_chat_open() {
|
||||
use winit::keyboard::NamedKey;
|
||||
let named = match key {
|
||||
KeyCode::Escape => Some(NamedKey::Escape),
|
||||
KeyCode::Enter | KeyCode::NumpadEnter => Some(NamedKey::Enter),
|
||||
KeyCode::Backspace => Some(NamedKey::Backspace),
|
||||
_ => None,
|
||||
};
|
||||
self.remote_chat_input(named, None);
|
||||
return Ok(());
|
||||
}
|
||||
if self.screen == Screen::Chat {
|
||||
self.chat_key_code(key, None);
|
||||
return Ok(());
|
||||
@@ -715,7 +749,9 @@ impl Game<'_> {
|
||||
"tridents": self.tridents.len(),
|
||||
"rockets": self.rockets.len(),
|
||||
"fight": self.dragon_status(),
|
||||
"carts": self.carts.iter().map(|c| serde_json::json!({"kind": c.kind.id(), "position": [c.position.x, c.position.y, c.position.z], "velocity": [c.velocity.x, c.velocity.y, c.velocity.z]})).collect::<Vec<_>>(),
|
||||
// A client of a server (single player's server thread) knows the
|
||||
// carts it was sent.
|
||||
"carts": self.remote_carts().unwrap_or_else(|| self.carts.iter().map(|c| serde_json::json!({"kind": c.kind.id(), "position": [c.position.x, c.position.y, c.position.z], "velocity": [c.velocity.x, c.velocity.y, c.velocity.z]})).collect::<Vec<_>>()),
|
||||
"riding": self.riding,
|
||||
"using": self.using,
|
||||
"render_distance": self.streaming.radius,
|
||||
|
||||
@@ -362,3 +362,11 @@ pub(super) struct CreakingState {
|
||||
/// Which block states are creaking hearts, by state id.
|
||||
hearts: Vec<bool>,
|
||||
}
|
||||
|
||||
impl CreakingState {
|
||||
/// Exchange the chunks scanned for creaking hearts with another level's
|
||||
/// (game/levels.rs).
|
||||
pub(super) fn swap_level(&mut self, other: &mut Self) {
|
||||
std::mem::swap(&mut self.scanned, &mut other.scanned);
|
||||
}
|
||||
}
|
||||
@@ -1358,25 +1358,52 @@ impl Game<'_> {
|
||||
/// mining fatigue III for five minutes unless a stronger or longer one
|
||||
/// is on, with the curse sound and the elder's apparition.
|
||||
fn elder_curse(&mut self, at: [f64; 3]) {
|
||||
let mut cursed = Vec::new();
|
||||
self.for_each_player(|g| {
|
||||
if g.elder_curse_player(at) {
|
||||
cursed.push(g.lan_acting);
|
||||
}
|
||||
});
|
||||
// A guest's client shows the apparition and plays the curse itself
|
||||
// (ClientboundGameEventPacket.GUARDIAN_ELDER_EFFECT).
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
for conn in cursed.into_iter().flatten() {
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&horizon_protocol::packets::play::Clientbound::GameEvent(
|
||||
horizon_protocol::packets::play::GameEvent {
|
||||
event: horizon_client::lan::hub::game_event::GUARDIAN_ELDER_EFFECT,
|
||||
param: 1.0,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The curse on the player in place; whether it took.
|
||||
fn elder_curse_player(&mut self, at: [f64; 3]) -> bool {
|
||||
if self.creative || self.spectator() {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
return false;
|
||||
};
|
||||
let d = p.position.subtract(Vec3::new(at[0], at[1], at[2]));
|
||||
if d.x * d.x + d.y * d.y + d.z * d.z >= 2500.0 || p.health.dead_or_dying() {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
let active = self.potions.effects.get("minecraft:mining_fatigue");
|
||||
if active.is_some_and(|e| e.amplifier >= 2 && e.duration >= 1200) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
self.give_player_effect("minecraft:mining_fatigue", 6000, 2);
|
||||
self.audio
|
||||
.play("minecraft:entity.elder_guardian.curse", Some(at), 1.0, 1.0);
|
||||
self.creatures.elder_apparition = super::mob_blocks::APPARITION_TICKS;
|
||||
if self.lan_acting.is_none() {
|
||||
self.audio
|
||||
.play("minecraft:entity.elder_guardian.curse", Some(at), 1.0, 1.0);
|
||||
self.creatures.elder_apparition = super::mob_blocks::APPARITION_TICKS;
|
||||
}
|
||||
true
|
||||
}
|
||||
/// Damage of a given type to the player from a point.
|
||||
pub(super) fn mob_hits_player_kind(&mut self, amount: f32, from: [f64; 3], kind: &str) {
|
||||
|
||||
@@ -38,7 +38,7 @@ impl DebugHost for Host<'_, '_> {
|
||||
// the menu as it always has (key_pressed).
|
||||
let g = &mut *self.game;
|
||||
if without_menu && g.screen == Screen::Playing {
|
||||
g.pause_menu = !(g.remote.is_none() && g.lan.is_none());
|
||||
g.pause_menu = !(g.has_singleplayer_server() && !g.is_published());
|
||||
g.screen = Screen::Paused;
|
||||
g.capture(false);
|
||||
g.mining = None;
|
||||
@@ -88,8 +88,7 @@ impl Game<'_> {
|
||||
|| self.book.is_some()
|
||||
|| (self.screen == Screen::Creative && self.creative_screen.tab().search)
|
||||
|| self.recipe_search_focused()
|
||||
|| self.station_typing()
|
||||
|| self.remote_chat_open();
|
||||
|| self.station_typing();
|
||||
// Container screens use Escape, the inventory key and the hotbar and
|
||||
// drop keys first; any other screen closes on Escape.
|
||||
let screen_key = match self.screen {
|
||||
@@ -187,27 +186,24 @@ impl Game<'_> {
|
||||
/// they show, `BandwidthDebugMonitor.tick` always.
|
||||
pub(super) fn tick_debug_charts(&mut self) {
|
||||
let shown = self.debug && self.debug_charts.network;
|
||||
let Some(r) = &mut self.remote else {
|
||||
let Some(r) = &self.remote else {
|
||||
// The integrated server answers within the tick.
|
||||
if shown {
|
||||
self.debug_charts.ping.log(0);
|
||||
}
|
||||
return;
|
||||
};
|
||||
let totals = r.session.counters.lock().unwrap().as_ref().map(|c| {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
(
|
||||
c.bytes_in.load(Relaxed),
|
||||
c.sent.load(Relaxed),
|
||||
c.received.load(Relaxed),
|
||||
)
|
||||
let received = r.session.counters.lock().unwrap().as_ref().map(|c| {
|
||||
let order = std::sync::atomic::Ordering::Relaxed;
|
||||
[
|
||||
c.bytes_in.load(order),
|
||||
c.sent.load(order),
|
||||
c.received.load(order),
|
||||
]
|
||||
});
|
||||
let received = totals.map(|(bytes, sent, packets)| {
|
||||
r.packet_rates.tick(sent, packets);
|
||||
bytes
|
||||
});
|
||||
if let Some(total) = received {
|
||||
self.debug_charts.log_bandwidth(total);
|
||||
if let Some([bytes, sent, received]) = received {
|
||||
self.debug_charts.log_bandwidth(bytes);
|
||||
self.debug_charts.log_packets([sent, received]);
|
||||
}
|
||||
if shown {
|
||||
use horizon_protocol::packets::{play::Serverbound, status::Ping};
|
||||
@@ -408,27 +404,9 @@ impl Game<'_> {
|
||||
],
|
||||
);
|
||||
}
|
||||
// tps: DebugEntryTps. Single player without its server thread has no connection, so
|
||||
// it counts no packets.
|
||||
let rates = self.remote.as_ref().map_or((0.0, 0.0), |r| {
|
||||
(r.packet_rates.sent, r.packet_rates.received)
|
||||
});
|
||||
if let Some(r) = self.remote.as_ref().filter(|r| !r.local) {
|
||||
let status = if self.clock.stepping() {
|
||||
" (frozen - stepping)"
|
||||
} else if self.clock.frozen() {
|
||||
" (frozen)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let brand = r.session.brand.lock().unwrap().clone();
|
||||
d.line(format!(
|
||||
"\"{}\" server{status}, {:.0} tx, {:.0} rx",
|
||||
brand.as_deref().unwrap_or("null"),
|
||||
rates.0,
|
||||
rates.1
|
||||
));
|
||||
} else if self.player.is_some() || self.remote.is_some() {
|
||||
// tps: DebugEntryTps for local and remote connections.
|
||||
if self.player.is_some() {
|
||||
let [tx, rx] = self.debug_charts.packet_rates.map(|n| n.round() as i64);
|
||||
let (target, status) = if self.clock.sprinting() {
|
||||
("-".to_owned(), " (sprinting)")
|
||||
} else if self.clock.stepping() {
|
||||
@@ -441,10 +419,22 @@ impl Game<'_> {
|
||||
} else {
|
||||
(format!("{:.1}", 1000.0 / self.clock.rate()), "")
|
||||
};
|
||||
d.line(format!(
|
||||
"Integrated server @ {:.1}/{target} ms{status}, {:.0} tx, {:.0} rx",
|
||||
self.debug_charts.smoothed_tick_ms, rates.0, rates.1
|
||||
));
|
||||
if self.remote.is_none() || self.integrated.is_some() {
|
||||
let tick_ms = self
|
||||
.integrated
|
||||
.as_ref()
|
||||
.map_or(self.debug_charts.smoothed_tick_ms, |server| {
|
||||
server.smoothed_tick_ms()
|
||||
});
|
||||
d.line(format!(
|
||||
"Integrated server @ {tick_ms:.1}/{target} ms{status}, {tx} tx, {rx} rx",
|
||||
));
|
||||
} else if let Some(remote) = &self.remote {
|
||||
d.line(format!(
|
||||
"\"{}\" server{status}, {tx} tx, {rx} rx",
|
||||
remote.brand
|
||||
));
|
||||
}
|
||||
}
|
||||
let (left, right) = d.columns(&self.debug_chart_lines());
|
||||
let atlas = &self.atlas;
|
||||
|
||||
@@ -9,11 +9,13 @@
|
||||
use super::{Game, Options, lan::LanSettings};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
lan::{LoginGate, NetConfig, hub::HubConfig},
|
||||
lan::{LoginGate, NetConfig, NetLog, OnlineMode, hub::HubConfig},
|
||||
server::{
|
||||
eula,
|
||||
java_properties::now_millis,
|
||||
profiles,
|
||||
properties::{GAME_MODES, ServerProperties, SystemEntropy},
|
||||
user_cache::UserCache,
|
||||
user_lists::{
|
||||
BanEntry, BanList, IpBanList, NameAndId, OpEntry, OpList, WhiteEntry, WhiteList,
|
||||
format_date,
|
||||
@@ -57,9 +59,26 @@ struct Setup {
|
||||
started: Instant,
|
||||
}
|
||||
static SETUP: OnceLock<Mutex<Option<Setup>>> = OnceLock::new();
|
||||
/// usercache.json (Services.nameToIdCache): used by the server thread only.
|
||||
static USER_CACHE: Mutex<Option<UserCache>> = Mutex::new(None);
|
||||
|
||||
/// PlayerList.placeNewPlayer: a player who joined is cached (and the file saved).
|
||||
pub(super) fn player_joined(id: u128, name: &str) {
|
||||
if let Some(cache) = USER_CACHE.lock().unwrap().as_mut() {
|
||||
cache.add(NameAndId {
|
||||
id,
|
||||
name: name.to_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Log4j's console pattern: `[HH:mm:ss] [thread/LEVEL]: message`.
|
||||
pub(super) fn log(level: &str, message: &str) {
|
||||
log_from("Server thread", level, message);
|
||||
}
|
||||
|
||||
/// A line from another of vanilla's threads (the login's User Authenticator).
|
||||
fn log_from(thread: &str, level: &str, message: &str) {
|
||||
let file_level = match level {
|
||||
"WARN" => ::log::Level::Warn,
|
||||
"ERROR" => ::log::Level::Error,
|
||||
@@ -73,7 +92,7 @@ pub(super) fn log(level: &str, message: &str) {
|
||||
});
|
||||
let secs = now_millis().div_euclid(1000).rem_euclid(86_400);
|
||||
println!(
|
||||
"[{:02}:{:02}:{:02}] [Server thread/{level}]: {message}",
|
||||
"[{:02}:{:02}:{:02}] [{thread}/{level}]: {message}",
|
||||
secs / 3600,
|
||||
secs / 60 % 60,
|
||||
secs % 60
|
||||
@@ -343,6 +362,14 @@ impl Game<'_> {
|
||||
line.clear();
|
||||
}
|
||||
})?;
|
||||
// Main: Services.create loads the universe's usercache.json.
|
||||
let (mut cache, warnings) = UserCache::load(universe.join("usercache.json"));
|
||||
for warning in warnings {
|
||||
log_from("main", "WARN", &warning);
|
||||
}
|
||||
// DedicatedServer.initServer: names nobody has resolve offline only without authentication.
|
||||
cache.resolve_offline_users = !props.online_mode;
|
||||
*USER_CACHE.lock().unwrap() = Some(cache);
|
||||
log("INFO", "Starting minecraft server version 26.3");
|
||||
log("INFO", "Loading properties");
|
||||
let game_mode = props.game_mode;
|
||||
@@ -354,6 +381,19 @@ impl Game<'_> {
|
||||
),
|
||||
);
|
||||
let port = port.unwrap_or(props.server_port as u16);
|
||||
// MinecraftServer.initializeKeyPair (only online mode uses it).
|
||||
log("INFO", "Generating keypair");
|
||||
let online = if props.online_mode {
|
||||
let key = horizon_protocol::rsa::RsaPrivateKey::generate(1024)
|
||||
.map_err(|e| format!("Failed to generate key pair: {e}"))?;
|
||||
Some(Arc::new(OnlineMode {
|
||||
public_der: key.public_der(),
|
||||
key,
|
||||
prevent_proxy: props.prevent_proxy_connections,
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
log(
|
||||
"INFO",
|
||||
&format!(
|
||||
@@ -365,27 +405,21 @@ impl Game<'_> {
|
||||
}
|
||||
),
|
||||
);
|
||||
if props.online_mode {
|
||||
// Authentication needs Mojang's session servers (an HTTPS client
|
||||
// and RSA), which Horizon does not have yet: offline mode.
|
||||
if online.is_none() {
|
||||
log("WARN", "**** SERVER IS RUNNING IN OFFLINE/INSECURE MODE!");
|
||||
log(
|
||||
"WARN",
|
||||
"online-mode=true is not supported by Horizon yet: running in offline mode",
|
||||
"The server will make no attempt to authenticate usernames. Beware.",
|
||||
);
|
||||
log(
|
||||
"WARN",
|
||||
"While this makes the game possible to play without internet access, it also opens up the ability for hackers to connect with any username they choose.",
|
||||
);
|
||||
log(
|
||||
"WARN",
|
||||
"To change this, set \"online-mode\" to \"true\" in the server.properties file.",
|
||||
);
|
||||
}
|
||||
log("WARN", "**** SERVER IS RUNNING IN OFFLINE/INSECURE MODE!");
|
||||
log(
|
||||
"WARN",
|
||||
"The server will make no attempt to authenticate usernames. Beware.",
|
||||
);
|
||||
log(
|
||||
"WARN",
|
||||
"While this makes the game possible to play without internet access, it also opens up the ability for hackers to connect with any username they choose.",
|
||||
);
|
||||
log(
|
||||
"WARN",
|
||||
"To change this, set \"online-mode\" to \"true\" in the server.properties file.",
|
||||
);
|
||||
let mut lists = Lists::load();
|
||||
lists.use_whitelist = props.white_list;
|
||||
lists.enforce_whitelist = props.enforce_whitelist;
|
||||
@@ -451,6 +485,12 @@ impl Game<'_> {
|
||||
.any(|e| e.user.id == uuid && e.bypasses_player_limit)
|
||||
})),
|
||||
gate: Some(gate),
|
||||
online,
|
||||
log: Some(NetLog {
|
||||
write: Arc::new(log_from),
|
||||
lang: Arc::new(horizon_client::net::load_language(&self.options.resources)),
|
||||
log_ips: props.log_ips,
|
||||
}),
|
||||
};
|
||||
let config = HubConfig {
|
||||
default_game_mode: game_mode,
|
||||
@@ -463,6 +503,7 @@ impl Game<'_> {
|
||||
seed: self.level.seed,
|
||||
max_players: props.max_players.max(0) as usize,
|
||||
dedicated: true,
|
||||
integrated: false,
|
||||
};
|
||||
let settings = LanSettings {
|
||||
port,
|
||||
@@ -524,18 +565,12 @@ impl Game<'_> {
|
||||
);
|
||||
self.save_game()?;
|
||||
}
|
||||
self.update_dedicated_streaming();
|
||||
self.update_world();
|
||||
if paused {
|
||||
// MinecraftServer.tickServer: only the connections run.
|
||||
self.tick_lan();
|
||||
} else {
|
||||
if !state.autosave {
|
||||
// `save-off`: the periodic save inside the tick is skipped.
|
||||
self.ticks = self.ticks.max(1);
|
||||
}
|
||||
self.tick()?;
|
||||
if !paused && !state.autosave {
|
||||
// `save-off`: the periodic save inside the tick is skipped.
|
||||
self.ticks = self.ticks.max(1);
|
||||
}
|
||||
// MinecraftServer.tickServer: while paused only the connections run.
|
||||
self.tick_server(paused)?;
|
||||
if idle_timeout > 0 {
|
||||
self.kick_idle(idle_timeout);
|
||||
}
|
||||
@@ -576,7 +611,13 @@ impl Game<'_> {
|
||||
let center = self
|
||||
.lan
|
||||
.as_ref()
|
||||
.and_then(|h| h.hub.guests.iter().find(|g| g.placed).map(|g| g.chunk()))
|
||||
.and_then(|h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| h.hub.here(g))
|
||||
.map(|g| g.chunk())
|
||||
})
|
||||
.or_else(|| {
|
||||
self.spawn
|
||||
.map(|s| ((s.x.floor() as i32) >> 4, (s.z.floor() as i32) >> 4))
|
||||
@@ -622,23 +663,36 @@ impl Game<'_> {
|
||||
host.hub.resend_commands();
|
||||
}
|
||||
|
||||
/// A known player by name: online first, else the offline profile.
|
||||
fn resolve(&self, name: &str) -> NameAndId {
|
||||
self.lan
|
||||
.as_ref()
|
||||
.and_then(|h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.name.eq_ignore_ascii_case(name))
|
||||
/// A player on the server, by name.
|
||||
fn online_player(&self, name: &str) -> Option<NameAndId> {
|
||||
let hub = &self.lan.as_ref()?.hub;
|
||||
let g = hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.name.eq_ignore_ascii_case(name))?;
|
||||
Some(NameAndId {
|
||||
id: g.uuid,
|
||||
name: g.name.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// GameProfileArgument for a name: the user cache, else the profile
|
||||
/// service, else (offline) the offline player. None, told to the console:
|
||||
/// no such player.
|
||||
fn resolve(&self, name: &str) -> Option<NameAndId> {
|
||||
let found = USER_CACHE.lock().unwrap().as_mut().and_then(|cache| {
|
||||
cache.get_by_name(name, |lower| {
|
||||
let found = profiles::find_by_name(&profiles::discovery_url(), lower);
|
||||
if found.is_none() {
|
||||
log("WARN", &format!("Couldn't find profile with name: {lower}"));
|
||||
}
|
||||
found
|
||||
})
|
||||
.map_or_else(
|
||||
|| NameAndId::offline(name),
|
||||
|g| NameAndId {
|
||||
id: g.uuid,
|
||||
name: g.name.clone(),
|
||||
},
|
||||
)
|
||||
});
|
||||
if found.is_none() {
|
||||
self.console_reply(Text::translate("argument.player.unknown", &[]));
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// MinecraftServer.sendSystemMessage: what the console is told.
|
||||
@@ -721,7 +775,9 @@ impl Game<'_> {
|
||||
));
|
||||
}
|
||||
"op" | "deop" if !rest.is_empty() => {
|
||||
let user = self.resolve(rest[0]);
|
||||
let Some(user) = self.resolve(rest[0]) else {
|
||||
return;
|
||||
};
|
||||
let level = 4;
|
||||
let changed = {
|
||||
let mut l = state.lists.lock().unwrap();
|
||||
@@ -751,7 +807,9 @@ impl Game<'_> {
|
||||
}
|
||||
"whitelist" => self.console_whitelist(state, &rest),
|
||||
"ban" if !rest.is_empty() => {
|
||||
let user = self.resolve(rest[0]);
|
||||
let Some(user) = self.resolve(rest[0]) else {
|
||||
return;
|
||||
};
|
||||
let reason = (rest.len() > 1).then(|| rest[1..].join(" "));
|
||||
let added = state.lists.lock().unwrap().bans.add(BanEntry::new(
|
||||
user.clone(),
|
||||
@@ -841,7 +899,9 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
"pardon" if !rest.is_empty() => {
|
||||
let user = self.resolve(rest[0]);
|
||||
let Some(user) = self.resolve(rest[0]) else {
|
||||
return;
|
||||
};
|
||||
let removed = state.lists.lock().unwrap().bans.remove(&user);
|
||||
let key = if removed {
|
||||
"commands.pardon.success"
|
||||
@@ -907,15 +967,10 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
"kick" if !rest.is_empty() => {
|
||||
let user = self.resolve(rest[0]);
|
||||
let online = self
|
||||
.lan
|
||||
.as_ref()
|
||||
.is_some_and(|h| h.hub.guests.iter().any(|g| g.uuid == user.id));
|
||||
if !online {
|
||||
let Some(user) = self.online_player(rest[0]) else {
|
||||
self.console_reply(Text::translate("argument.entity.notfound.player", &[]));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let reason = if rest.len() > 1 {
|
||||
Tag::String(JavaString::from(rest[1..].join(" ").as_str()))
|
||||
} else {
|
||||
@@ -995,7 +1050,9 @@ impl Game<'_> {
|
||||
self.console_reply(Text::translate("commands.whitelist.reloaded", &[]));
|
||||
}
|
||||
["add", name] | ["remove", name] => {
|
||||
let user = self.resolve(name);
|
||||
let Some(user) = self.resolve(name) else {
|
||||
return;
|
||||
};
|
||||
let mut l = state.lists.lock().unwrap();
|
||||
let (ok, key) = if rest[0] == "add" {
|
||||
if l.white.contains(&user) {
|
||||
@@ -1072,6 +1129,13 @@ impl Game<'_> {
|
||||
for g in &h.hub.guests {
|
||||
horizon_client::lan::playerdata::save(g, &self.world_root);
|
||||
}
|
||||
}
|
||||
if let Some(h) = self.lan.as_ref() {
|
||||
for g in &h.hub.guests {
|
||||
self.save_guest_records(h, g);
|
||||
}
|
||||
}
|
||||
if let Some(h) = self.lan.as_mut() {
|
||||
let conns: Vec<u32> = h.hub.guests.iter().map(|g| g.conn).collect();
|
||||
for conn in conns {
|
||||
h.hub.kick(
|
||||
|
||||
@@ -222,7 +222,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
fn block_name(&self, p: [i32; 3]) -> std::sync::Arc<str> {
|
||||
pub(super) fn block_name(&self, p: [i32; 3]) -> std::sync::Arc<str> {
|
||||
self.res
|
||||
.facts
|
||||
.get(self.world.block(p[0], p[1], p[2]))
|
||||
@@ -366,31 +366,11 @@ impl Game<'_> {
|
||||
if self.travelling.is_some() || p.health.dead_or_dying() {
|
||||
return Ok(());
|
||||
}
|
||||
let w = f64::from(p.dimensions.width) / 2.0;
|
||||
let lo = [p.position.x - w, p.position.y, p.position.z - w];
|
||||
let hi = [
|
||||
p.position.x + w,
|
||||
p.position.y + f64::from(p.dimensions.height),
|
||||
p.position.z + w,
|
||||
];
|
||||
let mut portal = false;
|
||||
let mut gateway = None;
|
||||
for x in lo[0].floor() as i32..=hi[0].floor() as i32 {
|
||||
for y in lo[1].floor() as i32..=hi[1].floor() as i32 {
|
||||
for z in lo[2].floor() as i32..=hi[2].floor() as i32 {
|
||||
let name = self.block_name([x, y, z]);
|
||||
// EndPortalBlock entity-inside shape: y 6/16..12/16.
|
||||
if &*name == "minecraft:end_portal"
|
||||
&& lo[1] < f64::from(y) + 0.75
|
||||
&& hi[1] > f64::from(y) + 0.375
|
||||
{
|
||||
portal = true;
|
||||
} else if &*name == "minecraft:end_gateway" && self.dim == Dim::End {
|
||||
gateway = Some([x, y, z]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let (portal, gateway) = self.inside_end_portals(
|
||||
p.position,
|
||||
f64::from(p.dimensions.width),
|
||||
f64::from(p.dimensions.height),
|
||||
);
|
||||
if portal {
|
||||
if self.portals.player.cooldown == 0 {
|
||||
let target = if self.dim == Dim::End {
|
||||
@@ -417,6 +397,38 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether a `width` by `height` box at `position` is inside an End
|
||||
/// portal (its entity-inside shape), and the End gateway it touches.
|
||||
pub(super) fn inside_end_portals(
|
||||
&self,
|
||||
position: Vec3,
|
||||
width: f64,
|
||||
height: f64,
|
||||
) -> (bool, Option<[i32; 3]>) {
|
||||
let w = width / 2.0;
|
||||
let lo = [position.x - w, position.y, position.z - w];
|
||||
let hi = [position.x + w, position.y + height, position.z + w];
|
||||
let mut portal = false;
|
||||
let mut gateway = None;
|
||||
for x in lo[0].floor() as i32..=hi[0].floor() as i32 {
|
||||
for y in lo[1].floor() as i32..=hi[1].floor() as i32 {
|
||||
for z in lo[2].floor() as i32..=hi[2].floor() as i32 {
|
||||
let name = self.block_name([x, y, z]);
|
||||
// EndPortalBlock entity-inside shape: y 6/16..12/16.
|
||||
if &*name == "minecraft:end_portal"
|
||||
&& lo[1] < f64::from(y) + 0.75
|
||||
&& hi[1] > f64::from(y) + 0.375
|
||||
{
|
||||
portal = true;
|
||||
} else if &*name == "minecraft:end_gateway" && self.dim == Dim::End {
|
||||
gateway = Some([x, y, z]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(portal, gateway)
|
||||
}
|
||||
|
||||
/// EndPortalBlock.getPortalDestination for the pending enter_dimension.
|
||||
pub(super) fn end_destination(&self, target: Dim) -> Option<Vec3> {
|
||||
if !self.end_portal_travel {
|
||||
@@ -451,10 +463,36 @@ impl Game<'_> {
|
||||
self.previous_eye = 1.62;
|
||||
self.clock.reset();
|
||||
}
|
||||
let mut position = target;
|
||||
if let Some(job) = self.end.job.take() {
|
||||
position = self.resolve_gateway(job, target)?;
|
||||
} else if self.dim == Dim::End {
|
||||
let position = if let Some(job) = self.end.job.take() {
|
||||
self.resolve_gateway(job, target)?
|
||||
} else {
|
||||
let (position, yaw) = self.end_arrival(target)?;
|
||||
if let Some(yaw) = yaw {
|
||||
self.yaw = yaw;
|
||||
player.yaw = yaw;
|
||||
player.head_yaw = yaw;
|
||||
}
|
||||
position
|
||||
};
|
||||
player.position = position;
|
||||
player.velocity = Vec3::ZERO;
|
||||
player.fall_distance = 0.0;
|
||||
self.previous_position = player.position;
|
||||
self.player = Some(player);
|
||||
self.pad.player_replaced();
|
||||
self.sway = Default::default();
|
||||
self.apply_equipment();
|
||||
let pitch = self.world.random.next_float() * 0.4 + 0.8;
|
||||
self.audio
|
||||
.play("minecraft:block.portal.travel", None, 0.25, pitch);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where an End portal's traveller lands once `target` has loaded, and
|
||||
/// the yaw it faces there: the platform below the End's spawn point,
|
||||
/// facing west, or the top of the Overworld's spawn column.
|
||||
pub(super) fn end_arrival(&mut self, target: Vec3) -> Result<(Vec3, Option<f32>)> {
|
||||
if self.dim == Dim::End {
|
||||
// EndPlatformFeature.createEndPlatform below the spawn point.
|
||||
let origin = [END_SPAWN[0], END_SPAWN[1] - 1, END_SPAWN[2]];
|
||||
for dz in -2..=2 {
|
||||
@@ -473,28 +511,13 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
// Direction.WEST.toYRot().
|
||||
self.yaw = 90.0;
|
||||
player.yaw = 90.0;
|
||||
player.head_yaw = 90.0;
|
||||
} else {
|
||||
// ServerPlayer.findRespawnPositionAndUseSpawnBlock: onto the spawn column.
|
||||
let y = self
|
||||
.land_column(target.x.floor() as i32, target.z.floor() as i32)
|
||||
.map_or(target.y, f64::from);
|
||||
position = Vec3::new(target.x, y, target.z);
|
||||
return Ok((target, Some(90.0)));
|
||||
}
|
||||
player.position = position;
|
||||
player.velocity = Vec3::ZERO;
|
||||
player.fall_distance = 0.0;
|
||||
self.previous_position = player.position;
|
||||
self.player = Some(player);
|
||||
self.pad.player_replaced();
|
||||
self.sway = Default::default();
|
||||
self.apply_equipment();
|
||||
let pitch = self.world.random.next_float() * 0.4 + 0.8;
|
||||
self.audio
|
||||
.play("minecraft:block.portal.travel", None, 0.25, pitch);
|
||||
Ok(())
|
||||
// ServerPlayer.findRespawnPositionAndUseSpawnBlock: onto the spawn column.
|
||||
let y = self
|
||||
.land_column(target.x.floor() as i32, target.z.floor() as i32)
|
||||
.map_or(target.y, f64::from);
|
||||
Ok((Vec3::new(target.x, y, target.z), None))
|
||||
}
|
||||
|
||||
/// Dying away from the Overworld respawns at the world spawn there.
|
||||
|
||||
@@ -167,7 +167,10 @@ impl Game<'_> {
|
||||
Snapshot {
|
||||
screen: self.screen_label(),
|
||||
dimension: self.dim.id().to_string(),
|
||||
chat: self.ui.chat.received,
|
||||
chat: self
|
||||
.remote
|
||||
.as_ref()
|
||||
.map_or(self.ui.chat.received, |r| r.chat_received),
|
||||
counts: if world.is_some() {
|
||||
self.inventory_counts()
|
||||
} else {
|
||||
@@ -189,8 +192,18 @@ impl Game<'_> {
|
||||
if let Some(before) = &self.events.last
|
||||
&& *before != now
|
||||
{
|
||||
let new_chat = (now.chat - before.chat).min(100) as usize;
|
||||
let lines = self.ui.chat.recent_lines(new_chat);
|
||||
let new_chat = now.chat.saturating_sub(before.chat).min(100) as usize;
|
||||
// The remote client keeps its own lines, oldest first.
|
||||
let lines = match &self.remote {
|
||||
Some(r) => r
|
||||
.chat
|
||||
.iter()
|
||||
.rev()
|
||||
.take(new_chat)
|
||||
.map(|(t, _)| t.clone())
|
||||
.collect(),
|
||||
None => self.ui.chat.recent_lines(new_chat),
|
||||
};
|
||||
for event in diff(before, &now, &lines) {
|
||||
self.events.push(self.ticks, event);
|
||||
}
|
||||
|
||||
@@ -58,8 +58,12 @@ impl Game<'_> {
|
||||
pub(super) fn extras_scroll(&mut self, steps: f32) -> bool {
|
||||
self.settings.extras.zoom && self.extras.zoom.scroll(steps)
|
||||
}
|
||||
/// Mouse buttons while playing, for the keystroke overlay.
|
||||
/// Mouse buttons while playing, for zoom and the keystroke overlay.
|
||||
pub(super) fn extras_mouse(&mut self, button: MouseButton, pressed: bool) {
|
||||
let e = &self.settings.extras;
|
||||
if e.zoom && e.zoom_key == keybinds::mouse_name(button) {
|
||||
self.extras.zoom.key(pressed, false, true, e.zoom_level);
|
||||
}
|
||||
let i = match button {
|
||||
MouseButton::Left => 0,
|
||||
MouseButton::Right => 1,
|
||||
|
||||
@@ -23,7 +23,7 @@ impl Game<'_> {
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| {
|
||||
g.placed
|
||||
host.hub.here(g)
|
||||
&& g.loaded
|
||||
&& !g.dead
|
||||
&& !g.creative()
|
||||
|
||||
@@ -55,7 +55,13 @@ impl Game<'_> {
|
||||
.fov_player()
|
||||
.map_or(1.0, |p| field_of_view_modifier(&p, first_person, effect));
|
||||
let target = self.zoomed_fov_target(target);
|
||||
let eye = self.player.as_ref().map(|p| p.dimensions.eye_height);
|
||||
let eye = self.player.as_ref().map(|p| {
|
||||
if self.sleeping.is_some() {
|
||||
0.2
|
||||
} else {
|
||||
p.dimensions.eye_height
|
||||
}
|
||||
});
|
||||
self.view.tick(eye, target);
|
||||
}
|
||||
/// The player's `deathTime` while dead or dying.
|
||||
|
||||
@@ -67,7 +67,7 @@ impl Game<'_> {
|
||||
/// attack clicks since the last tick and the held attack button.
|
||||
pub(super) fn tick_local_hand(&mut self) -> Result<()> {
|
||||
let attack_clicks = std::mem::take(&mut self.attack_clicks);
|
||||
if self.screen == Screen::Playing && self.title.is_none() {
|
||||
if self.screen == Screen::Playing && self.title.is_none() && self.sleeping.is_none() {
|
||||
let mut clicks = std::mem::take(&mut self.clicks);
|
||||
// keyAttack.isDown(); playing implies the grabbed mouse here
|
||||
// (scripts under --control hold buttons without grabbing it).
|
||||
@@ -409,7 +409,10 @@ impl HandWorld for GameHands<'_, '_> {
|
||||
fn within_border(&mut self, _: [i32; 3]) -> bool {
|
||||
true
|
||||
}
|
||||
fn attack_block(&mut self, _: [i32; 3]) {}
|
||||
fn attack_block(&mut self, pos: [i32; 3]) {
|
||||
let result = self.game.attack_block(pos);
|
||||
self.keep(result);
|
||||
}
|
||||
fn destroy_progress(&mut self, pos: [i32; 3]) -> f32 {
|
||||
self.game.destroy_progress(pos)
|
||||
}
|
||||
|
||||
@@ -2,32 +2,34 @@
|
||||
//! its own "Server thread", as vanilla's does, and the window joins it as a
|
||||
//! player through memory, so a world tick never holds up a frame.
|
||||
//!
|
||||
//! Opt-in for now (`--server-thread` or `HORIZON_SERVER_THREAD=1`): the
|
||||
//! player joins as a network player, and the server side of a network player
|
||||
//! does not do everything single player does yet. Worlds whose player is
|
||||
//! outside the Overworld open the usual way, since network players stay in
|
||||
//! the Overworld so far.
|
||||
//! The window asks its server for what only the server knows (Open to LAN,
|
||||
//! the world's options) with [`Integrated::call`], as vanilla's client calls
|
||||
//! into its `IntegratedServer`.
|
||||
//!
|
||||
//! On by default. `--no-server-thread` or `HORIZON_SERVER_THREAD=0` runs
|
||||
//! the world in the frame instead (the escape hatch while the network
|
||||
//! player's side catches up with the in-frame one).
|
||||
use super::{
|
||||
Dim, Game, Options, PackResources, Resources,
|
||||
Game, Options, PackResources, Resources,
|
||||
lan::{LanSettings, Owner},
|
||||
};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
audio::Audio,
|
||||
hud::HudAtlas,
|
||||
lan::{MAX_PLAYERS, NetConfig, hub::HubConfig},
|
||||
save::Level,
|
||||
lan::{Lan, MAX_PLAYERS, NetConfig, hub::HubConfig},
|
||||
settings::Settings,
|
||||
title::WorldOptions,
|
||||
};
|
||||
use horizon_protocol::connection::Pipe;
|
||||
use horizon_protocol::{crypto::offline_uuid, text::translatable};
|
||||
use std::{
|
||||
net::TcpListener,
|
||||
path::Path,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
mpsc::{Sender, channel},
|
||||
atomic::{AtomicBool, AtomicU16, AtomicU32, Ordering},
|
||||
mpsc::{Receiver, Sender, channel, sync_channel},
|
||||
},
|
||||
thread::JoinHandle,
|
||||
time::{Duration, Instant},
|
||||
@@ -39,18 +41,49 @@ struct Flags {
|
||||
/// `Minecraft.pause`: a single-player pause screen is open.
|
||||
paused: AtomicBool,
|
||||
stop: AtomicBool,
|
||||
/// `IntegratedServer.getPort`: the port it is open to the LAN on, 0
|
||||
/// while it is not.
|
||||
published: AtomicU16,
|
||||
smoothed_tick_ms: AtomicU32,
|
||||
}
|
||||
|
||||
/// Work the window hands its server thread (`MinecraftServer.execute`).
|
||||
type Task = Box<dyn FnOnce(&mut Game<'_>) + Send>;
|
||||
|
||||
/// The window's handle on its server thread.
|
||||
pub(super) struct Integrated {
|
||||
flags: Arc<Flags>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
tasks: Sender<Task>,
|
||||
/// The world's name (`level.dat`'s `LevelName`), for the pause menu.
|
||||
pub world_name: String,
|
||||
}
|
||||
impl Integrated {
|
||||
pub fn smoothed_tick_ms(&self) -> f32 {
|
||||
f32::from_bits(self.flags.smoothed_tick_ms.load(Ordering::Relaxed))
|
||||
}
|
||||
/// `IntegratedServer.tickServer` skips the world while the game is paused.
|
||||
pub fn set_paused(&self, paused: bool) {
|
||||
self.flags.paused.store(paused, Ordering::Relaxed);
|
||||
}
|
||||
/// `IntegratedServer.getPort` while `isPublished`.
|
||||
pub fn published(&self) -> Option<u16> {
|
||||
Some(self.flags.published.load(Ordering::Relaxed)).filter(|&p| p != 0)
|
||||
}
|
||||
/// `executeBlocking`: run `f` on the server thread between two ticks and
|
||||
/// wait for its answer; `None` once the server has stopped.
|
||||
pub fn call<T: Send + 'static>(
|
||||
&self,
|
||||
f: impl FnOnce(&mut Game<'_>) -> T + Send + 'static,
|
||||
) -> Option<T> {
|
||||
let (tx, rx) = sync_channel(1);
|
||||
self.tasks
|
||||
.send(Box::new(move |g| {
|
||||
let _ = tx.send(f(g));
|
||||
}))
|
||||
.ok()?;
|
||||
rx.recv_timeout(Duration::from_secs(10)).ok()
|
||||
}
|
||||
/// `IntegratedServer.halt(true)`: returns once the world is saved and let go.
|
||||
pub fn stop(mut self) {
|
||||
self.halt();
|
||||
@@ -68,6 +101,84 @@ impl Drop for Integrated {
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// On the server thread, the window's agent runs a command as single
|
||||
/// player's console does (`as_console`): as the world's owner, at the
|
||||
/// owner permission level. The feedback goes to the owner's chat and is
|
||||
/// returned.
|
||||
pub(super) fn owner_console_command(&mut self, command: &str) -> Vec<String> {
|
||||
let Some(owner) = self.serving.as_ref().map(|s| s.owner) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
if let Some(conn) = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.uuid == owner)
|
||||
.map(|g| g.conn)
|
||||
{
|
||||
// The owner's level in place: between two ticks it is the one
|
||||
// ticked last (Commands.performCommand runs in the player's level).
|
||||
let dim = host.hub.guest(conn).and_then(|g| {
|
||||
[super::Dim::Overworld, super::Dim::Nether, super::Dim::End]
|
||||
.into_iter()
|
||||
.find(|d| g.dimension == d.id())
|
||||
});
|
||||
if let Some(dim) = dim
|
||||
&& let Err(e) = self.enter_level(Some(&mut host), dim)
|
||||
{
|
||||
log::error!("could not enter {}: {e}", dim.id());
|
||||
}
|
||||
let command = command.trim_start_matches('/').to_owned();
|
||||
let lines = self.as_guest(&mut host, conn, |g| {
|
||||
g.as_console(|g| {
|
||||
g.chat_capture = Some(Vec::new());
|
||||
g.run_command(&command);
|
||||
g.chat_capture.take().unwrap_or_default()
|
||||
})
|
||||
});
|
||||
self.sync_hub_settings(&mut host);
|
||||
for line in lines.unwrap_or_default() {
|
||||
out.push(line.plain());
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&horizon_protocol::packets::play::Clientbound::SystemChat(
|
||||
horizon_protocol::packets::play::SystemChat {
|
||||
content: horizon_client::lan::data::component_nbt(&line.component()),
|
||||
overlay: false,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
self.lan = Some(host);
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// The server thread's side of the window's handle (`Game::serving`).
|
||||
pub(super) struct Serving {
|
||||
flags: Arc<Flags>,
|
||||
tasks: Receiver<Task>,
|
||||
/// The window's profile.
|
||||
owner: u128,
|
||||
/// Open to LAN or closed from inside a player's command, while the hub
|
||||
/// is out of its place: applied after the tick.
|
||||
pending: Option<Publish>,
|
||||
}
|
||||
enum Publish {
|
||||
Open(TcpListener, LanSettings),
|
||||
Close,
|
||||
}
|
||||
|
||||
/// What the server thread hands the window once the world is open: its end
|
||||
/// of the connection and the world's name, or why it failed.
|
||||
type Ready = std::result::Result<(Pipe, String), String>;
|
||||
|
||||
/// The world to open and the player who owns it.
|
||||
struct Launch {
|
||||
folder: String,
|
||||
@@ -75,19 +186,32 @@ struct Launch {
|
||||
seed: Option<i64>,
|
||||
creative: bool,
|
||||
create: Option<WorldOptions>,
|
||||
/// The window's profile: the only one let in, and the world's player.
|
||||
/// The window's profile: the only one let in until the world is open to
|
||||
/// the LAN, and the world's player.
|
||||
owner: u128,
|
||||
/// Its name, for the LAN's MOTD.
|
||||
player: String,
|
||||
}
|
||||
|
||||
/// Whether this world opens on a server thread: asked for, and its player
|
||||
/// (if any yet) in the Overworld.
|
||||
pub(super) fn wanted(options: &Options, world: &Path) -> bool {
|
||||
options.server_thread
|
||||
&& options.dedicated.is_none()
|
||||
&& Level::read(world).is_none_or(|l| Dim::from_id(&l.dimension) == Dim::Overworld)
|
||||
/// Whether this world opens on a server thread: asked for.
|
||||
pub(super) fn wanted(options: &Options, _world: &Path) -> bool {
|
||||
options.server_thread && options.dedicated.is_none()
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Minecraft.hasSingleplayerServer`: a world of this window's own, run
|
||||
/// in the frame or on its server thread.
|
||||
pub(super) fn has_singleplayer_server(&self) -> bool {
|
||||
self.remote.is_none() || self.integrated.is_some()
|
||||
}
|
||||
/// `IntegratedServer.isPublished`: the world is open to the network.
|
||||
pub(super) fn is_published(&self) -> bool {
|
||||
self.lan.is_some()
|
||||
|| self
|
||||
.integrated
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.published().is_some())
|
||||
}
|
||||
/// `Minecraft.doWorldLoad`: start the server thread on the world, wait
|
||||
/// until it can be joined, then join it through memory.
|
||||
pub(super) fn open_integrated(
|
||||
@@ -110,6 +234,7 @@ impl Game<'_> {
|
||||
create: create.cloned(),
|
||||
// An offline-mode server names its players by their offline profile.
|
||||
owner: offline_uuid(&player),
|
||||
player: player.clone(),
|
||||
};
|
||||
let mut options = self.options.clone();
|
||||
// A server without a console: no local player, no window, no control socket.
|
||||
@@ -127,6 +252,7 @@ impl Game<'_> {
|
||||
let (res, pack, settings) = (self.res.clone(), self.pack.clone(), self.settings.clone());
|
||||
let flags = Arc::new(Flags::default());
|
||||
let (ready_tx, ready) = channel();
|
||||
let (tasks, server_tasks) = channel();
|
||||
let server_flags = flags.clone();
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("Server thread".into())
|
||||
@@ -137,7 +263,8 @@ impl Game<'_> {
|
||||
options,
|
||||
settings,
|
||||
launch,
|
||||
&server_flags,
|
||||
server_flags,
|
||||
server_tasks,
|
||||
&ready_tx,
|
||||
);
|
||||
if let Err(e) = result {
|
||||
@@ -145,12 +272,17 @@ impl Game<'_> {
|
||||
let _ = ready_tx.send(Err(e.to_string()));
|
||||
}
|
||||
})?;
|
||||
let handle = Integrated {
|
||||
let mut handle = Integrated {
|
||||
flags,
|
||||
thread: Some(thread),
|
||||
tasks,
|
||||
world_name: String::new(),
|
||||
};
|
||||
let pipe = match ready.recv() {
|
||||
Ok(Ok(pipe)) => pipe,
|
||||
Ok(Ok((pipe, world_name))) => {
|
||||
handle.world_name = world_name;
|
||||
pipe
|
||||
}
|
||||
Ok(Err(e)) => return Err(e.into()),
|
||||
Err(_) => return Err("the integrated server stopped".into()),
|
||||
};
|
||||
@@ -164,8 +296,9 @@ impl Game<'_> {
|
||||
fn run_integrated(
|
||||
&mut self,
|
||||
launch: Launch,
|
||||
flags: &Flags,
|
||||
ready: &Sender<std::result::Result<Pipe, String>>,
|
||||
flags: Arc<Flags>,
|
||||
tasks: Receiver<Task>,
|
||||
ready: &Sender<Ready>,
|
||||
) -> Result<()> {
|
||||
self.open_world(
|
||||
&launch.folder,
|
||||
@@ -178,7 +311,8 @@ impl Game<'_> {
|
||||
let view_distance = self.settings.render_distance.clamp(2, 32);
|
||||
let config = HubConfig {
|
||||
default_game_mode: self.level.mode(),
|
||||
guest_commands: true,
|
||||
// Only the owner until it is opened to the LAN.
|
||||
guest_commands: false,
|
||||
force_game_mode: false,
|
||||
hardcore: self.level.hardcore,
|
||||
view_distance,
|
||||
@@ -188,23 +322,28 @@ impl Game<'_> {
|
||||
max_players: MAX_PLAYERS,
|
||||
// No player of its own: the hub runs without a host.
|
||||
dedicated: true,
|
||||
integrated: true,
|
||||
};
|
||||
let owner = launch.owner;
|
||||
let published = flags.clone();
|
||||
let net = NetConfig {
|
||||
// No network until it is opened to the LAN.
|
||||
// No network until it is opened to the LAN; the memory connection
|
||||
// is not compressed (`Connection.isMemoryConnection`).
|
||||
listen: false,
|
||||
// A memory connection is not compressed (`Connection.isMemoryConnection`).
|
||||
compression_threshold: -1,
|
||||
enable_status: false,
|
||||
announce: false,
|
||||
gate: Some(Arc::new(move |_, uuid, _| {
|
||||
(uuid != owner).then(|| translatable("multiplayer.disconnect.not_whitelisted", &[]))
|
||||
(uuid != owner && published.published.load(Ordering::Relaxed) == 0)
|
||||
.then(|| translatable("multiplayer.disconnect.not_whitelisted", &[]))
|
||||
})),
|
||||
..NetConfig::lan(0, self.level.name.clone(), MAX_PLAYERS)
|
||||
// IntegratedServer.initServer: the MOTD names the owner and the world.
|
||||
..NetConfig::lan(
|
||||
0,
|
||||
format!("{} - {}", launch.player, self.level.name),
|
||||
MAX_PLAYERS,
|
||||
)
|
||||
};
|
||||
let settings = LanSettings {
|
||||
port: 0,
|
||||
guest_commands: true,
|
||||
guest_commands: false,
|
||||
force_game_mode: false,
|
||||
};
|
||||
self.lan_host(settings, net, config, None)?;
|
||||
@@ -223,18 +362,30 @@ impl Game<'_> {
|
||||
});
|
||||
host.hub.lan.connect_local()?
|
||||
};
|
||||
let _ = ready.send(Ok(pipe));
|
||||
self.serving = Some(Serving {
|
||||
flags: flags.clone(),
|
||||
tasks,
|
||||
owner,
|
||||
pending: None,
|
||||
});
|
||||
let _ = ready.send(Ok((pipe, self.level.name.clone())));
|
||||
self.settle_spawn()?;
|
||||
let mut next = Instant::now();
|
||||
while !flags.stop.load(Ordering::Relaxed) {
|
||||
self.update_dedicated_streaming();
|
||||
self.update_world();
|
||||
self.run_tasks();
|
||||
if flags.paused.load(Ordering::Relaxed) {
|
||||
// Only the connections run.
|
||||
self.tick_lan();
|
||||
self.tick_server(true)?;
|
||||
} else {
|
||||
self.tick()?;
|
||||
let started = Instant::now();
|
||||
self.tick_server(false)?;
|
||||
self.debug_charts.log_tick(started, started.elapsed());
|
||||
flags.smoothed_tick_ms.store(
|
||||
self.debug_charts.smoothed_tick_ms.to_bits(),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
}
|
||||
self.apply_publish();
|
||||
let rate = self.clock.rate().max(1.0);
|
||||
next += Duration::from_secs_f64(1.0 / f64::from(rate));
|
||||
let now = Instant::now();
|
||||
@@ -250,6 +401,10 @@ impl Game<'_> {
|
||||
/// `MinecraftServer.stopServer`: the players are saved and sent away,
|
||||
/// then the world is saved.
|
||||
fn stop_integrated(&mut self) -> Result<()> {
|
||||
// IntegratedServer.stopServer: teardownPublishedState.
|
||||
if let Some(s) = self.serving.take() {
|
||||
s.flags.published.store(0, Ordering::Relaxed);
|
||||
}
|
||||
if let Some(mut host) = self.lan.take() {
|
||||
for g in &host.hub.guests {
|
||||
self.save_guest(&host, g);
|
||||
@@ -272,18 +427,191 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the world options screen shows of an integrated server.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) struct ServerOptions {
|
||||
pub default_mode: i32,
|
||||
/// The owner's own game mode.
|
||||
pub personal_mode: i32,
|
||||
pub cheats: bool,
|
||||
pub difficulty: u8,
|
||||
pub hardcore: bool,
|
||||
pub difficulty_locked: bool,
|
||||
pub port: Option<u16>,
|
||||
pub guest_commands: bool,
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
/// The server thread's side.
|
||||
impl Game<'_> {
|
||||
/// Whether this game is an integrated server's.
|
||||
pub(super) fn serving(&self) -> bool {
|
||||
self.serving.is_some()
|
||||
}
|
||||
/// The window's requests, between two ticks.
|
||||
fn run_tasks(&mut self) {
|
||||
let tasks: Vec<Task> = match &self.serving {
|
||||
Some(s) => s.tasks.try_iter().collect(),
|
||||
None => return,
|
||||
};
|
||||
for task in tasks {
|
||||
task(self);
|
||||
self.apply_publish();
|
||||
}
|
||||
}
|
||||
/// `IntegratedServer.getPort` while published.
|
||||
pub(super) fn published_port(&self) -> Option<u16> {
|
||||
let s = self.serving.as_ref()?;
|
||||
Some(s.flags.published.load(Ordering::Relaxed)).filter(|&p| p != 0)
|
||||
}
|
||||
/// `IntegratedServer.publishServer`: listen on `settings.port` (0 picks
|
||||
/// one) and announce the world. The socket is bound at once so a busy
|
||||
/// port fails here; the hub starts listening once it is at hand.
|
||||
pub(super) fn publish(&mut self, settings: LanSettings) -> std::result::Result<u16, String> {
|
||||
let Some(s) = self.serving.as_mut() else {
|
||||
return Err("not an integrated server".into());
|
||||
};
|
||||
if s.flags.published.load(Ordering::Relaxed) != 0 {
|
||||
return Err("already published".into());
|
||||
}
|
||||
let listener = Lan::bind(None, settings.port).map_err(|e| e.to_string())?;
|
||||
let port = listener.local_addr().map_err(|e| e.to_string())?.port();
|
||||
log::info!("Published LAN server on port {port}");
|
||||
s.flags.published.store(port, Ordering::Relaxed);
|
||||
s.pending = Some(Publish::Open(listener, LanSettings { port, ..settings }));
|
||||
self.apply_publish();
|
||||
Ok(port)
|
||||
}
|
||||
/// `IntegratedServer.unpublishServer`: stop listening and send every
|
||||
/// player but the owner away. False when it was not published.
|
||||
pub(super) fn unpublish(&mut self) -> bool {
|
||||
let Some(s) = self.serving.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
if s.flags.published.swap(0, Ordering::Relaxed) == 0 {
|
||||
return false;
|
||||
}
|
||||
s.pending = Some(Publish::Close);
|
||||
self.apply_publish();
|
||||
true
|
||||
}
|
||||
/// Publish or unpublish for real, once the hub is in its place (a
|
||||
/// player's `/publish` runs while it is out).
|
||||
fn apply_publish(&mut self) {
|
||||
if self.lan.is_none() {
|
||||
return;
|
||||
}
|
||||
let Some(s) = self.serving.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let (Some(pending), owner) = (s.pending.take(), s.owner) else {
|
||||
return;
|
||||
};
|
||||
let allow_commands = self.allow_commands();
|
||||
let Some(host) = self.lan.as_mut() else {
|
||||
return;
|
||||
};
|
||||
match pending {
|
||||
Publish::Open(listener, settings) => {
|
||||
if let Err(e) = host.hub.lan.listen_on(listener, true) {
|
||||
log::error!("Horizon: Open to LAN: {e}");
|
||||
}
|
||||
host.settings = settings;
|
||||
host.hub.config.force_game_mode = settings.force_game_mode;
|
||||
// IntegratedServer.setGuestCommandAccess, then updatePermissions.
|
||||
host.hub.config.guest_commands = settings.guest_commands && allow_commands;
|
||||
host.hub.resend_commands();
|
||||
}
|
||||
Publish::Close => {
|
||||
host.hub.lan.stop_listening();
|
||||
log::info!(
|
||||
"Unpublishing integrated server (was on port {})",
|
||||
host.settings.port
|
||||
);
|
||||
let others: Vec<u32> = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| g.uuid != owner)
|
||||
.map(|g| g.conn)
|
||||
.collect();
|
||||
for conn in others {
|
||||
host.hub.kick(
|
||||
conn,
|
||||
translatable("multiplayer.disconnect.server_shutdown", &[]),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The world options screen's state, read on the server.
|
||||
pub(super) fn server_options(&self) -> ServerOptions {
|
||||
let owner = self.serving.as_ref().map(|s| s.owner);
|
||||
let host = self.lan.as_ref();
|
||||
let personal_mode = host
|
||||
.and_then(|h| h.hub.guests.iter().find(|g| Some(g.uuid) == owner))
|
||||
.map_or(self.level.mode(), |g| g.game_mode);
|
||||
let published = self.published_port();
|
||||
ServerOptions {
|
||||
default_mode: self.level.mode(),
|
||||
personal_mode,
|
||||
cheats: self.allow_commands(),
|
||||
difficulty: self.ui.difficulty,
|
||||
hardcore: self.level.hardcore,
|
||||
difficulty_locked: self.level.difficulty_locked,
|
||||
port: published,
|
||||
guest_commands: published.is_some() && host.is_some_and(|h| h.settings.guest_commands),
|
||||
force: published.is_none() || host.is_some_and(|h| h.settings.force_game_mode),
|
||||
}
|
||||
}
|
||||
/// `IntegratedServer.setPersonalGameType`: the owner's game mode.
|
||||
pub(super) fn set_owner_mode(&mut self, mode: i32) {
|
||||
let Some(owner) = self.serving.as_ref().map(|s| s.owner) else {
|
||||
return;
|
||||
};
|
||||
if let Some(host) = self.lan.as_mut()
|
||||
&& let Some(conn) = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.uuid == owner)
|
||||
.map(|g| g.conn)
|
||||
{
|
||||
host.hub.set_game_mode(conn, mode);
|
||||
}
|
||||
}
|
||||
/// `IntegratedServer.setWorldAllowCommands`: the owner is an operator
|
||||
/// while the world allows commands; everyone's level is sent again.
|
||||
pub(super) fn owner_allow_commands(&mut self, allow: bool) {
|
||||
let Some(owner) = self.serving.as_ref().map(|s| s.owner) else {
|
||||
return;
|
||||
};
|
||||
let published = self.published_port().is_some();
|
||||
if let Some(host) = self.lan.as_mut() {
|
||||
host.hub.ops.insert(owner, if allow { 4 } else { 0 });
|
||||
if published {
|
||||
host.hub.config.guest_commands = allow && host.settings.guest_commands;
|
||||
}
|
||||
host.hub.resend_commands();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The server thread: a game of its own over the shared resources.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn serve(
|
||||
res: &Arc<Resources>,
|
||||
pack: PackResources,
|
||||
options: Options,
|
||||
settings: Settings,
|
||||
launch: Launch,
|
||||
flags: &Flags,
|
||||
ready: &Sender<std::result::Result<Pipe, String>>,
|
||||
flags: Arc<Flags>,
|
||||
tasks: Receiver<Task>,
|
||||
ready: &Sender<Ready>,
|
||||
) -> Result<()> {
|
||||
let atlas = HudAtlas::build(&options.resources)?;
|
||||
// It draws nothing: only the font, which books and chat measure with.
|
||||
let atlas = HudAtlas::fonts(&options.resources)?;
|
||||
let audio = Audio::silent(&options.resources);
|
||||
let mut server = Game::new(res, pack, atlas, options, settings, audio)?;
|
||||
server.run_integrated(launch, flags, ready)
|
||||
server.run_integrated(launch, flags, tasks, ready)
|
||||
}
|
||||
@@ -31,7 +31,7 @@ use std::{
|
||||
static SYNC: OnceLock<std::result::Result<Arc<SyncData>, String>> = OnceLock::new();
|
||||
|
||||
/// The settings of the open world (WorldOptionsScreen's multiplayer section).
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct LanSettings {
|
||||
pub port: u16,
|
||||
pub guest_commands: bool,
|
||||
@@ -52,11 +52,15 @@ pub(super) struct LanHost {
|
||||
/// Blocks broken this tick (their sound is a level event for the guests).
|
||||
pub broken: Vec<[f64; 3]>,
|
||||
/// Hurt and death state of the mobs guests see.
|
||||
mob_state: HashMap<u32, (i32, bool)>,
|
||||
mob_state: HashMap<super::Dim, HashMap<u32, (i32, bool)>>,
|
||||
/// The guest each mob sees as its player, when not the host (game/lan_mobs.rs).
|
||||
pub mob_players: HashMap<u32, u32>,
|
||||
/// An integrated server's owner (game/integrated.rs).
|
||||
pub owner: Option<Owner>,
|
||||
/// The players' locator bars (game/lan_waypoints.rs), and the guests who
|
||||
/// are dead, whose respawn replaces their waypoint.
|
||||
pub waypoints: lan::waypoints::Waypoints,
|
||||
pub waypoint_dead: std::collections::HashSet<u128>,
|
||||
}
|
||||
|
||||
/// The player who owns an integrated server's world: a guest whose state is
|
||||
@@ -316,6 +320,15 @@ impl Game<'_> {
|
||||
if words[0] != "lan" {
|
||||
return Ok(None);
|
||||
}
|
||||
if let Some(server) = &self.integrated
|
||||
&& words.get(1) == Some(&"status")
|
||||
{
|
||||
// The hub is the server thread's.
|
||||
let status = server
|
||||
.call(|g| g.lan_status())
|
||||
.ok_or("the integrated server stopped")?;
|
||||
return Ok(Some(status));
|
||||
}
|
||||
match words.get(1).copied() {
|
||||
Some("open") => {
|
||||
if self.title.is_some() {
|
||||
@@ -337,43 +350,58 @@ impl Game<'_> {
|
||||
self.lan_close(true);
|
||||
Ok(Some(json!({ "ok": true })))
|
||||
}
|
||||
Some("status") => Ok(Some(match &self.lan {
|
||||
Some(h) => json!({
|
||||
"open": true,
|
||||
"host": h.host_name,
|
||||
"port": h.settings.port,
|
||||
"guests": h.hub.guests.iter().map(|g| json!({
|
||||
"name": g.name,
|
||||
"position": g.position,
|
||||
"placed": g.placed,
|
||||
"loaded": g.loaded,
|
||||
"game_mode": g.game_mode,
|
||||
"sneaking": g.sneaking,
|
||||
"health": g.health,
|
||||
// FoodData: food level, saturation and exhaustion.
|
||||
"food": [
|
||||
f64::from(g.food),
|
||||
f64::from(g.saturation),
|
||||
g.motion.as_ref().map_or(0.0, |m| f64::from(m.health.food.exhaustion)),
|
||||
],
|
||||
"chunks": g.sent_chunks.len(),
|
||||
"tracked": g.tracker.len(),
|
||||
// The armour the host draws on this guest, head to feet.
|
||||
"model_armor": h.models.get(&g.conn).map(|m| {
|
||||
[&m.extra.head, &m.extra.chest, &m.extra.legs, &m.extra.feet]
|
||||
.map(|s| s.as_deref())
|
||||
}),
|
||||
})).collect::<Vec<_>>(),
|
||||
}),
|
||||
None => json!({ "open": false }),
|
||||
})),
|
||||
Some("status") => Ok(Some(self.lan_status())),
|
||||
_ => Err("lan open|close|status".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `horizon-ctl lan status`.
|
||||
fn lan_status(&self) -> serde_json::Value {
|
||||
use serde_json::json;
|
||||
match &self.lan {
|
||||
Some(h) if !self.serving() || self.published_port().is_some() => json!({
|
||||
"open": true,
|
||||
"host": h.host_name,
|
||||
"port": h.settings.port,
|
||||
"guests": h.hub.guests.iter().map(|g| json!({
|
||||
"name": g.name,
|
||||
"position": g.position,
|
||||
"placed": g.placed,
|
||||
"loaded": g.loaded,
|
||||
"game_mode": g.game_mode,
|
||||
"sneaking": g.sneaking,
|
||||
"health": g.health,
|
||||
// FoodData: food level, saturation and exhaustion.
|
||||
"food": [
|
||||
f64::from(g.food),
|
||||
f64::from(g.saturation),
|
||||
g.motion.as_ref().map_or(0.0, |m| f64::from(m.health.food.exhaustion)),
|
||||
],
|
||||
"chunks": g.sent_chunks.len(),
|
||||
"tracked": g.tracker.len(),
|
||||
// The armour the host draws on this guest, head to feet.
|
||||
"model_armor": h.models.get(&g.conn).map(|m| {
|
||||
[&m.extra.head, &m.extra.chest, &m.extra.legs, &m.extra.feet]
|
||||
.map(|s| s.as_deref())
|
||||
}),
|
||||
})).collect::<Vec<_>>(),
|
||||
}),
|
||||
_ => json!({ "open": false }),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn lan_open(&mut self, settings: LanSettings) -> Result<()> {
|
||||
self.lan_start(settings)?;
|
||||
let port = self.lan.as_ref().map_or(0, |l| l.settings.port);
|
||||
let port = if let Some(server) = &self.integrated {
|
||||
// ShareToLanScreen: IntegratedServer.publishServer.
|
||||
server
|
||||
.call(move |g| g.publish(settings))
|
||||
.ok_or("the integrated server stopped")??
|
||||
} else if self.serving() {
|
||||
return self.publish(settings).map(drop).map_err(Into::into);
|
||||
} else {
|
||||
self.lan_start(settings)?;
|
||||
self.lan.as_ref().map_or(0, |l| l.settings.port)
|
||||
};
|
||||
self.system_message(Text::translate(
|
||||
"menu.multiplayerOptions.publish.started.lan",
|
||||
&[Text::literal(port.to_string())],
|
||||
@@ -408,6 +436,7 @@ impl Game<'_> {
|
||||
seed: self.level.seed,
|
||||
max_players: MAX_PLAYERS,
|
||||
dedicated: false,
|
||||
integrated: false,
|
||||
};
|
||||
let net = NetConfig::lan(settings.port, motd, MAX_PLAYERS);
|
||||
self.lan_host(settings, net, config, Some(host_uuid))
|
||||
@@ -477,6 +506,8 @@ impl Game<'_> {
|
||||
mob_state: HashMap::new(),
|
||||
mob_players: HashMap::new(),
|
||||
owner: None,
|
||||
waypoints: Default::default(),
|
||||
waypoint_dead: Default::default(),
|
||||
});
|
||||
self.audio.capture = Some(Vec::new());
|
||||
Ok(())
|
||||
@@ -484,6 +515,22 @@ impl Game<'_> {
|
||||
|
||||
/// IntegratedServer.unpublishServer: the guests are sent away.
|
||||
pub(super) fn lan_close(&mut self, announce: bool) {
|
||||
if self.integrated.is_some() || self.serving() {
|
||||
let closed = match &self.integrated {
|
||||
Some(server) => server.call(|g| g.unpublish()).unwrap_or(false),
|
||||
None => self.unpublish(),
|
||||
};
|
||||
if closed {
|
||||
self.friends_lan_closed();
|
||||
}
|
||||
if closed && announce && self.integrated.is_some() {
|
||||
self.system_message(Text::translate(
|
||||
"menu.multiplayerOptions.publish.stopped",
|
||||
&[],
|
||||
));
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.friends_lan_closed();
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return;
|
||||
@@ -516,6 +563,75 @@ impl Game<'_> {
|
||||
} else {
|
||||
playerdata::save(guest, &self.world_root);
|
||||
}
|
||||
self.save_guest_records(host, guest);
|
||||
}
|
||||
/// The file name of a player's advancements and statistics: an
|
||||
/// integrated server's owner keeps single player's.
|
||||
fn records_uuid(&self, host: &LanHost, uuid: u128) -> Option<[i32; 4]> {
|
||||
if host.owner.as_ref().is_some_and(|o| o.uuid == uuid) {
|
||||
self.level.player_uuid
|
||||
} else {
|
||||
Some(playerdata::uuid_ints(uuid))
|
||||
}
|
||||
}
|
||||
/// `PlayerAdvancements.save` and `ServerStatsCounter.save` of a network player.
|
||||
pub(super) fn save_guest_records(
|
||||
&self,
|
||||
host: &LanHost,
|
||||
guest: &horizon_client::lan::guest::Guest,
|
||||
) {
|
||||
if !guest.placed || self.adv.tree.list.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(uuid) = self.records_uuid(host, guest.uuid) else {
|
||||
return;
|
||||
};
|
||||
let path = super::stats::player_json(&self.world_root, "advancements", uuid);
|
||||
let json = guest
|
||||
.advancements
|
||||
.to_json(&self.adv.tree, horizon_world::DATA_VERSION);
|
||||
if let Some(dir) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
if let Ok(text) = serde_json::to_string_pretty(&json) {
|
||||
let _ = std::fs::write(path, text);
|
||||
}
|
||||
if let Some(play) = host.play.get(&guest.conn) {
|
||||
play.stats
|
||||
.write(&super::stats::player_json(&self.world_root, "stats", uuid));
|
||||
}
|
||||
}
|
||||
/// `PlayerAdvancements.load` and `ServerStatsCounter.parseLocal` of a
|
||||
/// network player who joined; every statistic goes out on the first
|
||||
/// request (`PlayerList.placeNewPlayer`'s `markAllDirty`).
|
||||
fn load_guest_records(&mut self, host: &mut LanHost, conn: u32) {
|
||||
let Some(uuid) = host
|
||||
.hub
|
||||
.guest(conn)
|
||||
.and_then(|g| self.records_uuid(host, g.uuid))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let tree = self.advancement_tree();
|
||||
let path = super::stats::player_json(&self.world_root, "advancements", uuid);
|
||||
let saved = std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok());
|
||||
if let Some(g) = host.hub.guest_mut(conn)
|
||||
&& let Some(json) = saved
|
||||
{
|
||||
let mut progress = horizon_client::advancements::Progress::from_json(&json);
|
||||
// PlayerAdvancements.applyFrom: advancements this server lacks are dropped.
|
||||
progress
|
||||
.criteria
|
||||
.retain(|id, _| tree.by_id.contains_key(id));
|
||||
g.advancements = progress;
|
||||
g.advancements_loaded = true;
|
||||
}
|
||||
let mut stats =
|
||||
super::stats::Stats::read(&super::stats::player_json(&self.world_root, "stats", uuid));
|
||||
stats.mark_all_dirty();
|
||||
host.play.entry(conn).or_default().stats = stats;
|
||||
}
|
||||
/// The integrated server's owner into the world's player, for `Level::write`.
|
||||
pub(super) fn store_owner(&mut self) {
|
||||
@@ -527,6 +643,7 @@ impl Game<'_> {
|
||||
};
|
||||
if let Some(g) = host.hub.guests.iter().find(|g| g.uuid == uuid && g.placed) {
|
||||
playerdata::store(g, &mut self.level);
|
||||
self.save_guest_records(host, g);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -539,18 +656,37 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a command changed while the hub was out (`as_guest`), back on
|
||||
/// the hub: `defaultgamemode` run by a guest or the integrated server's owner.
|
||||
pub(super) fn sync_hub_settings(&self, host: &mut LanHost) {
|
||||
host.hub.config.default_game_mode = self.level.mode();
|
||||
}
|
||||
|
||||
/// New command access and game mode forcing settings of the open world.
|
||||
pub(super) fn lan_apply_settings(&mut self, guest_commands: bool, force: bool) {
|
||||
if let Some(server) = &self.integrated {
|
||||
server.call(move |g| g.lan_apply_settings(guest_commands, force));
|
||||
return;
|
||||
}
|
||||
// An integrated server gives guests commands only where the world
|
||||
// allows them (IntegratedServer.getCustomPermissionLevel).
|
||||
let effective = guest_commands && (!self.serving() || self.allow_commands());
|
||||
if let Some(host) = self.lan.as_mut() {
|
||||
host.settings.guest_commands = guest_commands;
|
||||
host.settings.force_game_mode = force;
|
||||
host.hub.config.guest_commands = guest_commands;
|
||||
host.hub.config.guest_commands = effective;
|
||||
host.hub.config.force_game_mode = force;
|
||||
host.hub.resend_commands();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn lan_port(&self) -> Option<u16> {
|
||||
if let Some(server) = &self.integrated {
|
||||
return server.published();
|
||||
}
|
||||
if self.serving() {
|
||||
return self.published_port();
|
||||
}
|
||||
self.lan.as_ref().map(|h| h.settings.port)
|
||||
}
|
||||
|
||||
@@ -630,6 +766,8 @@ impl Game<'_> {
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return;
|
||||
};
|
||||
// What the tick sends a guest goes to its connection at the end.
|
||||
let holding = lan::Holding::start();
|
||||
let state = self.lan_host_state(&host);
|
||||
// A dedicated server runs the hub without a host player.
|
||||
if state.is_some() || host.hub.config.dedicated {
|
||||
@@ -657,7 +795,19 @@ impl Game<'_> {
|
||||
tick_rate: self.clock.rate(),
|
||||
tick_frozen: self.clock.frozen(),
|
||||
};
|
||||
host.hub.tick(&mut ctx)
|
||||
// A server with several levels runs a pass for each
|
||||
// (`tick_server`): the network is read once, before the first.
|
||||
let pass = self.level_pass;
|
||||
let mut events = if pass.first {
|
||||
host.hub.poll(&mut ctx)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
events.extend(host.hub.tick_level(&mut ctx));
|
||||
if pass.last {
|
||||
host.hub.end_tick();
|
||||
}
|
||||
events
|
||||
};
|
||||
if let Some(state) = &state
|
||||
&& host.host_mode != state.game_mode
|
||||
@@ -673,6 +823,7 @@ impl Game<'_> {
|
||||
&& let Some(g) = host.hub.guest(conn)
|
||||
{
|
||||
// PlayerList.placeNewPlayer, then the broadcast join message.
|
||||
super::dedicated::player_joined(g.uuid, &g.name);
|
||||
let [x, y, z] = g.position;
|
||||
super::dedicated::log(
|
||||
"INFO",
|
||||
@@ -697,11 +848,20 @@ impl Game<'_> {
|
||||
if let Some(pos) = guest.menu.as_ref().and_then(|m| m.pos)
|
||||
&& !guest.spectator()
|
||||
{
|
||||
self.container_openers_changed(pos, false);
|
||||
// In the level they were in.
|
||||
let back = self.dim;
|
||||
let theirs = super::Dim::from_id(&guest.dimension);
|
||||
if self.enter_level(Some(&mut host), theirs).is_ok() {
|
||||
self.container_openers_changed(pos, false);
|
||||
}
|
||||
if let Err(e) = self.enter_level(Some(&mut host), back) {
|
||||
log::error!("could not reopen {}: {e}", back.id());
|
||||
}
|
||||
}
|
||||
self.lan_crafting_left(&mut host, &mut guest);
|
||||
self.save_guest(&host, &guest);
|
||||
host.models.remove(&guest.conn);
|
||||
host.play.remove(&guest.conn);
|
||||
if self.options.dedicated.is_some() && guest.placed {
|
||||
super::dedicated::log(
|
||||
"INFO",
|
||||
@@ -726,9 +886,15 @@ impl Game<'_> {
|
||||
}
|
||||
self.lan_flush_effects(&mut host);
|
||||
self.tick_guests(&mut host);
|
||||
self.tick_server_travel(&mut host);
|
||||
self.lan_swings(&mut host);
|
||||
self.lan_update_streaming(&host);
|
||||
self.lan_update_models(&mut host);
|
||||
self.lan_update_waypoints(&mut host, state.as_ref());
|
||||
drop(holding);
|
||||
for g in &host.hub.guests {
|
||||
g.sender.flush();
|
||||
}
|
||||
self.lan = Some(host);
|
||||
}
|
||||
|
||||
@@ -743,6 +909,7 @@ impl Game<'_> {
|
||||
let default_mode = host.hub.config.default_game_mode;
|
||||
let force = host.hub.config.force_game_mode;
|
||||
let root = self.world_root.clone();
|
||||
let in_place = host.hub.config.dimension.clone();
|
||||
let owner = host.owner.as_mut();
|
||||
let Some(g) = host.hub.guest_mut(conn) else {
|
||||
return;
|
||||
@@ -765,7 +932,28 @@ impl Game<'_> {
|
||||
g.previous_position = spawn;
|
||||
g.health = 20.0;
|
||||
g.food = 20;
|
||||
g.dimension = super::Dim::Overworld.id().to_owned();
|
||||
}
|
||||
// A server places each player in their own level
|
||||
// (`PlayerList.placeNewPlayer`); the in-frame game has one.
|
||||
let back = self.dim;
|
||||
let level = super::Dim::from_id(&g.dimension);
|
||||
let elsewhere = self.options.dedicated.is_some() && level != back;
|
||||
if !elsewhere {
|
||||
g.dimension = in_place;
|
||||
}
|
||||
self.load_guest_records(host, conn);
|
||||
let theirs;
|
||||
let entities = if elsewhere {
|
||||
if let Err(e) = self.enter_level(Some(host), level) {
|
||||
log::error!("could not open {}: {e}", level.id());
|
||||
return;
|
||||
}
|
||||
theirs = self.lan_entities(&mut host.hub);
|
||||
&theirs[..]
|
||||
} else {
|
||||
entities
|
||||
};
|
||||
let mut ctx = TickCtx {
|
||||
world: &mut self.world,
|
||||
host: state,
|
||||
@@ -779,6 +967,9 @@ impl Game<'_> {
|
||||
tick_frozen: self.clock.frozen(),
|
||||
};
|
||||
host.hub.place_new_player(conn, &mut ctx);
|
||||
if elsewhere && let Err(e) = self.enter_level(Some(host), back) {
|
||||
log::error!("could not reopen {}: {e}", back.id());
|
||||
}
|
||||
}
|
||||
|
||||
/// Level events the game queued, for the guests (`process_level_events`).
|
||||
@@ -833,7 +1024,11 @@ impl Game<'_> {
|
||||
let mut seen = HashMap::new();
|
||||
for m in &self.mobs {
|
||||
let dead = !m.alive();
|
||||
let old = host.mob_state.get(&m.id).copied();
|
||||
let old = host
|
||||
.mob_state
|
||||
.get(&self.dim)
|
||||
.and_then(|s| s.get(&m.id))
|
||||
.copied();
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
if m.hurt_time > old.map_or(0, |o| o.0) && !dead {
|
||||
host.hub.entity_event(m.id as i32, 2, at);
|
||||
@@ -843,7 +1038,7 @@ impl Game<'_> {
|
||||
}
|
||||
seen.insert(m.id, (m.hurt_time, dead));
|
||||
}
|
||||
host.mob_state = seen;
|
||||
host.mob_state.insert(self.dim, seen);
|
||||
}
|
||||
|
||||
/// Keep the chunks around every guest loaded.
|
||||
@@ -852,7 +1047,7 @@ impl Game<'_> {
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| g.placed)
|
||||
.filter(|g| host.hub.here(g))
|
||||
// The tracked square's corners and their lit neighbours lie
|
||||
// within (view distance + 2) * √2 of the player's chunk.
|
||||
.map(|g| (g.chunk(), (g.view_distance + 2) * 3 / 2 + 1))
|
||||
|
||||
@@ -13,6 +13,7 @@ use horizon_client::{
|
||||
items::ItemStack,
|
||||
lan::{
|
||||
guest::SPECTATOR,
|
||||
hub::TickCtx,
|
||||
menu::{Rules, Slots},
|
||||
},
|
||||
motion::{MotionEvent, MotionWorld, PlayerMotion},
|
||||
@@ -36,6 +37,11 @@ pub(super) struct GuestPlay {
|
||||
swing: horizon_client::first_person::SwingState,
|
||||
/// The block being dug and the face the client aimed at.
|
||||
digging: Option<([i32; 3], [i32; 3])>,
|
||||
/// The server tick the digging started (ServerPlayerGameMode.destroyProgressStart).
|
||||
dig_start: u64,
|
||||
/// A block the client stopped digging before the server's time was up: it breaks once
|
||||
/// its progress since `dig_start` is complete (ServerPlayerGameMode.hasDelayedDestroy).
|
||||
delayed: Option<([i32; 3], u64)>,
|
||||
last_progress: i32,
|
||||
/// Hand movements the client announced and the host animated.
|
||||
swung: bool,
|
||||
@@ -43,6 +49,13 @@ pub(super) struct GuestPlay {
|
||||
pub(super) merchant: Option<super::lan_trading::GuestMerchant>,
|
||||
/// The inventory menu and the crafting table the guest crafts in.
|
||||
pub(super) crafting: super::lan_crafting::GuestCrafting,
|
||||
/// What the guest's body was given for its effects and enchantments.
|
||||
potions: super::potions::BodyPotions,
|
||||
/// `ServerPlayer.getStats` and the inventory `inventory_changed` last saw.
|
||||
pub(super) stats: super::stats::Stats,
|
||||
last_inventory: u64,
|
||||
/// `Entity.portalProcess` and `portalCooldown` (game/server_travel.rs).
|
||||
pub(super) portal: horizon_client::nether_portal::PortalState,
|
||||
}
|
||||
|
||||
struct ItemRules<'a>(&'a horizon_client::items::Items);
|
||||
@@ -92,7 +105,11 @@ impl Game<'_> {
|
||||
let mut play = host.play.remove(&conn).unwrap_or_default();
|
||||
let g = &mut host.hub.guests[i];
|
||||
// The guest's body.
|
||||
let mut motion = g.motion.take()?;
|
||||
let Some(mut motion) = g.motion.take() else {
|
||||
host.play.insert(conn, play);
|
||||
return None;
|
||||
};
|
||||
self.potions.swap_player(&mut g.effects, &mut play.potions);
|
||||
motion.position = Vec3::new(g.position[0], g.position[1], g.position[2]);
|
||||
motion.ground.on_ground = g.on_ground;
|
||||
motion.yaw = g.yaw;
|
||||
@@ -125,13 +142,20 @@ impl Game<'_> {
|
||||
total: g.xp_total,
|
||||
},
|
||||
);
|
||||
let sneak = self.keys.contains(&KeyCode::ShiftLeft);
|
||||
// The host's held keys stay theirs: the guest holds only sneak, and a
|
||||
// screen the guest opens (`keys.clear()`) leaves the host walking.
|
||||
let host_keys = std::mem::take(&mut self.keys);
|
||||
if g.sneaking {
|
||||
self.keys.insert(KeyCode::ShiftLeft);
|
||||
} else {
|
||||
self.keys.remove(&KeyCode::ShiftLeft);
|
||||
}
|
||||
// The window's screens are the host's: a sign, book, command block or
|
||||
// mount menu the guest's action opens is not drawn over the host's
|
||||
// world (#687). The guest's client opens its own.
|
||||
let screen = std::mem::replace(&mut self.screen, Screen::Playing);
|
||||
let host_sign = self.sign_edit.take();
|
||||
let host_book = self.book.take();
|
||||
let host_command_block = self.command_block_screen.take();
|
||||
let host_mount_menu = std::mem::take(&mut self.mount_menu);
|
||||
// The host's crafting grid stays theirs; a crafting table the guest
|
||||
// opens shows as the 3x3 size.
|
||||
let craft_size = std::mem::replace(&mut self.craft_size, 2);
|
||||
@@ -153,6 +177,15 @@ impl Game<'_> {
|
||||
let host_ender = self.ender_open;
|
||||
self.close_ender_chest();
|
||||
std::mem::swap(&mut self.ender_items, &mut g.ender_items);
|
||||
// The guest's advancements, recipe book and statistics.
|
||||
self.advancement_tree();
|
||||
std::mem::swap(&mut self.adv.progress, &mut g.advancements);
|
||||
std::mem::swap(&mut self.adv.changed, &mut g.advancements_changed);
|
||||
std::mem::swap(&mut self.adv.unlocked, &mut g.recipes_unlocked);
|
||||
std::mem::swap(&mut self.level.recipe_book, &mut g.recipe_book);
|
||||
std::mem::swap(&mut self.stats, &mut play.stats);
|
||||
let adv_loaded = std::mem::replace(&mut self.adv.loaded, true);
|
||||
let last_inventory = std::mem::replace(&mut self.adv.last_inventory, play.last_inventory);
|
||||
self.lan_acting = Some(conn);
|
||||
self.lan_acting_name = Some(g.name.clone());
|
||||
self.lan_acting_uuid = Some(g.uuid);
|
||||
@@ -181,13 +214,23 @@ impl Game<'_> {
|
||||
play.swing = std::mem::replace(&mut self.swing, swing);
|
||||
self.container = container;
|
||||
self.screen = screen;
|
||||
if sneak {
|
||||
self.keys.insert(KeyCode::ShiftLeft);
|
||||
} else {
|
||||
self.keys.remove(&KeyCode::ShiftLeft);
|
||||
}
|
||||
self.sign_edit = host_sign;
|
||||
self.book = host_book;
|
||||
self.command_block_screen = host_command_block;
|
||||
self.mount_menu = host_mount_menu;
|
||||
self.keys = host_keys;
|
||||
let g = &mut host.hub.guests[i];
|
||||
std::mem::swap(&mut self.ender_items, &mut g.ender_items);
|
||||
std::mem::swap(&mut self.adv.progress, &mut g.advancements);
|
||||
std::mem::swap(&mut self.adv.changed, &mut g.advancements_changed);
|
||||
std::mem::swap(&mut self.adv.unlocked, &mut g.recipes_unlocked);
|
||||
std::mem::swap(&mut self.level.recipe_book, &mut g.recipe_book);
|
||||
std::mem::swap(&mut self.stats, &mut play.stats);
|
||||
self.adv.loaded = adv_loaded;
|
||||
play.last_inventory = std::mem::replace(&mut self.adv.last_inventory, last_inventory);
|
||||
let stat_events = std::mem::take(&mut play.stats.events);
|
||||
let name = g.name.clone();
|
||||
self.potions.swap_player(&mut g.effects, &mut play.potions);
|
||||
if let Some(key) = host_ender {
|
||||
self.world
|
||||
.block_entities
|
||||
@@ -231,6 +274,7 @@ impl Game<'_> {
|
||||
host.hub.set_game_mode(conn, new_mode);
|
||||
}
|
||||
host.play.insert(conn, play);
|
||||
self.stat_criteria_changed(&name, stat_events);
|
||||
if let Some(pos) = opened
|
||||
&& !self.lan_open_container(host, conn, pos)
|
||||
&& !host.hub.guests[i].spectator()
|
||||
@@ -247,6 +291,32 @@ impl Game<'_> {
|
||||
Some(result)
|
||||
}
|
||||
|
||||
/// `ServerLevel.players()` in turn: the host's own player, then each
|
||||
/// guest in the level in place with their state swapped in (`as_guest`).
|
||||
/// What reaches the players around a point (a beacon's powers, a splash
|
||||
/// potion, a warden's darkness, an elder guardian's curse) lands on each
|
||||
/// of them, not only on the host. A server has no player of its own.
|
||||
/// Inside a guest's action (the hub taken) only that player is in place.
|
||||
pub(super) fn for_each_player(&mut self, mut f: impl FnMut(&mut Self)) {
|
||||
if self.player.is_some() {
|
||||
f(self);
|
||||
}
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return;
|
||||
};
|
||||
let conns: Vec<u32> = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| host.hub.here(g) && g.loaded && !g.dead)
|
||||
.map(|g| g.conn)
|
||||
.collect();
|
||||
for conn in conns {
|
||||
self.as_guest(&mut host, conn, &mut f);
|
||||
}
|
||||
self.lan = Some(host);
|
||||
}
|
||||
|
||||
/// The game mode fields without `set_mode`'s side effects on the host.
|
||||
fn set_mode_raw(&mut self, id: i32) {
|
||||
use horizon_client::command::args::GameMode;
|
||||
@@ -274,6 +344,14 @@ impl Game<'_> {
|
||||
"container.enderchest",
|
||||
Slots::Ender,
|
||||
)
|
||||
} else if let Some(BlockEntity::Furnace(f)) = self.world.block_entities.get(&pos) {
|
||||
// AbstractFurnaceBlock.openContainer: FurnaceMenu, BlastFurnaceMenu, SmokerMenu.
|
||||
let (menu, title) = match f.kind.recipe_type() {
|
||||
"minecraft:blasting" => ("minecraft:blast_furnace", "container.blast_furnace"),
|
||||
"minecraft:smoking" => ("minecraft:smoker", "container.smoker"),
|
||||
_ => ("minecraft:furnace", "container.furnace"),
|
||||
};
|
||||
(menu, title, Slots::Block(pos))
|
||||
} else if let Some((other, right)) = self.chest_partner(pos) {
|
||||
// ChestBlock.combine: the right half's slots come first.
|
||||
let halves = if right {
|
||||
@@ -350,35 +428,41 @@ impl Game<'_> {
|
||||
let face = direction(i32::from(a.direction));
|
||||
let play = host.play.entry(conn).or_default();
|
||||
play.digging = Some((a.pos, face));
|
||||
play.dig_start = host.hub.ticks;
|
||||
play.last_progress = -1;
|
||||
// The pause between two blocks is the client's (MultiPlayerGameMode's
|
||||
// destroyDelay); the server digs from the START it receives.
|
||||
play.destroy_delay = 0;
|
||||
}
|
||||
1 => {}
|
||||
2 | 3 => {
|
||||
3 => {
|
||||
host.hub.ack(conn, a.sequence);
|
||||
self.guest_stop_digging(host, conn, a.pos);
|
||||
}
|
||||
2 => {
|
||||
host.hub.ack(conn, a.sequence);
|
||||
let play = host.play.entry(conn).or_default();
|
||||
play.digging = None;
|
||||
play.mining = None;
|
||||
if a.action == 2 {
|
||||
let state = self.world.block(a.pos[0], a.pos[1], a.pos[2]).0 as i32;
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::BlockUpdate(play::BlockUpdate { pos: a.pos, state }),
|
||||
);
|
||||
host.hub.broadcast_near(
|
||||
[
|
||||
f64::from(a.pos[0]),
|
||||
f64::from(a.pos[1]),
|
||||
f64::from(a.pos[2]),
|
||||
],
|
||||
64.0,
|
||||
&C::BlockDestruction(play::BlockDestruction {
|
||||
id: host.hub.guests[gi].entity_id,
|
||||
pos: a.pos,
|
||||
progress: 255,
|
||||
}),
|
||||
Some(conn),
|
||||
);
|
||||
}
|
||||
let state = self.world.block(a.pos[0], a.pos[1], a.pos[2]).0 as i32;
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::BlockUpdate(play::BlockUpdate { pos: a.pos, state }),
|
||||
);
|
||||
host.hub.broadcast_near(
|
||||
[
|
||||
f64::from(a.pos[0]),
|
||||
f64::from(a.pos[1]),
|
||||
f64::from(a.pos[2]),
|
||||
],
|
||||
64.0,
|
||||
&C::BlockDestruction(play::BlockDestruction {
|
||||
id: host.hub.guests[gi].entity_id,
|
||||
pos: a.pos,
|
||||
progress: 255,
|
||||
}),
|
||||
Some(conn),
|
||||
);
|
||||
}
|
||||
4 | 5 => {
|
||||
if !spectator {
|
||||
@@ -395,10 +479,15 @@ impl Game<'_> {
|
||||
}
|
||||
});
|
||||
}
|
||||
7 => {
|
||||
// SWAP_ITEM_WITH_OFFHAND: the hands swap and the item in use stops
|
||||
// (ServerGamePacketListenerImpl.handlePlayerAction), not for spectators.
|
||||
7 if !spectator => {
|
||||
let g = &mut host.hub.guests[gi];
|
||||
let slot = g.selected;
|
||||
std::mem::swap(&mut g.inventory[slot], &mut g.offhand);
|
||||
if let Some(play) = host.play.get_mut(&conn) {
|
||||
play.using = None;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
@@ -474,6 +563,7 @@ impl Game<'_> {
|
||||
S::ContainerClick(p) if self.lan_merchant_click(host, conn, &p) => {}
|
||||
S::ContainerClick(p) if self.lan_inventory_click(host, conn, &p) => {}
|
||||
S::ContainerClick(p) if self.lan_crafting_click(host, conn, &p) => {}
|
||||
S::ContainerClick(p) if self.lan_furnace_click(host, conn, &p) => {}
|
||||
S::SelectTrade(p) => self.lan_select_trade(host, conn, p.item),
|
||||
S::ContainerClose(p) if self.lan_merchant_closed(host, conn, Some(p.container)) => {
|
||||
let g = &mut host.hub.guests[gi];
|
||||
@@ -507,6 +597,70 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
S::ChatCommand(c) => self.guest_command(host, conn, &c.command),
|
||||
S::ChangeDifficulty(p) if host.hub.guests[gi].permission >= 2 => {
|
||||
if !self.level.difficulty_locked && (0..=3).contains(&p.difficulty) {
|
||||
self.ui.difficulty = if self.level.hardcore {
|
||||
3
|
||||
} else {
|
||||
p.difficulty as u8
|
||||
};
|
||||
self.sync_rules();
|
||||
if self.ui.difficulty == 0 {
|
||||
self.mobs.retain(|m| !m.hostile());
|
||||
}
|
||||
host.hub
|
||||
.broadcast(&C::ChangeDifficulty(play::ChangeDifficulty {
|
||||
difficulty: i32::from(self.ui.difficulty),
|
||||
locked: self.level.difficulty_locked,
|
||||
}));
|
||||
}
|
||||
}
|
||||
S::LockDifficulty(p) if host.hub.guests[gi].permission >= 2 => {
|
||||
self.level.difficulty_locked = p.locked;
|
||||
host.hub
|
||||
.broadcast(&C::ChangeDifficulty(play::ChangeDifficulty {
|
||||
difficulty: i32::from(self.ui.difficulty),
|
||||
locked: self.level.difficulty_locked,
|
||||
}));
|
||||
}
|
||||
S::ClientCommand(p) if p.action == 2 && host.hub.guests[gi].permission >= 2 => {
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::GameRuleValues(play::GameRuleValues {
|
||||
entries: horizon_client::gamerules::RULES
|
||||
.iter()
|
||||
.map(|r| play::GameRuleEntry {
|
||||
name: format!("minecraft:{}", r.name),
|
||||
value: self.ui.rules.get(r.name).unwrap_or(r.default).to_string(),
|
||||
})
|
||||
.collect(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
S::SetGameRule(p) if host.hub.guests[gi].permission >= 2 => {
|
||||
for entry in p.entries {
|
||||
if self.ui.rules.set_string(&entry.name, &entry.value) {
|
||||
let message = horizon_client::text::Text::tr(
|
||||
"commands.gamerule.set",
|
||||
&[&entry.name, &entry.value],
|
||||
);
|
||||
let content =
|
||||
horizon_client::lan::data::component_nbt(&message.component());
|
||||
for g in &host.hub.guests {
|
||||
if g.permission >= 2 {
|
||||
g.send(&C::SystemChat(play::SystemChat {
|
||||
content: content.clone(),
|
||||
overlay: false,
|
||||
}));
|
||||
}
|
||||
}
|
||||
if self.options.dedicated.is_none() && self.allow_commands() {
|
||||
self.system_message(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.sync_rules();
|
||||
}
|
||||
S::ChangeGameMode(p) if host.hub.guests[gi].permission >= 2 => {
|
||||
if let Some(mode) = usize::try_from(p.mode)
|
||||
.ok()
|
||||
@@ -544,7 +698,17 @@ impl Game<'_> {
|
||||
{
|
||||
self.guest_suggestions(host, conn, &payload);
|
||||
}
|
||||
S::PlaceRecipe(p) => self.lan_place_recipe(host, conn, &p),
|
||||
S::ClientCommand(c) if c.action == 0 => self.guest_respawn(host, conn),
|
||||
// ServerStatsCounter.sendStats.
|
||||
S::ClientCommand(c) if c.action == 1 => {
|
||||
let stats = host
|
||||
.play
|
||||
.get_mut(&conn)
|
||||
.map(|p| p.stats.take_dirty())
|
||||
.unwrap_or_default();
|
||||
host.hub.send_stats(conn, &stats);
|
||||
}
|
||||
S::PickItemFromBlock(p) => {
|
||||
let state = self.world.block(p.pos[0], p.pos[1], p.pos[2]);
|
||||
let item = self.res.facts.get(state).item.clone();
|
||||
@@ -669,6 +833,32 @@ impl Game<'_> {
|
||||
);
|
||||
return;
|
||||
}
|
||||
// An integrated server runs every player's commands at their own
|
||||
// permission level (`CommandSourceStack.permissions`).
|
||||
if host.hub.config.integrated {
|
||||
let Some(level) = host.hub.guest(conn).map(|g| g.permission) else {
|
||||
return;
|
||||
};
|
||||
let command = command.trim_start_matches('/').to_owned();
|
||||
let lines = self.as_guest(host, conn, |g| {
|
||||
let console = std::mem::replace(&mut g.ui.registries.permission, level);
|
||||
g.chat_capture = Some(Vec::new());
|
||||
g.run_command(&command);
|
||||
g.ui.registries.permission = console;
|
||||
g.chat_capture.take().unwrap_or_default()
|
||||
});
|
||||
self.sync_hub_settings(host);
|
||||
for line in lines.unwrap_or_default() {
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::SystemChat(play::SystemChat {
|
||||
content: horizon_client::lan::data::component_nbt(&line.component()),
|
||||
overlay: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// A LAN world follows its Allow Commands; a dedicated server leaves
|
||||
// it to the ops list (the guest's permission level).
|
||||
let allowed = self.options.dedicated.is_some() || self.allow_commands();
|
||||
@@ -683,9 +873,14 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let command = command.trim_start_matches('/').to_owned();
|
||||
// The guest's own level (Commands.performCommand with the player's source): an
|
||||
// operator's on a dedicated server, never the host's.
|
||||
let permission = host.hub.guest(conn).map_or(0, |g| g.permission);
|
||||
let lines = self.as_guest(host, conn, |g| {
|
||||
g.chat_capture = Some(Vec::new());
|
||||
let level = std::mem::replace(&mut g.ui.registries.permission, permission);
|
||||
g.run_command(&command);
|
||||
g.ui.registries.permission = level;
|
||||
g.chat_capture
|
||||
.take()
|
||||
.unwrap_or_default()
|
||||
@@ -693,6 +888,7 @@ impl Game<'_> {
|
||||
.map(|t| horizon_client::lan::data::component_nbt(&t.component()))
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
self.sync_hub_settings(host);
|
||||
for content in lines.unwrap_or_default() {
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
@@ -752,6 +948,18 @@ impl Game<'_> {
|
||||
if !host.hub.guests[i].dead {
|
||||
return;
|
||||
}
|
||||
// PlayerList.respawn: a server's player comes back in the Overworld,
|
||||
// where the world spawn is (respawn anchors and beds aside).
|
||||
let back = self.dim;
|
||||
let away = self.options.dedicated.is_some()
|
||||
&& host.hub.guests[i].dimension != super::Dim::Overworld.id();
|
||||
if away {
|
||||
self.server_trips.retain(|t| !t.is(conn));
|
||||
if let Err(e) = self.enter_level(Some(host), super::Dim::Overworld) {
|
||||
log::error!("could not open the Overworld: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
let g = &mut host.hub.guests[i];
|
||||
g.dead = false;
|
||||
g.health = 20.0;
|
||||
@@ -759,24 +967,55 @@ impl Game<'_> {
|
||||
g.saturation = 5.0;
|
||||
g.sent_health = None;
|
||||
g.motion = None;
|
||||
host.hub.respawn(i, spawn);
|
||||
let mut xp = Experience {
|
||||
level: g.xp_level,
|
||||
progress: g.xp_progress,
|
||||
total: g.xp_total,
|
||||
};
|
||||
xp.respawn(self.ui.rules.bool("keep_inventory") || g.game_mode == SPECTATOR);
|
||||
(g.xp_level, g.xp_progress, g.xp_total) = (xp.level, xp.progress, xp.total);
|
||||
// A new body: nothing given to the old one carries over.
|
||||
if let Some(play) = host.play.get_mut(&conn) {
|
||||
play.potions = Default::default();
|
||||
}
|
||||
let ctx = TickCtx {
|
||||
world: &mut self.world,
|
||||
host: None,
|
||||
entities: &[],
|
||||
next_entity: &mut self.next_entity,
|
||||
spawn,
|
||||
difficulty: i32::from(self.ui.difficulty),
|
||||
difficulty_locked: self.level.difficulty_locked,
|
||||
clock_rate: if self.ui.rules.bool("advance_time") {
|
||||
self.ui.clock.rate
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
tick_rate: self.clock.rate(),
|
||||
tick_frozen: self.clock.frozen(),
|
||||
};
|
||||
host.hub.respawn(i, spawn, &ctx);
|
||||
if away && let Err(e) = self.enter_level(Some(host), back) {
|
||||
log::error!("could not reopen {}: {e}", back.id());
|
||||
}
|
||||
}
|
||||
|
||||
/// Per tick, for every guest: dig, use items, pick up drops, die.
|
||||
pub(super) fn tick_guests(&mut self, host: &mut LanHost) {
|
||||
self.lan_merchant_tick(host);
|
||||
self.announce_guest_advancements(host);
|
||||
let conns: Vec<u32> = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| g.placed && g.loaded)
|
||||
.filter(|g| host.hub.here(g) && g.loaded)
|
||||
.map(|g| g.conn)
|
||||
.collect();
|
||||
for conn in conns {
|
||||
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
continue;
|
||||
};
|
||||
if host.hub.guests[gi].dead {
|
||||
if host.hub.guests[gi].dead || self.on_server_trip(conn) {
|
||||
continue;
|
||||
}
|
||||
let play = host.play.entry(conn).or_default();
|
||||
@@ -806,20 +1045,49 @@ impl Game<'_> {
|
||||
}
|
||||
});
|
||||
}
|
||||
self.guest_delayed_destroy(host, conn);
|
||||
self.guest_server_tick(host, conn);
|
||||
// Player.tick's statistics and ServerPlayer.tick's triggers, then
|
||||
// PlayerAdvancements.flushDirty.
|
||||
let sleeping = host.hub.guests[gi].sleeping;
|
||||
self.as_guest(host, conn, |g| {
|
||||
g.count_tick_stats(sleeping);
|
||||
g.tick_advancements();
|
||||
});
|
||||
host.hub.flush_progress(conn);
|
||||
self.guest_dig_feedback(host, conn);
|
||||
self.guest_pickups(host, gi);
|
||||
let g = &mut host.hub.guests[gi];
|
||||
if g.health <= 0.0 && !g.dead {
|
||||
g.dead = true;
|
||||
g.last_death = Some(g.position.map(|v| v.floor() as i32));
|
||||
let (id, at) = (g.entity_id, g.position);
|
||||
let reward = Experience {
|
||||
level: g.xp_level,
|
||||
progress: g.xp_progress,
|
||||
total: g.xp_total,
|
||||
}
|
||||
.death_reward(self.ui.rules.bool("keep_inventory") || g.game_mode == SPECTATOR);
|
||||
let message = literal(&format!("{} died", g.name));
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::PlayerCombatKill(play::PlayerCombatKill {
|
||||
id: host.hub.guests[gi].entity_id,
|
||||
message,
|
||||
}),
|
||||
&C::PlayerCombatKill(play::PlayerCombatKill { id, message }),
|
||||
);
|
||||
// LivingEntity.die: the death animation, the dying player included.
|
||||
host.hub.entity_event(id, 3, at);
|
||||
self.spawn_orbs(at, reward);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `PlayerList.broadcastSystemMessage` of the advancements guests made
|
||||
/// (their own actions, commands and ticks).
|
||||
fn announce_guest_advancements(&mut self, host: &mut LanHost) {
|
||||
for text in std::mem::take(&mut self.adv.announced) {
|
||||
host.hub
|
||||
.system_message(horizon_client::lan::data::component_nbt(&text.component()));
|
||||
if self.options.dedicated.is_none() {
|
||||
self.system_message(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -846,12 +1114,17 @@ impl Game<'_> {
|
||||
g.moves_from_ground
|
||||
};
|
||||
if g.spectator() {
|
||||
// Spectators' effects still run (LivingEntity.baseTick).
|
||||
if !g.effects.list.is_empty() {
|
||||
self.as_guest(host, conn, |game| game.tick_mob_effects());
|
||||
}
|
||||
return;
|
||||
}
|
||||
let in_wall = !g.sleeping && self.in_wall(g.position, g.eye_height());
|
||||
let at = g.position;
|
||||
let sounds = self.as_guest(host, conn, |game| {
|
||||
let (rules, health) = (game.world.health_rules, &game.motion.health);
|
||||
let (rules, motion) = (game.world.health_rules, game.motion);
|
||||
let health = &motion.health;
|
||||
let Some(player) = game.player.as_mut() else {
|
||||
return Vec::new();
|
||||
};
|
||||
@@ -866,6 +1139,7 @@ impl Game<'_> {
|
||||
w.player.health.advance_clock();
|
||||
let _ = w.fluid_base_tick();
|
||||
w.player.ground.on_ground = from_ground;
|
||||
let travelled: Vec<[f64; 3]> = moves.iter().map(|m| m.0).collect();
|
||||
for (moved, on_ground) in moves {
|
||||
let movement = Vec3::new(moved[0], moved[1], moved[2]);
|
||||
if w.player.ground.on_ground && !on_ground && moved[1] > 0.0 {
|
||||
@@ -889,9 +1163,21 @@ impl Game<'_> {
|
||||
let _ = w.tick_environment_damage();
|
||||
let _ = w.apply_fire_contact();
|
||||
let _ = w.server_hazards_tick(in_wall);
|
||||
let _ = w.player.peaceful_regeneration(rules);
|
||||
let _ = w.player.food_tick(health, rules);
|
||||
std::mem::take(&mut w.player.events)
|
||||
let mut events = std::mem::take(&mut w.player.events);
|
||||
// ServerGamePacketListenerImpl.handleMovePlayer:
|
||||
// Player.checkMovementStatistics for each move.
|
||||
for d in travelled {
|
||||
game.movement_stats(Vec3::new(d[0], d[1], d[2]));
|
||||
}
|
||||
// LivingEntity.baseTick ends with tickEffects.
|
||||
game.tick_mob_effects();
|
||||
let Some(player) = game.player.as_mut() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let _ = player.peaceful_regeneration(rules);
|
||||
let _ = player.food_tick(health, rules);
|
||||
events.append(&mut player.events);
|
||||
events
|
||||
.into_iter()
|
||||
.filter_map(|e| match e {
|
||||
MotionEvent::HurtSound(sound) => Some(sound),
|
||||
@@ -919,6 +1205,81 @@ impl Game<'_> {
|
||||
})
|
||||
}
|
||||
|
||||
/// ServerPlayerGameMode.handleBlockBreakAction, STOP_DESTROY_BLOCK: the client finished
|
||||
/// digging `pos` (and predicted the break). The server breaks it if its own count of the
|
||||
/// digging since START is at least 70 % done; otherwise it keeps digging alone and breaks
|
||||
/// it when done (`guest_delayed_destroy`), so the client's broken block is never left
|
||||
/// standing on the server.
|
||||
fn guest_stop_digging(&mut self, host: &mut LanHost, conn: u32, pos: [i32; 3]) {
|
||||
let ticks = host.hub.ticks;
|
||||
let play = host.play.entry(conn).or_default();
|
||||
let dug = play.digging.take().is_some_and(|(p, _)| p == pos);
|
||||
play.mining = None;
|
||||
let start = play.dig_start;
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if !dug || self.res.facts.is_air(state) {
|
||||
return;
|
||||
}
|
||||
let progress = self
|
||||
.as_guest(host, conn, |g| g.destroy_progress(pos))
|
||||
.map_or(0.0, |p| p * (ticks - start + 1) as f32);
|
||||
if progress >= 0.7 {
|
||||
self.guest_destroy(host, conn, pos);
|
||||
} else {
|
||||
let play = host.play.entry(conn).or_default();
|
||||
if play.delayed.is_none() {
|
||||
play.delayed = Some((pos, start));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ServerPlayerGameMode.tick's delayed destroy: the block breaks once its progress since
|
||||
/// the digging started is complete, unless something else removed it first.
|
||||
fn guest_delayed_destroy(&mut self, host: &mut LanHost, conn: u32) {
|
||||
let Some((pos, start)) = host.play.get(&conn).and_then(|p| p.delayed) else {
|
||||
return;
|
||||
};
|
||||
let ticks = host.hub.ticks;
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let progress = if self.res.facts.is_air(state) {
|
||||
None
|
||||
} else {
|
||||
self.as_guest(host, conn, |g| g.destroy_progress(pos))
|
||||
.map(|p| p * (ticks - start + 1) as f32)
|
||||
};
|
||||
match progress {
|
||||
Some(p) if p < 1.0 => {}
|
||||
done => {
|
||||
if let Some(play) = host.play.get_mut(&conn) {
|
||||
play.delayed = None;
|
||||
}
|
||||
if done.is_some() {
|
||||
self.guest_destroy(host, conn, pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ServerPlayerGameMode.destroyBlock for a guest, its crack cleared for the others
|
||||
/// (`destroyBlockProgress(id, pos, -1)`).
|
||||
fn guest_destroy(&mut self, host: &mut LanHost, conn: u32, pos: [i32; 3]) {
|
||||
self.as_guest(host, conn, |g| {
|
||||
let _ = g.break_block(pos);
|
||||
});
|
||||
if let Some(g) = host.hub.guest(conn) {
|
||||
host.hub.broadcast_near(
|
||||
[f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])],
|
||||
64.0,
|
||||
&C::BlockDestruction(play::BlockDestruction {
|
||||
id: g.entity_id,
|
||||
pos,
|
||||
progress: 255,
|
||||
}),
|
||||
Some(conn),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The crack animation other players see.
|
||||
fn guest_dig_feedback(&mut self, host: &mut LanHost, conn: u32) {
|
||||
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
@@ -1039,6 +1400,9 @@ impl Game<'_> {
|
||||
let Some(g) = host.hub.guests.iter().find(|g| g.conn == conn) else {
|
||||
continue;
|
||||
};
|
||||
if !host.hub.here(g) {
|
||||
continue;
|
||||
}
|
||||
let (id, at) = (g.entity_id, g.position);
|
||||
let play = host.play.get_mut(&conn).expect("play");
|
||||
let swinging = play.swing.swinging() && play.swing.ticks == 0;
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
//! closes (`AbstractContainerMenu.clearContainer`).
|
||||
use super::{Game, lan::LanHost};
|
||||
use horizon_client::{
|
||||
containers::BlockEntity,
|
||||
items::ItemStack,
|
||||
lan::guest::Guest,
|
||||
lan::{guest::Guest, menu::Slots},
|
||||
menu::{Env, Input, Kind, Menu},
|
||||
};
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
@@ -20,6 +21,9 @@ pub(super) struct GuestCrafting {
|
||||
inventory: Option<Box<Menu>>,
|
||||
/// The open crafting table: its window, state id and menu.
|
||||
table: Option<(i32, i32, Box<Menu>)>,
|
||||
/// The open furnace's window and menu (`AbstractFurnaceMenu`); its slots live in
|
||||
/// the furnace block entity and the hub's container menu keeps the client in sync.
|
||||
furnace: Option<(i32, Box<Menu>)>,
|
||||
}
|
||||
|
||||
/// ServerboundContainerClickPacket's input.
|
||||
@@ -153,6 +157,154 @@ impl Game<'_> {
|
||||
true
|
||||
}
|
||||
|
||||
/// A click on the guest's open furnace, blast furnace or smoker: the host's
|
||||
/// `AbstractFurnaceMenu` (fuel slot, take-only result, quick moves by recipe and
|
||||
/// fuel) over the block entity's slots. False for other windows.
|
||||
pub(super) fn lan_furnace_click(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
p: &play::ContainerClick,
|
||||
) -> bool {
|
||||
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
return false;
|
||||
};
|
||||
let Some(pos) = host.hub.guests[gi]
|
||||
.menu
|
||||
.as_ref()
|
||||
.filter(|m| p.container != 0 && m.window == p.container)
|
||||
.and_then(|m| match m.slots {
|
||||
Slots::Block(pos) => Some(pos),
|
||||
_ => None,
|
||||
})
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Some(BlockEntity::Furnace(f)) = self.world.block_entities.get(&pos) else {
|
||||
return false;
|
||||
};
|
||||
let recipe = match f.kind.recipe_type() {
|
||||
"minecraft:blasting" => "blasting",
|
||||
"minecraft:smoking" => "smoking",
|
||||
_ => "smelting",
|
||||
};
|
||||
let items = f.items.clone();
|
||||
if host.hub.guests[gi].spectator() {
|
||||
return true;
|
||||
}
|
||||
let mut menu = host
|
||||
.play
|
||||
.entry(conn)
|
||||
.or_default()
|
||||
.crafting
|
||||
.furnace
|
||||
.take()
|
||||
.filter(|(window, _)| *window == p.container)
|
||||
.map_or_else(|| Box::new(Menu::new(Kind::Furnace(recipe))), |(_, m)| m);
|
||||
let changed = self.guest_menu_op(host, gi, &mut menu, |menu, env| {
|
||||
for (k, s) in items.iter().enumerate() {
|
||||
menu.set_slot(env, k, s.clone());
|
||||
}
|
||||
let before: Vec<_> = (0..menu.slot_count())
|
||||
.map(|s| menu.slot(s).cloned())
|
||||
.collect();
|
||||
let _ = menu.click(env, i32::from(p.slot), i32::from(p.button), input(p.input));
|
||||
(0..menu.slot_count())
|
||||
.filter(|&s| menu.slot(s) != before[s].as_ref())
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
if let Some(BlockEntity::Furnace(f)) = self.world.block_entities.get_mut(&pos) {
|
||||
for k in 0..3 {
|
||||
f.items[k] = menu.slot(k).cloned();
|
||||
}
|
||||
// AbstractFurnaceBlockEntity.setItem: only a new input restarts cooking.
|
||||
f.input_clicked(&self.res.recipes, items[0].clone());
|
||||
}
|
||||
host.play.entry(conn).or_default().crafting.furnace = Some((p.container, menu));
|
||||
host.hub.inventory_clicked(conn, p, &self.world, &changed);
|
||||
true
|
||||
}
|
||||
|
||||
/// ServerGamePacketListenerImpl.handlePlaceRecipe: the guest's recipe
|
||||
/// book places a known recipe in their inventory grid or crafting table
|
||||
/// (`ServerPlaceRecipe`), and answers with a ghost recipe when the items
|
||||
/// are missing.
|
||||
pub(super) fn lan_place_recipe(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
p: &horizon_protocol::packets::recipes::PlaceRecipe,
|
||||
) {
|
||||
use horizon_client::menu::place::PostPlace;
|
||||
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
return;
|
||||
};
|
||||
let g = &host.hub.guests[gi];
|
||||
if g.spectator() {
|
||||
return;
|
||||
}
|
||||
let Some(id) = self
|
||||
.res
|
||||
.stations
|
||||
.recipe_index
|
||||
.recipe_of_display(p.recipe)
|
||||
.map(str::to_owned)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// An unknown or impossible recipe is refused outright.
|
||||
if !g.recipe_book.knows(&id)
|
||||
|| self
|
||||
.res
|
||||
.recipes
|
||||
.placement(&id)
|
||||
.is_none_or(|placement| placement.impossible())
|
||||
{
|
||||
return;
|
||||
}
|
||||
let creative = g.creative();
|
||||
let placed = if p.container == 0 {
|
||||
let crafting = host.play.entry(conn).or_default();
|
||||
let mut menu = crafting
|
||||
.crafting
|
||||
.inventory
|
||||
.take()
|
||||
.unwrap_or_else(|| Box::new(Menu::new(Kind::Inventory)));
|
||||
let grid = host.hub.guests[gi].craft_grid.clone();
|
||||
let placed = self.guest_menu_op(host, gi, &mut menu, |menu, env| {
|
||||
for (k, s) in grid.into_iter().enumerate() {
|
||||
menu.set_slot(env, 1 + k, s);
|
||||
}
|
||||
menu.place_recipe(env, &id, p.use_max, creative, None)
|
||||
});
|
||||
let g = &mut host.hub.guests[gi];
|
||||
for k in 0..4 {
|
||||
g.craft_grid[k] = menu.slot(1 + k).cloned();
|
||||
}
|
||||
g.craft_result = menu.slot(0).cloned();
|
||||
host.play.entry(conn).or_default().crafting.inventory = Some(menu);
|
||||
host.hub.send_inventory(gi);
|
||||
placed
|
||||
} else {
|
||||
let Some((window, state, mut menu)) = host
|
||||
.play
|
||||
.get_mut(&conn)
|
||||
.and_then(|play| play.crafting.table.take_if(|(w, _, _)| *w == p.container))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let placed = self.guest_menu_op(host, gi, &mut menu, |menu, env| {
|
||||
menu.place_recipe(env, &id, p.use_max, creative, None)
|
||||
});
|
||||
host.play.entry(conn).or_default().crafting.table = Some((window, state, menu));
|
||||
self.lan_send_table_content(host, conn);
|
||||
placed
|
||||
};
|
||||
if placed == PostPlace::Ghost {
|
||||
host.hub.send_ghost_recipe(conn, p.container, &id);
|
||||
}
|
||||
}
|
||||
|
||||
/// The guest opened a crafting table (`CraftingTableBlock.getMenuProvider`).
|
||||
pub(super) fn lan_open_crafting(&mut self, host: &mut LanHost, conn: u32) {
|
||||
self.lan_crafting_closed(host, conn, None);
|
||||
|
||||
@@ -92,7 +92,7 @@ impl Game<'_> {
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| g.placed && g.loaded && !g.dead)
|
||||
.filter(|g| host.hub.here(g) && g.loaded && !g.dead)
|
||||
.map(|g| {
|
||||
let moved = [0, 1, 2].map(|k| g.position[k] - g.previous_position[k]);
|
||||
(g.conn, g.uuid, moved)
|
||||
@@ -138,7 +138,7 @@ impl Game<'_> {
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| g.placed && g.loaded && !g.dead && !g.spectator())
|
||||
.filter(|g| host.hub.here(g) && g.loaded && !g.dead && !g.spectator())
|
||||
.map(|g| (Who::Mob(g.entity_id as u32), guest_bounds(g)))
|
||||
.collect()
|
||||
}
|
||||
@@ -209,9 +209,12 @@ pub(super) struct Present {
|
||||
impl Game<'_> {
|
||||
/// Every guest in the world (`ServerLevel.getPlayers` past the host).
|
||||
pub(super) fn lan_present(&self) -> Vec<Present> {
|
||||
let guests = self.lan.iter().flat_map(|h| &h.hub.guests);
|
||||
let guests = self
|
||||
.lan
|
||||
.iter()
|
||||
.flat_map(|h| h.hub.guests.iter().filter(|g| h.hub.here(g)));
|
||||
guests
|
||||
.filter(|g| g.placed && g.loaded)
|
||||
.filter(|g| g.loaded)
|
||||
.map(|g| {
|
||||
let [x, y, z] = g.position;
|
||||
Present {
|
||||
|
||||
@@ -19,6 +19,7 @@ pub(super) struct Strike {
|
||||
scale: f32,
|
||||
crit: bool,
|
||||
from: [f64; 3],
|
||||
sweep: Option<[f64; 3]>,
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -31,7 +32,7 @@ impl Game<'_> {
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| g.placed && g.loaded && !g.dead && !g.spectator())
|
||||
.filter(|g| host.hub.here(g) && g.loaded && !g.dead && !g.spectator())
|
||||
.filter_map(|g| Some((g.conn, ray_box(eye, dir, reach, guest_bounds(g))?)))
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
}
|
||||
@@ -63,7 +64,7 @@ impl Game<'_> {
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.entity_id == id && g.conn != conn && g.placed && !g.dead)
|
||||
.find(|g| g.entity_id == id && g.conn != conn && host.hub.here(g) && !g.dead)
|
||||
.map(|g| Victim::Guest(g.conn))
|
||||
}
|
||||
|
||||
@@ -73,6 +74,7 @@ impl Game<'_> {
|
||||
let p = self.player.as_ref()?;
|
||||
let from = [p.position.x, p.position.y, p.position.z];
|
||||
let (amount, scale, crit) = self.melee_damage("minecraft:player");
|
||||
let sweep = self.sweep_attack_position(scale, crit);
|
||||
self.attack_ticks = 0;
|
||||
if let Some(p) = self.player.as_mut()
|
||||
&& !self.creative
|
||||
@@ -85,6 +87,7 @@ impl Game<'_> {
|
||||
scale,
|
||||
crit,
|
||||
from,
|
||||
sweep,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -105,6 +108,10 @@ impl Game<'_> {
|
||||
(true, false, true) => "minecraft:entity.player.attack.strong",
|
||||
(true, false, false) => "minecraft:entity.player.attack.weak",
|
||||
};
|
||||
self.audio.play(sound, Some(s.from), 1.0, 1.0);
|
||||
if let Some(at) = s.sweep.filter(|_| hurt) {
|
||||
self.sweep_attack(at, Some(s.from));
|
||||
} else {
|
||||
self.audio.play(sound, Some(s.from), 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,11 @@
|
||||
//! difficulty, LAN on/off with its port, command access for guests and
|
||||
//! forcing the game mode. Its difficulty lock asks first (PopupScreen), and
|
||||
//! Restrictions opens RestrictionsScreen.
|
||||
//! Parity reference: https://mcsrc.dev/2/26.3/net/minecraft/client/gui/screens/WorldOptionsScreen
|
||||
use super::{
|
||||
Game, Screen,
|
||||
game_rules_screen::{Exit, RulesScreen},
|
||||
integrated::ServerOptions,
|
||||
lan::LanSettings,
|
||||
};
|
||||
use horizon_client::{
|
||||
@@ -15,9 +17,12 @@ use horizon_client::{
|
||||
text::{Text, named_color},
|
||||
ui::{self, EditBox},
|
||||
};
|
||||
use horizon_protocol::packets::play::{self, Serverbound as S};
|
||||
use winit::{event::KeyEvent, keyboard::PhysicalKey};
|
||||
|
||||
pub(super) struct WorldOptions {
|
||||
remote: bool,
|
||||
gamemaster: bool,
|
||||
default_mode: i32,
|
||||
personal_mode: i32,
|
||||
cheats: bool,
|
||||
@@ -50,7 +55,7 @@ enum View {
|
||||
Restrictions,
|
||||
GameRules,
|
||||
}
|
||||
#[derive(Clone, PartialEq)]
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
struct Snapshot {
|
||||
default_mode: i32,
|
||||
personal_mode: i32,
|
||||
@@ -108,6 +113,36 @@ fn labelled(key: &str, value: Text) -> Text {
|
||||
}
|
||||
|
||||
impl WorldOptions {
|
||||
/// Vanilla 26.3 WorldOptionsScreen.generalOptions: guests see difficulty,
|
||||
/// game rules and restrictions; game modes, commands and LAN are host-only.
|
||||
fn visible_buttons(&self, w: f32) -> Vec<(Button, f32, f32, f32)> {
|
||||
if !self.remote {
|
||||
return Self::buttons(w, self.top());
|
||||
}
|
||||
let x = Self::left(w);
|
||||
let top = self.top();
|
||||
vec![
|
||||
(Button::Difficulty, x, top + 29.0, 130.0),
|
||||
(Button::Lock, x + 130.0, top + 29.0, 20.0),
|
||||
(Button::GameRules, x + 158.0, top + 29.0, 150.0),
|
||||
(Button::Restrictions, x, top + 53.0, 150.0),
|
||||
]
|
||||
}
|
||||
fn content_height(&self) -> f32 {
|
||||
if self.remote { 81.0 } else { CONTENT }
|
||||
}
|
||||
fn scroll_limit(&self, h: f32) -> f32 {
|
||||
(self.content_height() - (h - HEADER - FOOTER)).max(0.0)
|
||||
}
|
||||
fn server_difficulty(&mut self, difficulty: u8, locked: bool) {
|
||||
// A server update is the new baseline, not a local edit. In particular,
|
||||
// the reply to Apply may arrive after the screen has already reopened.
|
||||
self.difficulty = difficulty;
|
||||
self.difficulty_locked = locked;
|
||||
self.locked = locked || self.hardcore;
|
||||
self.initial.difficulty = self.difficulty;
|
||||
self.initial.locked = self.locked;
|
||||
}
|
||||
fn parsed_port(&self) -> Option<u16> {
|
||||
if self.port.value.is_empty() {
|
||||
return Some(self.hint);
|
||||
@@ -175,16 +210,13 @@ impl WorldOptions {
|
||||
/// scroll area.
|
||||
fn hit(&self, (x, y): (f32, f32), (w, h): (f32, f32)) -> Option<Button> {
|
||||
let inside = y >= HEADER && y < h - FOOTER;
|
||||
Self::buttons(w, self.top())
|
||||
self.visible_buttons(w)
|
||||
.into_iter()
|
||||
.filter(|_| inside)
|
||||
.chain(Self::footer(w, h))
|
||||
.find(|(b, bx, by, bw)| self.active(*b) && ui::hovered((x, y), *bx, *by, *bw, 20.0))
|
||||
.map(|b| b.0)
|
||||
}
|
||||
fn max_scroll(h: f32) -> f32 {
|
||||
(CONTENT - (h - HEADER - FOOTER)).max(0.0)
|
||||
}
|
||||
fn label(&self, b: Button) -> Text {
|
||||
let mode = |m: i32| {
|
||||
Text::translate(
|
||||
@@ -229,6 +261,17 @@ impl WorldOptions {
|
||||
}
|
||||
/// WorldOptionsScreen.updateButton and the update*Button methods.
|
||||
fn active(&self, b: Button) -> bool {
|
||||
if self.remote {
|
||||
return match b {
|
||||
Button::Difficulty | Button::Lock => {
|
||||
self.gamemaster && !self.hardcore && !self.difficulty_locked
|
||||
}
|
||||
Button::GameRules => self.gamemaster,
|
||||
Button::Restrictions | Button::Cancel => true,
|
||||
Button::Apply => self.gamemaster && self.changed(),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
match b {
|
||||
Button::DefaultMode | Button::PersonalMode => self.cheats && !self.hardcore,
|
||||
// An owner's commands come with Allow Commands.
|
||||
@@ -404,30 +447,64 @@ impl Restrictions {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn remote_world_options_permission(&mut self, permission: bool) {
|
||||
if let Some(o) = self.world_options.as_mut() {
|
||||
let lost = o.gamemaster && !permission;
|
||||
o.gamemaster = permission;
|
||||
if lost && o.remote {
|
||||
self.close_world_options();
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) fn remote_world_options_difficulty(&mut self, difficulty: u8, locked: bool) {
|
||||
if let Some(o) = self.world_options.as_mut() {
|
||||
o.server_difficulty(difficulty, locked);
|
||||
}
|
||||
}
|
||||
pub(super) fn remote_world_options_rules(&mut self, entries: Vec<play::GameRuleEntry>) {
|
||||
let Some(o) = self
|
||||
.world_options
|
||||
.as_mut()
|
||||
.filter(|o| o.view == View::GameRules)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let mut rules = horizon_client::gamerules::GameRules::default();
|
||||
for entry in entries {
|
||||
rules.set_string(&entry.name, &entry.value);
|
||||
}
|
||||
o.rules = Some(RulesScreen::new(&rules));
|
||||
}
|
||||
pub(super) fn open_world_options(&mut self) {
|
||||
let hint = horizon_client::lan::available_port();
|
||||
let lan = self.lan.is_some();
|
||||
let mut port = EditBox::new(0.0, 0.0, 150.0, 20.0);
|
||||
port.max_length = 5;
|
||||
let (guest_commands, force) = self.lan.as_ref().map_or((false, true), |h| {
|
||||
(h.settings.guest_commands, h.settings.force_game_mode)
|
||||
});
|
||||
if let Some(p) = self.lan_port() {
|
||||
// The world is the server thread's when there is one.
|
||||
let state = match &self.integrated {
|
||||
Some(server) => match server.call(|g| g.server_options()) {
|
||||
Some(state) => state,
|
||||
None => return,
|
||||
},
|
||||
None => self.local_server_options(),
|
||||
};
|
||||
if let Some(p) = state.port {
|
||||
port.set_value(&p.to_string());
|
||||
}
|
||||
let mut options = WorldOptions {
|
||||
default_mode: self.level.mode(),
|
||||
personal_mode: self.mode_id(),
|
||||
cheats: self.allow_commands(),
|
||||
difficulty: self.ui.difficulty,
|
||||
lan,
|
||||
remote: self.external_server(),
|
||||
gamemaster: self.ui.registries.permission >= 2,
|
||||
default_mode: state.default_mode,
|
||||
personal_mode: state.personal_mode,
|
||||
cheats: state.cheats,
|
||||
difficulty: state.difficulty,
|
||||
lan: state.port.is_some(),
|
||||
port,
|
||||
hint,
|
||||
guest_commands,
|
||||
force,
|
||||
hardcore: self.level.hardcore,
|
||||
difficulty_locked: self.level.difficulty_locked,
|
||||
locked: self.level.difficulty_locked || self.level.hardcore,
|
||||
guest_commands: state.guest_commands,
|
||||
force: state.force,
|
||||
hardcore: state.hardcore,
|
||||
difficulty_locked: state.difficulty_locked,
|
||||
locked: state.difficulty_locked || state.hardcore,
|
||||
scroll: 0.0,
|
||||
view: View::Options,
|
||||
rules: None,
|
||||
@@ -458,7 +535,10 @@ impl Game<'_> {
|
||||
pub(super) fn world_options_escape(&mut self) {
|
||||
match self.world_options.as_mut() {
|
||||
Some(o) if o.view == View::GameRules => {
|
||||
if let Some(exit) = o.rules.as_mut().and_then(RulesScreen::escape) {
|
||||
if o.rules.is_none() {
|
||||
// A server may be slow to answer REQUEST_GAMERULE_VALUES.
|
||||
o.view = View::Options;
|
||||
} else if let Some(exit) = o.rules.as_mut().and_then(RulesScreen::escape) {
|
||||
self.close_game_rules(exit);
|
||||
}
|
||||
}
|
||||
@@ -478,6 +558,20 @@ impl Game<'_> {
|
||||
let Exit::Apply(changes) = exit else {
|
||||
return;
|
||||
};
|
||||
if self.remote.is_some() {
|
||||
if self.ui.registries.permission >= 2 && !changes.is_empty() {
|
||||
self.send(S::SetGameRule(play::SetGameRule {
|
||||
entries: changes
|
||||
.into_iter()
|
||||
.map(|(name, value)| play::GameRuleEntry {
|
||||
name: format!("minecraft:{name}"),
|
||||
value: value.to_string(),
|
||||
})
|
||||
.collect(),
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if !self.allow_commands() || changes.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -503,6 +597,16 @@ impl Game<'_> {
|
||||
View::GameRules => {
|
||||
if let Some(rules) = o.rules.as_mut() {
|
||||
rules.draw(batch, &self.atlas, self.cursor);
|
||||
} else {
|
||||
ui::menu_background(
|
||||
batch,
|
||||
&self.atlas,
|
||||
0.0,
|
||||
0.0,
|
||||
batch.width,
|
||||
batch.height,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -585,7 +689,7 @@ impl Game<'_> {
|
||||
fn draw_options_view(&self, o: &mut WorldOptions, batch: &mut HudBatch, cursor: (f32, f32)) {
|
||||
let atlas = &self.atlas;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
o.scroll = o.scroll.clamp(0.0, WorldOptions::max_scroll(h));
|
||||
o.scroll = o.scroll.clamp(0.0, o.scroll_limit(h));
|
||||
// extractMenuBackground: the in-world background, the list
|
||||
// background under the content, then the separators over it.
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
|
||||
@@ -607,6 +711,9 @@ impl Game<'_> {
|
||||
("options.worldOptions.general.title", GENERAL_TOP),
|
||||
("options.worldOptions.multiplayer.title", MULTIPLAYER_TOP),
|
||||
] {
|
||||
if o.remote && key == "options.worldOptions.multiplayer.title" {
|
||||
continue;
|
||||
}
|
||||
// A FocusableTextWidget padded by 4, centred over both columns.
|
||||
let t = Text::translate(key, &[]).underlined().bold();
|
||||
let tw = atlas.font.width_text(&t);
|
||||
@@ -614,7 +721,7 @@ impl Game<'_> {
|
||||
batch.text_styled(atlas, &t, tx, top + y + 4.0, u32::MAX, true);
|
||||
}
|
||||
let inside = cursor.1 >= HEADER && cursor.1 < h - FOOTER;
|
||||
for (b, bx, by, bw) in WorldOptions::buttons(w, top) {
|
||||
for (b, bx, by, bw) in o.visible_buttons(w) {
|
||||
let active = o.active(b);
|
||||
let hover = active && inside && ui::hovered(cursor, bx, by, bw, 20.0);
|
||||
if b == Button::Lock {
|
||||
@@ -632,32 +739,34 @@ impl Game<'_> {
|
||||
}
|
||||
// The port box beside the LAN button: editable only with LAN on,
|
||||
// its hint the port to use (setPortError colours the text).
|
||||
o.port.x = x + 158.0;
|
||||
o.port.y = top + PORT_TOP;
|
||||
o.port.focused = o.lan && o.port.focused;
|
||||
o.port.text_color = if !o.lan {
|
||||
rgba(0x70, 0x70, 0x70, 255)
|
||||
} else if o.parsed_port().is_some() {
|
||||
rgba(0xE0, 0xE0, 0xE0, 255)
|
||||
} else {
|
||||
rgba(0xDF, 0x50, 0x50, 255)
|
||||
};
|
||||
o.port.draw(batch, atlas, None);
|
||||
if o.port.value.is_empty() && !o.port.focused {
|
||||
let hint = if o.lan {
|
||||
Text::literal(o.hint.to_string())
|
||||
if !o.remote {
|
||||
o.port.x = x + 158.0;
|
||||
o.port.y = top + PORT_TOP;
|
||||
o.port.focused = o.lan && o.port.focused;
|
||||
o.port.text_color = if !o.lan {
|
||||
rgba(0x70, 0x70, 0x70, 255)
|
||||
} else if o.parsed_port().is_some() {
|
||||
rgba(0xE0, 0xE0, 0xE0, 255)
|
||||
} else {
|
||||
Text::translate("lanServer.port", &[])
|
||||
rgba(0xDF, 0x50, 0x50, 255)
|
||||
};
|
||||
// EditBox.DEFAULT_HINT_STYLE: dark gray.
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&hint,
|
||||
o.port.text_x(),
|
||||
o.port.text_y(),
|
||||
rgba(0x55, 0x55, 0x55, 255),
|
||||
true,
|
||||
);
|
||||
o.port.draw(batch, atlas, None);
|
||||
if o.port.value.is_empty() && !o.port.focused {
|
||||
let hint = if o.lan {
|
||||
Text::literal(o.hint.to_string())
|
||||
} else {
|
||||
Text::translate("lanServer.port", &[])
|
||||
};
|
||||
// EditBox.DEFAULT_HINT_STYLE: dark gray.
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&hint,
|
||||
o.port.text_x(),
|
||||
o.port.text_y(),
|
||||
rgba(0x55, 0x55, 0x55, 255),
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
batch.clip_since(first, 0.0, HEADER, w, h - FOOTER);
|
||||
ui::scrollbar(
|
||||
@@ -666,7 +775,7 @@ impl Game<'_> {
|
||||
(w / 2.0).floor() + 166.0,
|
||||
HEADER,
|
||||
h - FOOTER,
|
||||
CONTENT,
|
||||
o.content_height(),
|
||||
o.scroll.trunc(),
|
||||
);
|
||||
ui::separators(batch, atlas, HEADER, h - FOOTER, true);
|
||||
@@ -682,7 +791,7 @@ impl Game<'_> {
|
||||
let h = self.gui_size().1;
|
||||
match self.world_options.as_mut() {
|
||||
Some(o) if o.view == View::Options => {
|
||||
o.scroll = (o.scroll - delta * 10.0).clamp(0.0, WorldOptions::max_scroll(h));
|
||||
o.scroll = (o.scroll - delta * 10.0).clamp(0.0, o.scroll_limit(h));
|
||||
}
|
||||
Some(o) => {
|
||||
if let Some(rules) = o.rules.as_mut() {
|
||||
@@ -755,7 +864,8 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
let inside = self.cursor.1 >= HEADER && self.cursor.1 < h - FOOTER;
|
||||
o.port.focused = o.lan
|
||||
o.port.focused = !o.remote
|
||||
&& o.lan
|
||||
&& inside
|
||||
&& ui::hovered(
|
||||
self.cursor,
|
||||
@@ -784,7 +894,12 @@ impl Game<'_> {
|
||||
Some(Button::Lock) => o.locked = !o.locked,
|
||||
Some(Button::Restrictions) => o.view = View::Restrictions,
|
||||
Some(Button::GameRules) => {
|
||||
o.rules = Some(RulesScreen::new(&self.ui.rules));
|
||||
if self.remote.is_some() {
|
||||
o.rules = None;
|
||||
self.send(S::ClientCommand(play::ClientCommand { action: 2 }));
|
||||
} else {
|
||||
o.rules = Some(RulesScreen::new(&self.ui.rules));
|
||||
}
|
||||
o.view = View::GameRules;
|
||||
}
|
||||
Some(Button::Lan) => {
|
||||
@@ -808,7 +923,7 @@ impl Game<'_> {
|
||||
|
||||
fn apply_and_close_world_options(&mut self, o: WorldOptions) {
|
||||
let wanted = o.snapshot();
|
||||
let before = o.initial.clone();
|
||||
let before = o.initial;
|
||||
self.world_options = Some(o);
|
||||
self.apply_world_options(&before, &wanted);
|
||||
self.close_world_options();
|
||||
@@ -891,7 +1006,60 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The world options of a world run in the frame.
|
||||
fn local_server_options(&self) -> ServerOptions {
|
||||
let (guest_commands, force) = self.lan.as_ref().map_or((false, true), |h| {
|
||||
(h.settings.guest_commands, h.settings.force_game_mode)
|
||||
});
|
||||
ServerOptions {
|
||||
default_mode: self.level.mode(),
|
||||
personal_mode: self.mode_id(),
|
||||
cheats: self.allow_commands(),
|
||||
difficulty: self.ui.difficulty,
|
||||
hardcore: self.level.hardcore,
|
||||
difficulty_locked: self.level.difficulty_locked,
|
||||
port: self.lan_port(),
|
||||
guest_commands,
|
||||
force,
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_world_options(&mut self, before: &Snapshot, wanted: &Snapshot) {
|
||||
if self.external_server() {
|
||||
if self.ui.registries.permission >= 2 {
|
||||
if wanted.difficulty != before.difficulty {
|
||||
self.send(S::ChangeDifficulty(play::ChangeDifficultyRequest {
|
||||
difficulty: i32::from(wanted.difficulty),
|
||||
}));
|
||||
}
|
||||
if wanted.locked && !before.locked {
|
||||
self.send(S::LockDifficulty(play::LockDifficulty { locked: true }));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(server) = &self.integrated {
|
||||
// The server applies them; the window tells what came of
|
||||
// Open to LAN (ShareToLanScreen's messages).
|
||||
let (b, w) = (*before, *wanted);
|
||||
server.call(move |g| g.apply_world_options(&b, &w));
|
||||
if w.lan != b.lan || (w.lan && w.port != b.port) {
|
||||
match (w.lan, self.lan_port()) {
|
||||
(true, Some(port)) => self.system_message(Text::translate(
|
||||
"menu.multiplayerOptions.publish.started.lan",
|
||||
&[Text::literal(port.to_string())],
|
||||
)),
|
||||
(true, None) => {
|
||||
self.system_message(Text::translate("commands.publish.failed", &[]));
|
||||
}
|
||||
(false, _) => self.system_message(Text::translate(
|
||||
"menu.multiplayerOptions.publish.stopped",
|
||||
&[],
|
||||
)),
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if wanted.difficulty != before.difficulty {
|
||||
let name = DIFFICULTIES[usize::from(wanted.difficulty) & 3];
|
||||
self.as_console(|g| g.run_command(&format!("difficulty {name}")));
|
||||
@@ -904,10 +1072,15 @@ impl Game<'_> {
|
||||
self.set_default_game_mode(wanted.default_mode);
|
||||
}
|
||||
if wanted.personal_mode != before.personal_mode {
|
||||
self.set_mode_id(wanted.personal_mode);
|
||||
if self.serving() {
|
||||
self.set_owner_mode(wanted.personal_mode);
|
||||
} else {
|
||||
self.set_mode_id(wanted.personal_mode);
|
||||
}
|
||||
}
|
||||
if wanted.cheats != before.cheats {
|
||||
self.set_allow_commands(wanted.cheats);
|
||||
self.owner_allow_commands(wanted.cheats);
|
||||
}
|
||||
let settings_changed =
|
||||
wanted.guest_commands != before.guest_commands || wanted.force != before.force;
|
||||
@@ -935,6 +1108,8 @@ mod tests {
|
||||
|
||||
fn options(cheats: bool, hardcore: bool) -> WorldOptions {
|
||||
let mut o = WorldOptions {
|
||||
remote: false,
|
||||
gamemaster: cheats,
|
||||
default_mode: 1,
|
||||
personal_mode: 1,
|
||||
cheats,
|
||||
@@ -988,8 +1163,8 @@ mod tests {
|
||||
assert_eq!(at(Button::Force), (217.0, 223.0, 150.0));
|
||||
assert_eq!(at(Button::Apply), (59.0, 213.0, 150.0));
|
||||
// 218 pixels of content over 174 between the separators.
|
||||
assert_eq!(WorldOptions::max_scroll(240.0), 44.0);
|
||||
assert_eq!(WorldOptions::max_scroll(400.0), 0.0);
|
||||
assert_eq!(options(true, false).scroll_limit(240.0), 44.0);
|
||||
assert_eq!(options(true, false).scroll_limit(400.0), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1033,4 +1208,74 @@ mod tests {
|
||||
let o = options(true, true);
|
||||
assert!(o.locked && !o.active(Button::Lock));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_options_follow_vanilla_guest_layout_and_permissions() {
|
||||
let mut o = options(false, false);
|
||||
o.remote = true;
|
||||
// Vanilla omits host-only rows instead of disabling the whole screen.
|
||||
let rows = o.visible_buttons(427.0);
|
||||
assert_eq!(
|
||||
rows.iter().map(|b| b.0).collect::<Vec<_>>(),
|
||||
vec![
|
||||
Button::Difficulty,
|
||||
Button::Lock,
|
||||
Button::GameRules,
|
||||
Button::Restrictions,
|
||||
]
|
||||
);
|
||||
assert_eq!(rows[0], (Button::Difficulty, 59.0, 62.0, 130.0));
|
||||
assert_eq!(rows[2], (Button::GameRules, 217.0, 62.0, 150.0));
|
||||
assert_eq!(rows[3], (Button::Restrictions, 59.0, 86.0, 150.0));
|
||||
assert_eq!(o.scroll_limit(240.0), 0.0);
|
||||
// A non-operator cannot activate the difficulty or rule controls.
|
||||
assert_eq!(o.hit((100.0, 70.0), (427.0, 240.0)), None);
|
||||
assert_eq!(o.hit((240.0, 70.0), (427.0, 240.0)), None);
|
||||
assert_eq!(
|
||||
o.hit((100.0, 90.0), (427.0, 240.0)),
|
||||
Some(Button::Restrictions)
|
||||
);
|
||||
o.gamemaster = true;
|
||||
assert_eq!(
|
||||
o.hit((100.0, 70.0), (427.0, 240.0)),
|
||||
Some(Button::Difficulty)
|
||||
);
|
||||
assert_eq!(
|
||||
o.hit((240.0, 70.0), (427.0, 240.0)),
|
||||
Some(Button::GameRules)
|
||||
);
|
||||
o.difficulty_locked = true;
|
||||
assert_eq!(o.hit((100.0, 70.0), (427.0, 240.0)), None);
|
||||
// Remote hardcore does not hide an operator's Game Rules screen.
|
||||
o.hardcore = true;
|
||||
assert!(o.active(Button::GameRules));
|
||||
assert!(!o.active(Button::Lock));
|
||||
for b in [
|
||||
Button::DefaultMode,
|
||||
Button::PersonalMode,
|
||||
Button::Cheats,
|
||||
Button::Lan,
|
||||
Button::CommandAccess,
|
||||
Button::Force,
|
||||
] {
|
||||
assert!(!o.active(b));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_delayed_server_reply_does_not_mark_reopened_options_as_edited() {
|
||||
let mut o = options(false, false);
|
||||
o.remote = true;
|
||||
o.gamemaster = true;
|
||||
// Reopened while the previous Apply's reply was still in flight.
|
||||
o.server_difficulty(3, false);
|
||||
assert_eq!(o.difficulty, 3);
|
||||
assert!(!o.active(Button::Apply));
|
||||
o.difficulty = 0;
|
||||
assert!(o.active(Button::Apply));
|
||||
o.server_difficulty(3, true);
|
||||
assert!(!o.active(Button::Apply));
|
||||
assert!(o.locked);
|
||||
assert!(!o.active(Button::Difficulty));
|
||||
}
|
||||
}
|
||||
@@ -372,7 +372,7 @@ impl Game<'_> {
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.conn == conn)
|
||||
.find(|g| g.conn == conn && host.hub.here(g))
|
||||
.map(|g| uuid_words(g.uuid))
|
||||
else {
|
||||
continue;
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
//! The locator bar of the players on a served world (lan/waypoints.rs): what
|
||||
//! each player looks like to the waypoint manager this tick.
|
||||
use super::{Game, lan::LanHost};
|
||||
use horizon_client::{
|
||||
items::ItemStack,
|
||||
lan::{
|
||||
hub::HostState,
|
||||
waypoints::{self, HIDING_HEADS, Icon, PLAYER_RANGE},
|
||||
},
|
||||
};
|
||||
|
||||
/// `waypoint_transmit_range`: hidden by crouching, invisibility or a head worn.
|
||||
fn transmit_range(crouching: bool, invisible: bool, head: Option<&ItemStack>) -> f64 {
|
||||
let hiding_head = head.is_some_and(|s| HIDING_HEADS.contains(&&*s.item));
|
||||
if crouching || invisible || hiding_head {
|
||||
0.0
|
||||
} else {
|
||||
PLAYER_RANGE
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The player's team and its colour, as the locator bar shows it.
|
||||
fn waypoint_team(&self, name: &str) -> Option<(String, Option<i32>)> {
|
||||
let team = self.score.board.players_team(name)?;
|
||||
let color = team.color.as_deref().and_then(waypoints::team_rgb);
|
||||
Some((team.name.clone(), color))
|
||||
}
|
||||
|
||||
pub(super) fn lan_update_waypoints(&mut self, host: &mut LanHost, state: Option<&HostState>) {
|
||||
let mut players = Vec::new();
|
||||
if let Some(h) = state {
|
||||
let icon = self
|
||||
.cmds
|
||||
.waypoint_icons
|
||||
.get(&super::commands::Target::Player)
|
||||
.map(|i| Icon {
|
||||
style: i.style.clone(),
|
||||
color: i.color,
|
||||
})
|
||||
.unwrap_or_default();
|
||||
players.push(waypoints::Player {
|
||||
uuid: h.uuid,
|
||||
conn: None,
|
||||
position: h.position,
|
||||
spectator: self.spectator(),
|
||||
transmit_range: transmit_range(
|
||||
h.sneaking,
|
||||
self.potions.effects.get("invisibility").is_some(),
|
||||
h.equipment[5].as_ref(),
|
||||
),
|
||||
receive_range: PLAYER_RANGE,
|
||||
first_tick: false,
|
||||
view_distance: host.hub.config.view_distance,
|
||||
team: self.waypoint_team(&h.name),
|
||||
icon,
|
||||
});
|
||||
}
|
||||
let mut respawned = Vec::new();
|
||||
for g in host.hub.guests.iter().filter(|g| g.placed) {
|
||||
// PlayerList.respawn makes a new ServerPlayer.
|
||||
if g.dead {
|
||||
host.waypoint_dead.insert(g.uuid);
|
||||
} else if host.waypoint_dead.remove(&g.uuid) {
|
||||
respawned.push(g.uuid);
|
||||
}
|
||||
// Player.updatePlayerPose: the shift key crouches unless flying.
|
||||
let crouching = g.sneaking && !g.flying && !g.fall_flying && !g.sleeping;
|
||||
players.push(waypoints::Player {
|
||||
uuid: g.uuid,
|
||||
conn: Some(g.conn),
|
||||
position: g.position,
|
||||
spectator: g.spectator(),
|
||||
transmit_range: transmit_range(
|
||||
crouching,
|
||||
g.effects.get("invisibility").is_some(),
|
||||
g.armor[0].as_ref(),
|
||||
),
|
||||
receive_range: PLAYER_RANGE,
|
||||
first_tick: g.ticks == 0,
|
||||
view_distance: g.view_distance,
|
||||
team: self.waypoint_team(&g.name),
|
||||
icon: Icon::default(),
|
||||
});
|
||||
}
|
||||
host.waypoint_dead
|
||||
.retain(|uuid| players.iter().any(|p| p.uuid == *uuid));
|
||||
let locator_bar = self.ui.rules.bool("locator_bar");
|
||||
for (conn, packet) in host.waypoints.tick(&players, &respawned, locator_bar) {
|
||||
if let Some(g) = host.hub.guest(conn) {
|
||||
g.send(&packet);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
//! A server's levels, one per dimension (`MinecraftServer.levels`).
|
||||
//!
|
||||
//! The game's tick code reads one level: the one in place, held in `Game`'s
|
||||
//! own fields (the world, its streaming and save, block ticks, entities).
|
||||
//! A server (the dedicated server and single player's server thread) keeps
|
||||
//! the other dimensions' levels here and ticks each one that has players by
|
||||
//! swapping it into place (`enter_level`), the way `as_guest` swaps a player
|
||||
//! in, so the tick code runs unchanged, one level at a time. The in-frame
|
||||
//! game holds one level, which follows its player (`enter_dimension`).
|
||||
//!
|
||||
//! Shared by every level, as vanilla's derived levels share the
|
||||
//! Overworld's level data: the game time, the day time and the weather, the
|
||||
//! game rules, the scoreboard, the maps and the entities waiting to arrive
|
||||
//! through a portal.
|
||||
|
||||
use super::{
|
||||
Dim, ExperienceOrb, FallingBlock, Game, ItemEntity, Mob, boats, commands,
|
||||
creaking::CreakingState, dragon, fireworks, lan::LanHost, lightning, machines, minecarts,
|
||||
ominous_item_spawner, persist, raids::Raiding, sculk::SculkState, tnt, trident,
|
||||
};
|
||||
use horizon_client::{
|
||||
Result, block_entity_anim::BlockEntityAnims, random_tick_sections::RandomTickSections,
|
||||
save::SaveDir, streaming::Streaming, tick_chunk_membership::TickChunkMembership, world::World,
|
||||
};
|
||||
use horizon_world::level::{LevelEvent, Ticks};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
/// Server ticks a level other than the Overworld stays loaded with no player
|
||||
/// in it, before it is saved and closed (vanilla unloads a level's chunks
|
||||
/// once no ticket holds them; the Overworld stays loaded).
|
||||
const IDLE_TICKS: u32 = 300;
|
||||
|
||||
/// A level that is not in place: every per-level field of `Game`.
|
||||
pub(super) struct Parked {
|
||||
world: World,
|
||||
streaming: Streaming,
|
||||
save: Arc<SaveDir>,
|
||||
block_ticks: Ticks,
|
||||
tick_chunks: TickChunkMembership,
|
||||
random_tick_sections: RandomTickSections,
|
||||
level_events: Vec<LevelEvent>,
|
||||
falling: Vec<FallingBlock>,
|
||||
paintings: Vec<horizon_client::entities::Painting>,
|
||||
item_frames: Vec<horizon_client::entities::ItemFrame>,
|
||||
armor_stands: Vec<horizon_client::entities::ArmorStand>,
|
||||
others: Vec<commands::OtherEntity>,
|
||||
boats: Vec<boats::Boat>,
|
||||
carts: Vec<minecarts::Minecart>,
|
||||
jukebox_sounds: HashMap<(i32, i32, i32), u64>,
|
||||
bolts: Vec<lightning::Bolt>,
|
||||
crystals: Vec<dragon::Crystal>,
|
||||
dragon: Option<dragon::Dragon>,
|
||||
dragon_fireballs: Vec<dragon::DragonFireball>,
|
||||
breath_clouds: Vec<dragon::Breath>,
|
||||
tridents: Vec<trident::ThrownTrident>,
|
||||
rockets: Vec<fireworks::Rocket>,
|
||||
ominous_spawners: Vec<ominous_item_spawner::OminousItemSpawner>,
|
||||
tnts: Vec<tnt::PrimedTnt>,
|
||||
hoppers: HashMap<(i32, i32, i32), machines::HopperState>,
|
||||
mobs: Vec<Mob>,
|
||||
projectiles: Vec<horizon_client::projectiles::Projectile>,
|
||||
dropped: Vec<ItemEntity>,
|
||||
orbs: Vec<ExperienceOrb>,
|
||||
persist: persist::EntityMemory,
|
||||
lectern_pulses: Vec<((i32, i32, i32), i32)>,
|
||||
lid_openers: HashMap<(i32, i32, i32), i32>,
|
||||
lan_events: Vec<(i32, [i32; 3], i32, Option<u32>)>,
|
||||
lan_block_events: Vec<([i32; 3], i32, i32, u16)>,
|
||||
lan_entity_events: Vec<(i32, i8, [f64; 3])>,
|
||||
/// Only the per-level parts of these are used (`swap_level`).
|
||||
anims: BlockEntityAnims,
|
||||
raiding: Raiding,
|
||||
sculk: SculkState,
|
||||
creaking: CreakingState,
|
||||
/// Server ticks since a player was last in the level.
|
||||
idle: u32,
|
||||
}
|
||||
|
||||
/// Which passes of the server tick the level in place runs: the first
|
||||
/// reads the network (`Hub::poll`), the last ends the tick for every player
|
||||
/// (`Hub::end_tick`). A tick with one level is both.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct Pass {
|
||||
pub first: bool,
|
||||
pub last: bool,
|
||||
}
|
||||
|
||||
impl Default for Pass {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
first: true,
|
||||
last: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The levels not in place, by dimension.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Levels {
|
||||
parked: HashMap<Dim, Parked>,
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// A new level for `dim`, opened from the save (`MinecraftServer.createLevels`).
|
||||
fn open_level(&self, dim: Dim) -> Result<Parked> {
|
||||
let (streaming, save) =
|
||||
self.make_streaming(&self.world_root.clone(), dim, self.level.seed)?;
|
||||
let mut world = World::new(self.res.facts.air, self.level.seed);
|
||||
world.fast_lava = dim == Dim::Nether;
|
||||
Ok(Parked {
|
||||
world,
|
||||
streaming,
|
||||
save,
|
||||
block_ticks: Ticks::new(self.level.seed as i32),
|
||||
tick_chunks: Default::default(),
|
||||
random_tick_sections: Default::default(),
|
||||
level_events: Vec::new(),
|
||||
falling: Vec::new(),
|
||||
paintings: Vec::new(),
|
||||
item_frames: Vec::new(),
|
||||
armor_stands: Vec::new(),
|
||||
others: Vec::new(),
|
||||
boats: Vec::new(),
|
||||
carts: Vec::new(),
|
||||
jukebox_sounds: HashMap::new(),
|
||||
bolts: Vec::new(),
|
||||
crystals: Vec::new(),
|
||||
dragon: None,
|
||||
dragon_fireballs: Vec::new(),
|
||||
breath_clouds: Vec::new(),
|
||||
tridents: Vec::new(),
|
||||
rockets: Vec::new(),
|
||||
ominous_spawners: Vec::new(),
|
||||
tnts: Vec::new(),
|
||||
hoppers: HashMap::new(),
|
||||
mobs: Vec::new(),
|
||||
projectiles: Vec::new(),
|
||||
dropped: Vec::new(),
|
||||
orbs: Vec::new(),
|
||||
persist: Default::default(),
|
||||
lectern_pulses: Vec::new(),
|
||||
lid_openers: HashMap::new(),
|
||||
lan_events: Vec::new(),
|
||||
lan_block_events: Vec::new(),
|
||||
lan_entity_events: Vec::new(),
|
||||
anims: Default::default(),
|
||||
raiding: Default::default(),
|
||||
sculk: Default::default(),
|
||||
creaking: Default::default(),
|
||||
idle: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Exchange the level in place with `p`.
|
||||
fn swap_level(&mut self, p: &mut Parked) {
|
||||
use std::mem::swap;
|
||||
swap(&mut self.world, &mut p.world);
|
||||
swap(&mut self.streaming, &mut p.streaming);
|
||||
swap(&mut self.save, &mut p.save);
|
||||
swap(&mut self.block_ticks, &mut p.block_ticks);
|
||||
swap(&mut self.tick_chunks, &mut p.tick_chunks);
|
||||
swap(&mut self.random_tick_sections, &mut p.random_tick_sections);
|
||||
swap(&mut self.level_events, &mut p.level_events);
|
||||
swap(&mut self.falling, &mut p.falling);
|
||||
swap(&mut self.paintings, &mut p.paintings);
|
||||
swap(&mut self.item_frames, &mut p.item_frames);
|
||||
swap(&mut self.armor_stands, &mut p.armor_stands);
|
||||
swap(&mut self.others, &mut p.others);
|
||||
swap(&mut self.boats, &mut p.boats);
|
||||
swap(&mut self.carts, &mut p.carts);
|
||||
swap(&mut self.jukebox_sounds, &mut p.jukebox_sounds);
|
||||
swap(&mut self.bolts, &mut p.bolts);
|
||||
swap(&mut self.crystals, &mut p.crystals);
|
||||
swap(&mut self.dragon, &mut p.dragon);
|
||||
swap(&mut self.dragon_fireballs, &mut p.dragon_fireballs);
|
||||
swap(&mut self.breath_clouds, &mut p.breath_clouds);
|
||||
swap(&mut self.tridents, &mut p.tridents);
|
||||
swap(&mut self.rockets, &mut p.rockets);
|
||||
swap(&mut self.ominous_spawners, &mut p.ominous_spawners);
|
||||
swap(&mut self.tnts, &mut p.tnts);
|
||||
swap(&mut self.hoppers, &mut p.hoppers);
|
||||
swap(&mut self.mobs, &mut p.mobs);
|
||||
swap(&mut self.creatures.projectiles, &mut p.projectiles);
|
||||
swap(&mut self.dropped, &mut p.dropped);
|
||||
swap(&mut self.orbs, &mut p.orbs);
|
||||
swap(&mut self.persist, &mut p.persist);
|
||||
swap(&mut self.lectern_pulses, &mut p.lectern_pulses);
|
||||
swap(&mut self.lid_openers, &mut p.lid_openers);
|
||||
swap(&mut self.lan_events, &mut p.lan_events);
|
||||
swap(&mut self.lan_block_events, &mut p.lan_block_events);
|
||||
swap(&mut self.lan_entity_events, &mut p.lan_entity_events);
|
||||
let (a, b) = (&mut self.be_anims, &mut p.anims);
|
||||
swap(&mut a.chests, &mut b.chests);
|
||||
swap(&mut a.books, &mut b.books);
|
||||
swap(&mut a.shulkers, &mut b.shulkers);
|
||||
swap(&mut a.bells, &mut b.bells);
|
||||
swap(&mut a.conduits, &mut b.conduits);
|
||||
swap(&mut a.skulls, &mut b.skulls);
|
||||
swap(&mut a.pots, &mut b.pots);
|
||||
swap(&mut a.gateways, &mut b.gateways);
|
||||
swap(&mut a.gateway_servers, &mut b.gateway_servers);
|
||||
self.raiding.swap_level(&mut p.raiding);
|
||||
self.sculk.swap_level(&mut p.sculk);
|
||||
self.creaking.swap_level(&mut p.creaking);
|
||||
// What only the client draws starts over.
|
||||
self.particles.clear();
|
||||
self.pickups.clear();
|
||||
}
|
||||
|
||||
/// Put `target`'s level in place, opening it first if it is not loaded.
|
||||
/// The level that was in place is kept with the others; the shared
|
||||
/// clock and weather go with the swap. `host` is the hub when the caller
|
||||
/// holds it (`self.lan` is taken during the network tick).
|
||||
pub(super) fn enter_level(&mut self, host: Option<&mut LanHost>, target: Dim) -> Result<()> {
|
||||
// The in-frame game keeps its one level (`enter_dimension`).
|
||||
if self.options.dedicated.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
if target != self.dim {
|
||||
let (mut p, opened) = match self.levels.parked.remove(&target) {
|
||||
Some(p) => (p, false),
|
||||
None => (self.open_level(target)?, true),
|
||||
};
|
||||
p.world.game_time = self.world.game_time;
|
||||
p.world.day_time = self.world.day_time;
|
||||
p.world.weather = self.world.weather;
|
||||
// The sculk tables' watched blocks are built once per game.
|
||||
p.world.watched = std::mem::take(&mut self.world.watched);
|
||||
self.swap_level(&mut p);
|
||||
p.idle = 0;
|
||||
let left = std::mem::replace(&mut self.dim, target);
|
||||
self.levels.parked.insert(left, p);
|
||||
if opened {
|
||||
// The new streaming's radius and meshing options.
|
||||
self.apply_settings();
|
||||
}
|
||||
}
|
||||
let host = match host {
|
||||
Some(h) => Some(h),
|
||||
None => self.lan.as_mut(),
|
||||
};
|
||||
if let Some(h) = host {
|
||||
h.hub.set_dimension(target.id());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One server tick (`MinecraftServer.tickServer`): each level with
|
||||
/// players in turn (`tickChildren`), its chunks streamed and loaded and
|
||||
/// its world ticked. The first pass reads the network and runs the
|
||||
/// tick's shared parts (the clock, the weather, functions, the autosave);
|
||||
/// the next ones only their level. While `paused`, only the connections
|
||||
/// run.
|
||||
pub(super) fn tick_server(&mut self, paused: bool) -> Result<()> {
|
||||
let dims = self.levels_to_tick();
|
||||
let n = dims.len();
|
||||
let mut result = Ok(());
|
||||
for (k, dim) in dims.into_iter().enumerate() {
|
||||
if let Err(e) = self.enter_level(None, dim) {
|
||||
result = Err(e);
|
||||
break;
|
||||
}
|
||||
self.level_pass = Pass {
|
||||
first: k == 0,
|
||||
last: k + 1 == n,
|
||||
};
|
||||
self.update_dedicated_streaming();
|
||||
self.update_world();
|
||||
result = if paused {
|
||||
self.tick_lan();
|
||||
Ok(())
|
||||
} else if k == 0 {
|
||||
self.tick()
|
||||
} else {
|
||||
let running = self.clock.world_runs();
|
||||
if running {
|
||||
self.tick_weather_effects();
|
||||
}
|
||||
self.tick_world(running)
|
||||
};
|
||||
if result.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.level_pass = Pass::default();
|
||||
result?;
|
||||
self.close_idle_levels()
|
||||
}
|
||||
|
||||
/// The levels this server tick runs, in `MinecraftServer.levels` order
|
||||
/// (the Overworld, the Nether, the End): every level with a player in
|
||||
/// it, or the Overworld while nobody is online.
|
||||
pub(super) fn levels_to_tick(&self) -> Vec<Dim> {
|
||||
let guests = self
|
||||
.lan
|
||||
.as_ref()
|
||||
.map_or(&[][..], |h| h.hub.guests.as_slice());
|
||||
let mut dims: Vec<Dim> = [Dim::Overworld, Dim::Nether, Dim::End]
|
||||
.into_iter()
|
||||
.filter(|d| guests.iter().any(|g| g.dimension == d.id()))
|
||||
.collect();
|
||||
if dims.is_empty() {
|
||||
dims.push(Dim::Overworld);
|
||||
}
|
||||
dims
|
||||
}
|
||||
|
||||
/// Count the parked levels' empty ticks, and save and close the ones
|
||||
/// (other than the Overworld) nobody has been in for a while.
|
||||
pub(super) fn close_idle_levels(&mut self) -> Result<()> {
|
||||
let occupied = self.levels_to_tick();
|
||||
let mut closing = Vec::new();
|
||||
for (dim, p) in &mut self.levels.parked {
|
||||
if occupied.contains(dim) {
|
||||
p.idle = 0;
|
||||
continue;
|
||||
}
|
||||
p.idle += 1;
|
||||
if *dim != Dim::Overworld && p.idle >= IDLE_TICKS {
|
||||
closing.push(*dim);
|
||||
}
|
||||
}
|
||||
if closing.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let back = self.dim;
|
||||
for dim in closing {
|
||||
self.enter_level(None, dim)?;
|
||||
self.save_level(true)?;
|
||||
self.enter_level(None, back)?;
|
||||
self.levels.parked.remove(&dim);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Save every parked level (`MinecraftServer.saveAllChunks`); the level
|
||||
/// in place is the caller's to save.
|
||||
pub(super) fn save_parked_levels(&mut self, wait: bool) -> Result<()> {
|
||||
if self.levels.parked.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let back = self.dim;
|
||||
let dims: Vec<Dim> = self.levels.parked.keys().copied().collect();
|
||||
for dim in dims {
|
||||
self.enter_level(None, dim)?;
|
||||
self.save_level(wait)?;
|
||||
}
|
||||
self.enter_level(None, back)
|
||||
}
|
||||
}
|
||||
@@ -92,13 +92,13 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// `ServerLevel.isAllowedToEnterPortal`.
|
||||
fn may_enter(&self, target: Dim) -> bool {
|
||||
pub(super) fn may_enter(&self, target: Dim) -> bool {
|
||||
target != Dim::Nether || self.ui.rules.bool("allow_entering_nether_using_portals")
|
||||
}
|
||||
/// The plan of an entity of `width` by `height` at `at` going through the
|
||||
/// portal at `entry` (`getDimensionTransitionFromExit` and the scaled,
|
||||
/// clamped exit of `getPortalDestination`).
|
||||
fn portal_plan(
|
||||
pub(super) fn portal_plan(
|
||||
&self,
|
||||
entry: BlockPos,
|
||||
at: Vec3,
|
||||
@@ -195,7 +195,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// The exit portal of `plan` in the loaded dimension: the closest one
|
||||
/// within reach, else one built by `PortalForcer.createPortal`.
|
||||
fn nether_exit_portal(&mut self, plan: &Plan) -> Option<FoundRectangle> {
|
||||
pub(super) fn nether_exit_portal(&mut self, plan: &Plan) -> Option<FoundRectangle> {
|
||||
let to_nether = plan.dim == Dim::Nether;
|
||||
let radius = if to_nether { 16 } else { 128 };
|
||||
let border = self.border_bounds();
|
||||
@@ -231,7 +231,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// `NetherPortalBlock.createDimensionTransition`: the arrival position
|
||||
/// and the yaw turned by.
|
||||
fn arrival(&self, plan: &Plan, exit: FoundRectangle) -> (Vec3, f32) {
|
||||
pub(super) fn arrival(&self, plan: &Plan, exit: FoundRectangle) -> (Vec3, f32) {
|
||||
let b = &self.res.behaviours;
|
||||
let corner = exit.min_corner;
|
||||
let exit_x_axis = b
|
||||
@@ -312,8 +312,16 @@ impl Game<'_> {
|
||||
self.portals.travel = Some(plan);
|
||||
self.end_portal_travel = false;
|
||||
self.enter_dimension(plan.dim)?;
|
||||
// The destination is all on disk now: load around the portal its
|
||||
// points of interest lead to, or the approximate exit.
|
||||
let centre = self.arrival_centre(&plan);
|
||||
if let Some((_, target)) = self.travelling.as_mut() {
|
||||
*target = centre;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Where to wait for the destination of `plan` to load, once its level
|
||||
/// is open: the portal its points of interest lead to, or the
|
||||
/// approximate exit.
|
||||
pub(super) fn arrival_centre(&self, plan: &Plan) -> Vec3 {
|
||||
let to_nether = plan.dim == Dim::Nether;
|
||||
let portals = self.portal_blocks(plan.approximate, if to_nether { 16 } else { 128 });
|
||||
let (min_section, max_section) = if to_nether { (0, 15) } else { (-4, 19) };
|
||||
@@ -327,14 +335,11 @@ impl Game<'_> {
|
||||
max_section,
|
||||
);
|
||||
let centre = found.unwrap_or(plan.approximate);
|
||||
if let Some((_, target)) = self.travelling.as_mut() {
|
||||
*target = Vec3::new(
|
||||
f64::from(centre.x) + 0.5,
|
||||
f64::from(centre.y),
|
||||
f64::from(centre.z) + 0.5,
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
Vec3::new(
|
||||
f64::from(centre.x) + 0.5,
|
||||
f64::from(centre.y),
|
||||
f64::from(centre.z) + 0.5,
|
||||
)
|
||||
}
|
||||
/// The player's arrival once the destination has loaded around the
|
||||
/// approximate exit: the waiting entities first, then the exit portal
|
||||
@@ -481,9 +486,10 @@ impl Game<'_> {
|
||||
.filter(|m| self.mob_can_use_portal(m))
|
||||
.map(|m| (m.id, m.position, m.width(), m.height()))
|
||||
.collect();
|
||||
let mut portal_sections = horizon_client::fast_hash::FxHashMap::default();
|
||||
for (id, to, width, height) in items.into_iter().chain(mobs) {
|
||||
let from = self.portals.before.get(&id).copied().unwrap_or(to);
|
||||
if let Some(pos) = self.swept_portal(from, to, width, height) {
|
||||
if let Some(pos) = self.swept_portal(from, to, width, height, &mut portal_sections) {
|
||||
inside.push((id, pos));
|
||||
}
|
||||
}
|
||||
@@ -497,7 +503,14 @@ impl Game<'_> {
|
||||
}
|
||||
/// The first nether portal block an entity box swept through
|
||||
/// (`Entity.checkInsideBlocks`, whose first portal sets the entry).
|
||||
fn swept_portal(&self, from: Vec3, to: Vec3, width: f32, height: f32) -> Option<BlockPos> {
|
||||
fn swept_portal(
|
||||
&self,
|
||||
from: Vec3,
|
||||
to: Vec3,
|
||||
width: f32,
|
||||
height: f32,
|
||||
portal_sections: &mut horizon_client::fast_hash::FxHashMap<(i32, i32, i32), bool>,
|
||||
) -> Option<BlockPos> {
|
||||
let half = f64::from(width) / 2.0;
|
||||
let bounds = horizon_core::geometry::Aabb::new(
|
||||
Vec3::new(to.x - half, to.y, to.z - half),
|
||||
@@ -508,6 +521,13 @@ impl Game<'_> {
|
||||
f64::from(1.0e-5_f32),
|
||||
f64::from(1.0e-5_f32),
|
||||
));
|
||||
// Every block the sweep visits lies in the box covering both ends.
|
||||
let reach = bounds
|
||||
.union_bounds(bounds.move_by(from.subtract(to)))
|
||||
.inflate(Vec3::new(1.0, 1.0, 1.0));
|
||||
if !self.may_hold_portal(reach, portal_sections) {
|
||||
return None;
|
||||
}
|
||||
let mut found = None;
|
||||
let _ = horizon_core::geometry::sweep_intersected_blocks(
|
||||
from,
|
||||
@@ -529,6 +549,54 @@ impl Game<'_> {
|
||||
);
|
||||
found
|
||||
}
|
||||
/// Whether a section `reach` touches has a nether portal state in its
|
||||
/// palette: without one, no block there is a portal. Answers are kept
|
||||
/// per section for the tick; a box over many sections is not checked.
|
||||
fn may_hold_portal(
|
||||
&self,
|
||||
reach: horizon_core::geometry::Aabb,
|
||||
seen: &mut horizon_client::fast_hash::FxHashMap<(i32, i32, i32), bool>,
|
||||
) -> bool {
|
||||
use horizon_client::chunk::{MIN_Y, SECTIONS};
|
||||
let (min, max) = (reach.min_position(), reach.max_position());
|
||||
if !min.is_finite() || !max.is_finite() {
|
||||
return true;
|
||||
}
|
||||
let (min, max) = (BlockPos::containing(min), BlockPos::containing(max));
|
||||
let top = SECTIONS as i32 - 1;
|
||||
let (x0, x1) = (min.x >> 4, max.x >> 4);
|
||||
let (z0, z1) = (min.z >> 4, max.z >> 4);
|
||||
let (y0, y1) = (
|
||||
((min.y - MIN_Y) >> 4).clamp(0, top),
|
||||
((max.y - MIN_Y) >> 4).clamp(0, top),
|
||||
);
|
||||
let count = (i64::from(x1) - i64::from(x0) + 1)
|
||||
* (i64::from(y1) - i64::from(y0) + 1)
|
||||
* (i64::from(z1) - i64::from(z0) + 1);
|
||||
if count > 64 {
|
||||
return true;
|
||||
}
|
||||
let behaviours = &self.res.behaviours;
|
||||
let chunks = &self.world.chunks;
|
||||
(x0..=x1).any(|cx| {
|
||||
(z0..=z1).any(|cz| {
|
||||
(y0..=y1).any(|sy| {
|
||||
*seen.entry((cx, sy, cz)).or_insert_with(|| {
|
||||
chunks
|
||||
.get(&(cx, cz))
|
||||
.and_then(|c| c.sections.get(sy as usize))
|
||||
.is_some_and(|s| {
|
||||
s.blocks.maybe_has(|v| {
|
||||
behaviours
|
||||
.portal_axis(horizon_world::registry::BlockStateId(v))
|
||||
.is_some()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
/// `Entity.teleportCrossDimension` away from the loaded dimension: the
|
||||
/// entity is saved and removed, and waits for its dimension to load.
|
||||
fn send_through_portal(&mut self, id: u32, entry: BlockPos) {
|
||||
|
||||
@@ -186,16 +186,11 @@ impl Game<'_> {
|
||||
}
|
||||
/// Mouse buttons pressed while a key is being bound.
|
||||
pub(super) fn options_waiting_for_key(&self) -> bool {
|
||||
self.ui.options.selected_key.is_some() && self.ui.options.page() == Page::KeyBinds
|
||||
self.ui.options.selected_key.is_some()
|
||||
}
|
||||
pub(super) fn options_bind_mouse(&mut self, button: MouseButton) -> Result<()> {
|
||||
if let Some(name) = self.ui.options.selected_key.take() {
|
||||
self.settings
|
||||
.keys
|
||||
.set(name, &horizon_client::keybinds::mouse_name(button));
|
||||
self.handle_option_events(vec![Event::Changed])?;
|
||||
}
|
||||
Ok(())
|
||||
let events = self.ui.options.bind_mouse(&mut self.settings, button);
|
||||
self.handle_option_events(events)
|
||||
}
|
||||
pub(super) fn handle_option_events(&mut self, events: Vec<Event>) -> Result<()> {
|
||||
for e in events {
|
||||
@@ -206,6 +201,7 @@ impl Game<'_> {
|
||||
self.apply_settings();
|
||||
self.sync_mods();
|
||||
}
|
||||
Event::Close if self.ui.options.resource_error.is_some() => {}
|
||||
Event::Close => {
|
||||
self.settings.save(&Self::options_path());
|
||||
self.screen = if std::mem::take(&mut self.options_to_world_options) {
|
||||
@@ -244,6 +240,7 @@ impl Game<'_> {
|
||||
let s = &self.settings;
|
||||
if let Some(r) = self.renderer.as_mut() {
|
||||
r.gamma = s.gamma;
|
||||
r.fullbright = s.extras.fullbright;
|
||||
r.set_present_mode(s.vsync, s.horizon.present_mode);
|
||||
r.animated_textures = s.horizon.animated_textures;
|
||||
r.set_mipmap_levels(s.mipmap_levels.max(0) as u32);
|
||||
|
||||
@@ -35,7 +35,7 @@ pub fn open_folder(dir: &Path) {
|
||||
}
|
||||
}
|
||||
/// The resource root to load from, given the vanilla root and the selection.
|
||||
pub fn startup_root(vanilla: &Path, selected: &[String]) -> PathBuf {
|
||||
pub fn startup_root(vanilla: &Path, selected: &[String]) -> std::io::Result<PathBuf> {
|
||||
let all = packs::discover(vanilla, &packs_dir());
|
||||
let enabled = packs::enabled(&all, selected);
|
||||
packs::resource_root(vanilla, &enabled, &cache_dir())
|
||||
@@ -104,9 +104,18 @@ impl Game<'_> {
|
||||
if self.ui.vanilla_root.as_os_str().is_empty() {
|
||||
self.ui.vanilla_root = vanilla.clone();
|
||||
}
|
||||
let root = startup_root(&vanilla, &self.settings.resource_packs);
|
||||
if let Err(e) = self.reload_resources(&root) {
|
||||
let result = startup_root(&vanilla, &self.settings.resource_packs)
|
||||
.map_err(Into::into)
|
||||
.and_then(|root| self.reload_resources(&root));
|
||||
if let Err(e) = result {
|
||||
log::warn!("Horizon: resource reload failed: {e}");
|
||||
if self.screen != super::Screen::Options {
|
||||
self.open_options_at(
|
||||
self.title.is_some(),
|
||||
horizon_client::options_ui::Page::Packs,
|
||||
);
|
||||
}
|
||||
self.ui.options.resource_error = Some(e.to_string());
|
||||
}
|
||||
}
|
||||
fn reload_resources(&mut self, root: &Path) -> horizon_client::Result<()> {
|
||||
@@ -121,12 +130,35 @@ impl Game<'_> {
|
||||
self.pack.context.biomes.clone(),
|
||||
self.pack.context.light.clone(),
|
||||
)?;
|
||||
// Keep the working GPU resources, but release its exclusive window surface.
|
||||
if let Some(renderer) = &mut self.renderer {
|
||||
renderer.release_surface();
|
||||
}
|
||||
let renderer = match self
|
||||
.window
|
||||
.clone()
|
||||
.map(|window| self.renderer_for(window, &pack, &atlas))
|
||||
.transpose()
|
||||
{
|
||||
Ok(renderer) => renderer,
|
||||
Err(error) => {
|
||||
if let Some(renderer) = &mut self.renderer {
|
||||
renderer.recreate_surface().map_err(|restore| {
|
||||
format!(
|
||||
"{error}; could not restore the previous rendering surface: {restore}"
|
||||
)
|
||||
})?;
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
// Everything loaded: switch over.
|
||||
self.random_tick_sections.clear();
|
||||
self.options.resources = root.to_path_buf();
|
||||
let context = pack.context.clone();
|
||||
self.pack = pack;
|
||||
self.atlas = atlas;
|
||||
horizon_client::ui_theme::reset_atlas();
|
||||
// Villager hats come from the pack's texture metadata (shared data, swapped in place).
|
||||
*self
|
||||
.res
|
||||
@@ -142,9 +174,8 @@ impl Game<'_> {
|
||||
self.pending_uploads.clear();
|
||||
// New GPU textures: a renderer built from the reloaded resources; the
|
||||
// sections are uploaded again as they are meshed.
|
||||
if let Some(window) = self.window.clone() {
|
||||
self.renderer = None;
|
||||
self.renderer = Some(self.new_renderer(window)?);
|
||||
if let Some(renderer) = renderer {
|
||||
self.renderer = Some(renderer);
|
||||
self.apply_ui_settings();
|
||||
}
|
||||
self.ui.chat.rescale(&self.atlas);
|
||||
|
||||
@@ -365,13 +365,6 @@ impl Game<'_> {
|
||||
self.close_book();
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(remote) = self.remote.as_mut()
|
||||
&& remote.chat_input.is_some()
|
||||
{
|
||||
remote.chat_input = None;
|
||||
self.capture(true);
|
||||
return Ok(());
|
||||
}
|
||||
self.key_pressed(KeyCode::Escape)
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ use winit::{
|
||||
dpi::PhysicalPosition,
|
||||
event::{DeviceId, ElementState, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent},
|
||||
event_loop::ActiveEventLoop,
|
||||
keyboard::{KeyCode, NamedKey},
|
||||
keyboard::KeyCode,
|
||||
};
|
||||
|
||||
/// D-pad auto-repeat: the first step at once, the next after a pause.
|
||||
@@ -859,7 +859,6 @@ impl Game<'_> {
|
||||
|| (self.screen == Screen::Creative && self.creative_screen.tab().search)
|
||||
|| self.recipe_search_focused()
|
||||
|| self.station_typing()
|
||||
|| self.remote_chat_open()
|
||||
}
|
||||
|
||||
/// Open the on-screen keyboard over the focused field: Horizon's, or
|
||||
@@ -890,7 +889,7 @@ impl Game<'_> {
|
||||
&& self.pad.in_use
|
||||
&& menu.open_polls >= 3
|
||||
&& self.pad_focus_mode()
|
||||
&& (self.screen == Screen::Chat || self.sign_edit.is_some() || self.remote_chat_open())
|
||||
&& (self.screen == Screen::Chat || self.sign_edit.is_some())
|
||||
{
|
||||
self.pad.menu.osk_dismissed = true;
|
||||
self.pad_open_keyboard();
|
||||
@@ -901,10 +900,10 @@ impl Game<'_> {
|
||||
/// the field that has focus.
|
||||
fn pad_osk_output(&mut self, out: osk::Output) {
|
||||
use osk::Output;
|
||||
let (key, named, text) = match &out {
|
||||
Output::Text(t) => (None, None, Some(t.as_str())),
|
||||
Output::Backspace => (Some(KeyCode::Backspace), Some(NamedKey::Backspace), None),
|
||||
Output::Enter => (Some(KeyCode::Enter), Some(NamedKey::Enter), None),
|
||||
let (key, text) = match &out {
|
||||
Output::Text(t) => (None, Some(t.as_str())),
|
||||
Output::Backspace => (Some(KeyCode::Backspace), None),
|
||||
Output::Enter => (Some(KeyCode::Enter), None),
|
||||
Output::Close => {
|
||||
self.pad.menu.osk = None;
|
||||
self.pad.menu.osk_dismissed = true;
|
||||
@@ -933,8 +932,6 @@ impl Game<'_> {
|
||||
if key != Some(KeyCode::Enter) {
|
||||
self.station_text(text, backspace);
|
||||
}
|
||||
} else if self.remote_chat_open() {
|
||||
self.remote_chat_input(named, text);
|
||||
} else if self.screen == Screen::WorldOptions {
|
||||
if key != Some(KeyCode::Enter) {
|
||||
self.world_options_text(text);
|
||||
@@ -985,8 +982,7 @@ impl Game<'_> {
|
||||
.menu
|
||||
.focus
|
||||
.is_some_and(|f| f.center().1 > batch.height / 2.0)
|
||||
|| self.screen == Screen::Chat
|
||||
|| self.remote_chat_open();
|
||||
|| self.screen == Screen::Chat;
|
||||
let mut ui = horizon_client::ui_theme::Ui::new(&self.atlas, batch, self.cursor);
|
||||
keyboard.draw(&mut ui, at_top, self.ui.options.pad_style);
|
||||
ui.finish();
|
||||
|
||||
@@ -201,7 +201,7 @@ impl Game<'_> {
|
||||
)
|
||||
};
|
||||
let p = self.player.as_ref()?;
|
||||
let sleeping = self.sleeping.map(|(head, _)| {
|
||||
let sleeping = self.sleeping.map(|head| {
|
||||
let state = self.world.block(head[0], head[1], head[2]);
|
||||
let props = self
|
||||
.pack
|
||||
@@ -313,6 +313,15 @@ impl Game<'_> {
|
||||
.map_or(25, |s| Self::charge_ticks(s).max(1)) as i32;
|
||||
let attack = (self.swing.old_animation, self.swing.animation);
|
||||
let swinging = self.swing.swinging();
|
||||
// currentSwing.hand().asArm(mainArm).
|
||||
let swing_right_arm = swinging.then(|| {
|
||||
(self.swing.attack_hand() == horizon_client::first_person::Hand::Main)
|
||||
!= self.settings.main_hand_left
|
||||
});
|
||||
// LivingEntity.isVisuallySwimming and Entity.isInWater.
|
||||
let visually_swimming =
|
||||
p.pose == Pose::Swimming || (!p.fall_flying && p.pose == Pose::FallFlying);
|
||||
let in_water = p.fluids.was_touching_water;
|
||||
let (yaw, pitch) = (self.yaw, self.pitch);
|
||||
let main = self.inventory[self.selected]
|
||||
.as_ref()
|
||||
@@ -378,6 +387,8 @@ impl Game<'_> {
|
||||
m.extra.charged = charged;
|
||||
m.extra.ints[5] = charge_duration;
|
||||
m.extra.elytra.tick(fall_flying, crouching, velocity);
|
||||
m.extra.swim.tick(visually_swimming, in_water);
|
||||
m.extra.swing_right_arm = swing_right_arm;
|
||||
m.hurt_time = hurt;
|
||||
}
|
||||
/// Whether the player's own model shows in the world view.
|
||||
@@ -427,7 +438,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// `extractEntityInInventoryFollowsMouse`: the player in the box
|
||||
/// `[x0, x1) x [y0, y1)`, `size` pixels per block, turned toward the
|
||||
/// cursor; drawn as depth-sorted, back-face-culled quads.
|
||||
/// cursor; drawn with a depth buffer and back-face culling.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn draw_player_preview(
|
||||
&self,
|
||||
@@ -495,9 +506,9 @@ impl Game<'_> {
|
||||
let quads = player_preview_quads(verts, gui, fire, &atlas.sprites);
|
||||
let start = batch.vertices.len();
|
||||
for (_, p, uv, color) in quads {
|
||||
batch.quad(p, uv, color);
|
||||
batch.quad_3d(p, uv, color);
|
||||
}
|
||||
batch.clip_since(start, x0, y0, x1, y1);
|
||||
batch.depth_since(start, [x0, y0, x1, y1]);
|
||||
}
|
||||
/// The survival inventory's player box (`InventoryScreen`).
|
||||
pub(super) fn draw_inventory_player(&self, batch: &mut HudBatch, ox: f32, oy: f32) {
|
||||
@@ -570,7 +581,7 @@ fn player_preview_quads(
|
||||
gui: glam::Mat4,
|
||||
fire: Option<(f32, f32)>,
|
||||
sprites: &std::collections::HashMap<String, horizon_client::hud::Rect>,
|
||||
) -> Vec<(f32, [[f32; 2]; 4], [[f32; 2]; 4], u32)> {
|
||||
) -> Vec<(f32, [[f32; 3]; 4], [[f32; 2]; 4], u32)> {
|
||||
let flame_start = verts.len();
|
||||
if let Some((width, height)) = fire {
|
||||
horizon_client::fire_render::flames(
|
||||
@@ -584,7 +595,7 @@ fn player_preview_quads(
|
||||
&mut verts,
|
||||
);
|
||||
}
|
||||
let mut quads: Vec<(f32, [[f32; 2]; 4], [[f32; 2]; 4], u32)> = Vec::new();
|
||||
let mut quads: Vec<(f32, [[f32; 3]; 4], [[f32; 2]; 4], u32)> = Vec::new();
|
||||
for (index, q) in verts.as_chunks::<4>().0.iter().enumerate() {
|
||||
let p: [glam::Vec3; 4] =
|
||||
std::array::from_fn(|i| gui.transform_point3(glam::Vec3::from_array(q[i].pos)));
|
||||
@@ -606,12 +617,12 @@ fn player_preview_quads(
|
||||
let depth = p.iter().map(|v| v.z).sum::<f32>() / 4.0;
|
||||
quads.push((
|
||||
depth,
|
||||
p.map(|v| [v.x, v.y]),
|
||||
p.map(|v| v.to_array()),
|
||||
std::array::from_fn(|i| q[i].uv),
|
||||
color,
|
||||
));
|
||||
}
|
||||
// Far (low z) first: the viewer is on the +z side.
|
||||
// Keep far-to-near order for translucent faces; opaque overlap uses depth.
|
||||
quads.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
quads
|
||||
}
|
||||
@@ -619,6 +630,60 @@ fn player_preview_quads(
|
||||
#[cfg(test)]
|
||||
mod preview_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn preview_keeps_crossing_faces_depth_and_clips_without_dropping_faces() {
|
||||
use horizon_client::{
|
||||
hud::{Blend, HudBatch},
|
||||
mesher::Vertex,
|
||||
};
|
||||
let face = |z: [f32; 4]| {
|
||||
[[-2.0, -1.0], [-2.0, 1.0], [2.0, 1.0], [2.0, -1.0]]
|
||||
.into_iter()
|
||||
.zip(z)
|
||||
.map(|([x, y], z)| Vertex {
|
||||
pos: [x, y, z],
|
||||
uv: [0.0; 2],
|
||||
layer: 0,
|
||||
color: u32::MAX,
|
||||
light: 0,
|
||||
normal: [0; 4],
|
||||
})
|
||||
};
|
||||
// A shield and arm can intersect: neither average depth orders both ends.
|
||||
let verts = face([1.0, 1.0, -1.0, -1.0]).chain(face([0.0; 4])).collect();
|
||||
let quads = player_preview_quads(verts, glam::Mat4::IDENTITY, None, &Default::default());
|
||||
assert_eq!(quads.len(), 2);
|
||||
let mut batch = HudBatch::new([320, 240]);
|
||||
for (_, p, uv, color) in quads {
|
||||
batch.quad_3d(p, uv, color);
|
||||
}
|
||||
// Its centre is outside this scissor; the visible part must still be drawn.
|
||||
batch.depth_since(0, [1.0, 0.0, 2.0, 1.0]);
|
||||
assert_eq!(batch.vertices.len(), 12);
|
||||
let shield = &batch.vertices[..6];
|
||||
let arm = &batch.vertices[6..];
|
||||
assert!(shield[0].depth < arm[0].depth);
|
||||
assert!(shield[2].depth > arm[2].depth);
|
||||
assert_eq!(batch.runs(), vec![(0..12, Blend::Depth([1, 0, 1, 1]))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared model data in HORIZON_TEST_RESOURCES"]
|
||||
fn player_render_transform_uses_vanilla_avatar_scale() {
|
||||
let root = std::path::PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES").expect("HORIZON_TEST_RESOURCES"),
|
||||
);
|
||||
let mut m = Mob::new(1, player_type(&root).unwrap(), Vec3::ZERO, 0.0);
|
||||
for (id, expected) in [("minecraft:player", 0.9375), ("minecraft:zombie", 1.0)] {
|
||||
Arc::make_mut(&mut m.kind).id = id.into();
|
||||
let transform =
|
||||
horizon_client::mobs::render::base_transform_at(&m, 1.0, glam::Mat4::IDENTITY);
|
||||
for axis in [glam::Vec3::X, glam::Vec3::Y, glam::Vec3::Z] {
|
||||
assert!((transform.transform_vector3(axis).length() - expected).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn burning_inventory_preview_keeps_visible_fullbright_flames() {
|
||||
let rect = |u0| horizon_client::hud::Rect {
|
||||
|
||||
@@ -49,6 +49,14 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
}
|
||||
/// What a player's body was last given for its effects and enchantments:
|
||||
/// a guest keeps their own while the host's are in `PotionState`.
|
||||
#[derive(Default)]
|
||||
pub(super) struct BodyPotions {
|
||||
applied: Vec<(&'static str, &'static str, f64, &'static str)>,
|
||||
protection_for: Option<Vec<Option<ItemStack>>>,
|
||||
enchant_modifiers: Vec<(String, String, f64, String)>,
|
||||
}
|
||||
/// Effects, thrown potions and the attribute modifiers last applied.
|
||||
#[derive(Default)]
|
||||
pub(super) struct PotionState {
|
||||
@@ -63,6 +71,14 @@ pub(super) struct PotionState {
|
||||
}
|
||||
|
||||
impl PotionState {
|
||||
/// Trade the player's own state (effects and what their body was given)
|
||||
/// with a guest's, around `as_guest`. Potions in flight stay.
|
||||
pub(super) fn swap_player(&mut self, effects: &mut PlayerEffects, body: &mut BodyPotions) {
|
||||
std::mem::swap(&mut self.effects, effects);
|
||||
std::mem::swap(&mut self.applied, &mut body.applied);
|
||||
std::mem::swap(&mut self.protection_for, &mut body.protection_for);
|
||||
std::mem::swap(&mut self.enchant_modifiers, &mut body.enchant_modifiers);
|
||||
}
|
||||
/// Thrown potions in flight as (entity type, position, motion), for the
|
||||
/// dispenser parity fixture.
|
||||
pub(super) fn thrown_entities(&self) -> Vec<(&'static str, Vec3, Vec3)> {
|
||||
@@ -274,8 +290,16 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.tickEffects`, attribute modifiers and the status flags.
|
||||
/// The local player's effects (`tick_mob_effects`), then the potions
|
||||
/// in flight, which a server without a local player still has.
|
||||
pub(super) fn tick_effects(&mut self) {
|
||||
self.tick_mob_effects();
|
||||
self.tick_thrown_potions();
|
||||
}
|
||||
/// `LivingEntity.tickEffects` (server side) for `self.player`, the local
|
||||
/// player or a guest acting (`as_guest`), then its attribute modifiers
|
||||
/// and status flags.
|
||||
pub(super) fn tick_mob_effects(&mut self) {
|
||||
let Some(player) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
@@ -294,6 +318,20 @@ impl Game<'_> {
|
||||
for action in actions {
|
||||
self.apply_effect_action(action);
|
||||
}
|
||||
self.apply_effect_status();
|
||||
}
|
||||
/// A client's own effects (`LivingEntity.tickEffects` on the client,
|
||||
/// `MobEffectInstance.tickClient`): they count down, and the server
|
||||
/// removes them. Their modifiers and flags move the predicted player.
|
||||
pub(super) fn tick_client_effects(&mut self) {
|
||||
for e in &mut self.potions.effects.list {
|
||||
e.tick_client();
|
||||
}
|
||||
self.apply_effect_status();
|
||||
}
|
||||
/// The effects' attribute modifiers, armor protection and status flags
|
||||
/// on `self.player`.
|
||||
fn apply_effect_status(&mut self) {
|
||||
let modifiers = self.potions.effects.modifiers();
|
||||
if modifiers != self.potions.applied
|
||||
&& let Some(p) = self.player.as_mut()
|
||||
@@ -346,7 +384,6 @@ impl Game<'_> {
|
||||
status.protection = std::mem::take(&mut p.status.protection);
|
||||
p.status = status;
|
||||
}
|
||||
self.tick_thrown_potions();
|
||||
}
|
||||
fn tick_thrown_potions(&mut self) {
|
||||
let mut thrown = std::mem::take(&mut self.potions.thrown);
|
||||
@@ -430,6 +467,41 @@ impl Game<'_> {
|
||||
self.splash(&stack, at);
|
||||
}
|
||||
}
|
||||
/// A splash potion's effects on the player in place, by their distance.
|
||||
fn splash_on_player(
|
||||
&mut self,
|
||||
effects: &'static [(&'static str, i32, i32)],
|
||||
at: Vec3,
|
||||
scale: f64,
|
||||
) {
|
||||
if let Some(p) = self.player.as_ref() {
|
||||
let center = Vec3::new(
|
||||
p.position.x,
|
||||
p.position.y + f64::from(p.dimensions.height) / 2.0,
|
||||
p.position.z,
|
||||
);
|
||||
let d2 =
|
||||
(center.x - at.x).powi(2) + (center.y - at.y).powi(2) + (center.z - at.z).powi(2);
|
||||
if d2 < 16.0 && (center.y - at.y).abs() <= 2.0 + f64::from(p.dimensions.height) {
|
||||
let intensity = 1.0 - d2.sqrt() / 4.0;
|
||||
for (id, duration, amp) in effects {
|
||||
let instant = brewing::effect(id).is_some_and(|e| e.instant);
|
||||
if instant {
|
||||
if let Some(a) = horizon_client::effects::instant(id, *amp, intensity) {
|
||||
self.apply_effect_action(a);
|
||||
}
|
||||
} else {
|
||||
let d = (intensity * f64::from(*duration) * scale + 0.5) as i32;
|
||||
if d > 20
|
||||
&& let Some(e) = brewing::effect(id)
|
||||
{
|
||||
self.potions.effects.add(EffectInstance::new(e.id, d, *amp));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ThrownSplashPotion.onHitAsPotion` (lingering clouds apply as a weaker splash).
|
||||
fn splash(&mut self, stack: &ItemStack, at: Vec3) {
|
||||
if &*stack.item == "minecraft:experience_bottle" {
|
||||
@@ -490,34 +562,9 @@ impl Game<'_> {
|
||||
let Some(def) = stack.potion().and_then(brewing::potion) else {
|
||||
return;
|
||||
};
|
||||
// The player.
|
||||
if let Some(p) = self.player.as_ref() {
|
||||
let center = Vec3::new(
|
||||
p.position.x,
|
||||
p.position.y + f64::from(p.dimensions.height) / 2.0,
|
||||
p.position.z,
|
||||
);
|
||||
let d2 =
|
||||
(center.x - at.x).powi(2) + (center.y - at.y).powi(2) + (center.z - at.z).powi(2);
|
||||
if d2 < 16.0 && (center.y - at.y).abs() <= 2.0 + f64::from(p.dimensions.height) {
|
||||
let intensity = 1.0 - d2.sqrt() / 4.0;
|
||||
for (id, duration, amp) in def.effects {
|
||||
let instant = brewing::effect(id).is_some_and(|e| e.instant);
|
||||
if instant {
|
||||
if let Some(a) = horizon_client::effects::instant(id, *amp, intensity) {
|
||||
self.apply_effect_action(a);
|
||||
}
|
||||
} else {
|
||||
let d = (intensity * f64::from(*duration) * scale + 0.5) as i32;
|
||||
if d > 20
|
||||
&& let Some(e) = brewing::effect(id)
|
||||
{
|
||||
self.potions.effects.add(EffectInstance::new(e.id, d, *amp));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every player in range (`ThrownSplashPotion.applySplash` over
|
||||
// `LivingEntity`s, the players among them).
|
||||
self.for_each_player(|g| g.splash_on_player(def.effects, at, scale));
|
||||
// Mobs take the instant effects (undead invert healing and harming).
|
||||
let undead = &self.res.stations.enchantments.entity_tags;
|
||||
let mut hits = Vec::new();
|
||||
@@ -606,13 +653,7 @@ impl Game<'_> {
|
||||
/// Effects saved in level.dat (`Player.active_effects`).
|
||||
pub(super) fn restore_effects(&mut self) {
|
||||
self.potions = PotionState::default();
|
||||
for (id, duration, amplifier, ambient, _) in self.level.active_effects.clone() {
|
||||
if let Some(def) = brewing::effect(&id) {
|
||||
let mut e = EffectInstance::new(def.id, duration, amplifier);
|
||||
e.ambient = ambient;
|
||||
self.potions.effects.add(e);
|
||||
}
|
||||
}
|
||||
self.potions.effects = PlayerEffects::from_saved(&self.level.active_effects);
|
||||
}
|
||||
pub(super) fn effects_json(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
|
||||
@@ -39,6 +39,17 @@ pub(super) struct Raiding {
|
||||
homes: HashMap<i32, (i64, std::sync::Arc<Vec<[i32; 3]>>)>,
|
||||
}
|
||||
|
||||
impl Raiding {
|
||||
/// Exchange the level-held parts (`ServerLevel.getRaids`) with another
|
||||
/// level's (game/levels.rs).
|
||||
pub(super) fn swap_level(&mut self, other: &mut Self) {
|
||||
std::mem::swap(&mut self.raids, &mut other.raids);
|
||||
std::mem::swap(&mut self.loaded, &mut other.loaded);
|
||||
std::mem::swap(&mut self.bar_hurts, &mut other.bar_hurts);
|
||||
std::mem::swap(&mut self.homes, &mut other.homes);
|
||||
}
|
||||
}
|
||||
|
||||
/// `SectionPos` of a block position.
|
||||
pub(super) fn section(p: [i32; 3]) -> (i32, i32, i32) {
|
||||
(p[0] >> 4, p[1] >> 4, p[2] >> 4)
|
||||
@@ -613,17 +624,17 @@ impl Game<'_> {
|
||||
raider::state_mut(m).ticks_outside_raid = ticks;
|
||||
}
|
||||
}
|
||||
Action::Victory { heroes, amplifier } => {
|
||||
let me = self.level.player_uuid;
|
||||
if me.is_some_and(|u| heroes.contains(&u)) && !self.spectator() {
|
||||
// Raid.tick on a victory: each hero who is still alive.
|
||||
Action::Victory { heroes, amplifier } => self.for_each_player(|g| {
|
||||
if heroes.contains(&g.acting_uuid()) && !g.spectator() {
|
||||
let mut effect =
|
||||
EffectInstance::new("hero_of_the_village", raid::HERO_DURATION, amplifier);
|
||||
effect.visible = false;
|
||||
self.potions.effects.add(effect);
|
||||
self.stats.custom("raid_win", 1);
|
||||
self.trigger_player("minecraft:hero_of_the_village");
|
||||
g.potions.effects.add(effect);
|
||||
g.stats.custom("raid_win", 1);
|
||||
g.trigger_player("minecraft:hero_of_the_village");
|
||||
}
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
/// `Raid.playSound`: the horn, 13 blocks away from the player toward the
|
||||
|
||||
@@ -110,7 +110,7 @@ fn sprite(name: &str) -> String {
|
||||
impl Game<'_> {
|
||||
/// The book the open screen has, if any.
|
||||
fn active_book(&self) -> Option<BookType> {
|
||||
if self.screen != Screen::Inventory || self.remote.is_some() || self.station.is_some() {
|
||||
if self.screen != Screen::Inventory || self.station.is_some() {
|
||||
return None;
|
||||
}
|
||||
if self.mount_active() {
|
||||
@@ -262,6 +262,33 @@ impl Game<'_> {
|
||||
/// The items the book counts (`RecipeBookComponent.updateStackedContents`):
|
||||
/// the inventory and the menu's crafting slots, as the menu engine counts them.
|
||||
fn book_available(&mut self, _book: BookType) -> Stacked {
|
||||
if self.remote.is_some() {
|
||||
// A server's menu: the slots it last sent, counted the same way.
|
||||
use horizon_client::menu::{Env, Kind, Menu};
|
||||
let table = self.craft_size == 3;
|
||||
let grid: Vec<_> = (0..self.craft_size * self.craft_size)
|
||||
.map(|k| self.slot_mut(100 + k).clone())
|
||||
.collect();
|
||||
let mut menu = Menu::new(if table {
|
||||
Kind::Crafting
|
||||
} else {
|
||||
Kind::Inventory
|
||||
});
|
||||
let env = Env {
|
||||
items: &self.res.items,
|
||||
recipes: &self.res.recipes,
|
||||
brewing: &self.res.stations.brewing,
|
||||
banners: &self.res.stations.banners,
|
||||
trade: None,
|
||||
};
|
||||
for i in 0..36 {
|
||||
menu.set_player_item(i, self.inventory[i].clone());
|
||||
}
|
||||
for (k, stack) in grid.into_iter().enumerate() {
|
||||
menu.set_slot(&env, 1 + k, stack);
|
||||
}
|
||||
return menu.stacked_contents(&env);
|
||||
}
|
||||
let mut out = Stacked::default();
|
||||
let stock = &mut out;
|
||||
self.menu_session(|menu, env, _| {
|
||||
@@ -274,6 +301,10 @@ impl Game<'_> {
|
||||
/// replacing batch (vanilla's server iterates an identity hash set; this
|
||||
/// one goes in id order), then each unlock and removal.
|
||||
fn sync_client_book(&mut self) {
|
||||
// On a server the book is the server's packets' (remote_recipe_book_*).
|
||||
if self.remote.is_some() {
|
||||
return;
|
||||
}
|
||||
let known = &self.level.recipe_book.recipes;
|
||||
let index = &self.res.stations.recipe_index;
|
||||
let ui = &mut self.recipe_ui;
|
||||
@@ -660,6 +691,10 @@ impl Game<'_> {
|
||||
}
|
||||
false
|
||||
}
|
||||
/// How many displays the client's book holds.
|
||||
pub(super) fn recipe_book_known(&self) -> usize {
|
||||
self.recipe_ui.client.displays().len()
|
||||
}
|
||||
/// The recipe book's buttons a controller can focus: the book button,
|
||||
/// recipes, page arrows, the craftable filter and the tabs (GUI pixels;
|
||||
/// the search box registers itself as a field).
|
||||
@@ -800,6 +835,18 @@ impl Game<'_> {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if self.remote.is_some() {
|
||||
// The server places it and answers with the slots, or a ghost.
|
||||
let container = self.remote_menu_id();
|
||||
self.send(horizon_protocol::packets::play::Serverbound::PlaceRecipe(
|
||||
horizon_protocol::packets::recipes::PlaceRecipe {
|
||||
container,
|
||||
recipe: entry.display_id,
|
||||
use_max,
|
||||
},
|
||||
));
|
||||
return;
|
||||
}
|
||||
let creative = self.creative;
|
||||
let mut post = None;
|
||||
let answer = &mut post;
|
||||
@@ -828,6 +875,80 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- a server's recipe book ----
|
||||
|
||||
/// ClientboundRecipeBookAddPacket (`ClientRecipeBook.add`): the
|
||||
/// displays join the book, highlighted when new, and the recipes whose
|
||||
/// entries ask for it show the toast.
|
||||
pub(super) fn remote_recipe_book_add(
|
||||
&mut self,
|
||||
p: horizon_protocol::packets::recipes::RecipeBookAdd,
|
||||
) {
|
||||
let ui = &mut self.recipe_ui;
|
||||
if p.replace {
|
||||
ui.client.clear();
|
||||
}
|
||||
let mut toast = Vec::new();
|
||||
for e in &p.entries {
|
||||
ui.client.add(e.id);
|
||||
if e.flags & 2 != 0 {
|
||||
ui.client.highlight.insert(e.id);
|
||||
}
|
||||
if e.flags & 1 != 0
|
||||
&& let Some(id) = self.res.stations.recipe_index.recipe_of_display(e.id)
|
||||
&& !toast.iter().any(|t| t == id)
|
||||
{
|
||||
toast.push(id.to_owned());
|
||||
}
|
||||
}
|
||||
// A local world opened later syncs its book from scratch.
|
||||
ui.client_known = None;
|
||||
ui.client_version += 1;
|
||||
self.recipe_toast(&toast);
|
||||
}
|
||||
/// ClientboundRecipeBookRemovePacket.
|
||||
pub(super) fn remote_recipe_book_remove(
|
||||
&mut self,
|
||||
p: horizon_protocol::packets::recipes::RecipeBookRemove,
|
||||
) {
|
||||
for id in p.ids {
|
||||
self.recipe_ui.client.remove(id);
|
||||
}
|
||||
self.recipe_ui.client_version += 1;
|
||||
}
|
||||
/// ClientboundRecipeBookSettingsPacket: each book's open and filter flags.
|
||||
pub(super) fn remote_recipe_book_settings(
|
||||
&mut self,
|
||||
p: horizon_protocol::packets::recipes::RecipeBookSettings,
|
||||
) {
|
||||
for (i, (open, filtering)) in p.books.into_iter().enumerate() {
|
||||
self.level.recipe_book.open[i] = open;
|
||||
self.level.recipe_book.filtering[i] = filtering;
|
||||
}
|
||||
}
|
||||
/// ClientboundPlaceGhostRecipePacket: the recipe last placed, faded into
|
||||
/// the open menu's grid.
|
||||
pub(super) fn remote_ghost_recipe(
|
||||
&mut self,
|
||||
p: horizon_protocol::packets::recipes::PlaceGhostRecipe,
|
||||
) {
|
||||
let open = self.remote_menu_id();
|
||||
let Some(book_type) = self.active_book() else {
|
||||
return;
|
||||
};
|
||||
if p.container != open {
|
||||
return;
|
||||
}
|
||||
let entry = self.recipe_ui.last_placed.as_ref().and_then(|id| {
|
||||
let book = self.recipe_ui.book.as_ref()?;
|
||||
book.entry(id).cloned()
|
||||
});
|
||||
if let Some(entry) = entry {
|
||||
self.recipe_ui.ghost.clear();
|
||||
self.fill_ghost(book_type, &entry);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ghost recipe ----
|
||||
|
||||
/// `fillGhostRecipe`: the recipe's ingredients faded into the slots.
|
||||
|
||||
@@ -68,6 +68,7 @@ struct Lerp {
|
||||
|
||||
pub(super) struct Remote {
|
||||
pub session: Session,
|
||||
pub brand: String,
|
||||
builtin: Arc<Builtin>,
|
||||
lang: Arc<Language>,
|
||||
registries: Arc<Dynamic>,
|
||||
@@ -88,10 +89,11 @@ pub(super) struct Remote {
|
||||
was_sprinting: bool,
|
||||
carried: Option<usize>,
|
||||
loaded_sent: bool,
|
||||
/// Single player's integrated server, joined through memory.
|
||||
pub local: bool,
|
||||
/// The packets per second F3 shows.
|
||||
pub packet_rates: horizon_client::debug_chart::PacketRates,
|
||||
/// BlockStatePredictionHandler: blocks changed ahead of the server (a predicted break),
|
||||
/// with the sequence of the last action that changed them and the state the server
|
||||
/// last said they have. Until the server acknowledges that sequence its block updates
|
||||
/// only replace the remembered state; then the block takes it.
|
||||
predictions: HashMap<[i32; 3], (i32, i32)>,
|
||||
/// Open container: (window id, menu name).
|
||||
menu: Option<(i32, String)>,
|
||||
/// The open menu's title as the server named it.
|
||||
@@ -105,7 +107,10 @@ pub(super) struct Remote {
|
||||
health: (f32, i32, f32),
|
||||
game_mode: i32,
|
||||
pub chat: Vec<(String, Instant)>,
|
||||
pub chat_input: Option<String>,
|
||||
/// Lines added since the start, for the control socket's events.
|
||||
pub chat_received: u64,
|
||||
/// The server's command tree (ClientboundCommandsPacket).
|
||||
pub commands: Option<horizon_client::command::Dispatcher>,
|
||||
action_bar: Option<(String, Instant)>,
|
||||
players: HashMap<u128, String>,
|
||||
/// `ClientPacketListener.playerInfoMap` as player-list entries, and
|
||||
@@ -130,6 +135,8 @@ pub(super) struct Remote {
|
||||
decode_errors: u32,
|
||||
clock: Option<i32>,
|
||||
clock_rate: f32,
|
||||
/// `ClientChunkCache.Storage`'s view centre (ClientboundSetChunkCacheCenterPacket).
|
||||
chunk_center: (i32, i32),
|
||||
}
|
||||
|
||||
fn face_direction(face: [i32; 3]) -> u8 {
|
||||
@@ -202,7 +209,6 @@ impl Game<'_> {
|
||||
// A signed-in account always plays under its own name.
|
||||
let auth = horizon_client::auth::current();
|
||||
let name = auth.as_ref().map_or(name, |a| a.name.as_str());
|
||||
let is_local = local.is_some();
|
||||
let session = match local {
|
||||
Some(pipe) => Session::connect_local(pipe, name, view, lang.clone()),
|
||||
None => {
|
||||
@@ -247,6 +253,7 @@ impl Game<'_> {
|
||||
progress: 0.0,
|
||||
total: 0,
|
||||
};
|
||||
self.ui.registries.permission = 0;
|
||||
self.screen = Screen::Playing;
|
||||
self.title = None;
|
||||
self.dim = Dim::Overworld;
|
||||
@@ -256,6 +263,7 @@ impl Game<'_> {
|
||||
self.apply_settings();
|
||||
self.remote = Some(Remote {
|
||||
session,
|
||||
brand: "unknown".into(),
|
||||
builtin: Arc::new(builtin),
|
||||
lang,
|
||||
registries: Arc::new(Dynamic::default()),
|
||||
@@ -276,8 +284,7 @@ impl Game<'_> {
|
||||
was_sprinting: false,
|
||||
carried: None,
|
||||
loaded_sent: false,
|
||||
local: is_local,
|
||||
packet_rates: Default::default(),
|
||||
predictions: HashMap::new(),
|
||||
menu: None,
|
||||
menu_title: None,
|
||||
state_id: 0,
|
||||
@@ -288,7 +295,8 @@ impl Game<'_> {
|
||||
health: (20.0, 20, 5.0),
|
||||
game_mode: 0,
|
||||
chat: Vec::new(),
|
||||
chat_input: None,
|
||||
chat_received: 0,
|
||||
commands: None,
|
||||
action_bar: None,
|
||||
players: HashMap::new(),
|
||||
tab_players: HashMap::new(),
|
||||
@@ -306,7 +314,13 @@ impl Game<'_> {
|
||||
decode_errors: 0,
|
||||
clock: None,
|
||||
clock_rate: 1.0,
|
||||
chunk_center: (0, 0),
|
||||
});
|
||||
// The level's TickRateManager follows the server's ticking packets.
|
||||
self.clock = horizon_client::clock::FixedStep::client();
|
||||
// The player's advancements and statistics are the server's.
|
||||
self.adv = Default::default();
|
||||
self.stats = Default::default();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -329,6 +343,9 @@ impl Game<'_> {
|
||||
None => false,
|
||||
};
|
||||
horizon_client::crash::clear_detail("Server");
|
||||
self.clock = Default::default();
|
||||
self.adv = Default::default();
|
||||
self.stats = Default::default();
|
||||
self.debug_charts.reset();
|
||||
self.tab_list.reset();
|
||||
self.score.board = Default::default();
|
||||
@@ -387,6 +404,31 @@ impl Game<'_> {
|
||||
.unwrap_or("minecraft:air"),
|
||||
)
|
||||
}
|
||||
/// A mob effect's network id as the effect (`brewing::EFFECTS` id).
|
||||
fn remote_effect_id(&self, effect: i32) -> Option<&'static str> {
|
||||
let r = self.remote.as_ref()?;
|
||||
let name = r.builtin.get("minecraft:mob_effect")?.name(effect)?;
|
||||
horizon_client::brewing::effect(name).map(|d| d.id)
|
||||
}
|
||||
/// ClientPacketListener.handleUpdateMobEffect for the player itself
|
||||
/// (`LivingEntity.forceAddEffect`): the effect replaces any of its kind.
|
||||
/// Other entities' effects are not shown.
|
||||
fn remote_effect(&mut self, p: &play::UpdateMobEffect) {
|
||||
if !self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
|
||||
return;
|
||||
}
|
||||
let Some(id) = self.remote_effect_id(p.effect) else {
|
||||
return;
|
||||
};
|
||||
let mut e = horizon_client::effects::EffectInstance::new(id, p.duration, p.amplifier);
|
||||
e.ambient = p.flags & 1 != 0;
|
||||
e.visible = p.flags & 2 != 0;
|
||||
let list = &mut self.potions.effects.list;
|
||||
match list.iter_mut().find(|x| x.id == id) {
|
||||
Some(existing) => *existing = e,
|
||||
None => list.push(e),
|
||||
}
|
||||
}
|
||||
fn item_id(&self, name: &str) -> i32 {
|
||||
let r = self.remote.as_ref().expect("remote");
|
||||
r.builtin
|
||||
@@ -422,16 +464,53 @@ impl Game<'_> {
|
||||
}
|
||||
Some(stack)
|
||||
}
|
||||
fn chat_line(&mut self, text: String) {
|
||||
pub(super) fn chat_line(&mut self, text: String) {
|
||||
log::info!("[chat] {text}");
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.chat.push((text, Instant::now()));
|
||||
r.chat_received += 1;
|
||||
if r.chat.len() > 100 {
|
||||
r.chat.remove(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The minecarts the server sent, for the control socket's `status`
|
||||
/// (their velocity is not tracked here).
|
||||
pub(super) fn remote_carts(&self) -> Option<Vec<serde_json::Value>> {
|
||||
let r = self.remote.as_ref()?;
|
||||
let mut carts: Vec<&Tracked> = r
|
||||
.entities
|
||||
.values()
|
||||
.filter(|t| t.kind.ends_with("minecart"))
|
||||
.collect();
|
||||
carts.sort_by(|a, b| {
|
||||
a.position
|
||||
.partial_cmp(&b.position)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
Some(
|
||||
carts
|
||||
.into_iter()
|
||||
.map(|t| serde_json::json!({"kind": t.kind, "position": t.position, "velocity": [0.0, 0.0, 0.0]}))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The names in the server's player list, sorted.
|
||||
pub(super) fn remote_online_names(&self) -> Option<Vec<String>> {
|
||||
let r = self.remote.as_ref()?;
|
||||
let mut names: Vec<String> = r.players.values().cloned().collect();
|
||||
names.sort();
|
||||
Some(names)
|
||||
}
|
||||
/// The open menu's window id (0: the inventory).
|
||||
pub(super) fn remote_menu_id(&self) -> i32 {
|
||||
self.remote
|
||||
.as_ref()
|
||||
.and_then(|r| r.menu.as_ref())
|
||||
.map_or(0, |m| m.0)
|
||||
}
|
||||
/// The server's last `n` chat lines, newest first.
|
||||
pub(super) fn remote_chat(&self, n: usize) -> Option<Vec<String>> {
|
||||
let r = self.remote.as_ref()?;
|
||||
@@ -463,6 +542,11 @@ impl Game<'_> {
|
||||
r.status = format!("Joined as {name}; loading terrain...");
|
||||
}
|
||||
}
|
||||
Event::Brand(brand) => {
|
||||
if let Some(remote) = &mut self.remote {
|
||||
remote.brand = brand;
|
||||
}
|
||||
}
|
||||
Event::Reconfigured(registries) => self.set_registries(registries),
|
||||
Event::DecodeError(e) => {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
@@ -516,12 +600,9 @@ impl Game<'_> {
|
||||
r.dimension = dimension;
|
||||
r.game_mode = spawn.game_type;
|
||||
r.loaded_sent = false;
|
||||
r.predictions.clear();
|
||||
self.set_mode_id(spawn.game_type);
|
||||
self.dim = if spawn.dimension == "minecraft:the_nether" {
|
||||
Dim::Nether
|
||||
} else {
|
||||
Dim::Overworld
|
||||
};
|
||||
self.dim = Dim::from_id(&spawn.dimension);
|
||||
if !same {
|
||||
let mut streaming = Streaming::new(
|
||||
Arc::new(NoTerrain),
|
||||
@@ -548,6 +629,8 @@ impl Game<'_> {
|
||||
r.entities.clear();
|
||||
r.lerps.clear();
|
||||
r.pending_items.clear();
|
||||
// A new ClientLevel: a new chunk storage, centred on 0, 0.
|
||||
r.chunk_center = (0, 0);
|
||||
}
|
||||
if let Some(renderer) = self.renderer.as_mut() {
|
||||
renderer.clear_sections();
|
||||
@@ -624,6 +707,128 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(chunk)
|
||||
}
|
||||
/// `ClientChunkCache.Storage.inRange`: within the storage around the
|
||||
/// view centre, `calculateStorageRange` (the server's radius, at least
|
||||
/// two, plus three) chunks each way.
|
||||
fn remote_chunk_in_range(&self, x: i32, z: i32) -> bool {
|
||||
self.remote.as_ref().is_none_or(|r| {
|
||||
let range = r.view_distance.max(2) + 3;
|
||||
(x - r.chunk_center.0).abs() <= range && (z - r.chunk_center.1).abs() <= range
|
||||
})
|
||||
}
|
||||
/// The columns a moved or resized storage no longer holds.
|
||||
fn drop_remote_chunks_out_of_range(&mut self) {
|
||||
let gone: Vec<(i32, i32)> = self
|
||||
.world
|
||||
.chunks
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|&(x, z)| !self.remote_chunk_in_range(x, z))
|
||||
.collect();
|
||||
for (x, z) in gone {
|
||||
self.streaming.forget(&mut self.world, x, z);
|
||||
self.world
|
||||
.block_entities
|
||||
.retain(|k, _| (k.0 >> 4, k.2 >> 4) != (x, z));
|
||||
if let Some(renderer) = self.renderer.as_mut() {
|
||||
for sy in 0..SECTIONS as i32 {
|
||||
renderer.remove((x, sy, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `handleBlockEntityData`: the block entity at `pos`, when it is of the
|
||||
/// packet's type, loads the tag (`loadWithComponents`); an empty tag
|
||||
/// changes nothing.
|
||||
fn remote_block_entity_data(&mut self, pos: [i32; 3], kind: i32, tag: &Tag) {
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
let Some(name) = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.and_then(|r| r.builtin.get("minecraft:block_entity_type")?.name(kind))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(entity) = self.world.block_entities.get(&key) else {
|
||||
return;
|
||||
};
|
||||
let Tag::Compound(fields) = tag else {
|
||||
return;
|
||||
};
|
||||
if entity.id() != name || fields.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut full = fields.clone();
|
||||
full.insert("id".into(), Tag::String(name.into()));
|
||||
for (k, v) in ["x", "y", "z"].into_iter().zip(pos) {
|
||||
full.insert(k.into(), Tag::Int(v));
|
||||
}
|
||||
if let Some((_, loaded)) = horizon_client::save::decode_block_entity(&Tag::Compound(full)) {
|
||||
self.world.block_entities.insert(key, loaded);
|
||||
// The section is drawn again with what the block entity shows.
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let _ = self.world.set(pos[0], pos[1], pos[2], state);
|
||||
}
|
||||
}
|
||||
/// `handleAnimate`: a player wakes up, or an entity gets the critical
|
||||
/// hit (`CRIT`) or magic critical hit (`ENCHANTED_HIT`) emitter.
|
||||
fn remote_animate(&mut self, id: i32, action: u8) {
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
let me = self.remote.as_ref().is_some_and(|r| r.entity_id == id);
|
||||
let body = if me {
|
||||
self.player.as_ref().map(|p| {
|
||||
let d = p.dimensions;
|
||||
(
|
||||
[p.position.x, p.position.y, p.position.z],
|
||||
f64::from(d.width),
|
||||
f64::from(d.height),
|
||||
)
|
||||
})
|
||||
} else {
|
||||
self.mobs.iter().find(|m| m.id == id as u32).map(|m| {
|
||||
(
|
||||
[m.position.x, m.position.y, m.position.z],
|
||||
m.kind.width,
|
||||
m.kind.height,
|
||||
)
|
||||
})
|
||||
};
|
||||
let Some((feet, width, height)) = body else {
|
||||
return;
|
||||
};
|
||||
let particle = match action {
|
||||
// Player.stopSleepInBed.
|
||||
0 => {
|
||||
if me {
|
||||
self.sleeping = None;
|
||||
}
|
||||
return;
|
||||
}
|
||||
1 => ParticleType::Crit,
|
||||
2 => ParticleType::EnchantedHit,
|
||||
_ => return,
|
||||
};
|
||||
self.particles
|
||||
.tracking_emitter(ParticleOptions::simple(particle), feet, width, height);
|
||||
}
|
||||
/// `handleServerData`: the server list entry of the server joined keeps
|
||||
/// the icon the server sends (`ServerList.saveSingleServer`). Single
|
||||
/// player has no entry.
|
||||
fn remote_server_data(&mut self, icon: Option<&[u8]>) {
|
||||
let (Some(r), Some(png)) = (self.remote.as_ref(), icon) else {
|
||||
return;
|
||||
};
|
||||
if self.integrated.is_some() {
|
||||
return;
|
||||
}
|
||||
if let Ok(dir) = std::env::current_dir() {
|
||||
horizon_client::multiplayer::save_server_icon(
|
||||
&dir.join("saves"),
|
||||
&r.session.address,
|
||||
png,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// Keep the renderable block entities (chests, furnaces) in step with blocks.
|
||||
fn sync_block_entity(&mut self, pos: (i32, i32, i32), state: BlockStateId) {
|
||||
let name = self.res.facts.get(state).name.clone();
|
||||
@@ -643,6 +848,42 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// ClientLevel.setServerVerifiedBlockState: a block the client predicted keeps its
|
||||
/// predicted state until the server acknowledges the action; the server's state is
|
||||
/// remembered for then.
|
||||
fn server_block(&mut self, pos: [i32; 3], state: i32) {
|
||||
if let Some((_, known)) = self
|
||||
.remote
|
||||
.as_mut()
|
||||
.and_then(|r| r.predictions.get_mut(&pos))
|
||||
{
|
||||
*known = state;
|
||||
return;
|
||||
}
|
||||
self.set_block(pos, state);
|
||||
}
|
||||
/// BlockStatePredictionHandler.endPredictionsUpTo (ClientboundBlockChangedAckPacket): the
|
||||
/// actions up to `sequence` are settled, so the blocks they predicted take the state the
|
||||
/// server gave them (a break the server refused comes back).
|
||||
fn end_predictions(&mut self, sequence: i32) {
|
||||
let Some(r) = self.remote.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let settled: Vec<_> = r
|
||||
.predictions
|
||||
.iter()
|
||||
.filter(|(_, (s, _))| *s <= sequence)
|
||||
.map(|(&pos, &(_, state))| (pos, state))
|
||||
.collect();
|
||||
for (pos, state) in settled {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.predictions.remove(&pos);
|
||||
}
|
||||
if self.world.block(pos[0], pos[1], pos[2]).0 as i32 != state {
|
||||
self.set_block(pos, state);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn set_block(&mut self, pos: [i32; 3], state: i32) {
|
||||
let state = BlockStateId(state.max(0) as u32);
|
||||
if self.world.loaded(pos[0], pos[2])
|
||||
@@ -753,6 +994,8 @@ impl Game<'_> {
|
||||
5..=8 => Some(500 + s - 5),
|
||||
9..=35 => Some(s),
|
||||
36..=44 => Some(s - 36),
|
||||
// InventoryMenu.SHIELD_SLOT: the off hand.
|
||||
45 => Some(504),
|
||||
_ => None,
|
||||
},
|
||||
"minecraft:crafting" => match s {
|
||||
@@ -812,6 +1055,7 @@ impl Game<'_> {
|
||||
self.debug_charts.ping.log(delay);
|
||||
}
|
||||
C::Login(p) => {
|
||||
self.level.hardcore = p.hardcore;
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.online_mode = p.online_mode;
|
||||
r.entity_id = p.player_id;
|
||||
@@ -827,6 +1071,8 @@ impl Game<'_> {
|
||||
));
|
||||
}
|
||||
C::Respawn(p) => {
|
||||
// A new player, without effects: the server sends them again.
|
||||
self.potions.effects.clear();
|
||||
self.enter_remote_dimension(&p.spawn, true);
|
||||
self.screen = Screen::Playing;
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
@@ -849,6 +1095,15 @@ impl Game<'_> {
|
||||
})
|
||||
.clamp(-90.0, 90.0);
|
||||
}
|
||||
// ClientChunkCache.replaceWithPacketData: a column outside the
|
||||
// storage around the view centre is ignored.
|
||||
C::LevelChunkWithLight(p) if !self.remote_chunk_in_range(p.x, p.z) => {
|
||||
log::warn!(
|
||||
"Ignoring chunk since it's not in the view range: {}, {}",
|
||||
p.x,
|
||||
p.z
|
||||
);
|
||||
}
|
||||
C::LevelChunkWithLight(p) => match self.remote_chunk(&p) {
|
||||
Ok(chunk) => {
|
||||
let (x, z) = (p.x, p.z);
|
||||
@@ -864,6 +1119,8 @@ impl Game<'_> {
|
||||
);
|
||||
let state = self.world.block(pos.0, pos.1, pos.2);
|
||||
self.sync_block_entity(pos, state);
|
||||
// LevelChunk.replaceWithPacketData: the block entity loads its update tag.
|
||||
self.remote_block_entity_data([pos.0, pos.1, pos.2], info.kind, &info.tag);
|
||||
}
|
||||
}
|
||||
Err(e) => log::warn!("Horizon: chunk {} {}: {e}", p.x, p.z),
|
||||
@@ -886,7 +1143,7 @@ impl Game<'_> {
|
||||
chunks_per_tick: 64.0,
|
||||
}));
|
||||
}
|
||||
C::BlockUpdate(p) => self.set_block(p.pos, p.state),
|
||||
C::BlockUpdate(p) => self.server_block(p.pos, p.state),
|
||||
C::SectionBlocksUpdate(p) => {
|
||||
let [sx, sy, sz] = p.section;
|
||||
for change in p.changes {
|
||||
@@ -896,10 +1153,10 @@ impl Game<'_> {
|
||||
sy * 16 + (local & 15),
|
||||
sz * 16 + (local >> 4 & 15),
|
||||
];
|
||||
self.set_block(pos, (change >> 12) as i32);
|
||||
self.server_block(pos, (change >> 12) as i32);
|
||||
}
|
||||
}
|
||||
C::BlockChangedAck(_) => {}
|
||||
C::BlockChangedAck(p) => self.end_predictions(p.sequence),
|
||||
// ClientLevel.blockEvent: BlockEntity.triggerEvent (chest lids,
|
||||
// shulker boxes, bells) when the block is still the one it names.
|
||||
C::BlockEvent(p) => {
|
||||
@@ -1055,6 +1312,20 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Entity.DATA_SHARED_FLAGS_ID's crouching bit: another
|
||||
// player's crouch (their model and Entity.isDiscrete's name tag).
|
||||
if self
|
||||
.remote
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.level_players.contains_key(&p.id))
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
|
||||
{
|
||||
for (index, _, value) in &p.values {
|
||||
if let (0, Value::Byte(flags)) = (index, value) {
|
||||
m.extra.crouching = flags & 2 != 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (index, _, value) in &p.values {
|
||||
if *index == ITEM_ENTITY_ITEM
|
||||
&& let Value::Item(stack) = value
|
||||
@@ -1127,6 +1398,13 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
C::EntityEvent(p) => {
|
||||
// LocalPlayer.setPermissions also refreshes an open World Options screen.
|
||||
if (24..=28).contains(&p.event)
|
||||
&& self.remote.as_ref().is_some_and(|r| r.entity_id == p.id)
|
||||
{
|
||||
self.ui.registries.permission = (p.event - 24) as u8;
|
||||
self.remote_world_options_permission(p.event >= 26);
|
||||
}
|
||||
if p.event == 1
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
|
||||
&& m.species() == horizon_client::mobs::Species::Rabbit
|
||||
@@ -1140,7 +1418,21 @@ impl Game<'_> {
|
||||
m.health = 0.0;
|
||||
}
|
||||
}
|
||||
C::Animate(p) if p.action == 0 => {}
|
||||
C::Animate(p) => self.remote_animate(p.id, p.action),
|
||||
C::UpdateMobEffect(p) => self.remote_effect(&p),
|
||||
C::RemoveMobEffect(p) => {
|
||||
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id)
|
||||
&& let Some(id) = self.remote_effect_id(p.effect)
|
||||
{
|
||||
self.potions.effects.list.retain(|e| e.id != id);
|
||||
}
|
||||
}
|
||||
C::ChangeDifficulty(p) => {
|
||||
self.ui.difficulty = p.difficulty.clamp(0, 3) as u8;
|
||||
self.level.difficulty_locked = p.locked;
|
||||
self.remote_world_options_difficulty(self.ui.difficulty, p.locked);
|
||||
}
|
||||
C::GameRuleValues(p) => self.remote_world_options_rules(p.entries),
|
||||
C::SetHealth(p) => {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.health = (p.health, p.food, p.saturation);
|
||||
@@ -1158,9 +1450,16 @@ impl Game<'_> {
|
||||
.map(|r| r.lang.clone())
|
||||
.unwrap_or_default();
|
||||
let message = plain(&p.message, &lang);
|
||||
let me = self.remote.as_ref().is_some_and(|r| r.entity_id == p.id);
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.death_message = Some(message);
|
||||
}
|
||||
// ClientPacketListener.handlePlayerCombatKill: the death
|
||||
// screen, whatever health was last sent.
|
||||
if me && self.screen != Screen::Dead && self.player.is_some() {
|
||||
self.screen = Screen::Dead;
|
||||
self.capture(false);
|
||||
}
|
||||
}
|
||||
C::SetExperience(p) => {
|
||||
self.experience = Experience {
|
||||
@@ -1195,6 +1494,21 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
C::GameEvent(p) => match p.event {
|
||||
// GUARDIAN_ELDER_EFFECT: the apparition, and the curse sound
|
||||
// unless the elder guardian is silent.
|
||||
10 => {
|
||||
self.creatures.elder_apparition = super::mob_blocks::APPARITION_TICKS;
|
||||
if p.param == 1.0
|
||||
&& let Some(at) = self.player.as_ref().map(|m| m.position)
|
||||
{
|
||||
self.audio.play(
|
||||
"minecraft:entity.elder_guardian.curse",
|
||||
Some([at.x, at.y, at.z]),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
self.world.weather.raining = true;
|
||||
self.world.weather.rain_level = (0.0, 0.0);
|
||||
@@ -1252,6 +1566,8 @@ impl Game<'_> {
|
||||
match p.slot {
|
||||
0..=35 => self.inventory[p.slot as usize] = stack,
|
||||
36..=39 => self.armor[3 - (p.slot - 36) as usize] = stack,
|
||||
// Inventory.SLOT_OFFHAND.
|
||||
40 => self.offhand = stack,
|
||||
_ => {}
|
||||
}
|
||||
self.remote_synced();
|
||||
@@ -1271,6 +1587,10 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
C::OpenScreen(p) => self.remote_open_screen(p),
|
||||
C::RecipeBookAdd(p) => self.remote_recipe_book_add(p),
|
||||
C::RecipeBookRemove(p) => self.remote_recipe_book_remove(p),
|
||||
C::RecipeBookSettings(p) => self.remote_recipe_book_settings(p),
|
||||
C::PlaceGhostRecipe(p) => self.remote_ghost_recipe(p),
|
||||
C::MerchantOffers(p) => self.remote_merchant_offers(p),
|
||||
C::ContainerClose(_) => {
|
||||
if self.screen == Screen::Inventory {
|
||||
@@ -1279,6 +1599,15 @@ impl Game<'_> {
|
||||
self.capture(true);
|
||||
}
|
||||
}
|
||||
C::Commands(p) => {
|
||||
let tree = horizon_client::command::network::from_packet(
|
||||
&p,
|
||||
horizon_client::command::vanilla::dispatcher(),
|
||||
);
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.commands = Some(tree);
|
||||
}
|
||||
}
|
||||
C::SystemChat(p) => {
|
||||
let text = plain(
|
||||
&p.content,
|
||||
@@ -1459,6 +1788,68 @@ impl Game<'_> {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.view_distance = p.distance;
|
||||
}
|
||||
// ClientChunkCache.updateViewRadius: a new storage of that size.
|
||||
self.drop_remote_chunks_out_of_range();
|
||||
}
|
||||
C::SetChunkCacheCenter(p) => {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.chunk_center = (p.x, p.z);
|
||||
}
|
||||
self.drop_remote_chunks_out_of_range();
|
||||
}
|
||||
C::BlockEntityData(p) => self.remote_block_entity_data(p.pos, p.kind, &p.tag),
|
||||
// handleSetSpawn: ClientLevel.setRespawnData, which spawn compasses point at.
|
||||
C::SetDefaultSpawnPosition(p) => {
|
||||
let [x, y, z] = p.pos.pos;
|
||||
self.spawn = (p.pos.dimension == "minecraft:overworld")
|
||||
.then(|| Vec3::new(f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5));
|
||||
}
|
||||
C::ServerData(p) => self.remote_server_data(p.icon.as_deref()),
|
||||
// handleTickingState / handleTickingStep: the level's TickRateManager.
|
||||
C::TickingState(p) => {
|
||||
self.clock.set_rate(p.rate);
|
||||
self.clock.set_frozen(p.frozen);
|
||||
}
|
||||
C::TickingStep(p) => self.clock.set_frozen_ticks_to_run(p.steps),
|
||||
C::UpdateAdvancements(p) => {
|
||||
let icons = p
|
||||
.added
|
||||
.iter()
|
||||
.map(|a| {
|
||||
a.display
|
||||
.as_ref()
|
||||
.map_or_else(String::new, |d| self.item_name(d.icon.item).to_string())
|
||||
})
|
||||
.collect();
|
||||
self.remote_advancements(p, icons);
|
||||
}
|
||||
// handleAwardStats: StatsCounter.setValue for each.
|
||||
C::AwardStats(p) => {
|
||||
let Some(r) = self.remote.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut known = Vec::new();
|
||||
for (ty, value, count) in p.stats {
|
||||
let Some(ty) = r
|
||||
.builtin
|
||||
.get("minecraft:stat_type")
|
||||
.and_then(|t| t.name(ty))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let values = match ty {
|
||||
"minecraft:mined" => "minecraft:block",
|
||||
"minecraft:killed" | "minecraft:killed_by" => "minecraft:entity_type",
|
||||
"minecraft:custom" => "minecraft:custom_stat",
|
||||
_ => "minecraft:item",
|
||||
};
|
||||
if let Some(key) = r.builtin.get(values).and_then(|v| v.name(value)) {
|
||||
known.push((ty.to_owned(), key.to_owned(), count));
|
||||
}
|
||||
}
|
||||
for (ty, key, count) in known {
|
||||
self.stats.values.entry(ty).or_default().insert(key, count);
|
||||
}
|
||||
}
|
||||
C::SetActionBarText(p) | C::SetTitleText(p) => {
|
||||
let lang = self
|
||||
@@ -1791,10 +2182,15 @@ impl Game<'_> {
|
||||
/// One client tick while connected (Minecraft.tick with a ClientLevel).
|
||||
pub(super) fn tick_remote(&mut self) -> Result<()> {
|
||||
self.ticks += 1;
|
||||
self.world.game_time += 1;
|
||||
let rate = self.remote.as_ref().map_or(1.0, |r| r.clock_rate);
|
||||
if rate > 0.0 {
|
||||
self.world.day_time += rate.round() as i64;
|
||||
// Minecraft.tick: TickRateManager.tick; ClientLevel.tick advances
|
||||
// the time only while the level runs normally.
|
||||
let running = self.clock.world_runs();
|
||||
if running {
|
||||
self.world.game_time += 1;
|
||||
let rate = self.remote.as_ref().map_or(1.0, |r| r.clock_rate);
|
||||
if rate > 0.0 {
|
||||
self.world.day_time += rate.round() as i64;
|
||||
}
|
||||
}
|
||||
// Weather levels drift toward their targets between server updates.
|
||||
let w = &mut self.world.weather;
|
||||
@@ -1805,11 +2201,12 @@ impl Game<'_> {
|
||||
}
|
||||
// ClientLevel.tick: the border's size change.
|
||||
self.border.tick();
|
||||
self.tick_remote_entities();
|
||||
self.tick_remote_entities(running);
|
||||
// ClientLevel.tickBlockEntities: chest lids, shulker boxes, bells.
|
||||
self.tick_block_entity_anims();
|
||||
self.tick_hand();
|
||||
self.tick_particles();
|
||||
self.tick_client_effects();
|
||||
let ghast_collisions = self.happy_ghast_player_collisions();
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
return Ok(());
|
||||
@@ -1827,13 +2224,13 @@ impl Game<'_> {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.loaded_sent = true;
|
||||
}
|
||||
self.capture_loaded_world();
|
||||
}
|
||||
let stick = self.pad_move();
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
let chatting = self.remote.as_ref().is_some_and(|r| r.chat_input.is_some());
|
||||
let playing = self.screen == Screen::Playing && !chatting;
|
||||
let playing = self.screen == Screen::Playing;
|
||||
let held = |k| playing && self.keys.contains(&k);
|
||||
let keys = Keys {
|
||||
forward: held(KeyCode::KeyW),
|
||||
@@ -1914,12 +2311,18 @@ impl Game<'_> {
|
||||
self.send(S::ClientTickEnd(Empty));
|
||||
Ok(())
|
||||
}
|
||||
fn tick_remote_entities(&mut self) {
|
||||
/// ClientLevel.tickEntities; a frozen level only ticks the players
|
||||
/// (`TickRateManager.isEntityFrozen`).
|
||||
fn tick_remote_entities(&mut self, running: bool) {
|
||||
let Some(r) = self.remote.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let mut lerps = std::mem::take(&mut r.lerps);
|
||||
let players = &r.level_players;
|
||||
for m in &mut self.mobs {
|
||||
if !running && !players.contains_key(&(m.id as i32)) {
|
||||
continue;
|
||||
}
|
||||
m.previous = m.position;
|
||||
m.previous_yaw = m.yaw;
|
||||
m.previous_body_yaw = m.body_yaw;
|
||||
@@ -1957,7 +2360,7 @@ impl Game<'_> {
|
||||
horizon_client::mobs::rabbit::post_tick(m);
|
||||
}
|
||||
}
|
||||
for d in &mut self.dropped {
|
||||
for d in self.dropped.iter_mut().filter(|_| running) {
|
||||
d.previous = d.position;
|
||||
d.age += 1;
|
||||
if let Some(l) = lerps.get_mut(&(d.id as i32))
|
||||
@@ -2097,8 +2500,7 @@ impl Game<'_> {
|
||||
if r.destroy_delay > 0 {
|
||||
r.destroy_delay -= 1;
|
||||
}
|
||||
let chatting = r.chat_input.is_some();
|
||||
if !self.left || self.screen != Screen::Playing || chatting {
|
||||
if !self.left || self.screen != Screen::Playing {
|
||||
self.stop_remote_mining();
|
||||
self.mining = None;
|
||||
return;
|
||||
@@ -2115,11 +2517,12 @@ impl Game<'_> {
|
||||
if self.remote.as_ref().is_some_and(|r| r.destroy_delay > 0) {
|
||||
return;
|
||||
}
|
||||
self.remote_ensure_carried();
|
||||
self.swing_arm();
|
||||
self.send(S::Punch(Empty));
|
||||
if self.creative {
|
||||
let seq = self.next_sequence();
|
||||
self.predict_break(hit.pos, state);
|
||||
self.predict_break(hit.pos, state, seq);
|
||||
self.send(S::PlayerAction(play::PlayerAction {
|
||||
action: 0,
|
||||
pos: hit.pos,
|
||||
@@ -2168,7 +2571,7 @@ impl Game<'_> {
|
||||
sequence: seq,
|
||||
}));
|
||||
if progress >= 1.0 {
|
||||
self.predict_break(hit.pos, state);
|
||||
self.predict_break(hit.pos, state, seq);
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.destroy_delay = 5;
|
||||
}
|
||||
@@ -2205,7 +2608,7 @@ impl Game<'_> {
|
||||
self.crack_particle(pos, hit.face, state);
|
||||
if done >= 1.0 {
|
||||
let seq = self.next_sequence();
|
||||
self.predict_break(pos, state);
|
||||
self.predict_break(pos, state, seq);
|
||||
self.send(S::PlayerAction(play::PlayerAction {
|
||||
action: 3,
|
||||
pos,
|
||||
@@ -2237,7 +2640,14 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// MultiPlayerGameMode.destroyBlock: the predicted break.
|
||||
fn predict_break(&mut self, pos: [i32; 3], state: BlockStateId) {
|
||||
fn predict_break(&mut self, pos: [i32; 3], state: BlockStateId, sequence: i32) {
|
||||
// BlockStatePredictionHandler.retainKnownServerState.
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.predictions
|
||||
.entry(pos)
|
||||
.and_modify(|p| p.0 = sequence)
|
||||
.or_insert((sequence, state.0 as i32));
|
||||
}
|
||||
let s = self.res.facts.get(state).sounds.clone();
|
||||
let at = [
|
||||
f64::from(pos[0]) + 0.5,
|
||||
@@ -2259,7 +2669,22 @@ impl Game<'_> {
|
||||
self.mining = None;
|
||||
}
|
||||
/// Minecraft.startUseItem: use on the targeted block, then the item itself.
|
||||
/// MultiPlayerGameMode.ensureHasSentCarriedItem: the server hears of a
|
||||
/// hotbar change before an action that uses the held item.
|
||||
fn remote_ensure_carried(&mut self) {
|
||||
let selected = self.selected;
|
||||
let Some(r) = self.remote.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if r.carried != Some(selected) {
|
||||
r.carried = Some(selected);
|
||||
self.send(S::SetCarriedItem(play::SetCarriedItem {
|
||||
slot: selected as i16,
|
||||
}));
|
||||
}
|
||||
}
|
||||
fn remote_use(&mut self) {
|
||||
self.remote_ensure_carried();
|
||||
let held_block = self
|
||||
.held()
|
||||
.and_then(|h| self.res.items.get(&h.item))
|
||||
@@ -2320,13 +2745,14 @@ impl Game<'_> {
|
||||
}
|
||||
/// Left click: attack the targeted entity (MultiPlayerGameMode.attack).
|
||||
pub(super) fn remote_mouse(&mut self, button: MouseButton, pressed: bool) -> Result<()> {
|
||||
if self.remote.as_ref().is_some_and(|r| r.chat_input.is_some()) {
|
||||
if self.screen == Screen::Chat {
|
||||
return Ok(());
|
||||
}
|
||||
match button {
|
||||
MouseButton::Left => {
|
||||
if pressed && let Some((i, _)) = self.mob_hit() {
|
||||
let id = self.mobs[i].id as i32;
|
||||
self.remote_ensure_carried();
|
||||
self.send(S::Attack(play::Attack { id }));
|
||||
self.send(S::Punch(Empty));
|
||||
self.swing_arm();
|
||||
@@ -2379,14 +2805,8 @@ impl Game<'_> {
|
||||
}
|
||||
match key {
|
||||
KeyCode::KeyT | KeyCode::Slash | KeyCode::Enter => {
|
||||
let text = if key == KeyCode::Slash { "/" } else { "" };
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.chat_input = Some(text.to_owned());
|
||||
}
|
||||
self.keys.clear();
|
||||
self.left = false;
|
||||
self.right = false;
|
||||
self.capture(false);
|
||||
// ChatScreen, over the server's command tree.
|
||||
self.open_chat(if key == KeyCode::Slash { "/" } else { "" });
|
||||
Ok(true)
|
||||
}
|
||||
KeyCode::KeyQ => {
|
||||
@@ -2423,84 +2843,6 @@ impl Game<'_> {
|
||||
_ => Ok(false),
|
||||
}
|
||||
}
|
||||
pub(super) fn remote_chat_open(&self) -> bool {
|
||||
self.remote.as_ref().is_some_and(|r| r.chat_input.is_some())
|
||||
}
|
||||
/// ChatScreen: typing, Enter to send, Escape to cancel.
|
||||
pub(super) fn remote_chat_key(&mut self, event: &winit::event::KeyEvent) {
|
||||
use winit::keyboard::Key;
|
||||
if event.state != winit::event::ElementState::Pressed {
|
||||
return;
|
||||
}
|
||||
let shortcut = self.edit_shortcut(event);
|
||||
let Some(r) = self.remote.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let Some(input) = r.chat_input.as_mut() else {
|
||||
return;
|
||||
};
|
||||
// ChatScreen's input: an EditBox of 256.
|
||||
const INPUT: horizon_client::clipboard::FieldId = ("remote.chat", 0);
|
||||
if let Some(shortcut) = shortcut {
|
||||
Self::append_shortcut(
|
||||
&mut self.text_selection,
|
||||
&mut self.clipboard,
|
||||
INPUT,
|
||||
input,
|
||||
shortcut,
|
||||
256,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Logical keys: the text, not the scancode, decides (layouts, virtual keyboards).
|
||||
let named = match &event.logical_key {
|
||||
Key::Named(n) => Some(*n),
|
||||
_ => None,
|
||||
};
|
||||
self.remote_chat_input(named, event.text.as_deref());
|
||||
}
|
||||
/// A named key or typed text in the chat input (the keyboard, or a
|
||||
/// controller's on-screen keyboard).
|
||||
pub(super) fn remote_chat_input(
|
||||
&mut self,
|
||||
named: Option<winit::keyboard::NamedKey>,
|
||||
text: Option<&str>,
|
||||
) {
|
||||
use winit::keyboard::NamedKey;
|
||||
const INPUT: horizon_client::clipboard::FieldId = ("remote.chat", 0);
|
||||
let Some(r) = self.remote.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let Some(input) = r.chat_input.as_mut() else {
|
||||
return;
|
||||
};
|
||||
match named {
|
||||
Some(NamedKey::Escape) => {
|
||||
r.chat_input = None;
|
||||
self.capture(true);
|
||||
}
|
||||
Some(NamedKey::Backspace) => {
|
||||
if !self.text_selection.take(INPUT, input) {
|
||||
input.pop();
|
||||
}
|
||||
}
|
||||
Some(NamedKey::Enter) => {
|
||||
let message = r.chat_input.take().unwrap_or_default();
|
||||
self.send_chat(&message);
|
||||
self.capture(true);
|
||||
}
|
||||
_ => {
|
||||
if let Some(text) = text {
|
||||
self.text_selection.take(INPUT, input);
|
||||
for c in text.chars().filter(|c| !c.is_control()) {
|
||||
if input.chars().count() < 256 {
|
||||
input.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// ClientPacketListener.sendChat / sendCommand (unsigned).
|
||||
pub(super) fn send_chat(&mut self, message: &str) {
|
||||
let message = message.trim();
|
||||
@@ -2528,6 +2870,17 @@ impl Game<'_> {
|
||||
}));
|
||||
}
|
||||
}
|
||||
/// Minecraft.handleKeybinds, key.swapOffhand: the server swaps the hands
|
||||
/// (ServerboundPlayerActionPacket SWAP_ITEM_WITH_OFFHAND at BlockPos.ZERO, facing down) and
|
||||
/// sends both slots back; nothing is predicted.
|
||||
pub(super) fn remote_swap_hands(&mut self) {
|
||||
self.send(S::PlayerAction(play::PlayerAction {
|
||||
action: 7,
|
||||
pos: [0; 3],
|
||||
direction: 0,
|
||||
sequence: 0,
|
||||
}));
|
||||
}
|
||||
/// A container click: the server answers with the authoritative slots.
|
||||
pub(super) fn remote_click(&mut self, right: bool) {
|
||||
let Some(size) = self
|
||||
@@ -2832,11 +3185,11 @@ impl Game<'_> {
|
||||
let Some(r) = &self.remote else { return };
|
||||
let atlas = &self.atlas;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
let open = r.chat_input.is_some();
|
||||
let open = self.screen == Screen::Chat;
|
||||
let now = Instant::now();
|
||||
let mut y = h - 48.0;
|
||||
// Container screens cover the chat (vanilla draws them over the HUD).
|
||||
let shown = if matches!(self.screen, Screen::Playing | Screen::Dead) {
|
||||
let shown = if matches!(self.screen, Screen::Playing | Screen::Dead | Screen::Chat) {
|
||||
if open { 20 } else { 10 }
|
||||
} else {
|
||||
0
|
||||
@@ -2864,16 +3217,6 @@ impl Game<'_> {
|
||||
batch.text(atlas, text, 4.0, y, rgba(255, 255, 255, a), true);
|
||||
y -= 9.0;
|
||||
}
|
||||
if let Some(input) = &r.chat_input {
|
||||
batch.fill(atlas, 2.0, h - 14.0, w - 4.0, 12.0, rgba(0, 0, 0, 128));
|
||||
let blink = (self.started.elapsed().as_millis() / 300).is_multiple_of(2);
|
||||
let shown = if blink {
|
||||
format!("{input}_")
|
||||
} else {
|
||||
input.clone()
|
||||
};
|
||||
batch.text(atlas, &shown, 4.0, h - 12.0, rgba(224, 224, 224, 255), true);
|
||||
}
|
||||
if let Some((text, at)) = &r.action_bar
|
||||
&& now.duration_since(*at) < Duration::from_secs(3)
|
||||
{
|
||||
@@ -2948,3 +3291,20 @@ impl Game<'_> {
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Game;
|
||||
|
||||
#[test]
|
||||
fn every_inventory_menu_slot_is_a_game_slot_and_45_is_the_off_hand() {
|
||||
// InventoryMenu: result, 2x2 grid, armour, 27 + 9 inventory slots, then the off hand.
|
||||
assert_eq!(Game::menu_slot("inventory", 45), Some(504));
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for slot in 0..46 {
|
||||
let internal = Game::menu_slot("inventory", slot);
|
||||
assert!(internal.is_some_and(|i| seen.insert(i)), "slot {slot}");
|
||||
}
|
||||
assert_eq!(Game::menu_slot("inventory", 46), None);
|
||||
}
|
||||
}
|
||||
@@ -303,20 +303,33 @@ impl Game<'_> {
|
||||
|
||||
// ---- criteria ----------------------------------------------------------
|
||||
|
||||
/// `ServerStatsCounter.setValue`'s scoreboard side: the objectives of
|
||||
/// each changed statistic's criteria follow it for the player `name`.
|
||||
pub(super) fn stat_criteria_changed(
|
||||
&mut self,
|
||||
name: &str,
|
||||
events: Vec<(String, String, Option<i32>)>,
|
||||
) {
|
||||
let board = &mut self.score.board;
|
||||
if board.objectives.is_empty() {
|
||||
return;
|
||||
}
|
||||
for (ty, key, amount) in events {
|
||||
let criteria = sb::stat_criteria(&ty, &key);
|
||||
if board.has_objectives_for(&criteria) {
|
||||
board.for_all_objectives(&criteria, name, |v| {
|
||||
amount.map_or(0, |n| v.wrapping_add(n))
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.doTick` score updates and the statistic criteria.
|
||||
pub(super) fn tick_scoreboard(&mut self) {
|
||||
self.load_scoreboard();
|
||||
let events = std::mem::take(&mut self.stats.events);
|
||||
self.stat_criteria_changed(PLAYER_NAME, events);
|
||||
let board = &mut self.score.board;
|
||||
if !board.objectives.is_empty() {
|
||||
for (ty, key, amount) in events {
|
||||
let criteria = sb::stat_criteria(&ty, &key);
|
||||
if board.has_objectives_for(&criteria) {
|
||||
board.for_all_objectives(&criteria, PLAYER_NAME, |v| {
|
||||
amount.map_or(0, |n| v.wrapping_add(n))
|
||||
});
|
||||
}
|
||||
}
|
||||
let display = self.player_display_comp();
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
let health_and_absorption = p.health.health() + p.health.absorption();
|
||||
@@ -587,9 +600,7 @@ impl Game<'_> {
|
||||
/// objective.
|
||||
pub(super) fn draw_tab_list(&mut self, batch: &mut HudBatch) {
|
||||
use horizon_client::tab_list::{self, TabInputs};
|
||||
let chatting = self.remote.is_some() && self.remote_chat_open();
|
||||
let key = self.screen == super::Screen::Playing
|
||||
&& !chatting
|
||||
&& self.keys.contains(&winit::keyboard::KeyCode::Tab);
|
||||
let (players, online_mode, upside_down) = match self.remote_tab_players() {
|
||||
Some(remote) => remote,
|
||||
@@ -735,12 +746,10 @@ mod tab_list_tests {
|
||||
conn: uuid as u32,
|
||||
uuid,
|
||||
name: name.into(),
|
||||
properties: Vec::new(),
|
||||
info: ClientInformation::new(8),
|
||||
brand: "test".into(),
|
||||
sender: PlayerSender {
|
||||
tx,
|
||||
pending: Arc::default(),
|
||||
},
|
||||
sender: PlayerSender::new(tx, Arc::default()),
|
||||
latency: Arc::new(AtomicI32::new(42)),
|
||||
address: "127.0.0.1:25565".parse().unwrap(),
|
||||
},
|
||||
|
||||
@@ -8,7 +8,8 @@ use horizon_client::{
|
||||
};
|
||||
|
||||
/// PauseScreen buttons: (x, y, width, label key, action id, active).
|
||||
fn pause_buttons(w: f32, h: f32) -> Vec<(f32, f32, f32, &'static str, u8, bool)> {
|
||||
/// https://mcsrc.dev/2/26.3/net/minecraft/client/gui/screens/PauseScreen
|
||||
fn pause_buttons(w: f32, h: f32, remote: bool) -> Vec<(f32, f32, f32, &'static str, u8, bool)> {
|
||||
// GridLayout of two 98px columns (padding 4), aligned at (0.5, 0.25).
|
||||
let (grid_w, grid_h) = (212.0, 166.0);
|
||||
let x0 = ((w - grid_w) * 0.5).floor();
|
||||
@@ -26,9 +27,21 @@ fn pause_buttons(w: f32, h: f32) -> Vec<(f32, f32, f32, &'static str, u8, bool)>
|
||||
9,
|
||||
true,
|
||||
),
|
||||
(x0 + 4.0, y0 + 146.0, 204.0, "menu.returnToMenu", 2, true),
|
||||
(
|
||||
x0 + 4.0,
|
||||
y0 + 146.0,
|
||||
204.0,
|
||||
if remote {
|
||||
"menu.disconnect"
|
||||
} else {
|
||||
"menu.returnToMenu"
|
||||
},
|
||||
2,
|
||||
true,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// The icon row: report bugs, feedback, friends, player reporting.
|
||||
fn pause_icons(w: f32, h: f32) -> [(f32, f32, &'static str); 4] {
|
||||
let x0 = ((w - 212.0) * 0.5).floor() + 60.0;
|
||||
@@ -52,6 +65,10 @@ fn death_buttons(w: f32, h: f32) -> [(f32, f32, &'static str, u8); 2] {
|
||||
]
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// Singleplayer also has a connection, backed by the integrated server.
|
||||
pub(super) fn external_server(&self) -> bool {
|
||||
self.remote.is_some() && self.integrated.is_none()
|
||||
}
|
||||
/// Whether the pause menu is drawn in Horizon's style (over a blurred world).
|
||||
pub(super) fn horizon_pause(&self) -> bool {
|
||||
self.screen == Screen::Paused
|
||||
@@ -60,17 +77,14 @@ impl Game<'_> {
|
||||
&& horizon_client::ui_theme::horizon()
|
||||
}
|
||||
fn pause_labels(&self) -> (String, &'static str) {
|
||||
let world = if self.remote.is_some() {
|
||||
"Multiplayer".to_owned()
|
||||
if let Some(server) = &self.integrated {
|
||||
return (server.world_name.clone(), "Save and quit");
|
||||
}
|
||||
if self.external_server() {
|
||||
("Multiplayer".to_owned(), "Disconnect")
|
||||
} else {
|
||||
self.level.name.clone()
|
||||
};
|
||||
let quit = if self.remote.is_some() {
|
||||
"Disconnect"
|
||||
} else {
|
||||
"Save and quit"
|
||||
};
|
||||
(world, quit)
|
||||
(self.level.name.clone(), "Save and quit")
|
||||
}
|
||||
}
|
||||
pub(super) fn draw_pause_menu(&mut self, batch: &mut HudBatch) {
|
||||
if horizon_client::ui_theme::horizon() {
|
||||
@@ -79,7 +93,14 @@ impl Game<'_> {
|
||||
self.tick_friends();
|
||||
let friends = self.friends_view();
|
||||
let mut ui = horizon_client::ui_theme::Ui::new(&self.atlas, batch, self.cursor);
|
||||
horizon_client::front::pause::draw(&mut ui, &world, quit, opened, &friends);
|
||||
horizon_client::front::pause::draw(
|
||||
&mut ui,
|
||||
&world,
|
||||
quit,
|
||||
opened,
|
||||
&friends,
|
||||
self.external_server(),
|
||||
);
|
||||
ui.finish();
|
||||
return;
|
||||
}
|
||||
@@ -96,7 +117,7 @@ impl Game<'_> {
|
||||
u32::MAX,
|
||||
true,
|
||||
);
|
||||
for (x, y, bw, key, _, active) in pause_buttons(w, h) {
|
||||
for (x, y, bw, key, _, active) in pause_buttons(w, h, self.external_server()) {
|
||||
let hover = active && ui::hovered(self.cursor, x, y, bw, 20.0);
|
||||
ui::button(
|
||||
batch,
|
||||
@@ -142,9 +163,10 @@ impl Game<'_> {
|
||||
&world,
|
||||
quit,
|
||||
&self.friends_view(),
|
||||
self.external_server(),
|
||||
)
|
||||
} else {
|
||||
pause_buttons(w, h)
|
||||
pause_buttons(w, h, self.external_server())
|
||||
.into_iter()
|
||||
.find(|b| b.5 && ui::hovered(self.cursor, b.0, b.1, b.2, 20.0))
|
||||
.map(|b| b.4)
|
||||
@@ -263,6 +285,12 @@ impl Game<'_> {
|
||||
.into_iter()
|
||||
.find(|d| ui::hovered(self.cursor, d.0, d.1, 200.0, 20.0))
|
||||
.map(|d| d.3);
|
||||
if hit == Some(0) && self.remote.is_none() {
|
||||
let keep = self.ui.rules.bool("keep_inventory") || self.spectator();
|
||||
self.experience.respawn(keep);
|
||||
// Autosaves keep a login position; death must resolve the respawn point instead.
|
||||
self.level.position = None;
|
||||
}
|
||||
match hit {
|
||||
Some(0) if self.remote.is_some() => {
|
||||
self.ui.death_text = None;
|
||||
@@ -298,3 +326,19 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::pause_buttons;
|
||||
|
||||
#[test]
|
||||
fn pause_menu_uses_disconnect_for_servers_and_keeps_world_options() {
|
||||
for (remote, label) in [(false, "menu.returnToMenu"), (true, "menu.disconnect")] {
|
||||
let buttons = pause_buttons(427.0, 240.0, remote);
|
||||
let quit = buttons.iter().find(|b| b.4 == 2).unwrap();
|
||||
assert_eq!(quit.3, label);
|
||||
assert!(quit.5);
|
||||
assert!(buttons.iter().any(|b| b.4 == 9 && b.5));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,15 @@ pub(super) struct SculkState {
|
||||
ticks: u32,
|
||||
pub tracker_loaded: bool,
|
||||
}
|
||||
|
||||
impl SculkState {
|
||||
/// Exchange the level-held parts (its sculk block entities and the
|
||||
/// chunks scanned for them) with another level's (game/levels.rs).
|
||||
pub(super) fn swap_level(&mut self, other: &mut Self) {
|
||||
std::mem::swap(&mut self.index, &mut other.index);
|
||||
std::mem::swap(&mut self.known, &mut other.known);
|
||||
}
|
||||
}
|
||||
/// An insertion-ordered set of block positions (`Level.blockEntityTickers`
|
||||
/// keeps the order block entities were added in).
|
||||
#[derive(Default)]
|
||||
@@ -373,6 +382,14 @@ impl Game<'_> {
|
||||
let mut found = Vec::new();
|
||||
for (s, section) in chunk.sections.iter().enumerate() {
|
||||
let y0 = MIN_Y + s as i32 * 16;
|
||||
// Without a sculk state in its palette, no block of the section
|
||||
// is one: most sections are passed over without reading a block.
|
||||
if !section
|
||||
.blocks
|
||||
.maybe_has(|v| u16::try_from(v).is_ok_and(|v| self.kind_of_state(v) != 0))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if section.blocks.uniform().is_some() {
|
||||
// A uniform section of sculk blocks does not exist.
|
||||
continue;
|
||||
@@ -1163,6 +1180,9 @@ impl Game<'_> {
|
||||
|
||||
/// `Warden.applyDarknessAround` / `MobEffectUtil.addEffectToPlayersAround`.
|
||||
pub(super) fn apply_darkness(&mut self, at: [f64; 3], radius: f64) {
|
||||
self.for_each_player(|g| g.darkness_on_player(at, radius));
|
||||
}
|
||||
fn darkness_on_player(&mut self, at: [f64; 3], radius: f64) {
|
||||
if self.creative || self.spectator() {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
//! A server's players between dimensions.
|
||||
//!
|
||||
//! A server holds a level per dimension (game/levels.rs). A player who steps
|
||||
//! into a Nether or End portal moves to the destination's level, which opens
|
||||
//! if nobody was there: the swap is the in-frame path's portal plans and
|
||||
//! arrival search, and the player hears it as `ServerPlayer.teleport` into
|
||||
//! another level: a Respawn packet keeping all data, then a teleport to the
|
||||
//! exit once the destination has loaded around them.
|
||||
|
||||
use super::nether_portal::{PLAYER_DELAY, Plan};
|
||||
use super::{Dim, Game, lan::LanHost};
|
||||
use horizon_client::lan::hub::TickCtx;
|
||||
use horizon_core::{geometry::Vec3, position::BlockPos};
|
||||
|
||||
/// A trip under way: the player is in the destination's level, whose
|
||||
/// chunks are loading around `target`.
|
||||
pub(super) struct Trip {
|
||||
conn: u32,
|
||||
dim: Dim,
|
||||
target: Vec3,
|
||||
/// Through an End portal.
|
||||
end: bool,
|
||||
/// The Nether portal plan, whose exit is found or built on arrival.
|
||||
plan: Option<Plan>,
|
||||
}
|
||||
|
||||
/// A player's box: Player's standing dimensions.
|
||||
const WIDTH: f64 = 0.6;
|
||||
const HEIGHT: f64 = 1.8;
|
||||
|
||||
impl Trip {
|
||||
pub(super) fn is(&self, conn: u32) -> bool {
|
||||
self.conn == conn
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Whether `conn` is between two levels: the server leaves its body
|
||||
/// alone until it arrives.
|
||||
pub(super) fn on_server_trip(&self, conn: u32) -> bool {
|
||||
self.server_trips.iter().any(|t| t.conn == conn)
|
||||
}
|
||||
|
||||
/// Per server tick in each level, after the guests' own: the arrivals of
|
||||
/// the trips into this level, then `Entity.handlePortal` for each player
|
||||
/// in it (Nether portals count up their transition time, End portals
|
||||
/// take them at once).
|
||||
pub(super) fn tick_server_travel(&mut self, host: &mut LanHost) {
|
||||
if self.options.dedicated.is_none() {
|
||||
return;
|
||||
}
|
||||
self.arrive_server_trips(host);
|
||||
let conns: Vec<u32> = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| host.hub.here(g) && g.loaded && !g.dead && !g.spectator())
|
||||
.map(|g| g.conn)
|
||||
.collect();
|
||||
for conn in conns {
|
||||
if self.on_server_trip(conn) {
|
||||
continue;
|
||||
}
|
||||
// `Entity.portalProcess` and `portalCooldown` are the player's own.
|
||||
let mut portal = host.play.entry(conn).or_default().portal;
|
||||
std::mem::swap(&mut self.portals.player, &mut portal);
|
||||
self.handle_server_portal(host, conn);
|
||||
std::mem::swap(&mut self.portals.player, &mut portal);
|
||||
host.play.entry(conn).or_default().portal = portal;
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.handlePortal` for one player, whose portal state is in place.
|
||||
fn handle_server_portal(&mut self, host: &mut LanHost, conn: u32) {
|
||||
let Some(g) = host.hub.guest(conn) else {
|
||||
return;
|
||||
};
|
||||
let (at, invulnerable, sleeping) = (
|
||||
Vec3::new(g.position[0], g.position[1], g.position[2]),
|
||||
g.creative(),
|
||||
g.sleeping,
|
||||
);
|
||||
// Entity.checkInsideBlocks: the Nether portal blocks the box is in.
|
||||
if let Some(pos) = self.nether_portal_inside(at) {
|
||||
self.portals.player.set_inside(pos, PLAYER_DELAY);
|
||||
}
|
||||
let (end_portal, _) = self.inside_end_portals(at, WIDTH, HEIGHT);
|
||||
if end_portal {
|
||||
if self.portals.player.cooldown == 0 {
|
||||
let target = if self.dim == Dim::End {
|
||||
Dim::Overworld
|
||||
} else {
|
||||
Dim::End
|
||||
};
|
||||
self.start_server_trip(host, conn, target, None);
|
||||
}
|
||||
// Entity.setAsInsidePortal while cooling down restarts the cooldown.
|
||||
self.portals.player.cooldown = 10;
|
||||
return;
|
||||
}
|
||||
let rule = if invulnerable {
|
||||
"players_nether_portal_creative_delay"
|
||||
} else {
|
||||
"players_nether_portal_default_delay"
|
||||
};
|
||||
let transition = self.ui.rules.int(rule).max(0);
|
||||
let Some(entry) = self
|
||||
.portals
|
||||
.player
|
||||
.handle(!sleeping, transition, PLAYER_DELAY)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let plan = self.portal_plan(entry, at, WIDTH as f32, HEIGHT as f32, true);
|
||||
if !self.may_enter(plan.dim) {
|
||||
return;
|
||||
}
|
||||
self.start_server_trip(host, conn, plan.dim, Some(plan));
|
||||
}
|
||||
|
||||
/// A Nether portal block a player's box is in.
|
||||
fn nether_portal_inside(&self, at: Vec3) -> Option<BlockPos> {
|
||||
let w = WIDTH / 2.0 - 1.0e-5;
|
||||
let lo = [at.x - w, at.y + 1.0e-5, at.z - w];
|
||||
let hi = [at.x + w, at.y + HEIGHT - 1.0e-5, at.z + w];
|
||||
for x in lo[0].floor() as i32..=hi[0].floor() as i32 {
|
||||
for y in lo[1].floor() as i32..=hi[1].floor() as i32 {
|
||||
for z in lo[2].floor() as i32..=hi[2].floor() as i32 {
|
||||
if &*self.block_name([x, y, z]) == "minecraft:nether_portal" {
|
||||
return Some(BlockPos::new(x, y, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The player moves to `target`'s level (opened if nobody was there)
|
||||
/// and is told it is there, at the approximate exit, while it loads. The
|
||||
/// level whose tick is running is put back in place afterwards.
|
||||
fn start_server_trip(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
target: Dim,
|
||||
plan: Option<Plan>,
|
||||
) {
|
||||
let Some(i) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
return;
|
||||
};
|
||||
let g = &host.hub.guests[i];
|
||||
let from = Vec3::new(g.position[0], g.position[1], g.position[2]);
|
||||
let yaw = g.yaw;
|
||||
let source = self.dim;
|
||||
let end = plan.is_none();
|
||||
// Coordinates scale by 8 between the Overworld and the Nether; an
|
||||
// End portal has fixed destinations.
|
||||
let scale = match (source, target) {
|
||||
(Dim::Overworld, Dim::Nether) | (Dim::End, Dim::Nether) => 1.0 / 8.0,
|
||||
(Dim::Nether, Dim::Overworld) => 8.0,
|
||||
_ => 1.0,
|
||||
};
|
||||
self.end_portal_travel = end;
|
||||
let scaled = self.end_destination(target).unwrap_or(Vec3::new(
|
||||
from.x * scale,
|
||||
from.y,
|
||||
from.z * scale,
|
||||
));
|
||||
self.end_portal_travel = false;
|
||||
if let Err(e) = self.enter_level(Some(host), target) {
|
||||
log::error!("could not open {}: {e}", target.id());
|
||||
return;
|
||||
}
|
||||
let destination = match plan {
|
||||
Some(plan) => self.arrival_centre(&plan),
|
||||
None => scaled,
|
||||
};
|
||||
let at = [destination.x, destination.y, destination.z];
|
||||
let ctx = self.server_ctx();
|
||||
host.hub.change_level(i, at, yaw, &ctx);
|
||||
self.as_guest(host, conn, |g| g.trigger_dimension(source, target));
|
||||
self.server_trips.push(Trip {
|
||||
conn,
|
||||
dim: target,
|
||||
target: destination,
|
||||
end,
|
||||
plan,
|
||||
});
|
||||
if let Err(e) = self.enter_level(Some(host), source) {
|
||||
log::error!("could not reopen {}: {e}", source.id());
|
||||
}
|
||||
}
|
||||
|
||||
/// The trips into the level in place whose destination has loaded
|
||||
/// around their target: the exit (the Nether portal found or built, the
|
||||
/// End platform, the Overworld's spawn column) and the teleport there.
|
||||
fn arrive_server_trips(&mut self, host: &mut LanHost) {
|
||||
let mut k = 0;
|
||||
while k < self.server_trips.len() {
|
||||
let trip = &self.server_trips[k];
|
||||
let Some(i) = host.hub.guests.iter().position(|g| g.conn == trip.conn) else {
|
||||
// They left on the way.
|
||||
self.server_trips.remove(k);
|
||||
continue;
|
||||
};
|
||||
let (cx, cz) = (
|
||||
(trip.target.x.floor() as i32) >> 4,
|
||||
(trip.target.z.floor() as i32) >> 4,
|
||||
);
|
||||
if trip.dim != self.dim
|
||||
|| !(-2..=2).all(|dz| (-2..=2).all(|dx| self.streaming.is_lit(cx + dx, cz + dz)))
|
||||
{
|
||||
k += 1;
|
||||
continue;
|
||||
}
|
||||
let trip = self.server_trips.remove(k);
|
||||
self.resolve_arrivals();
|
||||
let yaw = host.hub.guests[i].yaw;
|
||||
let (position, yaw) = if trip.end {
|
||||
match self.end_arrival(trip.target) {
|
||||
Ok((at, turned)) => (at, turned.unwrap_or(yaw)),
|
||||
Err(e) => {
|
||||
log::error!("End arrival: {e}");
|
||||
(trip.target, yaw)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let exit = trip
|
||||
.plan
|
||||
.as_ref()
|
||||
.and_then(|plan| self.nether_exit_portal(plan));
|
||||
match (trip.plan, exit) {
|
||||
(Some(plan), Some(exit)) => {
|
||||
let (at, rotation) = self.arrival(&plan, exit);
|
||||
(at, yaw + rotation)
|
||||
}
|
||||
_ => (trip.target, yaw),
|
||||
}
|
||||
};
|
||||
// Player.getDimensionChangingDelay.
|
||||
host.play.entry(trip.conn).or_default().portal.cooldown = 10;
|
||||
let g = &mut host.hub.guests[i];
|
||||
g.yaw = yaw;
|
||||
// A new body at the exit: no fall distance or motion carried over.
|
||||
g.motion = None;
|
||||
host.hub
|
||||
.teleport(i, Some([position.x, position.y, position.z]));
|
||||
}
|
||||
}
|
||||
|
||||
/// The hub's view of the server for packets sent outside its own tick.
|
||||
pub(super) fn server_ctx(&mut self) -> TickCtx<'_> {
|
||||
let spawn = self.spawn.map_or([0.5, 80.0, 0.5], |s| [s.x, s.y, s.z]);
|
||||
TickCtx {
|
||||
world: &mut self.world,
|
||||
host: None,
|
||||
entities: &[],
|
||||
next_entity: &mut self.next_entity,
|
||||
spawn,
|
||||
difficulty: i32::from(self.ui.difficulty),
|
||||
difficulty_locked: self.level.difficulty_locked,
|
||||
clock_rate: if self.ui.rules.bool("advance_time") {
|
||||
self.ui.clock.rate
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
tick_rate: self.clock.rate(),
|
||||
tick_frozen: self.clock.frozen(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
use super::{Game, GameLevel, Screen, dispenser::new_item_motion, storage::is_shulker_box};
|
||||
use horizon_client::{
|
||||
containers::{Banner, BlockEntity},
|
||||
item_data,
|
||||
items::ItemStack,
|
||||
};
|
||||
use horizon_core::position::BlockPos;
|
||||
@@ -15,6 +16,7 @@ use horizon_world::{
|
||||
LevelEvent,
|
||||
removal::{self, PreRemove},
|
||||
},
|
||||
nbt::{JavaString, Tag},
|
||||
registry::BlockStateId,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
@@ -80,14 +82,37 @@ impl Pending {
|
||||
stack.data_mut().pot_decorations = Some(Box::new(sides));
|
||||
}
|
||||
if stack.item.ends_with("_banner")
|
||||
&& let Some(Banner { layers, name }) = self.banner.remove(&pos)
|
||||
&& let Some(Banner {
|
||||
layers,
|
||||
name,
|
||||
components,
|
||||
}) = self.banner.remove(&pos)
|
||||
{
|
||||
let data = stack.data_mut();
|
||||
data.banner_patterns = layers;
|
||||
if name.is_some() {
|
||||
data.custom_name = name;
|
||||
let mut patch = match item_data::encode_components(stack) {
|
||||
Some(Tag::Compound(c)) => c,
|
||||
_ => Default::default(),
|
||||
};
|
||||
// The remaining components in the banner loot table's copy_components.
|
||||
for key in [
|
||||
"minecraft:item_name",
|
||||
"minecraft:rarity",
|
||||
"minecraft:tooltip_display",
|
||||
] {
|
||||
if let Some(value) = components.get(&JavaString::from(key)) {
|
||||
patch.insert(key.into(), value.clone());
|
||||
}
|
||||
}
|
||||
stack.tidy();
|
||||
patch.insert(
|
||||
"minecraft:banner_patterns".into(),
|
||||
horizon_client::banner::layers_to_nbt(&layers),
|
||||
);
|
||||
if let Some(name) = name {
|
||||
patch.insert(
|
||||
"minecraft:custom_name".into(),
|
||||
Tag::String(name.as_str().into()),
|
||||
);
|
||||
}
|
||||
(stack.damage, stack.extra) = item_data::decode_components(Some(&Tag::Compound(patch)));
|
||||
}
|
||||
}
|
||||
/// Remember a decorated pot's sherds from its saved block entity.
|
||||
@@ -553,3 +578,49 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_client::{block_entity_item, item_data, mobs::raider, save};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
#[test]
|
||||
fn ominous_banner_survives_placement_save_and_drop() {
|
||||
let original = raider::ominous_banner();
|
||||
let Tag::Compound(components) = item_data::encode_components(&original).unwrap() else {
|
||||
panic!("banner components");
|
||||
};
|
||||
for block in ["minecraft:white_banner", "minecraft:white_wall_banner"] {
|
||||
let pos = (1, 64, 2);
|
||||
let fresh = BlockEntity::for_block(block).unwrap();
|
||||
let fresh = save::encode_block_entity(pos, &fresh, block);
|
||||
let placed = block_entity_item::place(&fresh, &components, false);
|
||||
let (_, placed) = save::decode_block_entity(&placed).unwrap();
|
||||
let saved = save::encode_block_entity(pos, &placed, block);
|
||||
let (_, loaded) = save::decode_block_entity(&saved).unwrap();
|
||||
let BlockEntity::Banner(banner) = &loaded else {
|
||||
panic!("placed banner");
|
||||
};
|
||||
assert_eq!(banner.layers.len(), item_data::OMINOUS_LAYERS.len());
|
||||
let mut pending = Pending::default();
|
||||
pending.record([pos.0, pos.1, pos.2], &loaded, block, None);
|
||||
let mut dropped = ItemStack::new("minecraft:white_banner", 1);
|
||||
pending.attach([pos.0, pos.1, pos.2], &mut dropped);
|
||||
assert!(dropped.data().unwrap().ominous_banner);
|
||||
assert!(horizon_client::banner::tooltip_layers(&dropped).is_empty());
|
||||
assert_eq!(
|
||||
horizon_client::item_tooltip::name_line(&dropped, &|_| None),
|
||||
"Ominous Banner"
|
||||
);
|
||||
assert_eq!(
|
||||
horizon_client::item_tooltip::rarity(&dropped, "common"),
|
||||
"uncommon"
|
||||
);
|
||||
assert_eq!(
|
||||
item_data::encode_components(&dropped),
|
||||
Some(Tag::Compound(components.clone()))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1860,18 +1860,10 @@ impl Game<'_> {
|
||||
pub(super) fn stack_tooltip(&self, stack: &ItemStack) -> Vec<(String, u32)> {
|
||||
let e: &Enchantments = &self.res.stations.enchantments;
|
||||
let props = e.props(&stack.item);
|
||||
let mut rarity = props.map_or("common", |p| p.rarity.as_str());
|
||||
// The ominous banner's rarity component.
|
||||
if stack.data().is_some_and(|d| d.ominous_banner) {
|
||||
rarity = "uncommon";
|
||||
}
|
||||
if !stack.enchantments().is_empty() {
|
||||
rarity = match rarity {
|
||||
"common" | "uncommon" => "rare",
|
||||
"rare" => "epic",
|
||||
r => r,
|
||||
};
|
||||
}
|
||||
let rarity = horizon_client::item_tooltip::rarity(
|
||||
stack,
|
||||
props.map_or("common", |p| p.rarity.as_str()),
|
||||
);
|
||||
let color = match rarity {
|
||||
"uncommon" => rgba(255, 255, 85, 255),
|
||||
"rare" => rgba(85, 255, 255, 255),
|
||||
@@ -1952,7 +1944,7 @@ impl Game<'_> {
|
||||
));
|
||||
}
|
||||
// BannerPatternLayers.addToTooltip: up to six layer names.
|
||||
for layer in horizon_client::banner::layers(stack).iter().take(6) {
|
||||
for layer in horizon_client::banner::tooltip_layers(stack).iter().take(6) {
|
||||
let key = self.res.stations.banners.layer_key(layer);
|
||||
lines.push((self.translate(&key, &key), rgba(170, 170, 170, 255)));
|
||||
}
|
||||
@@ -1977,6 +1969,34 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(info) = self.res.items.get(&stack.item) {
|
||||
let base = |name: &str, default| {
|
||||
self.player
|
||||
.as_ref()
|
||||
.and_then(|player| {
|
||||
let key = player.attributes.key(name)?;
|
||||
let handle = player.attributes.lookup(Some(&key))?;
|
||||
player
|
||||
.attributes
|
||||
.instance(&handle)
|
||||
.ok()
|
||||
.map(|value| value.base_value())
|
||||
})
|
||||
.unwrap_or(default)
|
||||
};
|
||||
let player_base = [
|
||||
base("minecraft:attack_damage", 1.0),
|
||||
base(
|
||||
"minecraft:attack_speed",
|
||||
horizon_client::attack_strength::BASE_ATTACK_SPEED,
|
||||
),
|
||||
];
|
||||
lines.extend(horizon_client::item_tooltip::attribute_lines(
|
||||
&info.modifiers,
|
||||
Some(player_base),
|
||||
&translate,
|
||||
));
|
||||
}
|
||||
if let Some(info) = self.res.items.get(&stack.item)
|
||||
&& info.max_damage > 0
|
||||
&& stack.damage > 0
|
||||
|
||||
@@ -3,8 +3,12 @@
|
||||
//! killed by, saved to `players/stats/<uuid>.json`, plus the phantom
|
||||
//! spawner that reads the time since rest (`PhantomSpawner`).
|
||||
use super::{Dim, Game};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_core::random::RandomSource;
|
||||
use std::collections::BTreeMap;
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
/// `stats[type][key]`, with `type` and `key` namespaced ids.
|
||||
#[derive(Default)]
|
||||
@@ -16,6 +20,8 @@ pub(super) struct Stats {
|
||||
/// Statistic changes (type, key, amount; `None` = reset) for the
|
||||
/// scoreboard criteria to follow.
|
||||
pub(super) events: Vec<(String, String, Option<i32>)>,
|
||||
/// Changed since the client was last sent them (`StatsCounter`'s dirty set).
|
||||
pub(super) dirty: BTreeSet<(String, String)>,
|
||||
}
|
||||
impl Stats {
|
||||
pub(super) fn add(&mut self, ty: &str, key: &str, n: i32) {
|
||||
@@ -28,6 +34,8 @@ impl Stats {
|
||||
*v = v.saturating_add(n);
|
||||
self.events
|
||||
.push((format!("minecraft:{ty}"), key.to_owned(), Some(n)));
|
||||
self.dirty
|
||||
.insert((format!("minecraft:{ty}"), key.to_owned()));
|
||||
}
|
||||
pub(super) fn get(&self, ty: &str, key: &str) -> i32 {
|
||||
self.values
|
||||
@@ -40,52 +48,51 @@ impl Stats {
|
||||
if let Some(m) = self.values.get_mut(&format!("minecraft:{ty}")) {
|
||||
m.insert(key.to_owned(), 0);
|
||||
}
|
||||
self.dirty
|
||||
.insert((format!("minecraft:{ty}"), key.to_owned()));
|
||||
self.events
|
||||
.push((format!("minecraft:{ty}"), key.to_owned(), None));
|
||||
}
|
||||
pub(super) fn custom(&mut self, key: &str, n: i32) {
|
||||
self.add("custom", &format!("minecraft:{key}"), n);
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
fn stats_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/stats")
|
||||
.join(format!("{text}.json")),
|
||||
)
|
||||
/// `ServerStatsCounter.markAllDirty`: everything goes out on the next request.
|
||||
pub(super) fn mark_all_dirty(&mut self) {
|
||||
self.dirty = self
|
||||
.values
|
||||
.iter()
|
||||
.flat_map(|(ty, m)| m.keys().map(|k| (ty.clone(), k.clone())))
|
||||
.collect();
|
||||
}
|
||||
/// `ServerStatsCounter.parseLocal`.
|
||||
pub(super) fn load_stats(&mut self) {
|
||||
if self.stats.loaded || self.remote.is_some() {
|
||||
return;
|
||||
}
|
||||
self.stats.loaded = true;
|
||||
let Some(path) = self.stats_path() else {
|
||||
return;
|
||||
/// `ServerStatsCounter.sendStats`' entries: the dirty statistics and
|
||||
/// their values, the set cleared.
|
||||
pub(super) fn take_dirty(&mut self) -> Vec<(String, String, i32)> {
|
||||
std::mem::take(&mut self.dirty)
|
||||
.into_iter()
|
||||
.map(|(ty, key)| {
|
||||
let v = self.values.get(&ty).and_then(|m| m.get(&key)).copied();
|
||||
(ty, key, v.unwrap_or(0))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// `ServerStatsCounter.parseLocal`: the counters of a stats file.
|
||||
pub(super) fn read(path: &Path) -> Self {
|
||||
let mut stats = Self {
|
||||
loaded: true,
|
||||
..Self::default()
|
||||
};
|
||||
let Ok(text) = std::fs::read_to_string(path) else {
|
||||
return;
|
||||
let Some(json) = std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
|
||||
else {
|
||||
return stats;
|
||||
};
|
||||
let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) else {
|
||||
return;
|
||||
};
|
||||
if let Some(stats) = json.get("stats").and_then(|s| s.as_object()) {
|
||||
for (ty, entries) in stats {
|
||||
if let Some(all) = json.get("stats").and_then(|s| s.as_object()) {
|
||||
for (ty, entries) in all {
|
||||
let Some(entries) = entries.as_object() else {
|
||||
continue;
|
||||
};
|
||||
let map = self.stats.values.entry(ty.clone()).or_default();
|
||||
let map = stats.values.entry(ty.clone()).or_default();
|
||||
for (key, v) in entries {
|
||||
if let Some(n) = v.as_i64() {
|
||||
map.insert(key.clone(), n as i32);
|
||||
@@ -93,17 +100,11 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
stats
|
||||
}
|
||||
/// `ServerStatsCounter.save`.
|
||||
pub(super) fn save_stats(&self) {
|
||||
if !self.stats.loaded || self.remote.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(path) = self.stats_path() else {
|
||||
return;
|
||||
};
|
||||
pub(super) fn write(&self, path: &Path) {
|
||||
let stats: serde_json::Map<String, serde_json::Value> = self
|
||||
.stats
|
||||
.values
|
||||
.iter()
|
||||
.filter(|(_, m)| !m.is_empty())
|
||||
@@ -124,9 +125,55 @@ impl Game<'_> {
|
||||
let _ = std::fs::write(path, text);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `players/<dir>/<uuid>.json` of a world (advancements, stats).
|
||||
pub(super) fn player_json(world: &Path, dir: &str, uuid: [i32; 4]) -> PathBuf {
|
||||
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]
|
||||
);
|
||||
world.join("players").join(dir).join(format!("{text}.json"))
|
||||
}
|
||||
impl Game<'_> {
|
||||
fn stats_path(&self) -> Option<PathBuf> {
|
||||
Some(player_json(
|
||||
&self.world_root,
|
||||
"stats",
|
||||
self.level.player_uuid?,
|
||||
))
|
||||
}
|
||||
/// `ServerStatsCounter.parseLocal`.
|
||||
pub(super) fn load_stats(&mut self) {
|
||||
if self.stats.loaded || self.remote.is_some() || self.no_player_here() {
|
||||
return;
|
||||
}
|
||||
self.stats.loaded = true;
|
||||
if let Some(path) = self.stats_path() {
|
||||
self.stats.values = Stats::read(&path).values;
|
||||
}
|
||||
}
|
||||
/// `ServerStatsCounter.save`.
|
||||
pub(super) fn save_stats(&self) {
|
||||
if !self.stats.loaded || self.remote.is_some() || self.no_player_here() {
|
||||
return;
|
||||
}
|
||||
if let Some(path) = self.stats_path() {
|
||||
self.stats.write(&path);
|
||||
}
|
||||
}
|
||||
/// The per-tick counters (`Player.tick`/`ServerPlayer.tick` awardStat).
|
||||
pub(super) fn tick_stats(&mut self) {
|
||||
self.load_stats();
|
||||
self.count_tick_stats(self.sleeping.is_some());
|
||||
self.tick_movement_stats();
|
||||
}
|
||||
/// `Player.tick`'s counters for the player in `self.player`.
|
||||
pub(super) fn count_tick_stats(&mut self, sleeping: bool) {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
@@ -137,21 +184,26 @@ impl Game<'_> {
|
||||
s.custom("total_world_time", 1);
|
||||
if alive {
|
||||
s.custom("time_since_death", 1);
|
||||
if self.sleeping.is_none() {
|
||||
if !sleeping {
|
||||
s.custom("time_since_rest", 1);
|
||||
}
|
||||
}
|
||||
if crouching {
|
||||
s.custom("sneak_time", 1);
|
||||
}
|
||||
self.tick_movement_stats();
|
||||
}
|
||||
/// `Player.checkMovementStatistics` / `checkRidingStatistics`.
|
||||
fn tick_movement_stats(&mut self) {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let d = p.position.subtract(self.previous_position);
|
||||
self.movement_stats(d);
|
||||
}
|
||||
/// `Player.checkMovementStatistics` / `checkRidingStatistics` for one move.
|
||||
pub(super) fn movement_stats(&mut self, d: Vec3) {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let horizontal = ((d.x * d.x + d.z * d.z).sqrt() * 100.0).round() as i32;
|
||||
let total = ((d.x * d.x + d.y * d.y + d.z * d.z).sqrt() * 100.0).round() as i32;
|
||||
// Teleports are not travel.
|
||||
|
||||
@@ -173,6 +173,12 @@ const FOOTER: f32 = 33.0;
|
||||
impl Game<'_> {
|
||||
pub(super) fn open_stats(&mut self) {
|
||||
self.load_stats();
|
||||
// StatsScreen.init: a server's client asks for its statistics.
|
||||
if self.remote.is_some() {
|
||||
self.send(horizon_protocol::packets::play::Serverbound::ClientCommand(
|
||||
horizon_protocol::packets::play::ClientCommand { action: 1 },
|
||||
));
|
||||
}
|
||||
self.stats_ui = StatsUi::default();
|
||||
self.screen = Screen::Stats;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use horizon_client::{
|
||||
hud::HudBatch,
|
||||
hud_layout,
|
||||
hud_overlays::HeldName,
|
||||
item_tooltip,
|
||||
text::{self, Text},
|
||||
};
|
||||
|
||||
@@ -24,22 +25,14 @@ impl Game<'_> {
|
||||
fn held_name(&self) -> Option<HeldName> {
|
||||
let stack = self.inventory.get(self.selected)?.as_ref()?;
|
||||
let custom = stack.custom_name();
|
||||
// ItemStack.getRarity: enchanting raises common and uncommon to rare
|
||||
// and rare to epic.
|
||||
let mut rarity = self.item_rarity(&stack.item);
|
||||
if !stack.enchantments().is_empty() {
|
||||
rarity = match rarity.as_str() {
|
||||
"common" | "uncommon" => "rare".into(),
|
||||
"rare" => "epic".into(),
|
||||
_ => rarity,
|
||||
};
|
||||
}
|
||||
let default_rarity = self.item_rarity(&stack.item);
|
||||
let rarity = item_tooltip::rarity(stack, &default_rarity);
|
||||
Some(HeldName {
|
||||
item: stack.item.to_string(),
|
||||
hover_name: Text::literal(
|
||||
custom.map_or_else(|| self.pack.names.item(&stack.item), str::to_owned),
|
||||
),
|
||||
rarity_color: match rarity.as_str() {
|
||||
hover_name: Text::literal(item_tooltip::name_line(stack, &|key| {
|
||||
self.pack.names.get(key).map(str::to_owned)
|
||||
})),
|
||||
rarity_color: match rarity {
|
||||
"uncommon" => text::YELLOW,
|
||||
"rare" => text::AQUA,
|
||||
"epic" => text::LIGHT_PURPLE,
|
||||
|
||||
@@ -28,7 +28,7 @@ use horizon_client::{
|
||||
menu::Input,
|
||||
stations::hover_name,
|
||||
};
|
||||
use winit::keyboard::KeyCode;
|
||||
use winit::{event::MouseButton, keyboard::KeyCode};
|
||||
|
||||
/// A sort button's side in GUI pixels.
|
||||
const SORT_BUTTON: f32 = 9.0;
|
||||
@@ -377,8 +377,14 @@ impl Game<'_> {
|
||||
/// The sort key: sorts the part under the cursor (the container when
|
||||
/// the cursor is over no slot).
|
||||
pub(super) fn sort_key_press(&mut self, key: KeyCode) -> bool {
|
||||
keybinds::key_name(key).is_some_and(|name| self.sort_input(name))
|
||||
}
|
||||
pub(super) fn sort_mouse_press(&mut self, button: MouseButton) -> bool {
|
||||
self.sort_input(&keybinds::mouse_name(button))
|
||||
}
|
||||
fn sort_input(&mut self, name: &str) -> bool {
|
||||
let e = &self.settings.extras;
|
||||
if !e.sort || keybinds::key_code(&e.sort_key) != Some(key) || self.recipe_search_focused() {
|
||||
if !e.sort || e.sort_key != name || self.recipe_search_focused() {
|
||||
return false;
|
||||
}
|
||||
let buttons = self.sort_buttons();
|
||||
|
||||
@@ -143,6 +143,15 @@ impl GameRules {
|
||||
pub fn bool(&self, name: &str) -> bool {
|
||||
matches!(self.get(name), Some(Value::Bool(true)))
|
||||
}
|
||||
/// Parse a network rule value, retaining type and range validation.
|
||||
pub fn set_string(&mut self, name: &str, text: &str) -> bool {
|
||||
let value = match self.get(name) {
|
||||
Some(Value::Bool(_)) => text.parse::<bool>().ok().map(Value::Bool),
|
||||
Some(Value::Int(_)) => text.parse::<i32>().ok().map(Value::Int),
|
||||
None => None,
|
||||
};
|
||||
value.is_some_and(|v| self.set(name, v))
|
||||
}
|
||||
pub fn int(&self, name: &str) -> i32 {
|
||||
match self.get(name) {
|
||||
Some(Value::Int(v)) => v,
|
||||
@@ -250,6 +259,22 @@ impl GameRules {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn network_values_validate_rule_names_types_and_bounds() {
|
||||
let mut rules = GameRules::default();
|
||||
assert!(rules.set_string("minecraft:keep_inventory", "true"));
|
||||
assert!(rules.bool("keep_inventory"));
|
||||
assert!(!rules.set_string("minecraft:keep_inventory", "1"));
|
||||
assert!(rules.bool("keep_inventory"));
|
||||
assert!(rules.set_string("minecraft:random_tick_speed", "7"));
|
||||
assert_eq!(rules.int("random_tick_speed"), 7);
|
||||
assert!(!rules.set_string("minecraft:random_tick_speed", "-1"));
|
||||
assert!(!rules.set_string("minecraft:random_tick_speed", "999999999999"));
|
||||
assert!(!rules.set_string("minecraft:unknown", "true"));
|
||||
assert!(!rules.set_string("other:keep_inventory", "false"));
|
||||
assert_eq!(rules.int("random_tick_speed"), 7);
|
||||
}
|
||||
#[test]
|
||||
fn defaults_bounds_and_round_trip() {
|
||||
let mut rules = GameRules::default();
|
||||
|
||||
+401
-156
@@ -64,6 +64,7 @@ mod pack_stages;
|
||||
pub mod panorama;
|
||||
mod resident;
|
||||
mod timing;
|
||||
pub mod translucency;
|
||||
pub(crate) mod upload;
|
||||
mod world_post;
|
||||
|
||||
@@ -98,6 +99,8 @@ struct SectionGpu {
|
||||
translucent: u32,
|
||||
/// The solid quads by facing: back-facing groups are skipped.
|
||||
faces: crate::mesher::FaceGroups,
|
||||
sort: translucency::SortState,
|
||||
sorted_indices: Option<(u32, u64)>,
|
||||
}
|
||||
/// Line segments the hit outline can hold.
|
||||
const MAX_OUTLINE_EDGES: u64 = 256;
|
||||
@@ -133,6 +136,8 @@ pub struct Frame<'a> {
|
||||
pub dark_disc: bool,
|
||||
/// The hit block outline as line segments (from, to) in world space.
|
||||
pub outline: Vec<[f64; 6]>,
|
||||
/// `BlockOutlineRenderState.isTranslucent`: draw after translucent terrain.
|
||||
pub outline_after_terrain: bool,
|
||||
/// `lines` with a colour and pixel width (`gizmos::camera_relative`):
|
||||
/// the fishing line and debug gizmos, depth tested, then gizmos drawn
|
||||
/// over everything.
|
||||
@@ -222,6 +227,7 @@ struct BorderUniform {
|
||||
}
|
||||
pub struct Renderer {
|
||||
instance: wgpu::Instance,
|
||||
adapter: wgpu::Adapter,
|
||||
/// The window's swapchain; `None` when rendering offscreen (`--offscreen`).
|
||||
surface: Option<wgpu::Surface<'static>>,
|
||||
/// The offscreen colour target, sized like `config`.
|
||||
@@ -290,7 +296,13 @@ pub struct Renderer {
|
||||
group: wgpu::BindGroup,
|
||||
globals: wgpu::Buffer,
|
||||
lightmap: wgpu::Texture,
|
||||
sprite_texture: wgpu::Texture,
|
||||
/// The block sprites, [`SPRITE_SPLIT`] layers per array (the first is
|
||||
/// `sprites` in terrain.wgsl, the others `sprites_1`...).
|
||||
sprite_textures: Vec<wgpu::Texture>,
|
||||
/// A 1-layer array bound where a pack needs fewer arrays.
|
||||
sprite_unused: wgpu::TextureView,
|
||||
/// Layers per sprite array ([`SPRITE_SPLIT`] but in tests).
|
||||
sprite_split: u32,
|
||||
mip_levels: u32,
|
||||
/// The mipmap levels sampled (the Mipmap Levels option), at most `mip_levels - 1`.
|
||||
mipmaps: u32,
|
||||
@@ -311,6 +323,9 @@ pub struct Renderer {
|
||||
depth: wgpu::TextureView,
|
||||
sections: resident::Resident<SectionGpu>,
|
||||
arena: arena::Arena,
|
||||
sorted_indices: arena::Arena,
|
||||
translucent_sorter: translucency::Sorter,
|
||||
translucency_cursor: usize,
|
||||
/// `DrawIndexedIndirectArgs` for this frame's solid then translucent draws.
|
||||
indirect: wgpu::Buffer,
|
||||
indirect_capacity: u64,
|
||||
@@ -322,8 +337,8 @@ pub struct Renderer {
|
||||
/// `DrawIndexedIndirectArgs` words: index count, instance count, first
|
||||
/// index, base vertex, first instance.
|
||||
draws: Vec<[u32; 5]>,
|
||||
/// The arena page of each of `draws`.
|
||||
draw_pages: Vec<u32>,
|
||||
/// Vertex arena page and optional sorted index arena page of each draw.
|
||||
draw_pages: Vec<(u32, Option<u32>)>,
|
||||
/// Block-entity blocks per meshed section (world position, state id).
|
||||
block_entities: HashMap<SectionKey, Vec<([i32; 3], u32)>>,
|
||||
/// VisGraph face connectivity per meshed section (cave culling).
|
||||
@@ -348,12 +363,87 @@ pub struct Renderer {
|
||||
pub screenshot_result: Option<std::result::Result<[u32; 2], String>>,
|
||||
/// Brightness option (lightmap gamma), 0..1.
|
||||
pub gamma: f32,
|
||||
/// Optional full-light lightmap (Horizon Extras).
|
||||
pub fullbright: bool,
|
||||
/// Advance animated block textures (Horizon setting; vanilla always does).
|
||||
pub animated_textures: bool,
|
||||
present_modes: Vec<wgpu::PresentMode>,
|
||||
}
|
||||
/// Every layer's mip levels, layer major (`first` is the first layer's
|
||||
/// index into `settings`), made by `MipmapGenerator`.
|
||||
/// Block sprite layers per texture array: the smallest
|
||||
/// `max_texture_array_layers` of the GPUs Horizon runs on. Mods add sprites
|
||||
/// past it into more arrays (terrain.wgsl's `sprite`).
|
||||
pub(crate) const SPRITE_SPLIT: u32 = 2048;
|
||||
/// [`SPRITE_SPLIT`], or a smaller split from `HORIZON_SPRITE_SPLIT` (dev
|
||||
/// only: vanilla's sprites drawn from several arrays must look the same).
|
||||
fn sprite_split() -> u32 {
|
||||
std::env::var("HORIZON_SPRITE_SPLIT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.filter(|&n| (1..=SPRITE_SPLIT).contains(&n))
|
||||
.unwrap_or(SPRITE_SPLIT)
|
||||
}
|
||||
/// Sprite arrays terrain.wgsl binds (`sprites`, `sprites_1`...).
|
||||
const SPRITE_ARRAYS: u32 = 4;
|
||||
/// The views of terrain.wgsl's sprite arrays, the unused ones a 1-layer
|
||||
/// stand-in.
|
||||
fn sprite_views(
|
||||
textures: &[wgpu::Texture],
|
||||
unused: &wgpu::TextureView,
|
||||
mips: Option<u32>,
|
||||
) -> Vec<wgpu::TextureView> {
|
||||
(0..SPRITE_ARRAYS as usize)
|
||||
.map(|i| match textures.get(i) {
|
||||
Some(t) => t.create_view(&wgpu::TextureViewDescriptor {
|
||||
dimension: Some(wgpu::TextureViewDimension::D2Array),
|
||||
mip_level_count: mips,
|
||||
..Default::default()
|
||||
}),
|
||||
None => unused.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// terrain.wgsl's group 0.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn terrain_group(
|
||||
device: &wgpu::Device,
|
||||
label: &str,
|
||||
layout: &wgpu::BindGroupLayout,
|
||||
globals: &wgpu::Buffer,
|
||||
sprites: &[wgpu::TextureView],
|
||||
sprite_sampler: &wgpu::Sampler,
|
||||
lightmap: &wgpu::TextureView,
|
||||
lightmap_sampler: &wgpu::Sampler,
|
||||
) -> wgpu::BindGroup {
|
||||
let view = |binding, view| wgpu::BindGroupEntry {
|
||||
binding,
|
||||
resource: wgpu::BindingResource::TextureView(view),
|
||||
};
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some(label),
|
||||
layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: globals.as_entire_binding(),
|
||||
},
|
||||
view(1, &sprites[0]),
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::Sampler(sprite_sampler),
|
||||
},
|
||||
view(3, lightmap),
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: wgpu::BindingResource::Sampler(lightmap_sampler),
|
||||
},
|
||||
view(5, &sprites[1]),
|
||||
view(6, &sprites[2]),
|
||||
view(7, &sprites[3]),
|
||||
],
|
||||
})
|
||||
}
|
||||
fn mip_chain(
|
||||
pixels: &[u8],
|
||||
size: u32,
|
||||
@@ -454,8 +544,14 @@ impl Renderer {
|
||||
hud_atlas: (&[u8], u32, u32),
|
||||
forcefield: (&[u8], u32),
|
||||
panorama_faces: &panorama::Faces,
|
||||
previous: Option<&Self>,
|
||||
) -> Result<Self> {
|
||||
let instance = wgpu::Instance::new(instance_descriptor());
|
||||
// Reload resources on the same device; replacing live Vulkan devices
|
||||
// can invalidate the driver's swapchain state on the same window.
|
||||
let instance = previous.map_or_else(
|
||||
|| wgpu::Instance::new(instance_descriptor()),
|
||||
|renderer| renderer.instance.clone(),
|
||||
);
|
||||
// Offscreen rendering needs no surface, so it can use a GPU the
|
||||
// window's display cannot present from (a headless compositor).
|
||||
let surface = if offscreen {
|
||||
@@ -463,15 +559,18 @@ impl Renderer {
|
||||
} else {
|
||||
Some(instance.create_surface(window.clone())?)
|
||||
};
|
||||
let adapter = instance
|
||||
.request_adapter(&wgpu::RequestAdapterOptions {
|
||||
power_preference: wgpu::PowerPreference::HighPerformance,
|
||||
compatible_surface: surface.as_ref(),
|
||||
force_fallback_adapter: false,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("{NO_GPU_DRIVER} ({e})"))?;
|
||||
let adapter = match previous {
|
||||
Some(renderer) => renderer.adapter.clone(),
|
||||
None => instance
|
||||
.request_adapter(&wgpu::RequestAdapterOptions {
|
||||
power_preference: wgpu::PowerPreference::HighPerformance,
|
||||
compatible_surface: surface.as_ref(),
|
||||
force_fallback_adapter: false,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("{NO_GPU_DRIVER} ({e})"))?,
|
||||
};
|
||||
let info = adapter.get_info();
|
||||
log::info!(
|
||||
"Horizon renderer: {} ({:?}, {:?})",
|
||||
@@ -494,22 +593,40 @@ impl Renderer {
|
||||
),
|
||||
);
|
||||
let limits = adapter.limits();
|
||||
if limits.max_texture_array_layers < layers {
|
||||
let split = sprite_split();
|
||||
if layers > split * SPRITE_ARRAYS {
|
||||
return Err(format!(
|
||||
"GPU supports {} texture layers; {layers} required",
|
||||
limits.max_texture_array_layers
|
||||
"{layers} block textures; Horizon draws at most {}",
|
||||
split * SPRITE_ARRAYS
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let (device, queue) = adapter
|
||||
.request_device(&wgpu::DeviceDescriptor {
|
||||
label: Some("Horizon device"),
|
||||
required_features: timing::GpuTimer::features(&adapter)
|
||||
| (adapter.features() & wgpu::Features::INDIRECT_FIRST_INSTANCE),
|
||||
required_limits: limits.clone(),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
if limits.max_texture_array_layers < layers.min(split) {
|
||||
return Err(format!(
|
||||
"GPU supports {} texture layers; {} required",
|
||||
limits.max_texture_array_layers,
|
||||
layers.min(split)
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let (device, queue) = match previous {
|
||||
Some(renderer) => (renderer.device.clone(), renderer.queue.clone()),
|
||||
None => {
|
||||
adapter
|
||||
.request_device(&wgpu::DeviceDescriptor {
|
||||
label: Some("Horizon device"),
|
||||
// Mappable primary buffers: section pages that no
|
||||
// longer fit in video memory go to system memory.
|
||||
required_features: timing::GpuTimer::features(&adapter)
|
||||
| (adapter.features()
|
||||
& (wgpu::Features::INDIRECT_FIRST_INSTANCE
|
||||
| wgpu::Features::MAPPABLE_PRIMARY_BUFFERS)),
|
||||
required_limits: limits.clone(),
|
||||
..Default::default()
|
||||
})
|
||||
.await?
|
||||
}
|
||||
};
|
||||
let size = window.inner_size();
|
||||
let (config, present_modes) = if let Some(surface) = &surface {
|
||||
let mut config = surface
|
||||
@@ -528,11 +645,16 @@ impl Renderer {
|
||||
if caps.usages.contains(wgpu::TextureUsages::COPY_SRC) {
|
||||
config.usage |= wgpu::TextureUsages::COPY_SRC;
|
||||
}
|
||||
config.present_mode = if caps.present_modes.contains(&wgpu::PresentMode::Mailbox) {
|
||||
wgpu::PresentMode::Mailbox
|
||||
} else {
|
||||
wgpu::PresentMode::Fifo
|
||||
};
|
||||
config.present_mode = previous
|
||||
.map(|renderer| renderer.config.present_mode)
|
||||
.filter(|mode| caps.present_modes.contains(mode))
|
||||
.unwrap_or_else(|| {
|
||||
if caps.present_modes.contains(&wgpu::PresentMode::Mailbox) {
|
||||
wgpu::PresentMode::Mailbox
|
||||
} else {
|
||||
wgpu::PresentMode::Fifo
|
||||
}
|
||||
});
|
||||
surface.configure(&device, &config);
|
||||
(config, caps.present_modes.clone())
|
||||
} else {
|
||||
@@ -565,6 +687,19 @@ impl Renderer {
|
||||
count: None,
|
||||
};
|
||||
let vf = wgpu::ShaderStages::VERTEX_FRAGMENT;
|
||||
let sprite_array = wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2Array,
|
||||
multisampled: false,
|
||||
};
|
||||
let sprite_arrays = layers.div_ceil(split).max(1);
|
||||
if sprite_arrays > 1 {
|
||||
log::info!("Horizon: {layers} block textures in {sprite_arrays} arrays");
|
||||
}
|
||||
let sprite_constants = [
|
||||
("SPRITE_ARRAYS", f64::from(sprite_arrays)),
|
||||
("SPRITE_SPLIT", f64::from(split)),
|
||||
];
|
||||
let bindings = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Horizon terrain bindings"),
|
||||
entries: &[
|
||||
@@ -605,6 +740,9 @@ impl Renderer {
|
||||
vf,
|
||||
wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
),
|
||||
entry(5, vf, sprite_array),
|
||||
entry(6, vf, sprite_array),
|
||||
entry(7, vf, sprite_array),
|
||||
],
|
||||
});
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
@@ -677,7 +815,10 @@ impl Renderer {
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some(fragment),
|
||||
compilation_options: Default::default(),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &sprite_constants,
|
||||
..Default::default()
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: config.format.remove_srgb_suffix(),
|
||||
blend,
|
||||
@@ -707,7 +848,7 @@ impl Renderer {
|
||||
&terrain_buffers,
|
||||
tri,
|
||||
Some(wgpu::Face::Back),
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
Some(alpha_blend),
|
||||
);
|
||||
@@ -740,7 +881,7 @@ impl Renderer {
|
||||
&line_buffers,
|
||||
wgpu::PrimitiveTopology::TriangleStrip,
|
||||
None,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
Some(alpha_blend),
|
||||
);
|
||||
@@ -784,30 +925,62 @@ impl Renderer {
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let (mips, mip_levels) = mip_chain(sprite_pixels, sprite_size, sprite_mips, 0, layers);
|
||||
let sprite_texture = device.create_texture_with_data(
|
||||
&queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: Some("Block sprites"),
|
||||
let layer_bytes = (sprite_size * sprite_size * 4) as usize;
|
||||
let mut mip_levels = 1;
|
||||
let sprite_textures: Vec<wgpu::Texture> = (0..sprite_arrays)
|
||||
.map(|array| {
|
||||
let first = array * split;
|
||||
let count = layers.saturating_sub(first).clamp(1, split);
|
||||
let start = (first as usize * layer_bytes).min(sprite_pixels.len());
|
||||
let (mips, levels) = mip_chain(
|
||||
&sprite_pixels[start..],
|
||||
sprite_size,
|
||||
sprite_mips,
|
||||
first,
|
||||
count,
|
||||
);
|
||||
mip_levels = levels;
|
||||
device.create_texture_with_data(
|
||||
&queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: Some("Block sprites"),
|
||||
size: wgpu::Extent3d {
|
||||
width: sprite_size,
|
||||
height: sprite_size,
|
||||
depth_or_array_layers: count,
|
||||
},
|
||||
mip_level_count: levels,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
&mips,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let sprite_unused = device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Block sprites (unused)"),
|
||||
size: wgpu::Extent3d {
|
||||
width: sprite_size,
|
||||
height: sprite_size,
|
||||
depth_or_array_layers: layers,
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: mip_levels,
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
&mips,
|
||||
);
|
||||
let sprite_view = sprite_texture.create_view(&wgpu::TextureViewDescriptor {
|
||||
dimension: Some(wgpu::TextureViewDimension::D2Array),
|
||||
..Default::default()
|
||||
});
|
||||
})
|
||||
.create_view(&wgpu::TextureViewDescriptor {
|
||||
dimension: Some(wgpu::TextureViewDimension::D2Array),
|
||||
..Default::default()
|
||||
});
|
||||
let sprite_views = sprite_views(&sprite_textures, &sprite_unused, None);
|
||||
let sprite_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Block sprites"),
|
||||
address_mode_u: wgpu::AddressMode::Repeat,
|
||||
@@ -838,58 +1011,26 @@ impl Renderer {
|
||||
min_filter: wgpu::FilterMode::Linear,
|
||||
..Default::default()
|
||||
});
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Horizon terrain resources"),
|
||||
layout: &bindings,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: globals.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(&sprite_view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::Sampler(&sprite_sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::TextureView(&lightmap_view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: wgpu::BindingResource::Sampler(&lightmap_sampler),
|
||||
},
|
||||
],
|
||||
});
|
||||
let crosshair_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Debug crosshair resources"),
|
||||
layout: &bindings,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: crosshair_globals.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(&sprite_view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::Sampler(&sprite_sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::TextureView(&lightmap_view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: wgpu::BindingResource::Sampler(&lightmap_sampler),
|
||||
},
|
||||
],
|
||||
});
|
||||
let group = terrain_group(
|
||||
&device,
|
||||
"Horizon terrain resources",
|
||||
&bindings,
|
||||
&globals,
|
||||
&sprite_views,
|
||||
&sprite_sampler,
|
||||
&lightmap_view,
|
||||
&lightmap_sampler,
|
||||
);
|
||||
let crosshair_group = terrain_group(
|
||||
&device,
|
||||
"Debug crosshair resources",
|
||||
&bindings,
|
||||
&crosshair_globals,
|
||||
&sprite_views,
|
||||
&sprite_sampler,
|
||||
&lightmap_view,
|
||||
&lightmap_sampler,
|
||||
);
|
||||
let index_quads = 1 << 16;
|
||||
let indices = Self::quad_indices(&device, index_quads);
|
||||
let instance_capacity = 4096;
|
||||
@@ -1504,6 +1645,7 @@ impl Renderer {
|
||||
std::mem::size_of::<TerrainVertex>() as u64,
|
||||
1 << 20,
|
||||
);
|
||||
let sorted_indices = arena::Arena::indices(&device, 1 << 16);
|
||||
let indirect = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Section draws"),
|
||||
size: 1024 * 20,
|
||||
@@ -1523,6 +1665,7 @@ impl Renderer {
|
||||
);
|
||||
Ok(Self {
|
||||
instance,
|
||||
adapter,
|
||||
surface,
|
||||
offscreen,
|
||||
in_flight: std::collections::VecDeque::new(),
|
||||
@@ -1575,7 +1718,9 @@ impl Renderer {
|
||||
group,
|
||||
globals,
|
||||
lightmap,
|
||||
sprite_texture,
|
||||
sprite_textures,
|
||||
sprite_unused,
|
||||
sprite_split: split,
|
||||
mip_levels,
|
||||
mipmaps: mip_levels - 1,
|
||||
terrain_layout: bindings,
|
||||
@@ -1594,6 +1739,9 @@ impl Renderer {
|
||||
depth,
|
||||
sections: resident::Resident::default(),
|
||||
arena,
|
||||
sorted_indices,
|
||||
translucent_sorter: translucency::Sorter::default(),
|
||||
translucency_cursor: 0,
|
||||
indirect,
|
||||
indirect_capacity: 1024,
|
||||
multi_draw,
|
||||
@@ -1616,6 +1764,7 @@ impl Renderer {
|
||||
screenshot: None,
|
||||
screenshot_result: None,
|
||||
gamma: 0.5,
|
||||
fullbright: false,
|
||||
animated_textures: true,
|
||||
present_modes,
|
||||
})
|
||||
@@ -1656,6 +1805,22 @@ impl Renderer {
|
||||
pub fn is_offscreen(&self) -> bool {
|
||||
self.surface.is_none()
|
||||
}
|
||||
/// Release the window surface before another renderer uses the same window.
|
||||
pub fn release_surface(&mut self) {
|
||||
self.surface = None;
|
||||
}
|
||||
/// Reattach a window after surface loss or a failed renderer replacement.
|
||||
pub fn recreate_surface(&mut self) -> Result<()> {
|
||||
if self.offscreen.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
// Wayland allows only one tearing-control object per window surface.
|
||||
self.release_surface();
|
||||
let surface = self.instance.create_surface(self.window.clone())?;
|
||||
surface.configure(&self.device, &self.config);
|
||||
self.surface = Some(surface);
|
||||
Ok(())
|
||||
}
|
||||
fn depth(device: &wgpu::Device, config: &wgpu::SurfaceConfiguration) -> wgpu::TextureView {
|
||||
device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
@@ -1760,7 +1925,22 @@ impl Renderer {
|
||||
self.indices = Self::quad_indices(&self.device, self.index_quads);
|
||||
}
|
||||
let vertices = (mesh.solid.len() + mesh.translucent.len()) as u64;
|
||||
let (page, start) = self.arena.alloc(&self.device, &self.queue, vertices);
|
||||
// With video and system memory both full, the section is not drawn.
|
||||
let Some((page, start)) = self.arena.alloc(&self.device, &self.queue, vertices) else {
|
||||
return;
|
||||
};
|
||||
let sorted_indices = if translucent > 0 {
|
||||
let indices =
|
||||
self.sorted_indices
|
||||
.alloc(&self.device, &self.queue, u64::from(translucent) * 6);
|
||||
if indices.is_none() {
|
||||
self.arena.release(page, start, vertices);
|
||||
return;
|
||||
}
|
||||
indices
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let stride = std::mem::size_of::<TerrainVertex>() as u64;
|
||||
for (offset, part) in [(0, &mesh.solid), (mesh.solid.len(), &mesh.translucent)] {
|
||||
if !part.is_empty() {
|
||||
@@ -1781,6 +1961,15 @@ impl Renderer {
|
||||
solid,
|
||||
translucent,
|
||||
faces: mesh.faces,
|
||||
sort: translucency::SortState::new(
|
||||
mesh.translucent
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|q| translucency::quad_center(q[0].pos, q[2].pos))
|
||||
.collect(),
|
||||
),
|
||||
sorted_indices,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1791,17 +1980,19 @@ impl Renderer {
|
||||
fn draw_sections(&self, pass: &mut wgpu::RenderPass<'_>, first: u32, count: u32) {
|
||||
let (mut i, end) = (first as usize, (first + count) as usize);
|
||||
while i < end {
|
||||
let page = self.draw_pages[i];
|
||||
let (page, index_page) = self.draw_pages[i];
|
||||
let run = self.draw_pages[i..end]
|
||||
.iter()
|
||||
.take_while(|&&p| p == page)
|
||||
.take_while(|&&p| p == (page, index_page))
|
||||
.count();
|
||||
pass.set_vertex_buffer(0, self.arena.buffer(page).slice(..));
|
||||
let indices = index_page.map_or(&self.indices, |p| self.sorted_indices.buffer(p));
|
||||
pass.set_index_buffer(indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
if self.multi_draw {
|
||||
pass.multi_draw_indexed_indirect(&self.indirect, i as u64 * 20, run as u32);
|
||||
} else {
|
||||
for d in &self.draws[i..i + run] {
|
||||
pass.draw_indexed(0..d[0], d[3] as i32, d[4]..d[4] + 1);
|
||||
pass.draw_indexed(d[2]..d[2] + d[0], d[3] as i32, d[4]..d[4] + 1);
|
||||
}
|
||||
}
|
||||
i += run;
|
||||
@@ -1809,6 +2000,10 @@ impl Renderer {
|
||||
}
|
||||
fn release_section(&mut self, key: SectionKey) {
|
||||
if let Some(s) = self.sections.remove(key) {
|
||||
if let Some((page, start)) = s.sorted_indices {
|
||||
self.sorted_indices
|
||||
.release(page, start, u64::from(s.translucent) * 6);
|
||||
}
|
||||
self.arena
|
||||
.release(s.page, s.start, u64::from(s.solid + s.translucent) * 4);
|
||||
}
|
||||
@@ -1821,6 +2016,8 @@ impl Renderer {
|
||||
self.block_entities.clear();
|
||||
self.sections.clear();
|
||||
self.arena.clear();
|
||||
self.sorted_indices.clear();
|
||||
self.translucency_cursor = 0;
|
||||
self.occlusion.clear();
|
||||
}
|
||||
pub fn remove(&mut self, key: SectionKey) {
|
||||
@@ -1864,7 +2061,7 @@ impl Renderer {
|
||||
pub fn set_mipmap_levels(&mut self, levels: u32) {
|
||||
let levels = sprite_mipmap_limit(
|
||||
levels,
|
||||
self.sprite_texture.width(),
|
||||
self.sprite_textures[0].width(),
|
||||
self.sprite_minimum_size,
|
||||
self.mip_levels,
|
||||
);
|
||||
@@ -1872,39 +2069,17 @@ impl Renderer {
|
||||
return;
|
||||
}
|
||||
self.mipmaps = levels;
|
||||
let sprite_view = self
|
||||
.sprite_texture
|
||||
.create_view(&wgpu::TextureViewDescriptor {
|
||||
dimension: Some(wgpu::TextureViewDimension::D2Array),
|
||||
mip_level_count: Some(levels + 1),
|
||||
..Default::default()
|
||||
});
|
||||
self.group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Horizon terrain resources"),
|
||||
layout: &self.terrain_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: self.globals.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(&sprite_view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::Sampler(&self.sprite_sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::TextureView(&self.lightmap_view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: wgpu::BindingResource::Sampler(&self.lightmap_sampler),
|
||||
},
|
||||
],
|
||||
});
|
||||
let views = sprite_views(&self.sprite_textures, &self.sprite_unused, Some(levels + 1));
|
||||
self.group = terrain_group(
|
||||
&self.device,
|
||||
"Horizon terrain resources",
|
||||
&self.terrain_layout,
|
||||
&self.globals,
|
||||
&views,
|
||||
&self.sprite_sampler,
|
||||
&self.lightmap_view,
|
||||
&self.lightmap_sampler,
|
||||
);
|
||||
}
|
||||
pub fn view_projection(&self, frame: &Frame) -> Mat4 {
|
||||
self.projection(
|
||||
@@ -1972,6 +2147,9 @@ impl Renderer {
|
||||
pixels[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
if self.fullbright {
|
||||
pixels.fill(255);
|
||||
}
|
||||
// The lightmap changes with the time of day, flicker and effects,
|
||||
// not every frame: upload only what differs from the texture.
|
||||
if pixels == self.lightmap_pixels && self.lightmap_uploaded {
|
||||
@@ -2001,12 +2179,12 @@ impl Renderer {
|
||||
self.uploads.texture(
|
||||
encoder,
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture: &self.sprite_texture,
|
||||
texture: &self.sprite_textures[(layer / self.sprite_split) as usize],
|
||||
mip_level: level,
|
||||
origin: wgpu::Origin3d {
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: layer,
|
||||
z: layer % self.sprite_split,
|
||||
},
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
@@ -2018,6 +2196,8 @@ impl Renderer {
|
||||
);
|
||||
}
|
||||
pub fn render(&mut self, frame: &Frame) -> Result<bool> {
|
||||
self.arena.trim();
|
||||
self.sorted_indices.trim();
|
||||
if self.suspended {
|
||||
return Ok(false);
|
||||
}
|
||||
@@ -2039,9 +2219,7 @@ impl Renderer {
|
||||
return Ok(false);
|
||||
}
|
||||
Some(wgpu::CurrentSurfaceTexture::Lost) => {
|
||||
let surface = self.instance.create_surface(self.window.clone())?;
|
||||
surface.configure(&self.device, &self.config);
|
||||
self.surface = Some(surface);
|
||||
self.recreate_surface()?;
|
||||
return Ok(false);
|
||||
}
|
||||
Some(wgpu::CurrentSurfaceTexture::Validation) => {
|
||||
@@ -2217,7 +2395,7 @@ impl Renderer {
|
||||
visible.clear();
|
||||
self.sections.visible(
|
||||
camera,
|
||||
frame.distance,
|
||||
(frame.distance / 16.0) as i32,
|
||||
&view,
|
||||
|k| !culling || reachable.contains(k),
|
||||
&mut visible,
|
||||
@@ -2263,19 +2441,51 @@ impl Renderer {
|
||||
s.faces.ranges(eye - Vec3::from(v.origin), |first, quads| {
|
||||
let first = s.start + u64::from(first) * 4;
|
||||
self.draws.push([quads * 6, 1, 0, first as u32, i as u32]);
|
||||
self.draw_pages.push(s.page);
|
||||
self.draw_pages.push((s.page, None));
|
||||
});
|
||||
}
|
||||
let solid_draws = self.draws.len() as u32;
|
||||
// LevelRenderer: nearby sections each frame, plus a rotating eighth
|
||||
// (at least 15) of the visible list. Cached views avoid needless sorts.
|
||||
let count = visible.len();
|
||||
let budget = (count / 8).max(15).min(count);
|
||||
self.translucency_cursor %= count.max(1);
|
||||
for (i, v) in visible.iter().enumerate().rev() {
|
||||
let s = self.sections.get(v.slot);
|
||||
if s.translucent > 0 {
|
||||
let s = self.sections.get_mut(v.slot);
|
||||
if let Some((index_page, index_start)) = s.sorted_indices {
|
||||
let nearby = (v.key.0 - camera_section.0).abs() <= 2
|
||||
&& (v.key.1 - camera_section.1).abs() <= 2
|
||||
&& (v.key.2 - camera_section.2).abs() <= 2;
|
||||
let scheduled = (i + count - self.translucency_cursor) % count < budget;
|
||||
let origin = [
|
||||
v.key.0 * 16,
|
||||
crate::chunk::MIN_Y + v.key.1 * 16,
|
||||
v.key.2 * 16,
|
||||
];
|
||||
if (!s.sort.is_sorted() || nearby || scheduled)
|
||||
&& let Some(indices) =
|
||||
s.sort
|
||||
.update(camera.to_array(), origin, &mut self.translucent_sorter)
|
||||
{
|
||||
self.uploads.buffer(
|
||||
&mut encoder,
|
||||
self.sorted_indices.buffer(index_page),
|
||||
index_start * 4,
|
||||
bytemuck::cast_slice(indices),
|
||||
);
|
||||
}
|
||||
let first = s.start + u64::from(s.solid) * 4;
|
||||
self.draws
|
||||
.push([s.translucent * 6, 1, 0, first as u32, i as u32]);
|
||||
self.draw_pages.push(s.page);
|
||||
self.draws.push([
|
||||
s.translucent * 6,
|
||||
1,
|
||||
index_start as u32,
|
||||
first as u32,
|
||||
i as u32,
|
||||
]);
|
||||
self.draw_pages.push((s.page, Some(index_page)));
|
||||
}
|
||||
}
|
||||
self.translucency_cursor += budget;
|
||||
let translucent_draws = self.draws.len() as u32 - solid_draws;
|
||||
if self.multi_draw && !self.draws.is_empty() {
|
||||
if self.draws.len() as u64 > self.indirect_capacity {
|
||||
@@ -2537,7 +2747,7 @@ impl Renderer {
|
||||
pass.draw_indexed(0..additive_quads * 6, (world_quads * 4) as i32, 0..1);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if outline_edges > 0 {
|
||||
if outline_edges > 0 && !frame.outline_after_terrain {
|
||||
pass.set_pipeline(&self.lines);
|
||||
pass.set_vertex_buffer(0, self.outline.slice(..));
|
||||
pass.draw(0..4, 0..outline_edges as u32);
|
||||
@@ -2556,6 +2766,14 @@ impl Renderer {
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.set_vertex_buffer(1, self.instances.slice(..));
|
||||
self.draw_sections(&mut pass, solid_draws, translucent_draws);
|
||||
// LevelRenderer.executeClassicTransparency: translucent targets'
|
||||
// outlines follow terrain, so its blending cannot wash them out.
|
||||
if outline_edges > 0 && frame.outline_after_terrain {
|
||||
pass.set_pipeline(&self.lines);
|
||||
pass.set_vertex_buffer(0, self.outline.slice(..));
|
||||
pass.draw(0..4, 0..outline_edges as u32);
|
||||
}
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
// LevelRenderer: the world border after translucent terrain and weather.
|
||||
if let Some(b) = frame.border.as_ref().filter(|_| !frame.title) {
|
||||
pass.set_pipeline(&self.border_pipeline);
|
||||
@@ -2776,6 +2994,7 @@ impl Renderer {
|
||||
&mut self.uploads,
|
||||
&mut encoder,
|
||||
&view_target,
|
||||
&self.depth,
|
||||
frame.hud,
|
||||
[self.config.width, self.config.height],
|
||||
);
|
||||
@@ -3288,13 +3507,39 @@ fn mix_frames(current: &mut [u8], next: &[u8], millis: u32) {
|
||||
let t = millis as f32 / 1000.0;
|
||||
for (c, n) in current.iter_mut().zip(next) {
|
||||
let (a, b) = (f32::from(*c) / 255.0, f32::from(*n) / 255.0);
|
||||
*c = ((a * (1.0 - t) + b * t) * 255.0).round() as u8;
|
||||
*c = round_unorm8((a * (1.0 - t) + b * t) * 255.0);
|
||||
}
|
||||
}
|
||||
/// `x.round() as u8` for `x >= 0`, without the `roundf` library call that
|
||||
/// x86-64 makes for every `f32::round` (it has no rounding instruction before
|
||||
/// SSE4.1): the fraction `x - trunc(x)` is exact below 2^23, so rounding half
|
||||
/// away from zero is adding one when it is at least a half.
|
||||
#[inline]
|
||||
fn round_unorm8(x: f32) -> u8 {
|
||||
let whole = x as u8;
|
||||
whole.saturating_add(u8::from(x - f32::from(whole) >= 0.5))
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod sprite_upload_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn blended_frames_round_like_f32_round() {
|
||||
for millis in (0..1000).step_by(7).chain([999]) {
|
||||
let t = millis as f32 / 1000.0;
|
||||
for a in 0..=255u8 {
|
||||
for b in 0..=255u8 {
|
||||
let x = (f32::from(a) / 255.0 * (1.0 - t) + f32::from(b) / 255.0 * t) * 255.0;
|
||||
assert_eq!(round_unorm8(x), x.round() as u8, "{a} {b} {millis}");
|
||||
}
|
||||
}
|
||||
}
|
||||
for x in [
|
||||
0.0, 0.49999997, 0.5, 1.5, 2.5, 254.49998, 254.5, 255.0, 255.00002, 300.0,
|
||||
] {
|
||||
assert_eq!(round_unorm8(x), x.round() as u8, "{x}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn real_rail_mips_do_not_change_with_larger_array_layers() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3");
|
||||
|
||||
@@ -49,35 +49,79 @@ struct Page {
|
||||
/// Terrain vertices in pages of at most `page_limit` vertices (devices cap
|
||||
/// buffer sizes): a section's vertices sit in one page.
|
||||
pub struct Arena {
|
||||
pages: Vec<Page>,
|
||||
pages: Vec<Option<Page>>,
|
||||
stride: u64,
|
||||
page_limit: u64,
|
||||
initial_capacity: u64,
|
||||
usage: wgpu::BufferUsages,
|
||||
/// A page had to go to system memory (logged once).
|
||||
spilled: bool,
|
||||
/// Tests: GPU-local pages fail as if video memory were full.
|
||||
#[cfg(test)]
|
||||
video_memory_full: bool,
|
||||
}
|
||||
|
||||
impl Arena {
|
||||
pub fn new(device: &wgpu::Device, stride: u64, capacity: u64) -> Self {
|
||||
Self::with_usage(device, stride, capacity, wgpu::BufferUsages::VERTEX)
|
||||
}
|
||||
pub fn indices(device: &wgpu::Device, capacity: u64) -> Self {
|
||||
Self::with_usage(device, 4, capacity, wgpu::BufferUsages::INDEX)
|
||||
}
|
||||
fn with_usage(
|
||||
device: &wgpu::Device,
|
||||
stride: u64,
|
||||
capacity: u64,
|
||||
usage: wgpu::BufferUsages,
|
||||
) -> Self {
|
||||
// 256 MiB pages, or less where the device's buffers are smaller.
|
||||
let bytes = device.limits().max_buffer_size.min(256 << 20);
|
||||
let page_limit = (bytes / stride) / GRAIN * GRAIN;
|
||||
let capacity = capacity.next_multiple_of(GRAIN).min(page_limit);
|
||||
Self {
|
||||
pages: vec![Page {
|
||||
buffer: Self::create(device, stride, capacity),
|
||||
capacity,
|
||||
free: FreeList::new(capacity),
|
||||
}],
|
||||
pages: Vec::new(),
|
||||
stride,
|
||||
page_limit,
|
||||
initial_capacity: capacity,
|
||||
usage,
|
||||
spilled: false,
|
||||
#[cfg(test)]
|
||||
video_memory_full: false,
|
||||
}
|
||||
}
|
||||
fn create(device: &wgpu::Device, stride: u64, capacity: u64) -> wgpu::Buffer {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Section vertex arena"),
|
||||
size: capacity * stride,
|
||||
usage: wgpu::BufferUsages::VERTEX
|
||||
| wgpu::BufferUsages::COPY_DST
|
||||
| wgpu::BufferUsages::COPY_SRC,
|
||||
mapped_at_creation: false,
|
||||
/// A buffer of `capacity` vertices in video memory or, when that is full
|
||||
/// and the device allows it, in system memory: Vulkan drivers fail an
|
||||
/// allocation rather than page like OpenGL ones, and wgpu's default error
|
||||
/// handler panics. `None` when neither has room.
|
||||
fn create(&mut self, device: &wgpu::Device, capacity: u64) -> Option<wgpu::Buffer> {
|
||||
let usage = self.usage | wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC;
|
||||
#[cfg(test)]
|
||||
let local = (!self.video_memory_full)
|
||||
.then(|| try_create(device, capacity * self.stride, usage))
|
||||
.flatten();
|
||||
#[cfg(not(test))]
|
||||
let local = try_create(device, capacity * self.stride, usage);
|
||||
local.or_else(|| {
|
||||
// Mappable buffers are placed in host-visible memory, which the
|
||||
// allocator takes from system memory once video memory is full.
|
||||
if !device
|
||||
.features()
|
||||
.contains(wgpu::Features::MAPPABLE_PRIMARY_BUFFERS)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let buffer = try_create(
|
||||
device,
|
||||
capacity * self.stride,
|
||||
usage | wgpu::BufferUsages::MAP_WRITE,
|
||||
)?;
|
||||
if !std::mem::replace(&mut self.spilled, true) {
|
||||
log::warn!(
|
||||
"Horizon: video memory is full; more terrain is kept in system memory \
|
||||
(lower the render distance if frames slow down)"
|
||||
);
|
||||
}
|
||||
Some(buffer)
|
||||
})
|
||||
}
|
||||
/// The rounded length a request of `vertices` occupies.
|
||||
@@ -86,55 +130,97 @@ impl Arena {
|
||||
}
|
||||
/// Bytes of every page's buffer.
|
||||
pub fn bytes(&self) -> u64 {
|
||||
self.pages.iter().map(|p| p.capacity).sum::<u64>() * self.stride
|
||||
self.pages.iter().flatten().map(|p| p.capacity).sum::<u64>() * self.stride
|
||||
}
|
||||
pub fn buffer(&self, page: u32) -> &wgpu::Buffer {
|
||||
&self.pages[page as usize].buffer
|
||||
&self.pages[page as usize]
|
||||
.as_ref()
|
||||
.expect("live arena page")
|
||||
.buffer
|
||||
}
|
||||
/// A range of `vertices`: (page, first vertex). Grows the newest page
|
||||
/// (copying its contents on the GPU) or starts a new one when nothing
|
||||
/// free is large enough.
|
||||
/// free is large enough; `None` when no memory is left for it.
|
||||
pub fn alloc(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
vertices: u64,
|
||||
) -> (u32, u64) {
|
||||
) -> Option<(u32, u64)> {
|
||||
let len = Self::span(vertices).min(self.page_limit);
|
||||
for (i, page) in self.pages.iter_mut().enumerate() {
|
||||
if let Some(start) = page.free.take(len) {
|
||||
return (i as u32, start);
|
||||
if let Some(page) = page
|
||||
&& let Some(start) = page.free.take(len)
|
||||
{
|
||||
return Some((i as u32, start));
|
||||
}
|
||||
}
|
||||
let last = self.pages.len() - 1;
|
||||
let current = self.pages[last].capacity;
|
||||
if current < self.page_limit {
|
||||
let last = self.pages.len().saturating_sub(1);
|
||||
if let Some(page) = self.pages.last().and_then(Option::as_ref)
|
||||
&& page.capacity < self.page_limit
|
||||
{
|
||||
let current = page.capacity;
|
||||
let grown = (current * 2).max(current + len).min(self.page_limit);
|
||||
if grown - current >= len || grown == self.page_limit {
|
||||
self.grow(device, queue, last, grown);
|
||||
if let Some(start) = self.pages[last].free.take(len) {
|
||||
return (last as u32, start);
|
||||
}
|
||||
if (grown - current >= len || grown == self.page_limit)
|
||||
&& self.grow(device, queue, last, grown)
|
||||
&& let Some(start) = self.pages[last].as_mut().unwrap().free.take(len)
|
||||
{
|
||||
return Some((last as u32, start));
|
||||
}
|
||||
}
|
||||
let capacity = (1 << 20u64).max(len).min(self.page_limit);
|
||||
let capacity = if self.pages.is_empty() {
|
||||
self.initial_capacity
|
||||
} else {
|
||||
1 << 20
|
||||
}
|
||||
.max(len)
|
||||
.min(self.page_limit);
|
||||
// A page just large enough when memory is short.
|
||||
let (buffer, capacity) = match self.create(device, capacity) {
|
||||
Some(buffer) => (buffer, capacity),
|
||||
None if capacity > len => (self.create(device, len)?, len),
|
||||
None => return None,
|
||||
};
|
||||
let mut free = FreeList::new(capacity);
|
||||
let start = free.take(len).expect("a new page fits one section");
|
||||
self.pages.push(Page {
|
||||
buffer: Self::create(device, self.stride, capacity),
|
||||
let page = Page {
|
||||
buffer,
|
||||
capacity,
|
||||
free,
|
||||
});
|
||||
((self.pages.len() - 1) as u32, start)
|
||||
};
|
||||
let index = self
|
||||
.pages
|
||||
.iter()
|
||||
.position(Option::is_none)
|
||||
.unwrap_or_else(|| {
|
||||
self.pages.push(None);
|
||||
self.pages.len() - 1
|
||||
});
|
||||
self.pages[index] = Some(page);
|
||||
Some((index as u32, start))
|
||||
}
|
||||
pub fn release(&mut self, page: u32, start: u64, vertices: u64) {
|
||||
let len = Self::span(vertices).min(self.page_limit);
|
||||
self.pages[page as usize].free.give(start, len);
|
||||
self.pages[page as usize]
|
||||
.as_mut()
|
||||
.expect("live arena page")
|
||||
.free
|
||||
.give(start, len);
|
||||
}
|
||||
fn grow(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, page: usize, capacity: u64) {
|
||||
/// Moves a page into a larger buffer; false (the page unchanged) when
|
||||
/// there is no memory for it.
|
||||
fn grow(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
page: usize,
|
||||
capacity: u64,
|
||||
) -> bool {
|
||||
let capacity = capacity.next_multiple_of(GRAIN).min(self.page_limit);
|
||||
let buffer = Self::create(device, self.stride, capacity);
|
||||
let old = &mut self.pages[page];
|
||||
let Some(buffer) = self.create(device, capacity) else {
|
||||
return false;
|
||||
};
|
||||
let old = self.pages[page].as_mut().expect("live arena page");
|
||||
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||
label: Some("Grow section arena"),
|
||||
});
|
||||
@@ -145,18 +231,124 @@ impl Arena {
|
||||
old.buffer = buffer;
|
||||
old.capacity = capacity;
|
||||
old.free.give(previous, capacity - previous);
|
||||
true
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
/// Reclaim after an upload batch, so replacing a section can reuse its range.
|
||||
pub fn trim(&mut self) {
|
||||
for page in &mut self.pages {
|
||||
page.free = FreeList::new(page.capacity);
|
||||
if let Some(p) = page
|
||||
&& p.free.0.len() == 1
|
||||
&& p.free.0.get(&0) == Some(&p.capacity)
|
||||
{
|
||||
*page = None;
|
||||
}
|
||||
}
|
||||
// Keep live page indices stable; later allocations reuse interior holes.
|
||||
while self.pages.last().is_some_and(Option::is_none) {
|
||||
self.pages.pop();
|
||||
}
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.pages.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// `create_buffer` with an out-of-memory failure returned instead of raised
|
||||
/// (raised errors are fatal).
|
||||
fn try_create(device: &wgpu::Device, size: u64, usage: wgpu::BufferUsages) -> Option<wgpu::Buffer> {
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Section buffer arena"),
|
||||
size,
|
||||
usage,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
// Native error scopes resolve at once.
|
||||
pollster::block_on(scope.pop()).is_none().then_some(buffer)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
device_with(wgpu::Features::empty())
|
||||
}
|
||||
|
||||
/// A device with those of `features` the adapter has.
|
||||
fn device_with(features: wgpu::Features) -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
eprintln!("arena GPU regression: {}", adapter.get_info().name);
|
||||
let required_features = adapter.features() & features;
|
||||
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
required_features,
|
||||
..Default::default()
|
||||
}))
|
||||
.ok()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_pages_are_reclaimed_without_moving_live_ranges() {
|
||||
let Some((device, queue)) = device() else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
};
|
||||
let mut arena = Arena::new(&device, 4, GRAIN);
|
||||
arena.page_limit = 2 * GRAIN;
|
||||
assert_eq!(arena.bytes(), 0);
|
||||
let (first, a) = arena.alloc(&device, &queue, GRAIN).unwrap();
|
||||
let (_, b) = arena.alloc(&device, &queue, GRAIN).unwrap();
|
||||
let (live, c) = arena.alloc(&device, &queue, 2 * GRAIN).unwrap();
|
||||
let buffer = arena.buffer(live).clone();
|
||||
let data = [1u8, 2, 3, 4];
|
||||
queue.write_buffer(&buffer, c * 4, &data);
|
||||
assert_eq!(arena.bytes(), 4 * GRAIN * 4);
|
||||
arena.release(first, a, GRAIN);
|
||||
arena.trim();
|
||||
assert_eq!(arena.bytes(), 4 * GRAIN * 4);
|
||||
arena.release(first, b, GRAIN);
|
||||
arena.trim();
|
||||
assert_eq!(arena.bytes(), 2 * GRAIN * 4);
|
||||
assert_eq!(arena.buffer(live), &buffer);
|
||||
let (reused, d) = arena.alloc(&device, &queue, GRAIN).unwrap();
|
||||
assert_eq!(reused, first);
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
wgpu::util::DownloadBuffer::read_buffer(
|
||||
&device,
|
||||
&queue,
|
||||
&arena.buffer(live).slice(c * 4..c * 4 + 4),
|
||||
move |result| tx.send(result.unwrap().to_vec()).unwrap(),
|
||||
);
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
assert_eq!(rx.recv().unwrap(), data);
|
||||
arena.release(reused, d, GRAIN);
|
||||
arena.release(live, c, 2 * GRAIN);
|
||||
arena.trim();
|
||||
assert_eq!(arena.bytes(), 0);
|
||||
assert!(arena.pages.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_an_arena_releases_buffers_and_can_allocate_again() {
|
||||
let Some((device, queue)) = device() else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
};
|
||||
for mut arena in [
|
||||
Arena::new(&device, 28, GRAIN),
|
||||
Arena::indices(&device, GRAIN),
|
||||
] {
|
||||
arena.alloc(&device, &queue, GRAIN).unwrap();
|
||||
assert_eq!(arena.bytes(), arena.stride * GRAIN);
|
||||
arena.clear();
|
||||
assert_eq!(arena.bytes(), 0);
|
||||
assert_eq!(arena.alloc(&device, &queue, GRAIN), Some((0, 0)));
|
||||
assert_eq!(arena.bytes(), arena.stride * GRAIN);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_ranges_coalesce() {
|
||||
let mut free = FreeList::new(4 * GRAIN);
|
||||
@@ -181,4 +373,63 @@ mod tests {
|
||||
free.give(a, GRAIN);
|
||||
assert_eq!(free.take(GRAIN), Some(a));
|
||||
}
|
||||
|
||||
fn read(device: &wgpu::Device, queue: &wgpu::Queue, buffer: &wgpu::Buffer, offset: u64) -> u32 {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
wgpu::util::DownloadBuffer::read_buffer(
|
||||
device,
|
||||
queue,
|
||||
&buffer.slice(offset..offset + 4),
|
||||
move |result| tx.send(result.unwrap().to_vec()).unwrap(),
|
||||
);
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
u32::from_le_bytes(rx.recv().unwrap()[..4].try_into().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_video_memory_spills_to_system_memory() {
|
||||
let Some((device, queue)) = device_with(wgpu::Features::MAPPABLE_PRIMARY_BUFFERS) else {
|
||||
return;
|
||||
};
|
||||
if !device
|
||||
.features()
|
||||
.contains(wgpu::Features::MAPPABLE_PRIMARY_BUFFERS)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let mut arena = Arena::indices(&device, GRAIN);
|
||||
let (page, start) = arena.alloc(&device, &queue, GRAIN).unwrap();
|
||||
queue.write_buffer(arena.buffer(page), start * 4, &7u32.to_le_bytes());
|
||||
arena.video_memory_full = true;
|
||||
// The full page can't grow in video memory: it moves to system
|
||||
// memory with its contents, and new ranges come from there.
|
||||
let (grown, next) = arena.alloc(&device, &queue, GRAIN).unwrap();
|
||||
assert!(arena.spilled);
|
||||
assert_eq!(grown, page);
|
||||
assert!(
|
||||
arena
|
||||
.buffer(page)
|
||||
.usage()
|
||||
.contains(wgpu::BufferUsages::MAP_WRITE)
|
||||
);
|
||||
queue.write_buffer(arena.buffer(grown), next * 4, &9u32.to_le_bytes());
|
||||
assert_eq!(read(&device, &queue, arena.buffer(page), start * 4), 7);
|
||||
assert_eq!(read(&device, &queue, arena.buffer(grown), next * 4), 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_memory_left_is_not_an_error() {
|
||||
let Some((device, queue)) = device() else {
|
||||
return;
|
||||
};
|
||||
let mut arena = Arena::indices(&device, GRAIN);
|
||||
arena.alloc(&device, &queue, GRAIN).unwrap();
|
||||
arena.video_memory_full = true;
|
||||
// Without mappable primary buffers nothing can spill: the request
|
||||
// fails, and the arena keeps what it has.
|
||||
assert_eq!(arena.alloc(&device, &queue, GRAIN), None);
|
||||
assert_eq!(arena.bytes(), GRAIN * 4);
|
||||
arena.release(0, 0, GRAIN);
|
||||
assert_eq!(arena.alloc(&device, &queue, GRAIN), Some((0, 0)));
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
//! the render distance rejects all of its sections with one test, and the
|
||||
//! sections that remain are reached by index rather than by hashing keys.
|
||||
use super::{Frustum, SectionKey};
|
||||
use crate::fast_hash::FxHashMap;
|
||||
use crate::{fast_hash::FxHashMap, streaming::in_view_distance};
|
||||
use glam::{DVec3, Vec3};
|
||||
|
||||
/// One chunk column's resident sections.
|
||||
@@ -58,6 +58,9 @@ impl<T> Resident<T> {
|
||||
pub fn get(&self, slot: u32) -> &T {
|
||||
&self.items[slot as usize].1
|
||||
}
|
||||
pub fn get_mut(&mut self, slot: u32) -> &mut T {
|
||||
&mut self.items[slot as usize].1
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.items.clear();
|
||||
self.slots.clear();
|
||||
@@ -149,26 +152,28 @@ fn origin(key: SectionKey, camera: DVec3) -> DVec3 {
|
||||
) - camera
|
||||
}
|
||||
|
||||
/// Whether the section at `key` is drawn: within `distance` blocks of the
|
||||
/// camera horizontally (plus a section and a half), and partly inside the
|
||||
/// frustum. `None` when it is not, else its squared centre distance and origin.
|
||||
fn in_view(
|
||||
key: SectionKey,
|
||||
camera: DVec3,
|
||||
distance: f32,
|
||||
frustum: &Frustum,
|
||||
) -> Option<(f64, Vec3)> {
|
||||
let origin = origin(key, camera);
|
||||
let center = origin + DVec3::splat(8.0);
|
||||
let horizontal = (center.x * center.x + center.z * center.z).sqrt();
|
||||
if horizontal > f64::from(distance) + 24.0 {
|
||||
/// The camera's chunk column.
|
||||
fn camera_column(camera: DVec3) -> (i32, i32) {
|
||||
(
|
||||
(camera.x.floor() as i32) >> 4,
|
||||
(camera.z.floor() as i32) >> 4,
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether the section at `key` is drawn: its column within the render
|
||||
/// distance of the camera's (`SectionOcclusionGraph.isInViewDistance`), and
|
||||
/// partly inside the frustum. `None` when it is not, else its squared centre
|
||||
/// distance and origin.
|
||||
fn in_view(key: SectionKey, camera: DVec3, radius: i32, frustum: &Frustum) -> Option<(f64, Vec3)> {
|
||||
if !in_view_distance(camera_column(camera), radius, key.0, key.2) {
|
||||
return None;
|
||||
}
|
||||
let origin = origin(key, camera);
|
||||
let min = origin.as_vec3();
|
||||
if !frustum.intersects(min, min + Vec3::splat(16.0)) {
|
||||
return None;
|
||||
}
|
||||
Some((center.length_squared(), min))
|
||||
Some(((origin + DVec3::splat(8.0)).length_squared(), min))
|
||||
}
|
||||
|
||||
impl<T> Resident<T> {
|
||||
@@ -178,22 +183,22 @@ impl<T> Resident<T> {
|
||||
/// Columns are tested first, with the same distance test (it depends
|
||||
/// only on x and z) and a box spanning their sections: every section box
|
||||
/// lies inside it and the frustum test rounds monotonically, so a column
|
||||
/// the frustum rejects holds no section it would accept.
|
||||
/// the frustum rejects holds no section it would accept. `radius` is the
|
||||
/// render distance in chunks.
|
||||
pub fn visible(
|
||||
&self,
|
||||
camera: DVec3,
|
||||
distance: f32,
|
||||
radius: i32,
|
||||
frustum: &Frustum,
|
||||
reachable: impl Fn(SectionKey) -> bool,
|
||||
out: &mut Vec<Visible>,
|
||||
) {
|
||||
let from = camera_column(camera);
|
||||
for column in &self.columns {
|
||||
let low = origin((column.x, column.low, column.z), camera);
|
||||
let center = low + DVec3::splat(8.0);
|
||||
if (center.x * center.x + center.z * center.z).sqrt() > f64::from(distance) + 24.0 {
|
||||
if !in_view_distance(from, radius, column.x, column.z) {
|
||||
continue;
|
||||
}
|
||||
let min = low.as_vec3();
|
||||
let min = origin((column.x, column.low, column.z), camera).as_vec3();
|
||||
let top = origin((column.x, column.high, column.z), camera).as_vec3();
|
||||
if !frustum.intersects(min, top + Vec3::splat(16.0)) {
|
||||
continue;
|
||||
@@ -203,7 +208,7 @@ impl<T> Resident<T> {
|
||||
if !reachable(key) {
|
||||
continue;
|
||||
}
|
||||
if let Some((d, o)) = in_view(key, camera, distance, frustum) {
|
||||
if let Some((d, o)) = in_view(key, camera, radius, frustum) {
|
||||
out.push(Visible {
|
||||
distance: d.to_bits(),
|
||||
key,
|
||||
@@ -243,20 +248,20 @@ mod tests {
|
||||
(DVec3::new(8.0, 64.0, 8.0), 45.0, 89.0),
|
||||
];
|
||||
for (camera, yaw, pitch) in cameras {
|
||||
for distance in [32.0, 96.0, 160.0] {
|
||||
for radius in [2, 6, 10] {
|
||||
let projection = super::super::world_projection(70.0, 16.0 / 9.0, 512.0);
|
||||
let view = crate::camera::view_matrix(yaw, pitch);
|
||||
let frustum = Frustum {
|
||||
planes: super::super::frustum(projection * view),
|
||||
};
|
||||
let mut fast = Vec::new();
|
||||
r.visible(camera, distance, &frustum, reachable, &mut fast);
|
||||
r.visible(camera, radius, &frustum, reachable, &mut fast);
|
||||
let mut slow: Vec<(SectionKey, f64, [f32; 3])> = r
|
||||
.items
|
||||
.iter()
|
||||
.filter(|(k, _)| reachable(*k))
|
||||
.filter_map(|(k, _)| {
|
||||
in_view(*k, camera, distance, &frustum).map(|(d, o)| (*k, d, o.to_array()))
|
||||
in_view(*k, camera, radius, &frustum).map(|(d, o)| (*k, d, o.to_array()))
|
||||
})
|
||||
.collect();
|
||||
slow.sort_unstable_by(|a, b| a.1.total_cmp(&b.1).then(a.0.cmp(&b.0)));
|
||||
@@ -268,7 +273,7 @@ mod tests {
|
||||
})
|
||||
.collect();
|
||||
assert!(!slow.is_empty());
|
||||
assert_eq!(fast, slow, "{camera} {yaw} {pitch} {distance}");
|
||||
assert_eq!(fast, slow, "{camera} {yaw} {pitch} {radius}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -356,7 +361,7 @@ mod tests {
|
||||
let t = Instant::now();
|
||||
keys.clear();
|
||||
keys.extend(map.keys().filter_map(|&k| {
|
||||
in_view(k, camera, 512.0, &frustum).map(|(d, o)| (k, d, o.to_array()))
|
||||
in_view(k, camera, 32, &frustum).map(|(d, o)| (k, d, o.to_array()))
|
||||
}));
|
||||
keys.sort_unstable_by(|a, b| a.1.total_cmp(&b.1));
|
||||
for (k, _, _) in &keys {
|
||||
@@ -365,7 +370,7 @@ mod tests {
|
||||
old = old.min(t.elapsed());
|
||||
let t = Instant::now();
|
||||
list.clear();
|
||||
r.visible(camera, 512.0, &frustum, |_| true, &mut list);
|
||||
r.visible(camera, 32, &frustum, |_| true, &mut list);
|
||||
for v in &list {
|
||||
sum += i64::from(r.get(v.slot)[1] + r.get(v.slot)[0]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
//! Vanilla 26.3 MeshData.SortState / VertexSorting.byDistance.
|
||||
//! Keep vertex order intact; only the quad indices change as the camera moves.
|
||||
|
||||
/// MeshData.decodeQuadCentroids uses the opposite corners, not all four vertices.
|
||||
pub fn quad_center(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
|
||||
std::array::from_fn(|i| (a[i] + b[i]) / 2.0)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Sorter {
|
||||
order: Vec<(u32, f32)>,
|
||||
indices: Vec<u32>,
|
||||
}
|
||||
|
||||
impl Sorter {
|
||||
pub fn sort(&mut self, centers: &[[f32; 3]], camera: [f32; 3]) -> &[u32] {
|
||||
self.order.clear();
|
||||
self.order.extend(centers.iter().enumerate().map(|(i, c)| {
|
||||
let [x, y, z] = std::array::from_fn(|j| camera[j] - c[j]);
|
||||
(i as u32, x * x + y * y + z * z)
|
||||
}));
|
||||
// VertexSorting uses a stable merge sort, preserving mesh order for ties.
|
||||
self.order.sort_by(|a, b| b.1.total_cmp(&a.1));
|
||||
self.indices.clear();
|
||||
for &(quad, _) in &self.order {
|
||||
let v = quad * 4;
|
||||
self.indices.extend([v, v + 1, v + 2, v + 2, v + 3, v]);
|
||||
}
|
||||
&self.indices
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SortState {
|
||||
centers: Vec<[f32; 3]>,
|
||||
last: Option<([i32; 3], [i32; 3])>,
|
||||
}
|
||||
|
||||
impl SortState {
|
||||
pub fn new(centers: Vec<[f32; 3]>) -> Self {
|
||||
Self {
|
||||
centers,
|
||||
last: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_sorted(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
/// TranslucencyPointOfView and ResortTransparencyTask: diagonal views keep
|
||||
/// their ordering until the point of view changes; aligned views also
|
||||
/// resort on crossing a block boundary. Sub-block movement needs no upload.
|
||||
pub fn update<'a>(
|
||||
&mut self,
|
||||
camera: [f64; 3],
|
||||
origin: [i32; 3],
|
||||
sorter: &'a mut Sorter,
|
||||
) -> Option<&'a [u32]> {
|
||||
let block = camera.map(|v| v.floor() as i32);
|
||||
let pov = std::array::from_fn(|i| ((block[i] >> 4) - (origin[i] >> 4)).clamp(-1, 1));
|
||||
if let Some((last_block, last_pov)) = self.last
|
||||
&& last_pov == pov
|
||||
&& (last_block == block || !pov.contains(&0))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
self.last = Some((block, pov));
|
||||
Some(sorter.sort(
|
||||
&self.centers,
|
||||
std::array::from_fn(|i| (camera[i] - f64::from(origin[i])) as f32),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn equal_distances_keep_mesh_order_and_triangle_winding() {
|
||||
let mut sorter = Sorter::default();
|
||||
assert_eq!(
|
||||
sorter.sort(
|
||||
&[[1.0, 0.0, 0.0], [-1.0, 0.0, 0.0], [4.0, 0.0, 0.0]],
|
||||
[0.0; 3]
|
||||
),
|
||||
&[8, 9, 10, 10, 11, 8, 0, 1, 2, 2, 3, 0, 4, 5, 6, 6, 7, 4]
|
||||
);
|
||||
assert!(sorter.sort(&[], [0.0; 3]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn movement_reverses_faces_without_moving_vertices() {
|
||||
let centers = [[0.5, 0.5, 0.0], [0.5, 0.5, 1.0]];
|
||||
let mut sorter = Sorter::default();
|
||||
assert_eq!(sorter.sort(¢ers, [0.5, 0.5, -2.0])[0], 4);
|
||||
assert_eq!(sorter.sort(¢ers, [0.5, 0.5, 3.0])[0], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_viewpoint_handles_negative_and_large_coordinates() {
|
||||
let origin = [-30_000_000, -64, 30_000_000];
|
||||
let mut state = SortState::new(vec![[0.5; 3]]);
|
||||
let mut sorter = Sorter::default();
|
||||
let camera = [-29_999_999.5, -63.5, 30_000_000.5];
|
||||
assert!(state.update(camera, origin, &mut sorter).is_some());
|
||||
assert!(state.update(camera, origin, &mut sorter).is_none());
|
||||
assert!(
|
||||
state
|
||||
.update([-29_999_999.1, -63.1, 30_000_000.9], origin, &mut sorter)
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
state
|
||||
.update([-29_999_998.9, -63.1, 30_000_000.9], origin, &mut sorter)
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -139,19 +139,74 @@ impl HudAtlas {
|
||||
(g.rect, g.advance)
|
||||
}
|
||||
pub fn build(root: &Path) -> Result<Self> {
|
||||
Self::pack(
|
||||
root,
|
||||
&[
|
||||
"gui",
|
||||
"item",
|
||||
"block",
|
||||
"font",
|
||||
"environment",
|
||||
"misc",
|
||||
"painting",
|
||||
"entity",
|
||||
"particle",
|
||||
"mob_effect",
|
||||
"map",
|
||||
],
|
||||
true,
|
||||
)
|
||||
}
|
||||
/// Empty slots written at run time: map textures (game/maps.rs), the
|
||||
/// minimap, downloaded skins, world icons and the Horizon menu text.
|
||||
fn slots(images: &mut Vec<PackedImage>) {
|
||||
for i in 0..MAP_SLOTS {
|
||||
images.push((format!("horizon/map{i}"), 128, 128, vec![0; 128 * 128 * 4]));
|
||||
}
|
||||
// The minimap extra's image (game/extras.rs).
|
||||
images.push((
|
||||
MINIMAP_SPRITE.into(),
|
||||
crate::minimap::SIZE as u32,
|
||||
crate::minimap::SIZE as u32,
|
||||
vec![0; crate::minimap::SIZE * crate::minimap::SIZE * 4],
|
||||
));
|
||||
// Downloaded skins (game/scoreboard.rs).
|
||||
for i in 0..SKIN_SLOTS {
|
||||
images.push((format!("horizon/skin{i}"), 64, 64, vec![0; 64 * 64 * 4]));
|
||||
}
|
||||
for i in 0..WORLD_ICON_SLOTS {
|
||||
images.push((
|
||||
format!("horizon/world-icon{i}"),
|
||||
64,
|
||||
64,
|
||||
vec![0; 64 * 64 * 4],
|
||||
));
|
||||
}
|
||||
// Horizon-style menu text and corner masks (ui_theme), rasterized at run time.
|
||||
images.push((
|
||||
"horizon/ui-text".into(),
|
||||
2048,
|
||||
1024,
|
||||
vec![0; 2048 * 1024 * 4],
|
||||
));
|
||||
}
|
||||
/// Only the font's metrics, for a game that draws nothing (the
|
||||
/// integrated server): text measures as with the full atlas, without
|
||||
/// holding its textures. The pixels are dropped once the glyph widths are
|
||||
/// read, since nothing uploads them.
|
||||
pub fn fonts(root: &Path) -> Result<Self> {
|
||||
let mut atlas = Self::pack(root, &["font"], false)?;
|
||||
atlas.pixels = Vec::new();
|
||||
Ok(atlas)
|
||||
}
|
||||
/// Pack the textures under these folders of `textures/`, and for the
|
||||
/// `full` atlas the generated sprites and the run-time slots too.
|
||||
fn pack(root: &Path, folders: &[&str], full: bool) -> Result<Self> {
|
||||
let textures = root.join("assets/minecraft/textures");
|
||||
let mut files = Vec::new();
|
||||
walk(&textures.join("gui"), &mut files);
|
||||
walk(&textures.join("item"), &mut files);
|
||||
walk(&textures.join("block"), &mut files);
|
||||
walk(&textures.join("font"), &mut files);
|
||||
walk(&textures.join("environment"), &mut files);
|
||||
walk(&textures.join("misc"), &mut files);
|
||||
walk(&textures.join("painting"), &mut files);
|
||||
walk(&textures.join("entity"), &mut files);
|
||||
walk(&textures.join("particle"), &mut files);
|
||||
walk(&textures.join("mob_effect"), &mut files);
|
||||
walk(&textures.join("map"), &mut files);
|
||||
for folder in folders {
|
||||
walk(&textures.join(folder), &mut files);
|
||||
}
|
||||
files.retain(|p| {
|
||||
let s = p.to_string_lossy();
|
||||
!s.contains("title/background") && !s.contains("realms") && !s.contains("presets")
|
||||
@@ -187,7 +242,9 @@ impl HudAtlas {
|
||||
.flat_map(|h| h.join().unwrap_or_default())
|
||||
.collect()
|
||||
});
|
||||
images.extend(atlas_sprites(root, &images));
|
||||
if full {
|
||||
images.extend(atlas_sprites(root, &images));
|
||||
}
|
||||
// The title screen's "JAVA EDITION" banner reads "RUST EDITION".
|
||||
if let Some(edition) = images
|
||||
.iter_mut()
|
||||
@@ -198,36 +255,9 @@ impl HudAtlas {
|
||||
edition.3 = pixels;
|
||||
}
|
||||
images.push(("horizon/white".into(), 4, 4, vec![255; 64]));
|
||||
// Map textures (game/maps.rs) are written into these slots at run time.
|
||||
for i in 0..MAP_SLOTS {
|
||||
images.push((format!("horizon/map{i}"), 128, 128, vec![0; 128 * 128 * 4]));
|
||||
if full {
|
||||
Self::slots(&mut images);
|
||||
}
|
||||
// The minimap extra's image (game/extras.rs).
|
||||
images.push((
|
||||
MINIMAP_SPRITE.into(),
|
||||
crate::minimap::SIZE as u32,
|
||||
crate::minimap::SIZE as u32,
|
||||
vec![0; crate::minimap::SIZE * crate::minimap::SIZE * 4],
|
||||
));
|
||||
// Downloaded skins (game/scoreboard.rs) likewise.
|
||||
for i in 0..SKIN_SLOTS {
|
||||
images.push((format!("horizon/skin{i}"), 64, 64, vec![0; 64 * 64 * 4]));
|
||||
}
|
||||
for i in 0..WORLD_ICON_SLOTS {
|
||||
images.push((
|
||||
format!("horizon/world-icon{i}"),
|
||||
64,
|
||||
64,
|
||||
vec![0; 64 * 64 * 4],
|
||||
));
|
||||
}
|
||||
// Horizon-style menu text and corner masks (ui_theme), rasterized at run time.
|
||||
images.push((
|
||||
"horizon/ui-text".into(),
|
||||
2048,
|
||||
1024,
|
||||
vec![0; 2048 * 1024 * 4],
|
||||
));
|
||||
images.sort_by(|a, b| b.2.cmp(&a.2).then(b.1.cmp(&a.1)));
|
||||
let width = 4096u32;
|
||||
let mut placements = Vec::with_capacity(images.len());
|
||||
@@ -319,6 +349,7 @@ pub struct HudVertex {
|
||||
pub pos: [f32; 2],
|
||||
pub uv: [f32; 2],
|
||||
pub color: u32,
|
||||
pub depth: f32,
|
||||
}
|
||||
/// The GUI Scale option (0 = auto).
|
||||
pub static GUI_SCALE: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||
@@ -330,10 +361,10 @@ pub struct HudBatch {
|
||||
pub height: f32,
|
||||
/// Glyph pixels to upload into the atlas before drawing.
|
||||
pub patches: Vec<crate::font::Patch>,
|
||||
/// Vertex ranges drawn with another blend than alpha blending, in order.
|
||||
/// Vertex ranges with another blend or depth/scissor state, in order.
|
||||
pub blends: Vec<(std::ops::Range<u32>, Blend)>,
|
||||
}
|
||||
/// How a run of HUD vertices blends with what is under it.
|
||||
/// How a run of HUD vertices blends and tests depth.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Blend {
|
||||
/// `BlendFunction.TRANSLUCENT`.
|
||||
@@ -342,6 +373,8 @@ pub enum Blend {
|
||||
Invert,
|
||||
/// `BlendFunction.ADDITIVE` (`RenderPipelines.GUI_TEXT_HIGHLIGHT`).
|
||||
Additive,
|
||||
/// Alpha blending with an isolated depth buffer and a pixel scissor.
|
||||
Depth([u32; 4]),
|
||||
}
|
||||
pub fn rgba(r: u8, g: u8, b: u8, a: u8) -> u32 {
|
||||
u32::from(a) << 24 | u32::from(b) << 16 | u32::from(g) << 8 | u32::from(r)
|
||||
@@ -430,9 +463,44 @@ impl HudBatch {
|
||||
pos: p[i],
|
||||
uv: uv[i],
|
||||
color,
|
||||
depth: 0.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
pub fn quad_3d(&mut self, p: [[f32; 3]; 4], uv: [[f32; 2]; 4], color: u32) {
|
||||
for i in [0, 1, 2, 0, 2, 3] {
|
||||
self.vertices.push(HudVertex {
|
||||
pos: [p[i][0], p[i][1]],
|
||||
uv: uv[i],
|
||||
color,
|
||||
depth: p[i][2],
|
||||
});
|
||||
}
|
||||
}
|
||||
/// Keep per-vertex depth for an orthographic GUI model, with +z toward the viewer.
|
||||
pub fn depth_since(&mut self, start: usize, clip: [f32; 4]) {
|
||||
let vertices = &mut self.vertices[start..];
|
||||
if vertices.is_empty() {
|
||||
return;
|
||||
}
|
||||
let min = vertices
|
||||
.iter()
|
||||
.map(|v| v.depth)
|
||||
.fold(f32::INFINITY, f32::min);
|
||||
let max = vertices
|
||||
.iter()
|
||||
.map(|v| v.depth)
|
||||
.fold(f32::NEG_INFINITY, f32::max);
|
||||
for v in vertices {
|
||||
v.depth = (max - v.depth) / (max - min).max(f32::EPSILON);
|
||||
}
|
||||
let [x0, y0, x1, y1] = clip.map(|v| (v * self.scale).max(0.0) as u32);
|
||||
// Separate previews clear depth independently, even if their scissors match.
|
||||
self.blends.push((
|
||||
start as u32..self.vertices.len() as u32,
|
||||
Blend::Depth([x0, y0, x1.saturating_sub(x0), y1.saturating_sub(y0)]),
|
||||
));
|
||||
}
|
||||
pub fn rect_uv(&mut self, x: f32, y: f32, w: f32, h: f32, r: Rect, color: u32) {
|
||||
self.quad(
|
||||
[[x, y], [x + w, y], [x + w, y + h], [x, y + h]],
|
||||
@@ -569,6 +637,7 @@ impl HudBatch {
|
||||
pos: p[i],
|
||||
uv,
|
||||
color: c[i],
|
||||
depth: 0.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -729,6 +798,7 @@ pub struct HudRenderer {
|
||||
/// `RenderPipelines.CROSSHAIR`.
|
||||
invert_pipeline: wgpu::RenderPipeline,
|
||||
additive_pipeline: wgpu::RenderPipeline,
|
||||
depth_pipeline: wgpu::RenderPipeline,
|
||||
group: wgpu::BindGroup,
|
||||
uniform: wgpu::Buffer,
|
||||
buffer: wgpu::Buffer,
|
||||
@@ -808,8 +878,8 @@ impl HudRenderer {
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let attributes = wgpu::vertex_attr_array![0=>Float32x2,1=>Float32x2,2=>Unorm8x4];
|
||||
let create = |label, blend, fragment_entry| {
|
||||
let attributes = wgpu::vertex_attr_array![0=>Float32x2,1=>Float32x2,2=>Unorm8x4,3=>Float32];
|
||||
let create = |label, blend, fragment_entry, depth: bool| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout: Some(&pipeline_layout),
|
||||
@@ -824,7 +894,13 @@ impl HudRenderer {
|
||||
})],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
depth_stencil: depth.then_some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
@@ -840,7 +916,13 @@ impl HudRenderer {
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let pipeline = create("HUD", wgpu::BlendState::ALPHA_BLENDING, "fs_main");
|
||||
let pipeline = create("HUD", wgpu::BlendState::ALPHA_BLENDING, "fs_main", false);
|
||||
let depth_pipeline = create(
|
||||
"HUD model",
|
||||
wgpu::BlendState::ALPHA_BLENDING,
|
||||
"fs_main",
|
||||
true,
|
||||
);
|
||||
// BlendFunction.INVERT: ONE_MINUS_DST_COLOR, ONE_MINUS_SRC_COLOR,
|
||||
// ONE, ZERO.
|
||||
let invert_pipeline = create(
|
||||
@@ -858,6 +940,7 @@ impl HudRenderer {
|
||||
},
|
||||
},
|
||||
"fs_crosshair",
|
||||
false,
|
||||
);
|
||||
// BlendFunction.ADDITIVE: ONE, ONE.
|
||||
let add = wgpu::BlendComponent {
|
||||
@@ -872,6 +955,7 @@ impl HudRenderer {
|
||||
alpha: add,
|
||||
},
|
||||
"fs_main",
|
||||
false,
|
||||
);
|
||||
let capacity = 1 << 16;
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
@@ -886,6 +970,7 @@ impl HudRenderer {
|
||||
pipeline,
|
||||
invert_pipeline,
|
||||
additive_pipeline,
|
||||
depth_pipeline,
|
||||
group,
|
||||
uniform,
|
||||
buffer,
|
||||
@@ -935,6 +1020,7 @@ impl HudRenderer {
|
||||
uploads: &mut crate::gfx::upload::Uploads,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
target: &wgpu::TextureView,
|
||||
depth: &wgpu::TextureView,
|
||||
batch: &HudBatch,
|
||||
size: [u32; 2],
|
||||
) {
|
||||
@@ -965,7 +1051,7 @@ impl HudRenderer {
|
||||
);
|
||||
}
|
||||
}
|
||||
if batch.vertices.is_empty() {
|
||||
if batch.vertices.is_empty() || size.contains(&0) {
|
||||
return;
|
||||
}
|
||||
let count = batch.vertices.len() as u64;
|
||||
@@ -1001,20 +1087,55 @@ impl HudRenderer {
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("HUD"),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pass.set_vertex_buffer(0, self.buffer.slice(..));
|
||||
for (range, blend) in batch.runs() {
|
||||
pass.set_pipeline(match blend {
|
||||
Blend::Alpha => &self.pipeline,
|
||||
Blend::Invert => &self.invert_pipeline,
|
||||
Blend::Additive => &self.additive_pipeline,
|
||||
let runs = batch.runs();
|
||||
let mut at = 0;
|
||||
while at < runs.len() {
|
||||
let clip = match runs[at].1 {
|
||||
Blend::Depth(clip) => Some(clip),
|
||||
_ => None,
|
||||
};
|
||||
let end = if clip.is_some() {
|
||||
at + 1
|
||||
} else {
|
||||
runs[at..]
|
||||
.iter()
|
||||
.position(|(_, b)| matches!(b, Blend::Depth(_)))
|
||||
.map_or(runs.len(), |n| at + n)
|
||||
};
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("HUD"),
|
||||
color_attachments: &attachments,
|
||||
depth_stencil_attachment: clip.map(|_| wgpu::RenderPassDepthStencilAttachment {
|
||||
view: depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(1.0),
|
||||
store: wgpu::StoreOp::Discard,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
pass.draw(range, 0..1);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pass.set_vertex_buffer(0, self.buffer.slice(..));
|
||||
if let Some([x, y, w, h]) = clip {
|
||||
let (x, y) = (x.min(size[0]), y.min(size[1]));
|
||||
let (w, h) = (w.min(size[0] - x), h.min(size[1] - y));
|
||||
if w == 0 || h == 0 {
|
||||
at = end;
|
||||
continue;
|
||||
}
|
||||
pass.set_scissor_rect(x, y, w, h);
|
||||
}
|
||||
for (range, blend) in &runs[at..end] {
|
||||
pass.set_pipeline(match blend {
|
||||
Blend::Alpha => &self.pipeline,
|
||||
Blend::Invert => &self.invert_pipeline,
|
||||
Blend::Additive => &self.additive_pipeline,
|
||||
Blend::Depth(_) => &self.depth_pipeline,
|
||||
});
|
||||
pass.draw(range.clone(), 0..1);
|
||||
}
|
||||
at = end;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1023,6 +1144,43 @@ impl HudRenderer {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_fonts_only_atlas_measures_text_like_the_full_one() {
|
||||
// A synthetic default font: two 8x8 glyphs, 3 and 5 pixels wide.
|
||||
let root = std::env::temp_dir().join(format!("horizon-fonts-atlas-{}", std::process::id()));
|
||||
let font = root.join("assets/minecraft/font");
|
||||
let textures = root.join("assets/minecraft/textures/font");
|
||||
fs::create_dir_all(&font).unwrap();
|
||||
fs::create_dir_all(&textures).unwrap();
|
||||
fs::write(
|
||||
font.join("default.json"),
|
||||
r#"{"providers":[{"type":"bitmap","file":"minecraft:font/test.png","ascent":7,"chars":["ab"]}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut pixels = vec![0u8; 16 * 8 * 4];
|
||||
for y in 0..8 {
|
||||
for x in (0..3).chain(8..13) {
|
||||
pixels[(y * 16 + x) * 4 + 3] = 255;
|
||||
}
|
||||
}
|
||||
let mut encoder =
|
||||
png::Encoder::new(fs::File::create(textures.join("test.png")).unwrap(), 16, 8);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&pixels)
|
||||
.unwrap();
|
||||
let full = HudAtlas::build(&root).unwrap();
|
||||
let fonts = HudAtlas::fonts(&root).unwrap();
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
assert!(fonts.pixels.is_empty());
|
||||
let width = full.font.width("abba");
|
||||
assert!(width > 0.0);
|
||||
assert_eq!(fonts.font.width("abba"), width);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_icons_are_painted_from_the_mob_effect_directory() {
|
||||
use crate::{effects::EffectInstance, hud_layout, hud_overlays, inventory_effects};
|
||||
@@ -1092,6 +1250,41 @@ mod tests {
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gui_models_keep_separate_depth_runs_between_overlay_draws() {
|
||||
let mut batch = HudBatch::new([640, 480]);
|
||||
batch.scale = 2.0;
|
||||
let p = [
|
||||
[0.0, 0.0, 2.0],
|
||||
[0.0, 1.0, 2.0],
|
||||
[1.0, 1.0, 2.0],
|
||||
[1.0, 0.0, 2.0],
|
||||
];
|
||||
let uv = [[0.0; 2]; 4];
|
||||
batch.quad([[0.0; 2]; 4], uv, u32::MAX);
|
||||
for _ in 0..2 {
|
||||
let start = batch.vertices.len();
|
||||
batch.quad_3d(p, uv, u32::MAX);
|
||||
batch.depth_since(start, [1.0, 2.0, 4.0, 6.0]);
|
||||
}
|
||||
batch.quad([[0.0; 2]; 4], uv, u32::MAX);
|
||||
assert_eq!(
|
||||
batch.runs(),
|
||||
vec![
|
||||
(0..6, Blend::Alpha),
|
||||
(6..12, Blend::Depth([2, 4, 6, 8])),
|
||||
(12..18, Blend::Depth([2, 4, 6, 8])),
|
||||
(18..24, Blend::Alpha),
|
||||
]
|
||||
);
|
||||
assert!(
|
||||
batch
|
||||
.vertices
|
||||
.iter()
|
||||
.all(|v| v.depth.is_finite() && (0.0..=1.0).contains(&v.depth))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hud_shader_validates_both_fragment_entries() {
|
||||
let module = naga::front::wgsl::parse_str(include_str!("hud.wgsl")).unwrap();
|
||||
|
||||
@@ -7,10 +7,10 @@ struct Out {
|
||||
@location(0) uv: vec2<f32>,
|
||||
@location(1) color: vec4<f32>,
|
||||
}
|
||||
@vertex fn vs_main(@location(0) pos: vec2<f32>, @location(1) uv: vec2<f32>, @location(2) color: vec4<f32>) -> Out {
|
||||
@vertex fn vs_main(@location(0) pos: vec2<f32>, @location(1) uv: vec2<f32>, @location(2) color: vec4<f32>, @location(3) depth: f32) -> Out {
|
||||
let pixels = pos * hud.size.z;
|
||||
var out: Out;
|
||||
out.clip = vec4(pixels.x / hud.size.x * 2.0 - 1.0, 1.0 - pixels.y / hud.size.y * 2.0, 0.0, 1.0);
|
||||
out.clip = vec4(pixels.x / hud.size.x * 2.0 - 1.0, 1.0 - pixels.y / hud.size.y * 2.0, depth, 1.0);
|
||||
out.uv = uv;
|
||||
out.color = color;
|
||||
return out;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! The lines of an item's tooltip as `ItemStack.getTooltipLines` writes them
|
||||
//! without a player and without advanced tooltips, for stacks as recipes
|
||||
//! produce them. The recipe book's search tree matches them, so recipes of
|
||||
//! tools and armour answer to "attack speed" or "when on head".
|
||||
use crate::items::{ItemInfo, ItemStack};
|
||||
//! Item tooltip formatting: equipment modifiers with a player's base values,
|
||||
//! and lines without player context for the recipe book's search tree.
|
||||
use crate::{
|
||||
hud::rgba,
|
||||
items::{ItemInfo, ItemStack},
|
||||
};
|
||||
|
||||
/// `EquipmentSlotGroup` in declaration order, the order tooltips list them in.
|
||||
const SLOT_GROUPS: [&str; 11] = [
|
||||
@@ -25,6 +26,7 @@ fn modifier_line(
|
||||
attribute: &str,
|
||||
amount: f64,
|
||||
operation: usize,
|
||||
total: bool,
|
||||
) -> Option<String> {
|
||||
let text = |key: &str| translate(key).unwrap_or_else(|| key.to_owned());
|
||||
let scale = match operation {
|
||||
@@ -32,7 +34,9 @@ fn modifier_line(
|
||||
0 => 1.0,
|
||||
_ => 100.0,
|
||||
};
|
||||
let (kind, shown) = if amount > 0.0 {
|
||||
let (kind, shown) = if total {
|
||||
("equals", amount * scale)
|
||||
} else if amount > 0.0 {
|
||||
("plus", amount * scale)
|
||||
} else if amount < 0.0 {
|
||||
("take", -amount * scale)
|
||||
@@ -47,22 +51,27 @@ fn modifier_line(
|
||||
)
|
||||
}
|
||||
|
||||
/// `ItemStack.addAttributeTooltips` for the item's own modifiers: per slot
|
||||
/// group an empty line, the group's heading and a line per modifier.
|
||||
fn attribute_lines(info: &ItemInfo, translate: &dyn Fn(&str) -> Option<String>) -> Vec<String> {
|
||||
/// `ItemStack.addAttributeTooltips`: slot groups, colors and optional player
|
||||
/// attack damage/speed bases. Recipe search has no player context.
|
||||
pub fn attribute_lines(
|
||||
modifiers: &[(String, String, f64, String, String)],
|
||||
player_base: Option<[f64; 2]>,
|
||||
translate: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Vec<(String, u32)> {
|
||||
let mut lines = Vec::new();
|
||||
for group in SLOT_GROUPS {
|
||||
let mut first = true;
|
||||
for (attribute, _, amount, operation, slot) in &info.modifiers {
|
||||
for (attribute, modifier, amount, operation, slot) in modifiers {
|
||||
if slot != group {
|
||||
continue;
|
||||
}
|
||||
if std::mem::take(&mut first) {
|
||||
lines.push(String::new());
|
||||
lines.push(
|
||||
lines.push((String::new(), u32::MAX));
|
||||
lines.push((
|
||||
translate(&format!("item.modifiers.{group}"))
|
||||
.unwrap_or_else(|| format!("item.modifiers.{group}")),
|
||||
);
|
||||
rgba(170, 170, 170, 255),
|
||||
));
|
||||
}
|
||||
let id = match operation.as_str() {
|
||||
"add_multiplied_base" => 1,
|
||||
@@ -70,7 +79,27 @@ fn attribute_lines(info: &ItemInfo, translate: &dyn Fn(&str) -> Option<String>)
|
||||
_ => 0,
|
||||
};
|
||||
let path = attribute.strip_prefix("minecraft:").unwrap_or(attribute);
|
||||
lines.extend(modifier_line(translate, path, *amount, id));
|
||||
let base = player_base.and_then(|[damage, speed]| match modifier.as_str() {
|
||||
"minecraft:base_attack_damage" => Some(damage),
|
||||
"minecraft:base_attack_speed" => Some(speed),
|
||||
_ => None,
|
||||
});
|
||||
if let Some(line) = modifier_line(
|
||||
translate,
|
||||
path,
|
||||
amount + base.unwrap_or(0.0),
|
||||
id,
|
||||
base.is_some(),
|
||||
) {
|
||||
let (line, color) = if base.is_some() {
|
||||
(format!(" {line}"), rgba(0, 170, 0, 255))
|
||||
} else if *amount > 0.0 {
|
||||
(line, rgba(85, 85, 255, 255))
|
||||
} else {
|
||||
(line, rgba(255, 85, 85, 255))
|
||||
};
|
||||
lines.push((line, color));
|
||||
}
|
||||
}
|
||||
}
|
||||
lines
|
||||
@@ -102,6 +131,10 @@ pub fn name_line(stack: &ItemStack, translate: &dyn Fn(&str) -> Option<String>)
|
||||
if let Some(name) = stack.custom_name() {
|
||||
return name.to_owned();
|
||||
}
|
||||
if stack.data().is_some_and(|d| d.ominous_banner) {
|
||||
return translate("block.minecraft.ominous_banner")
|
||||
.unwrap_or_else(|| "Ominous Banner".to_owned());
|
||||
}
|
||||
let key = if is_potion_item(&stack.item) {
|
||||
Some(crate::brewing::potion_name_key(&stack.item, stack.potion()))
|
||||
} else if let Some(pattern) = path.strip_suffix("_armor_trim_smithing_template") {
|
||||
@@ -120,6 +153,24 @@ pub fn name_line(stack: &ItemStack, translate: &dyn Fn(&str) -> Option<String>)
|
||||
.unwrap_or_else(|| path.replace('_', " "))
|
||||
}
|
||||
|
||||
/// ItemStack.getRarity: the ominous banner override, then the enchantment upgrade.
|
||||
pub fn rarity<'a>(stack: &ItemStack, default: &'a str) -> &'a str {
|
||||
let rarity = if stack.data().is_some_and(|d| d.ominous_banner) {
|
||||
"uncommon"
|
||||
} else {
|
||||
default
|
||||
};
|
||||
if stack.enchantments().is_empty() {
|
||||
rarity
|
||||
} else {
|
||||
match rarity {
|
||||
"common" | "uncommon" => "rare",
|
||||
"rare" => "epic",
|
||||
r => r,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The lines of `stack` below its name that its components give, in
|
||||
/// `ItemStack.addDetailsToTooltip` order.
|
||||
pub fn detail_lines(
|
||||
@@ -211,6 +262,7 @@ pub fn detail_lines(
|
||||
attribute,
|
||||
amount * (*amplifier + 1) as f64,
|
||||
operation,
|
||||
false,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -242,7 +294,11 @@ pub fn detail_lines(
|
||||
lines.push(text("item.dyed"));
|
||||
}
|
||||
if let Some(info) = info {
|
||||
lines.extend(attribute_lines(info, translate));
|
||||
lines.extend(
|
||||
attribute_lines(&info.modifiers, None, translate)
|
||||
.into_iter()
|
||||
.map(|(line, _)| line),
|
||||
);
|
||||
}
|
||||
lines
|
||||
}
|
||||
@@ -292,6 +348,26 @@ pub fn lines(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ominous_banner_name_and_rarity_use_the_stack() {
|
||||
let translate = |key: &str| match key {
|
||||
"block.minecraft.ominous_banner" => Some("Ominous Banner".to_owned()),
|
||||
"block.minecraft.white_banner" => Some("White Banner".to_owned()),
|
||||
_ => None,
|
||||
};
|
||||
let mut banner = crate::mobs::raider::ominous_banner();
|
||||
assert_eq!(name_line(&banner, &translate), "Ominous Banner");
|
||||
assert_eq!(name_line(&banner, &|_| None), "Ominous Banner");
|
||||
assert_eq!(rarity(&banner, "common"), "uncommon");
|
||||
banner.data_mut().custom_name = Some("Captain's Standard".into());
|
||||
assert_eq!(name_line(&banner, &translate), "Captain's Standard");
|
||||
banner.enchant("minecraft:unbreaking", 1);
|
||||
assert_eq!(rarity(&banner, "common"), "rare");
|
||||
let plain = ItemStack::new("minecraft:white_banner", 1);
|
||||
assert_eq!(name_line(&plain, &translate), "White Banner");
|
||||
assert_eq!(rarity(&plain, "common"), "common");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn container_lines_list_five_stacks_then_how_many_more() {
|
||||
let mut chest = ItemStack::new("minecraft:chest", 1);
|
||||
@@ -335,6 +411,115 @@ mod tests {
|
||||
assert_eq!(format_amount(0.375), "0.38");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equipment_attributes_use_player_totals_and_slot_group_colors() {
|
||||
let modifiers = [
|
||||
("attack_damage", "base_attack_damage", 6.0, "mainhand"),
|
||||
("attack_speed", "base_attack_speed", -2.4, "mainhand"),
|
||||
("armor", "armor", 3.0, "head"),
|
||||
]
|
||||
.map(|(attribute, id, amount, slot)| {
|
||||
(
|
||||
format!("minecraft:{attribute}"),
|
||||
format!("minecraft:{id}"),
|
||||
amount,
|
||||
"add_value".to_owned(),
|
||||
slot.to_owned(),
|
||||
)
|
||||
});
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"horizon-tooltip-language-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos(),
|
||||
));
|
||||
let dir = root.join("assets/minecraft/lang");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(
|
||||
dir.join("en_us.json"),
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"item.modifiers.mainhand": "When in Main Hand:",
|
||||
"item.modifiers.head": "When on Head:",
|
||||
"attribute.modifier.equals.0": "%s %s",
|
||||
"attribute.modifier.plus.0": "+%s %s",
|
||||
"attribute.modifier.take.0": "-%s %s",
|
||||
"attribute.name.attack_damage": "Attack Damage",
|
||||
"attribute.name.attack_speed": "Attack Speed",
|
||||
"attribute.name.armor": "Armor",
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let enchantments = crate::enchant::Enchantments::load(&root);
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
let lang = |key: &str| enchantments.translate(key).map(str::to_owned);
|
||||
let green = rgba(0, 170, 0, 255);
|
||||
let gray = rgba(170, 170, 170, 255);
|
||||
let blue = rgba(85, 85, 255, 255);
|
||||
let red = rgba(255, 85, 85, 255);
|
||||
assert_eq!(
|
||||
attribute_lines(&modifiers, Some([1.0, 4.0]), &lang),
|
||||
[
|
||||
(String::new(), u32::MAX),
|
||||
("When in Main Hand:".into(), gray),
|
||||
(" 7 Attack Damage".into(), green),
|
||||
(" 1.6 Attack Speed".into(), green),
|
||||
(String::new(), u32::MAX),
|
||||
("When on Head:".into(), gray),
|
||||
("+3 Armor".into(), blue),
|
||||
]
|
||||
);
|
||||
let recipe = attribute_lines(&modifiers, None, &lang);
|
||||
assert_eq!(recipe[2], ("+6 Attack Damage".into(), blue));
|
||||
assert_eq!(recipe[3], ("-2.4 Attack Speed".into(), red));
|
||||
let custom_base = attribute_lines(&modifiers, Some([2.0, 6.0]), &lang);
|
||||
assert_eq!(custom_base[2].0, " 8 Attack Damage");
|
||||
assert_eq!(custom_base[3].0, " 3.6 Attack Speed");
|
||||
assert_eq!(
|
||||
modifier_line(&lang, "attack_damage", 0.0, 0, true).as_deref(),
|
||||
Some("0 Attack Damage")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared items/language in HORIZON_TEST_RESOURCES"]
|
||||
fn equipment_attributes_with_prepared_items_and_language() {
|
||||
let root = std::path::PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES").expect("HORIZON_TEST_RESOURCES"),
|
||||
);
|
||||
let items = crate::items::Items::catalog(&root).unwrap();
|
||||
let enchantments = crate::enchant::Enchantments::load(&root);
|
||||
let translate = |key: &str| enchantments.translate(key).map(str::to_owned);
|
||||
for (item, expected) in [
|
||||
(
|
||||
"minecraft:diamond_sword",
|
||||
[
|
||||
"",
|
||||
"When in Main Hand:",
|
||||
" 7 Attack Damage",
|
||||
" 1.6 Attack Speed",
|
||||
],
|
||||
),
|
||||
(
|
||||
"minecraft:diamond_helmet",
|
||||
["", "When on Head:", "+3 Armor", "+2 Armor Toughness"],
|
||||
),
|
||||
] {
|
||||
let info = items.get(item).unwrap();
|
||||
let lines = attribute_lines(&info.modifiers, Some([1.0, 4.0]), &translate);
|
||||
assert_eq!(
|
||||
lines
|
||||
.iter()
|
||||
.map(|(text, _)| text.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
expected,
|
||||
"{item}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modifier_lines_follow_the_sign_and_operation() {
|
||||
let lang = |k: &str| {
|
||||
@@ -349,13 +534,13 @@ mod tests {
|
||||
)
|
||||
};
|
||||
assert_eq!(
|
||||
modifier_line(&lang, "movement_speed", 0.2, 2).as_deref(),
|
||||
modifier_line(&lang, "movement_speed", 0.2, 2, false).as_deref(),
|
||||
Some("+20% Speed")
|
||||
);
|
||||
assert_eq!(
|
||||
modifier_line(&lang, "movement_speed", -0.6, 2).as_deref(),
|
||||
modifier_line(&lang, "movement_speed", -0.6, 2, false).as_deref(),
|
||||
Some("-60% Speed")
|
||||
);
|
||||
assert_eq!(modifier_line(&lang, "movement_speed", 0.0, 2), None);
|
||||
assert_eq!(modifier_line(&lang, "movement_speed", 0.0, 2, false), None);
|
||||
}
|
||||
}
|
||||
@@ -274,16 +274,20 @@ pub(crate) fn load_tags(root: &Path, kind: &str) -> Result<HashMap<String, HashS
|
||||
}
|
||||
/// Every tag's contents in `TagLoader.tryBuildTag` order (a `LinkedHashSet`).
|
||||
pub(crate) fn load_tag_lists(root: &Path, kind: &str) -> Result<HashMap<String, Vec<String>>> {
|
||||
let dir = root.join(format!("data/minecraft/tags/{kind}"));
|
||||
let mut raw: HashMap<String, Vec<String>> = HashMap::new();
|
||||
fn walk(dir: &Path, base: &Path, raw: &mut HashMap<String, Vec<String>>) -> Result<()> {
|
||||
fn walk(
|
||||
dir: &Path,
|
||||
base: &Path,
|
||||
namespace: &str,
|
||||
raw: &mut HashMap<String, Vec<String>>,
|
||||
) -> Result<()> {
|
||||
for entry in fs::read_dir(dir)?.flatten() {
|
||||
let p = entry.path();
|
||||
if p.is_dir() {
|
||||
walk(&p, base, raw)?;
|
||||
walk(&p, base, namespace, raw)?;
|
||||
} else if p.extension().is_some_and(|e| e == "json") {
|
||||
let name = format!(
|
||||
"minecraft:{}",
|
||||
"{namespace}:{}",
|
||||
p.strip_prefix(base)?
|
||||
.with_extension("")
|
||||
.to_string_lossy()
|
||||
@@ -301,8 +305,12 @@ pub(crate) fn load_tag_lists(root: &Path, kind: &str) -> Result<HashMap<String,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
if dir.exists() {
|
||||
walk(&dir, &dir, &mut raw)?;
|
||||
// Vanilla's tags; with content mods also the convention (`c`) tags and the mods' own.
|
||||
for namespace in crate::mod_content::tag_namespaces(root) {
|
||||
let dir = root.join(format!("data/{namespace}/tags/{kind}"));
|
||||
if dir.exists() {
|
||||
walk(&dir, &dir, &namespace, &mut raw)?;
|
||||
}
|
||||
}
|
||||
fn resolve(name: &str, raw: &HashMap<String, Vec<String>>, out: &mut Vec<String>, depth: u32) {
|
||||
if depth > 32 {
|
||||
|
||||
@@ -373,11 +373,60 @@ impl KeyBinds {
|
||||
}
|
||||
Some(Err(button))
|
||||
}
|
||||
/// `AbstractContainerScreen.checkHotbarMouseClicked`: off hand, then hotbar.
|
||||
pub fn container_mouse_key(&self, button: MouseButton, creative: bool) -> Option<KeyCode> {
|
||||
let name = mouse_name(button);
|
||||
if matches!(button, MouseButton::Left | MouseButton::Right)
|
||||
|| (creative && self.get("key.pickItem") == name)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
std::iter::once("key.swapOffhand")
|
||||
.chain(
|
||||
MAPPINGS
|
||||
.iter()
|
||||
.map(|m| m.0)
|
||||
.filter(|n| n.starts_with("key.hotbar.")),
|
||||
)
|
||||
.find(|mapping| self.get(mapping) == name)
|
||||
.and_then(|mapping| key_code(Self::default_of(mapping)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn container_mouse_hotkeys_follow_slot_bindings_and_pick_item_priority() {
|
||||
for button in [MouseButton::Back, MouseButton::Forward, MouseButton::Middle] {
|
||||
for (mapping, default, _) in MAPPINGS.iter().filter(|m| m.0.starts_with("key.hotbar."))
|
||||
{
|
||||
let mut keys = KeyBinds::default();
|
||||
keys.set(mapping, &mouse_name(button));
|
||||
assert_eq!(keys.container_mouse_key(button, false), key_code(default));
|
||||
assert_eq!(
|
||||
keys.container_mouse_key(button, true),
|
||||
if button == MouseButton::Middle {
|
||||
None
|
||||
} else {
|
||||
key_code(default)
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut keys = KeyBinds::default();
|
||||
keys.set("key.hotbar.1", "key.mouse.4");
|
||||
keys.set("key.swapOffhand", "key.mouse.4");
|
||||
assert_eq!(
|
||||
keys.container_mouse_key(MouseButton::Back, false),
|
||||
Some(KeyCode::KeyF)
|
||||
);
|
||||
keys.set("key.hotbar.2", "key.mouse.left");
|
||||
assert_eq!(keys.container_mouse_key(MouseButton::Left, false), None);
|
||||
assert_eq!(keys.container_mouse_key(MouseButton::Right, false), None);
|
||||
assert_eq!(keys.container_mouse_key(MouseButton::Forward, false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebinding_translates_to_defaults() {
|
||||
let mut k = KeyBinds::default();
|
||||
|
||||
@@ -4,11 +4,14 @@
|
||||
//! ClientboundRecipeBookAddPacket) and the advancements the player may see go
|
||||
//! out in ClientboundUpdateAdvancementsPacket (PlayerAdvancements.flushDirty).
|
||||
use super::data::{SyncData, json_to_nbt};
|
||||
use crate::{advancements::Tree, recipe_book::RecipeIndex};
|
||||
use crate::{
|
||||
advancements::{Progress, Tree},
|
||||
recipe_book::{RecipeIndex, Settings},
|
||||
};
|
||||
use horizon_protocol::buf::Writer;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
collections::{BTreeMap, BTreeSet, HashMap},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
@@ -54,8 +57,16 @@ impl ServerAdvancements {
|
||||
}
|
||||
}
|
||||
|
||||
/// The first tick of a player without advancements.
|
||||
pub fn first_tick(&self, sync: &SyncData, now_millis: i64) -> FirstTick {
|
||||
/// The first tick of a player without advancements: the criteria
|
||||
/// `minecraft:tick` meets go into `progress` and the recipes their
|
||||
/// advancements reward into `book`.
|
||||
pub fn first_tick(
|
||||
&self,
|
||||
sync: &SyncData,
|
||||
now_millis: i64,
|
||||
progress: &mut Progress,
|
||||
book: &mut Settings,
|
||||
) -> FirstTick {
|
||||
// CriteriaTriggers.TICK: the criteria it meets, and what they complete.
|
||||
let mut obtained: Vec<Vec<&str>> = vec![Vec::new(); self.tree.list.len()];
|
||||
for (i, a) in self.tree.list.iter().enumerate() {
|
||||
@@ -77,25 +88,24 @@ impl ServerAdvancements {
|
||||
.all(|group| group.iter().any(|c| got.contains(&c.as_str())))
|
||||
})
|
||||
.collect();
|
||||
let date = crate::advancements::date_text(now_millis.div_euclid(1000));
|
||||
for (a, got) in self.tree.list.iter().zip(&obtained) {
|
||||
for c in got {
|
||||
progress.grant(&a.id, c, &date);
|
||||
}
|
||||
}
|
||||
// AdvancementRewards.grant: the recipes, in the advancements' order.
|
||||
let mut entries = Vec::new();
|
||||
for (i, a) in self.tree.list.iter().enumerate() {
|
||||
if !done[i] {
|
||||
continue;
|
||||
}
|
||||
book.add_recipes(&self.displays, &a.rewards.recipes);
|
||||
for id in &a.rewards.recipes {
|
||||
entries.extend(self.book_entries(id, sync));
|
||||
entries.extend(self.book_entries(id, sync, NOTIFY_AND_HIGHLIGHT));
|
||||
}
|
||||
}
|
||||
let recipe_book_add = (!entries.is_empty()).then(|| {
|
||||
let mut w = Writer::new();
|
||||
w.varint(entries.len() as i32);
|
||||
for e in entries {
|
||||
w.data.extend(e);
|
||||
}
|
||||
w.bool(false); // not a replacement
|
||||
w.data
|
||||
});
|
||||
let recipe_book_add = (!entries.is_empty()).then(|| book_add(entries, false));
|
||||
// flushDirty: every advancement the player may see, and its progress.
|
||||
let visible = self.tree.visible(&self.kids, |i| done[i]);
|
||||
let shown: Vec<usize> = (0..self.tree.list.len()).filter(|i| visible[*i]).collect();
|
||||
@@ -103,60 +113,14 @@ impl ServerAdvancements {
|
||||
w.bool(true); // the first packet resets the client's advancements
|
||||
w.varint(shown.len() as i32);
|
||||
for &i in &shown {
|
||||
let a = &self.tree.list[i];
|
||||
w.string(&a.id);
|
||||
w.bool(a.parent.is_some());
|
||||
if let Some(p) = &a.parent {
|
||||
w.string(p);
|
||||
}
|
||||
w.bool(a.display.is_some());
|
||||
if let Some(d) = &a.display {
|
||||
w.nbt(&json_to_nbt(&d.title));
|
||||
w.nbt(&json_to_nbt(&d.description));
|
||||
w.varint(sync.id("minecraft:item", &d.icon).unwrap_or(0));
|
||||
w.varint(1);
|
||||
w.varint(0);
|
||||
w.varint(0);
|
||||
w.varint(match d.frame {
|
||||
crate::advancements::Frame::Task => 0,
|
||||
crate::advancements::Frame::Challenge => 1,
|
||||
crate::advancements::Frame::Goal => 2,
|
||||
});
|
||||
w.i32(
|
||||
i32::from(d.background.is_some())
|
||||
| i32::from(d.show_toast) << 1
|
||||
| i32::from(d.hidden) << 2,
|
||||
);
|
||||
if let Some(b) = &d.background {
|
||||
w.string(b);
|
||||
}
|
||||
}
|
||||
w.varint(a.requirements.len() as i32);
|
||||
for group in &a.requirements {
|
||||
w.varint(group.len() as i32);
|
||||
for c in group {
|
||||
w.string(c);
|
||||
}
|
||||
}
|
||||
w.bool(false); // sends_telemetry_event
|
||||
let (x, y) = self.layout[i].unwrap_or((0, 0.0));
|
||||
w.f32(x as f32);
|
||||
w.f32(y);
|
||||
self.write_advancement(&mut w, i, sync);
|
||||
}
|
||||
w.varint(0); // nothing removed
|
||||
w.varint(shown.len() as i32);
|
||||
for &i in &shown {
|
||||
let a = &self.tree.list[i];
|
||||
w.string(&a.id);
|
||||
w.varint(a.criteria.len() as i32);
|
||||
for c in &a.criteria {
|
||||
w.string(&c.name);
|
||||
let got = obtained[i].contains(&c.name.as_str());
|
||||
w.bool(got);
|
||||
if got {
|
||||
w.i64(now_millis);
|
||||
}
|
||||
}
|
||||
self.write_progress(&mut w, i, |c| {
|
||||
obtained[i].contains(&c).then_some(now_millis)
|
||||
});
|
||||
}
|
||||
w.bool(true); // show the advancements
|
||||
FirstTick {
|
||||
@@ -165,9 +129,176 @@ impl ServerAdvancements {
|
||||
}
|
||||
}
|
||||
|
||||
/// `PlayerAdvancements.flushDirty`: the advancements that became visible
|
||||
/// or hidden since `visible` (which it updates; empty before the first
|
||||
/// packet, which resets the client's and carries everything visible) and
|
||||
/// the progress of the `changed` ones still visible. None when there is
|
||||
/// nothing to send.
|
||||
pub fn update(
|
||||
&self,
|
||||
sync: &SyncData,
|
||||
progress: &Progress,
|
||||
visible: &mut Vec<bool>,
|
||||
changed: &BTreeSet<usize>,
|
||||
show: bool,
|
||||
) -> Option<Vec<u8>> {
|
||||
let n = self.tree.list.len();
|
||||
let first = visible.len() != n;
|
||||
let now = self
|
||||
.tree
|
||||
.visible(&self.kids, |i| progress.done(&self.tree.list[i]));
|
||||
let was = |i: usize| !first && visible[i];
|
||||
let added: Vec<usize> = (0..n).filter(|&i| now[i] && !was(i)).collect();
|
||||
let removed: Vec<usize> = (0..n).filter(|&i| !now[i] && was(i)).collect();
|
||||
let progressed: Vec<usize> = if first {
|
||||
(0..n).filter(|&i| now[i]).collect()
|
||||
} else {
|
||||
changed
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&i| i < n && now[i])
|
||||
.collect()
|
||||
};
|
||||
*visible = now;
|
||||
if !first && added.is_empty() && removed.is_empty() && progressed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut w = Writer::new();
|
||||
w.bool(first);
|
||||
w.varint(added.len() as i32);
|
||||
for &i in &added {
|
||||
self.write_advancement(&mut w, i, sync);
|
||||
}
|
||||
w.varint(removed.len() as i32);
|
||||
for &i in &removed {
|
||||
w.string(&self.tree.list[i].id);
|
||||
}
|
||||
w.varint(progressed.len() as i32);
|
||||
for &i in &progressed {
|
||||
let id = &self.tree.list[i].id;
|
||||
self.write_progress(&mut w, i, |c| {
|
||||
progress
|
||||
.criteria
|
||||
.get(id)
|
||||
.and_then(|m| m.get(c))
|
||||
.map(|date| crate::advancements::date_millis(date).unwrap_or(0))
|
||||
});
|
||||
}
|
||||
w.bool(show);
|
||||
Some(w.data)
|
||||
}
|
||||
|
||||
/// Which advancements a player with `progress` may see.
|
||||
pub fn visibility(&self, progress: &Progress) -> Vec<bool> {
|
||||
self.tree
|
||||
.visible(&self.kids, |i| progress.done(&self.tree.list[i]))
|
||||
}
|
||||
|
||||
/// `ClientboundUpdateAdvancementsPacket.PositionedAdvancement`.
|
||||
fn write_advancement(&self, w: &mut Writer, i: usize, sync: &SyncData) {
|
||||
let a = &self.tree.list[i];
|
||||
w.string(&a.id);
|
||||
w.bool(a.parent.is_some());
|
||||
if let Some(p) = &a.parent {
|
||||
w.string(p);
|
||||
}
|
||||
w.bool(a.display.is_some());
|
||||
if let Some(d) = &a.display {
|
||||
w.nbt(&json_to_nbt(&d.title));
|
||||
w.nbt(&json_to_nbt(&d.description));
|
||||
w.varint(sync.id("minecraft:item", &d.icon).unwrap_or(0));
|
||||
w.varint(1);
|
||||
w.varint(0);
|
||||
w.varint(0);
|
||||
w.varint(match d.frame {
|
||||
crate::advancements::Frame::Task => 0,
|
||||
crate::advancements::Frame::Challenge => 1,
|
||||
crate::advancements::Frame::Goal => 2,
|
||||
});
|
||||
w.i32(
|
||||
i32::from(d.background.is_some())
|
||||
| i32::from(d.show_toast) << 1
|
||||
| i32::from(d.hidden) << 2,
|
||||
);
|
||||
if let Some(b) = &d.background {
|
||||
w.string(b);
|
||||
}
|
||||
}
|
||||
w.varint(a.requirements.len() as i32);
|
||||
for group in &a.requirements {
|
||||
w.varint(group.len() as i32);
|
||||
for c in group {
|
||||
w.string(c);
|
||||
}
|
||||
}
|
||||
w.bool(false); // sends_telemetry_event
|
||||
let (x, y) = self.layout[i].unwrap_or((0, 0.0));
|
||||
w.f32(x as f32);
|
||||
w.f32(y);
|
||||
}
|
||||
|
||||
/// An `AdvancementProgress`: each criterion and when it was obtained.
|
||||
fn write_progress(&self, w: &mut Writer, i: usize, obtained: impl Fn(&str) -> Option<i64>) {
|
||||
let a = &self.tree.list[i];
|
||||
w.string(&a.id);
|
||||
w.varint(a.criteria.len() as i32);
|
||||
for c in &a.criteria {
|
||||
w.string(&c.name);
|
||||
let at = obtained(&c.name);
|
||||
w.bool(at.is_some());
|
||||
if let Some(at) = at {
|
||||
w.i64(at);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ServerRecipeBook.addRecipes`' packet for recipes just unlocked:
|
||||
/// each display, with its notification and highlighted. None when no
|
||||
/// recipe has a display.
|
||||
pub fn recipes_added(&self, ids: &[String], sync: &SyncData) -> Option<Vec<u8>> {
|
||||
let entries: Vec<Vec<u8>> = ids
|
||||
.iter()
|
||||
.flat_map(|id| self.book_entries(id, sync, NOTIFY_AND_HIGHLIGHT))
|
||||
.collect();
|
||||
(!entries.is_empty()).then(|| book_add(entries, false))
|
||||
}
|
||||
|
||||
/// `ServerRecipeBook.sendInitialRecipeBook`: every known recipe, without
|
||||
/// notifications, highlighted while not yet displayed, replacing the
|
||||
/// client's book.
|
||||
pub fn initial_recipe_book(&self, book: &Settings, sync: &SyncData) -> Vec<u8> {
|
||||
let entries: Vec<Vec<u8>> = book
|
||||
.recipes
|
||||
.iter()
|
||||
.flat_map(|id| {
|
||||
let highlight = if book.to_be_displayed.contains(id) {
|
||||
2
|
||||
} else {
|
||||
0
|
||||
};
|
||||
self.book_entries(id, sync, highlight)
|
||||
})
|
||||
.collect();
|
||||
book_add(entries, true)
|
||||
}
|
||||
|
||||
/// The recipe's display for ClientboundPlaceGhostRecipePacket (the
|
||||
/// recipes the book lists).
|
||||
pub fn ghost_display(
|
||||
&self,
|
||||
id: &str,
|
||||
sync: &SyncData,
|
||||
) -> Option<horizon_protocol::packets::recipes::RecipeDisplay> {
|
||||
let bytes = self.book_entries(id, sync, 0).into_iter().next()?;
|
||||
let mut r = horizon_protocol::buf::Reader::new(&bytes);
|
||||
horizon_protocol::packets::recipes::RecipeBookEntry::read(&mut r)
|
||||
.ok()
|
||||
.map(|e| e.display)
|
||||
}
|
||||
|
||||
/// ClientboundRecipeBookAddPacket.Entry for each display of a recipe:
|
||||
/// highlighted, and with a notification unless the recipe turns it off.
|
||||
fn book_entries(&self, id: &str, sync: &SyncData) -> Vec<Vec<u8>> {
|
||||
fn book_entries(&self, id: &str, sync: &SyncData, flags: i8) -> Vec<Vec<u8>> {
|
||||
let Some(r) = self.recipes.get(id) else {
|
||||
return Vec::new();
|
||||
};
|
||||
@@ -245,12 +376,27 @@ impl ServerAdvancements {
|
||||
for v in placed {
|
||||
holder_set(&mut w, v, &item);
|
||||
}
|
||||
// The notification only where the recipe shows one.
|
||||
let notify = r["show_notification"].as_bool().unwrap_or(true);
|
||||
w.i8(i8::from(notify) | 2);
|
||||
w.i8(if notify { flags } else { flags & !1 });
|
||||
vec![w.data]
|
||||
}
|
||||
}
|
||||
|
||||
/// ClientboundRecipeBookAddPacket.Entry flags: notification, highlight.
|
||||
const NOTIFY_AND_HIGHLIGHT: i8 = 1 | 2;
|
||||
|
||||
/// ClientboundRecipeBookAddPacket: the entries, then whether they replace the book.
|
||||
fn book_add(entries: Vec<Vec<u8>>, replace: bool) -> Vec<u8> {
|
||||
let mut w = Writer::new();
|
||||
w.varint(entries.len() as i32);
|
||||
for e in entries {
|
||||
w.data.extend(e);
|
||||
}
|
||||
w.bool(replace);
|
||||
w.data
|
||||
}
|
||||
|
||||
/// ShapedRecipePattern.shrink: the pattern without blank border rows and columns.
|
||||
fn shrink(rows: &[Value]) -> Vec<Vec<char>> {
|
||||
let rows: Vec<Vec<char>> = rows
|
||||
|
||||
@@ -90,7 +90,78 @@ pub struct ChunkEncoder {
|
||||
pub block_ids: Vec<i32>,
|
||||
/// The save's tables, for the heightmaps.
|
||||
pub tables: Option<Arc<crate::save::SaveTables>>,
|
||||
/// Sections already looked at, by address (see `Encoded`).
|
||||
cache: std::sync::Mutex<HashMap<usize, Encoded>>,
|
||||
/// Each column's heightmaps, with the sections they were made from.
|
||||
heights: std::sync::Mutex<HashMap<(i32, i32), Heights>>,
|
||||
/// The level's sections among a column's: all of them in the Overworld,
|
||||
/// y 0 to 255 in the Nether and the End (`DimensionType` min_y and height).
|
||||
sections: std::ops::Range<usize>,
|
||||
/// Whether the level has sky light (`DimensionType.hasSkyLight`).
|
||||
sky: bool,
|
||||
}
|
||||
|
||||
/// A column's client heightmaps, valid while it holds the same sections.
|
||||
struct Heights {
|
||||
sections: Vec<std::sync::Weak<Section>>,
|
||||
maps: Vec<(i32, Vec<i64>)>,
|
||||
}
|
||||
|
||||
/// What a resident section gives the wire, kept while the section is
|
||||
/// unchanged. Resident sections are immutable behind `Arc` (edits go through
|
||||
/// `Arc::make_mut`, which moves an edited section to a new address while a
|
||||
/// `Weak` points at the old one), and the `Weak` keeps the old address from
|
||||
/// being reused: the same address is the same, unchanged section.
|
||||
struct Encoded {
|
||||
section: std::sync::Weak<Section>,
|
||||
non_air: bool,
|
||||
/// LevelChunkSection.write, and whether it holds block entity blocks.
|
||||
wire: Option<(Arc<[u8]>, bool)>,
|
||||
/// The section's light as the wire's two DataLayers, when not uniform.
|
||||
light: Option<Arc<Layers>>,
|
||||
}
|
||||
|
||||
/// One section's light as DataLayers (two blocks per byte, the even one in
|
||||
/// the low nibble), and whether each holds any light.
|
||||
struct Layers {
|
||||
sky: Vec<u8>,
|
||||
block: Vec<u8>,
|
||||
sky_any: bool,
|
||||
block_any: bool,
|
||||
}
|
||||
impl Layers {
|
||||
fn of(light: &Light) -> Self {
|
||||
let (mut sky, mut block) = (vec![0u8; 2048], vec![0u8; 2048]);
|
||||
let (sky_any, block_any) = match light {
|
||||
Light::Uniform(v) => {
|
||||
let (sk, bl) = (v >> 4, v & 15);
|
||||
sky.fill(sk | sk << 4);
|
||||
block.fill(bl | bl << 4);
|
||||
(sk != 0, bl != 0)
|
||||
}
|
||||
Light::Full(a) => {
|
||||
for ((pair, sky), block) in
|
||||
a.as_chunks::<2>().0.iter().zip(&mut sky).zip(&mut block)
|
||||
{
|
||||
let [even, odd] = *pair;
|
||||
*sky = even >> 4 | (odd & 0xF0);
|
||||
*block = (even & 15) | odd << 4;
|
||||
}
|
||||
let all = a.iter().fold(0u8, |acc, &v| acc | v);
|
||||
(all & 0xF0 != 0, all & 15 != 0)
|
||||
}
|
||||
};
|
||||
Self {
|
||||
sky,
|
||||
block,
|
||||
sky_any,
|
||||
block_any,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sections kept before the ones no longer resident are dropped.
|
||||
const CACHE_SWEEP: usize = 1 << 15;
|
||||
impl ChunkEncoder {
|
||||
/// `biome_names`: the internal biome list in order; `biome_registry`:
|
||||
/// the synchronized biome entries in network order.
|
||||
@@ -143,8 +214,106 @@ impl ChunkEncoder {
|
||||
biome_bits: ceil_log2(biome_registry.len()),
|
||||
block_ids,
|
||||
tables: None,
|
||||
cache: Default::default(),
|
||||
heights: Default::default(),
|
||||
sections: 0..SECTIONS,
|
||||
sky: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The client heightmaps (Heightmap.prime over the column), from the
|
||||
/// cache while the column's sections are unchanged.
|
||||
fn heightmaps(&self, chunk: &Chunk, tables: &crate::save::SaveTables) -> Vec<(i32, Vec<i64>)> {
|
||||
let same = |h: &Heights| {
|
||||
h.sections.len() == chunk.sections.len()
|
||||
&& h.sections
|
||||
.iter()
|
||||
.zip(&chunk.sections)
|
||||
.all(|(w, s)| std::ptr::eq(w.as_ptr(), Arc::as_ptr(s)))
|
||||
};
|
||||
let mut heights = self.heights.lock().unwrap();
|
||||
if let Some(h) = heights.get(&(chunk.x, chunk.z))
|
||||
&& same(h)
|
||||
{
|
||||
return h.maps.clone();
|
||||
}
|
||||
if heights.len() >= CACHE_SWEEP / 4 {
|
||||
heights.retain(|_, h| h.sections.iter().all(|w| w.strong_count() > 0));
|
||||
if heights.len() >= CACHE_SWEEP / 8 {
|
||||
heights.clear();
|
||||
}
|
||||
}
|
||||
let min_y = MIN_Y + 16 * self.sections.start as i32;
|
||||
let height = 16 * self.sections.len() as i32;
|
||||
let maps = crate::save::client_heightmaps_in(chunk, tables, min_y, height);
|
||||
heights.insert(
|
||||
(chunk.x, chunk.z),
|
||||
Heights {
|
||||
sections: chunk.sections.iter().map(Arc::downgrade).collect(),
|
||||
maps: maps.clone(),
|
||||
},
|
||||
);
|
||||
maps
|
||||
}
|
||||
|
||||
/// The cached entry for a section, made with `non_air` when missing.
|
||||
fn cached<T>(&self, section: &Arc<Section>, read: impl FnOnce(&mut Encoded) -> T) -> T {
|
||||
let mut cache = self.cache.lock().unwrap();
|
||||
if cache.len() >= CACHE_SWEEP {
|
||||
cache.retain(|_, e| e.section.strong_count() > 0);
|
||||
if cache.len() >= CACHE_SWEEP / 2 {
|
||||
cache.clear();
|
||||
}
|
||||
}
|
||||
let entry = cache
|
||||
.entry(Arc::as_ptr(section) as usize)
|
||||
.or_insert_with(|| Encoded {
|
||||
section: Arc::downgrade(section),
|
||||
non_air: self.non_air(section),
|
||||
wire: None,
|
||||
light: None,
|
||||
});
|
||||
read(entry)
|
||||
}
|
||||
|
||||
/// LevelChunkSection.hasOnlyAir, negated.
|
||||
fn non_air(&self, section: &Section) -> bool {
|
||||
let non_air = |v: u32| self.flags.get(v as usize).copied().unwrap_or(0) & NON_AIR != 0;
|
||||
// The palette first: most sections are all air, or one block.
|
||||
if !section.blocks.maybe_has(non_air) {
|
||||
false
|
||||
} else if let Some(v) = section.blocks.uniform() {
|
||||
non_air(v)
|
||||
} else {
|
||||
// Stops at the first block, usually near the bottom.
|
||||
(0..BLOCKS).any(|i| non_air(section.blocks.get(i)))
|
||||
}
|
||||
}
|
||||
|
||||
/// A section on the wire, from the cache or encoded now.
|
||||
fn section_wire(&self, section: &Arc<Section>) -> (Arc<[u8]>, bool) {
|
||||
if let Some(wire) = self.cached(section, |e| e.wire.clone()) {
|
||||
return wire;
|
||||
}
|
||||
let (encoded, entities) = self.section(section);
|
||||
let mut w = horizon_protocol::buf::Writer::new();
|
||||
encoded.write(&mut w);
|
||||
let wire: (Arc<[u8]>, bool) = (w.data.into(), entities);
|
||||
self.cached(section, |e| e.wire = Some(wire.clone()));
|
||||
wire
|
||||
}
|
||||
/// Encode for a level of `min_y` and `height` (multiples of 16 within
|
||||
/// the column), with or without sky light.
|
||||
pub fn set_dimension(&mut self, min_y: i32, height: i32, sky: bool) {
|
||||
let lo = usize::try_from((min_y - MIN_Y) >> 4)
|
||||
.unwrap_or(0)
|
||||
.min(SECTIONS);
|
||||
let n = usize::try_from(height >> 4).unwrap_or(SECTIONS);
|
||||
self.sections = lo..(lo + n).min(SECTIONS);
|
||||
self.sky = sky;
|
||||
// The heightmaps depend on the level's height.
|
||||
self.heights.get_mut().unwrap().clear();
|
||||
}
|
||||
pub fn entity_type(&self, state: u16) -> Option<i32> {
|
||||
self.entity_types
|
||||
.get(usize::from(state))
|
||||
@@ -170,11 +339,17 @@ impl ChunkEncoder {
|
||||
}
|
||||
f & ENTITY != 0
|
||||
};
|
||||
let mut unpacked = [0u16; BLOCKS];
|
||||
section.unpack(&mut unpacked);
|
||||
for &v in &unpacked {
|
||||
entities |= count(v, 1);
|
||||
values.push(i32::from(v));
|
||||
// One block everywhere (all air, mostly): what from_values would give.
|
||||
let uniform = section.blocks.uniform();
|
||||
if let Some(v) = uniform {
|
||||
entities = count(v as u16, BLOCKS as i16);
|
||||
} else {
|
||||
let mut unpacked = [0u16; BLOCKS];
|
||||
section.unpack(&mut unpacked);
|
||||
for &v in &unpacked {
|
||||
entities |= count(v, 1);
|
||||
values.push(i32::from(v));
|
||||
}
|
||||
}
|
||||
let biome_values: Vec<i32> = (0..BIOMES)
|
||||
.map(|j| {
|
||||
@@ -189,7 +364,14 @@ impl ChunkEncoder {
|
||||
horizon_protocol::chunk::Section {
|
||||
non_empty,
|
||||
fluids,
|
||||
states: Paletted::from_values(&values, Strategy::Blocks, 16),
|
||||
states: match uniform {
|
||||
Some(v) => Paletted {
|
||||
bits: 0,
|
||||
palette: vec![v as i32],
|
||||
data: Vec::new(),
|
||||
},
|
||||
None => Paletted::from_values(&values, Strategy::Blocks, 16),
|
||||
},
|
||||
biomes: Paletted::from_values(&biome_values, Strategy::Biomes, self.biome_bits),
|
||||
},
|
||||
entities,
|
||||
@@ -200,13 +382,8 @@ impl ChunkEncoder {
|
||||
/// The non-air sections of a column as bits (LevelChunkSection.hasOnlyAir).
|
||||
fn occupied(&self, chunk: &Chunk) -> u64 {
|
||||
let mut bits = 0u64;
|
||||
for (i, s) in chunk.sections.iter().enumerate() {
|
||||
let mut unpacked = [0u16; BLOCKS];
|
||||
s.unpack(&mut unpacked);
|
||||
let any = unpacked
|
||||
.iter()
|
||||
.any(|&v| self.flags.get(usize::from(v)).copied().unwrap_or(0) & NON_AIR != 0);
|
||||
if any {
|
||||
for (i, s) in chunk.sections[self.sections.clone()].iter().enumerate() {
|
||||
if self.cached(s, |e| e.non_air) {
|
||||
bits |= 1 << i;
|
||||
}
|
||||
}
|
||||
@@ -224,8 +401,8 @@ impl ChunkEncoder {
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut packet = self.encode(chunk, &world.block_entities);
|
||||
packet.light = light_data(chunk, around);
|
||||
let mut packet = self.encode_blocks(chunk, &world.block_entities);
|
||||
packet.light = self.light_data(chunk, around);
|
||||
packet
|
||||
}
|
||||
|
||||
@@ -234,10 +411,27 @@ impl ChunkEncoder {
|
||||
chunk: &Chunk,
|
||||
block_entities: &HashMap<(i32, i32, i32), BlockEntity>,
|
||||
) -> LevelChunkWithLight {
|
||||
let mut sections = Vec::with_capacity(SECTIONS);
|
||||
let mut packet = self.encode_blocks(chunk, block_entities);
|
||||
packet.light = self.light_data(chunk, u64::MAX);
|
||||
packet
|
||||
}
|
||||
|
||||
/// The packet without its light.
|
||||
fn encode_blocks(
|
||||
&self,
|
||||
chunk: &Chunk,
|
||||
block_entities: &HashMap<(i32, i32, i32), BlockEntity>,
|
||||
) -> LevelChunkWithLight {
|
||||
let mut buffer = Vec::new();
|
||||
let mut entity_infos = Vec::new();
|
||||
for (i, s) in chunk.sections.iter().enumerate() {
|
||||
let (encoded, has_entities) = self.section(s);
|
||||
for (i, s) in chunk
|
||||
.sections
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(self.sections.start)
|
||||
.take(self.sections.len())
|
||||
{
|
||||
let (encoded, has_entities) = self.section_wire(s);
|
||||
if has_entities {
|
||||
for y in 0..16 {
|
||||
for z in 0..16 {
|
||||
@@ -261,7 +455,7 @@ impl ChunkEncoder {
|
||||
}
|
||||
}
|
||||
}
|
||||
sections.push(encoded);
|
||||
buffer.extend_from_slice(&encoded);
|
||||
}
|
||||
// A loaded chunk's block entities enter its maps in the save's order
|
||||
// (ChunkAccess.pendingBlockEntities, then LevelChunk.blockEntities):
|
||||
@@ -293,14 +487,46 @@ impl ChunkEncoder {
|
||||
heightmaps: self
|
||||
.tables
|
||||
.as_ref()
|
||||
.map(|t| crate::save::client_heightmaps(chunk, t))
|
||||
.map(|t| self.heightmaps(chunk, t))
|
||||
.unwrap_or_default(),
|
||||
buffer: ChunkData::from_sections(§ions),
|
||||
buffer,
|
||||
block_entities: entity_infos,
|
||||
},
|
||||
light: light_data(chunk, u64::MAX),
|
||||
light: LightData {
|
||||
sky_mask: Vec::new(),
|
||||
block_mask: Vec::new(),
|
||||
empty_sky_mask: Vec::new(),
|
||||
empty_block_mask: Vec::new(),
|
||||
sky: Vec::new(),
|
||||
block: Vec::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// ClientboundLightUpdatePacketData: the light layers of the sections
|
||||
/// that exist around the column (`around`: the non-air sections of it
|
||||
/// and its neighbours, as bits). A resident section's layers come from
|
||||
/// the cache while it is unchanged (uniform light is quicker to fill than
|
||||
/// to keep).
|
||||
fn light_data(&self, chunk: &Chunk, around: u64) -> LightData {
|
||||
let mut data = light_data_with(&chunk.sections[self.sections.clone()], around, |s| {
|
||||
if matches!(s.light, Light::Uniform(_)) {
|
||||
return Arc::new(Layers::of(&s.light));
|
||||
}
|
||||
self.cached(s, |e| e.light.clone()).unwrap_or_else(|| {
|
||||
let layers = Arc::new(Layers::of(&s.light));
|
||||
self.cached(s, |e| e.light = Some(layers.clone()));
|
||||
layers
|
||||
})
|
||||
});
|
||||
if !self.sky {
|
||||
// No sky light storage at all.
|
||||
data.sky_mask.clear();
|
||||
data.empty_sky_mask.clear();
|
||||
data.sky.clear();
|
||||
}
|
||||
data
|
||||
}
|
||||
}
|
||||
|
||||
/// The order LevelChunk.getBlockEntities() iterates in: a fastutil
|
||||
@@ -373,7 +599,18 @@ pub fn entity_tag(entity: &BlockEntity) -> Option<Tag> {
|
||||
/// non-air section of this column or a neighbouring one (`around`, bit i for
|
||||
/// section i; LayerLightSectionStorage.updateSectionStatus). An existing
|
||||
/// layer is sent, or marked empty when all dark; the others are left out.
|
||||
fn light_data(chunk: &Chunk, around: u64) -> LightData {
|
||||
#[cfg(test)]
|
||||
fn light_data(sections: &[Arc<Section>], around: u64) -> LightData {
|
||||
light_data_with(sections, around, |s| Arc::new(Layers::of(&s.light)))
|
||||
}
|
||||
|
||||
/// The light layers, `section` giving a resident section's.
|
||||
fn light_data_with(
|
||||
sections: &[Arc<Section>],
|
||||
around: u64,
|
||||
section: impl Fn(&Arc<Section>) -> Arc<Layers>,
|
||||
) -> LightData {
|
||||
let n = sections.len();
|
||||
let mut data = LightData {
|
||||
sky_mask: Vec::new(),
|
||||
block_mask: Vec::new(),
|
||||
@@ -383,55 +620,27 @@ fn light_data(chunk: &Chunk, around: u64) -> LightData {
|
||||
block: Vec::new(),
|
||||
};
|
||||
// Light section b holds chunk section b - 1.
|
||||
let exists = |b: usize| {
|
||||
(b.saturating_sub(2)..=b.min(SECTIONS - 1)).any(|s| s + 2 >= b && around >> s & 1 != 0)
|
||||
};
|
||||
let full_sky = Light::Uniform(0xF0);
|
||||
for b in 0..SECTIONS + 2 {
|
||||
let exists =
|
||||
|b: usize| (b.saturating_sub(2)..=b.min(n - 1)).any(|s| s + 2 >= b && around >> s & 1 != 0);
|
||||
for b in 0..n + 2 {
|
||||
if !exists(b) {
|
||||
continue;
|
||||
}
|
||||
let light = match b {
|
||||
let layers = match b {
|
||||
// Below the world nothing is lit; above it the sky is full.
|
||||
0 => &Light::Uniform(0),
|
||||
_ if b == SECTIONS + 1 => &full_sky,
|
||||
_ => &chunk.sections[b - 1].light,
|
||||
0 => Arc::new(Layers::of(&Light::Uniform(0))),
|
||||
_ if b == n + 1 => Arc::new(Layers::of(&Light::Uniform(0xF0))),
|
||||
_ => section(§ions[b - 1]),
|
||||
};
|
||||
let (mut sky, mut block) = (vec![0u8; 2048], vec![0u8; 2048]);
|
||||
let (mut sky_any, mut block_any) = (false, false);
|
||||
match light {
|
||||
Light::Uniform(v) => {
|
||||
let (sk, bl) = (v >> 4, v & 15);
|
||||
sky_any = sk != 0;
|
||||
block_any = bl != 0;
|
||||
let (sb, bb) = (sk | sk << 4, bl | bl << 4);
|
||||
sky.fill(sb);
|
||||
block.fill(bb);
|
||||
}
|
||||
Light::Full(a) => {
|
||||
for (j, &v) in a.iter().enumerate() {
|
||||
let (sk, bl) = (v >> 4, v & 15);
|
||||
sky_any |= sk != 0;
|
||||
block_any |= bl != 0;
|
||||
if j & 1 == 0 {
|
||||
sky[j >> 1] |= sk;
|
||||
block[j >> 1] |= bl;
|
||||
} else {
|
||||
sky[j >> 1] |= sk << 4;
|
||||
block[j >> 1] |= bl << 4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if sky_any {
|
||||
if layers.sky_any {
|
||||
set_bit(&mut data.sky_mask, b);
|
||||
data.sky.push(sky);
|
||||
data.sky.push(layers.sky.clone());
|
||||
} else {
|
||||
set_bit(&mut data.empty_sky_mask, b);
|
||||
}
|
||||
if block_any {
|
||||
if layers.block_any {
|
||||
set_bit(&mut data.block_mask, b);
|
||||
data.block.push(block);
|
||||
data.block.push(layers.block.clone());
|
||||
} else {
|
||||
set_bit(&mut data.empty_block_mask, b);
|
||||
}
|
||||
@@ -460,16 +669,119 @@ mod tests {
|
||||
assert_eq!(block_entity_type("minecraft:stone"), None);
|
||||
}
|
||||
|
||||
fn encoder() -> ChunkEncoder {
|
||||
let states = ["minecraft:air", "minecraft:stone", "minecraft:water"]
|
||||
.map(|n| (Arc::<str>::from(n), n.ends_with("water")));
|
||||
ChunkEncoder::new(states, &Builtin::default(), &[], &[])
|
||||
}
|
||||
|
||||
/// The bytes on the wire for a section, encoded afresh.
|
||||
fn fresh(encoder: &ChunkEncoder, section: &Section) -> Vec<u8> {
|
||||
let mut w = horizon_protocol::buf::Writer::new();
|
||||
encoder.section(section).0.write(&mut w);
|
||||
w.data
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_sections_follow_edits() {
|
||||
let encoder = encoder();
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
chunk.set(1, MIN_Y + 2, 3, 1);
|
||||
let before = encoder.encode(&chunk, &HashMap::new());
|
||||
assert_eq!(encoder.occupied(&chunk), 1);
|
||||
assert_eq!(
|
||||
&*encoder.section_wire(&chunk.sections[0]).0,
|
||||
fresh(&encoder, &chunk.sections[0])
|
||||
);
|
||||
// An edit moves the section: the cache sees a new one.
|
||||
chunk.set(4, MIN_Y + 20, 4, 2);
|
||||
chunk.set(1, MIN_Y + 2, 3, 0);
|
||||
assert_eq!(encoder.occupied(&chunk), 2);
|
||||
let after = encoder.encode(&chunk, &HashMap::new());
|
||||
assert_ne!(before.chunk.buffer, after.chunk.buffer);
|
||||
let mut expected = Vec::new();
|
||||
for s in &chunk.sections {
|
||||
expected.extend(fresh(&encoder, s));
|
||||
}
|
||||
assert_eq!(after.chunk.buffer, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_layers_pack_two_blocks_per_byte() {
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
let mut light = Box::new([0u8; 4096]);
|
||||
for (i, v) in light.iter_mut().enumerate() {
|
||||
*v = (i * 37 % 251) as u8;
|
||||
}
|
||||
Arc::make_mut(&mut chunk.sections[3]).light = Light::Full(light.clone());
|
||||
let data = light_data(&chunk.sections, u64::MAX);
|
||||
// Layer 4 holds section 3. Below the world (layer 0) there is no sky
|
||||
// light, so the sky layers listed start at layer 1; section 3 has the
|
||||
// only block light.
|
||||
let (sky, block) = (&data.sky[3], &data.block[0]);
|
||||
for j in 0..4096 {
|
||||
let (s, b) = if j & 1 == 0 {
|
||||
(sky[j >> 1] & 15, block[j >> 1] & 15)
|
||||
} else {
|
||||
(sky[j >> 1] >> 4, block[j >> 1] >> 4)
|
||||
};
|
||||
assert_eq!((s, b), (light[j] >> 4, light[j] & 15), "block {j}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_light_follows_edits() {
|
||||
let encoder = encoder();
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
let mut light = Box::new([0x5Au8; 4096]);
|
||||
Arc::make_mut(&mut chunk.sections[2]).light = Light::Full(light.clone());
|
||||
let first = encoder.encode(&chunk, &HashMap::new()).light;
|
||||
assert_eq!(first, light_data(&chunk.sections, u64::MAX));
|
||||
// Relighting moves the section, so the cached layers aren't reused.
|
||||
light[7] = 0xF3;
|
||||
Arc::make_mut(&mut chunk.sections[2]).light = Light::Full(light);
|
||||
let second = encoder.encode(&chunk, &HashMap::new()).light;
|
||||
assert_ne!(first, second);
|
||||
assert_eq!(second, light_data(&chunk.sections, u64::MAX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_masks_cover_the_world_plus_borders() {
|
||||
let chunk = Chunk::empty(0, 0);
|
||||
// A column with nothing around it has no light layers at all.
|
||||
assert!(light_data(&chunk, 0).sky.is_empty());
|
||||
let data = light_data(&chunk, u64::MAX);
|
||||
assert!(light_data(&chunk.sections, 0).sky.is_empty());
|
||||
let data = light_data(&chunk.sections, u64::MAX);
|
||||
// Default sections are full sky light and no block light.
|
||||
assert_eq!(data.sky.len(), SECTIONS + 1);
|
||||
assert_eq!(data.block.len(), 0);
|
||||
assert_eq!(data.sky[0][0], 0xFF);
|
||||
assert!(horizon_protocol::buf::bit(&data.empty_block_mask, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nether_column_sends_its_sixteen_sections_without_sky_light() {
|
||||
let chunk = Chunk::empty(0, 0);
|
||||
let mut encoder = ChunkEncoder::new(
|
||||
[("minecraft:air".into(), false)],
|
||||
&Builtin::default(),
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
let overworld = encoder.encode(&chunk, &HashMap::new());
|
||||
encoder.set_dimension(0, 256, false);
|
||||
let nether = encoder.encode(&chunk, &HashMap::new());
|
||||
// An all-air section is the same bytes wherever it is.
|
||||
let per_section = overworld.chunk.buffer.len() / SECTIONS;
|
||||
assert_eq!(nether.chunk.buffer.len(), per_section * 16);
|
||||
assert!(nether.light.sky.is_empty() && nether.light.sky_mask.is_empty());
|
||||
// Block light layers: one below and one above the sixteen sections.
|
||||
assert!(horizon_protocol::buf::bit(
|
||||
&nether.light.empty_block_mask,
|
||||
17
|
||||
));
|
||||
assert!(!horizon_protocol::buf::bit(
|
||||
&nether.light.empty_block_mask,
|
||||
18
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
//! One accepted connection: handshake, status ping, offline-mode login and the
|
||||
//! One accepted connection: handshake, status ping, login (offline, or online
|
||||
//! with encryption and the session service's verdict) and the
|
||||
//! configuration phase (ServerStatusPacketListenerImpl,
|
||||
//! ServerLoginPacketListenerImpl, ServerConfigurationPacketListenerImpl). A
|
||||
//! player that completes configuration continues in the play state: the
|
||||
@@ -12,7 +13,7 @@ use super::{
|
||||
use horizon_protocol::{
|
||||
PROTOCOL_VERSION,
|
||||
connection::{ConnReader, ConnWriter, Connection},
|
||||
crypto::offline_uuid,
|
||||
crypto::{offline_uuid, server_hash, uuid_string},
|
||||
packets::{
|
||||
Empty,
|
||||
configuration::{self as cfg, ClientInformation, KnownPack},
|
||||
@@ -21,10 +22,11 @@ use horizon_protocol::{
|
||||
text::translatable,
|
||||
};
|
||||
use std::{
|
||||
cell::Cell,
|
||||
net::TcpStream,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicI32, AtomicI64, AtomicUsize, Ordering},
|
||||
Arc, Mutex, PoisonError,
|
||||
atomic::{AtomicI32, AtomicI64, AtomicU32, AtomicUsize, Ordering},
|
||||
mpsc::{Receiver, RecvTimeoutError, Sender, channel},
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
@@ -122,16 +124,64 @@ pub fn valid_name(name: &str) -> bool {
|
||||
(1..=16).contains(&name.len()) && name.chars().all(|c| c as u32 > 32 && (c as u32) < 127)
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static HOLDING: Cell<bool> = const { Cell::new(false) };
|
||||
}
|
||||
|
||||
/// While one is alive, the packets this thread sends to each player are held
|
||||
/// and handed to the player's writer thread together by
|
||||
/// [`PlayerSender::flush`]: one wake-up per player per tick rather than one
|
||||
/// per packet. The bytes and their order don't change.
|
||||
pub struct Holding(bool);
|
||||
impl Holding {
|
||||
pub fn start() -> Self {
|
||||
Self(HOLDING.replace(true))
|
||||
}
|
||||
}
|
||||
impl Drop for Holding {
|
||||
fn drop(&mut self) {
|
||||
HOLDING.set(self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// The sending half given to the game thread.
|
||||
#[derive(Clone)]
|
||||
pub struct PlayerSender {
|
||||
pub tx: Sender<Out>,
|
||||
pub pending: Arc<AtomicUsize>,
|
||||
held: Arc<Mutex<Vec<Vec<u8>>>>,
|
||||
}
|
||||
impl PlayerSender {
|
||||
pub fn new(tx: Sender<Out>, pending: Arc<AtomicUsize>) -> Self {
|
||||
Self {
|
||||
tx,
|
||||
pending,
|
||||
held: Arc::default(),
|
||||
}
|
||||
}
|
||||
fn held(&self) -> std::sync::MutexGuard<'_, Vec<Vec<u8>>> {
|
||||
self.held.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
pub fn send(&self, body: Vec<u8>) {
|
||||
self.pending.fetch_add(body.len(), Ordering::Relaxed);
|
||||
let _ = self.tx.send(Out::Packet(body));
|
||||
if HOLDING.get() {
|
||||
self.held().push(body);
|
||||
} else {
|
||||
self.flush();
|
||||
let _ = self.tx.send(Out::Packet(body));
|
||||
}
|
||||
}
|
||||
/// Hand the held packets to the writer thread.
|
||||
pub fn flush(&self) {
|
||||
let held = std::mem::take(&mut *self.held());
|
||||
if !held.is_empty() {
|
||||
let _ = self.tx.send(Out::Packets(held));
|
||||
}
|
||||
}
|
||||
/// Send a play disconnect with this reason after what was sent before.
|
||||
pub fn disconnect(&self, reason: horizon_world::nbt::Tag) {
|
||||
self.flush();
|
||||
let _ = self.tx.send(Out::Disconnect(reason));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,6 +355,145 @@ fn serve_status(c: &mut Connection, shared: &Shared) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// ServerLoginPacketListenerImpl.getUserName: the name and the address
|
||||
/// (Connection.getLoggableAddress, `/ip:port` as InetSocketAddress prints it).
|
||||
fn user_name(name: &str, address: std::net::SocketAddr, log: &super::NetLog) -> String {
|
||||
if log.log_ips {
|
||||
format!("{name} (/{address})")
|
||||
} else {
|
||||
format!("{name} (<ip address withheld>)")
|
||||
}
|
||||
}
|
||||
|
||||
/// ServerLoginPacketListenerImpl.disconnect: log the reason's text and send
|
||||
/// it. Returns the text, for the "lost connection" line that follows.
|
||||
fn refuse(
|
||||
c: &mut Connection,
|
||||
shared: &Shared,
|
||||
thread: &str,
|
||||
user: &str,
|
||||
reason: &horizon_world::nbt::Tag,
|
||||
) -> String {
|
||||
let text = shared
|
||||
.config
|
||||
.log
|
||||
.as_ref()
|
||||
.map(|log| horizon_protocol::text::plain(reason, &log.lang))
|
||||
.unwrap_or_default();
|
||||
if let Some(log) = &shared.config.log {
|
||||
(log.write)(thread, "INFO", &format!("Disconnecting {user}: {text}"));
|
||||
}
|
||||
login_disconnect(c, reason);
|
||||
text
|
||||
}
|
||||
|
||||
/// ServerLoginPacketListenerImpl.onDisconnect, once the server thread sees
|
||||
/// the connection closed: by a refusal, or by the client ("Disconnected").
|
||||
fn lost(shared: &Shared, user: &str, text: &str) {
|
||||
if let Some(log) = &shared.config.log {
|
||||
(log.write)(
|
||||
"Server thread",
|
||||
"INFO",
|
||||
&format!("{user} lost connection: {text}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// UNIQUE_THREAD_ID: the User Authenticator threads are numbered from 1.
|
||||
static AUTHENTICATORS: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
/// ServerLoginPacketListenerImpl.handleKey and the User Authenticator
|
||||
/// thread: check the client's answer to the encryption request, encrypt the
|
||||
/// connection, and ask the session service whether the player joined with
|
||||
/// this server's hash. The profile, or the text of the refusal.
|
||||
fn authenticate(
|
||||
c: &mut Connection,
|
||||
shared: &Shared,
|
||||
online: &super::OnlineMode,
|
||||
name: &str,
|
||||
address: std::net::SocketAddr,
|
||||
user: &str,
|
||||
) -> Result<Result<(u128, Vec<login::Property>), String>, String> {
|
||||
let e = |e: horizon_protocol::Error| e.to_string();
|
||||
let mut challenge = [0u8; 4];
|
||||
horizon_protocol::crypto::random_bytes(&mut challenge).map_err(e)?;
|
||||
c.send(
|
||||
&login::Clientbound::Hello(login::Hello {
|
||||
server_id: String::new(),
|
||||
public_key: online.public_der.clone(),
|
||||
challenge: challenge.to_vec(),
|
||||
should_authenticate: true,
|
||||
})
|
||||
.encode(),
|
||||
)
|
||||
.map_err(e)?;
|
||||
let (id, payload) = c.recv().map_err(e)?;
|
||||
let key = match login::Serverbound::decode(id, &payload).map_err(e)? {
|
||||
login::Serverbound::Key(k) => k,
|
||||
_ => return Err("Unexpected packet during login".into()),
|
||||
};
|
||||
// ServerboundKeyPacket.isChallengeValid and getSecretKey.
|
||||
let protocol_error = || "Protocol error".to_owned();
|
||||
if online.key.decrypt(&key.challenge).ok().as_deref() != Some(&challenge[..]) {
|
||||
return Err(protocol_error());
|
||||
}
|
||||
let secret: [u8; 16] = online
|
||||
.key
|
||||
.decrypt(&key.key)
|
||||
.ok()
|
||||
.and_then(|s| s.try_into().ok())
|
||||
.ok_or_else(protocol_error)?;
|
||||
let digest = server_hash("", &secret, &online.public_der);
|
||||
c.enable_encryption(&secret);
|
||||
let thread = format!(
|
||||
"User Authenticator #{}",
|
||||
AUTHENTICATORS.fetch_add(1, Ordering::Relaxed) + 1
|
||||
);
|
||||
let log = |level: &str, line: &str| {
|
||||
if let Some(log) = &shared.config.log {
|
||||
(log.write)(&thread, level, line);
|
||||
}
|
||||
};
|
||||
let ip = online.prevent_proxy.then(|| address.ip());
|
||||
match crate::auth::has_joined(name, &digest, ip) {
|
||||
Ok(Some(profile)) => {
|
||||
log(
|
||||
"INFO",
|
||||
&format!("UUID of player {name} is {}", uuid_string(profile.uuid)),
|
||||
);
|
||||
Ok(Ok((profile.uuid, profile.properties)))
|
||||
}
|
||||
Ok(None) => {
|
||||
let text = refuse(
|
||||
c,
|
||||
shared,
|
||||
&thread,
|
||||
user,
|
||||
&translatable("multiplayer.disconnect.unverified_username", &[]),
|
||||
);
|
||||
log(
|
||||
"ERROR",
|
||||
&format!("Username '{name}' tried to join with an invalid session"),
|
||||
);
|
||||
Ok(Err(text))
|
||||
}
|
||||
Err(_) => {
|
||||
let text = refuse(
|
||||
c,
|
||||
shared,
|
||||
&thread,
|
||||
user,
|
||||
&translatable("multiplayer.disconnect.authservers_down", &[]),
|
||||
);
|
||||
log(
|
||||
"ERROR",
|
||||
"Couldn't verify username because servers are unavailable",
|
||||
);
|
||||
Ok(Err(text))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn serve_login(
|
||||
mut c: Connection,
|
||||
address: std::net::SocketAddr,
|
||||
@@ -323,14 +512,36 @@ fn serve_login(
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
// Offline mode: the profile is derived from the name (PlayerList.canPlayerLogin
|
||||
// then rejects duplicates and a full server).
|
||||
let uuid = offline_uuid(&hello.name);
|
||||
let user = shared
|
||||
.config
|
||||
.log
|
||||
.as_ref()
|
||||
.map(|log| user_name(&hello.name, address, log))
|
||||
.unwrap_or_default();
|
||||
// handleHello: online mode authenticates first; offline mode derives the
|
||||
// profile from the name.
|
||||
let (uuid, properties) = match &shared.config.online {
|
||||
Some(online) => match authenticate(&mut c, shared, online, &hello.name, address, &user) {
|
||||
Ok(Ok(profile)) => profile,
|
||||
Ok(Err(refused)) => {
|
||||
lost(shared, &user, &refused);
|
||||
return Ok(());
|
||||
}
|
||||
Err(err) => {
|
||||
lost(shared, &user, "Disconnected");
|
||||
return Err(err);
|
||||
}
|
||||
},
|
||||
None => (offline_uuid(&hello.name), Vec::new()),
|
||||
};
|
||||
// verifyLoginAndFinishConnectionSetup: PlayerList.canPlayerLogin, then
|
||||
// duplicates and a full server.
|
||||
if let Some(reason) = shared.try_admit(address, uuid, &hello.name) {
|
||||
login_disconnect(&mut c, &reason);
|
||||
let text = refuse(&mut c, shared, "Server thread", &user, &reason);
|
||||
lost(shared, &user, &text);
|
||||
return Ok(());
|
||||
}
|
||||
let result = login_and_configure(&mut c, shared, uuid, &hello.name);
|
||||
let result = login_and_configure(&mut c, shared, uuid, &hello.name, properties);
|
||||
match result {
|
||||
Ok(Some(parts)) => {
|
||||
play(c, shared, parts, address);
|
||||
@@ -342,6 +553,9 @@ fn serve_login(
|
||||
}
|
||||
Err(err) => {
|
||||
shared.release(uuid);
|
||||
// ServerConfigurationPacketListenerImpl.onDisconnect.
|
||||
let profile = format!("{} ({})", hello.name, uuid_string(uuid));
|
||||
lost(shared, &profile, "Disconnected");
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
@@ -350,6 +564,7 @@ fn serve_login(
|
||||
struct Parts {
|
||||
uuid: u128,
|
||||
name: String,
|
||||
properties: Vec<login::Property>,
|
||||
info: ClientInformation,
|
||||
brand: String,
|
||||
}
|
||||
@@ -359,10 +574,12 @@ fn login_and_configure(
|
||||
shared: &Arc<Shared>,
|
||||
uuid: u128,
|
||||
name: &str,
|
||||
properties: Vec<login::Property>,
|
||||
) -> Result<Option<Parts>, String> {
|
||||
let e = |e: horizon_protocol::Error| e.to_string();
|
||||
// ServerLoginPacketListenerImpl: memory connections are not compressed.
|
||||
let threshold = shared.config.compression_threshold;
|
||||
if threshold >= 0 {
|
||||
if threshold >= 0 && !c.is_memory() {
|
||||
c.send(&login::Clientbound::Compression(login::Compression { threshold }).encode())
|
||||
.map_err(e)?;
|
||||
c.set_compression(threshold);
|
||||
@@ -372,7 +589,7 @@ fn login_and_configure(
|
||||
profile: login::GameProfile {
|
||||
uuid,
|
||||
name: name.to_owned(),
|
||||
properties: Vec::new(),
|
||||
properties: properties.clone(),
|
||||
},
|
||||
session: uuid,
|
||||
})
|
||||
@@ -451,6 +668,7 @@ fn login_and_configure(
|
||||
Ok(Some(Parts {
|
||||
uuid,
|
||||
name: name.to_owned(),
|
||||
properties,
|
||||
info,
|
||||
brand,
|
||||
}))
|
||||
@@ -465,10 +683,7 @@ fn play(c: Connection, shared: &Arc<Shared>, parts: Parts, address: std::net::So
|
||||
let pending = Arc::new(AtomicUsize::new(0));
|
||||
let latency = Arc::new(AtomicI32::new(0));
|
||||
let alive = Arc::new(AtomicI64::new(-1));
|
||||
let sender = PlayerSender {
|
||||
tx: tx.clone(),
|
||||
pending: pending.clone(),
|
||||
};
|
||||
let sender = PlayerSender::new(tx.clone(), pending.clone());
|
||||
let writer_thread = {
|
||||
let (alive, latency, pending) = (alive.clone(), latency.clone(), pending.clone());
|
||||
std::thread::Builder::new()
|
||||
@@ -480,6 +695,7 @@ fn play(c: Connection, shared: &Arc<Shared>, parts: Parts, address: std::net::So
|
||||
conn: conn_id,
|
||||
uuid: parts.uuid,
|
||||
name: parts.name,
|
||||
properties: parts.properties,
|
||||
info: parts.info,
|
||||
brand: parts.brand,
|
||||
sender,
|
||||
@@ -543,13 +759,18 @@ fn write_loop(
|
||||
let mut sent_at: Option<(i64, Instant)> = None;
|
||||
let mut next = Instant::now() + KEEP_ALIVE_EVERY;
|
||||
loop {
|
||||
let mut write = |body: Vec<u8>| {
|
||||
pending.fetch_sub(body.len(), Ordering::Relaxed);
|
||||
writer.send(&body).is_ok() && pending.load(Ordering::Relaxed) <= MAX_PENDING
|
||||
};
|
||||
match rx.recv_timeout(Duration::from_millis(200)) {
|
||||
Ok(Out::Packet(body)) => {
|
||||
pending.fetch_sub(body.len(), Ordering::Relaxed);
|
||||
if writer.send(&body).is_err() {
|
||||
if !write(body) {
|
||||
break;
|
||||
}
|
||||
if pending.load(Ordering::Relaxed) > MAX_PENDING {
|
||||
}
|
||||
Ok(Out::Packets(bodies)) => {
|
||||
if !bodies.into_iter().all(&mut write) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -593,3 +814,42 @@ fn write_loop(
|
||||
}
|
||||
writer.shutdown();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn bodies(rx: &Receiver<Out>) -> Vec<Vec<Vec<u8>>> {
|
||||
rx.try_iter()
|
||||
.map(|out| match out {
|
||||
Out::Packet(body) => vec![body],
|
||||
Out::Packets(bodies) => bodies,
|
||||
Out::Disconnect(_) => vec![b"disconnect".to_vec()],
|
||||
Out::Close => vec![b"close".to_vec()],
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn held_packets_go_together_and_in_order() {
|
||||
let (tx, rx) = channel();
|
||||
let sender = PlayerSender::new(tx, Arc::default());
|
||||
sender.send(vec![1]);
|
||||
assert_eq!(bodies(&rx), [vec![vec![1]]]);
|
||||
let holding = Holding::start();
|
||||
sender.send(vec![2]);
|
||||
sender.send(vec![3]);
|
||||
assert!(bodies(&rx).is_empty());
|
||||
drop(holding);
|
||||
// A packet sent after the tick still follows the held ones.
|
||||
sender.send(vec![4]);
|
||||
assert_eq!(bodies(&rx), [vec![vec![2], vec![3]], vec![vec![4]]]);
|
||||
let holding = Holding::start();
|
||||
sender.send(vec![5]);
|
||||
sender.disconnect(horizon_world::nbt::Tag::Byte(0));
|
||||
drop(holding);
|
||||
sender.flush();
|
||||
assert_eq!(bodies(&rx), [vec![vec![5]], vec![b"disconnect".to_vec()]]);
|
||||
assert_eq!(sender.pending.load(Ordering::Relaxed), 5);
|
||||
}
|
||||
}
|
||||
Loaded 100 of 235 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user