Compare commits

..
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 660a170f61 Count packets per second on F3 and show the server line
The F3 tps line printed a literal "0 tx, 0 rx" and was hidden on servers.
Horizon now averages the connection's packet counts once a second as
Connection.tick does, shows them on the integrated line when single player
runs through its server connection, and shows vanilla's "<brand>" server
line on a remote server with the brand the server sent.

Fixes #564

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 03:23:56 +02:00
235 changed files with 2195 additions and 24723 deletions

No files matched your search

+1 -1
View File
@@ -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 2400 # world_spawn takes ~1480 s on a busy runner
run: python3 tools/parity.py --changed ${{ github.event.pull_request.base.sha }} --timeout 1500
- name: Logs of the failed harnesses
if: failure()
uses: actions/upload-artifact@v4
-1
View File
@@ -28,6 +28,5 @@
/horizon-player.txt
/servers.dat
/screenshots/
/.horizon-cache/
/crash-reports/
/debug/
Generated
-4
View File
@@ -1573,7 +1573,6 @@ dependencies = [
"horizon-protocol",
"horizon-world",
"log",
"ureq",
]
[[package]]
@@ -1609,10 +1608,7 @@ dependencies = [
"horizon-core",
"libm",
"lz4_flex",
"postcard",
"serde",
"serde_json",
"sha2",
"xxhash-rust",
]
@@ -1,379 +0,0 @@
{
"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
]
}
}
}
}
@@ -1,176 +0,0 @@
{
"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
]
}
}
}
}
@@ -1,453 +0,0 @@
{
"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."
}
@@ -1,582 +0,0 @@
{
"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
]
}
}
}
}
@@ -1,188 +0,0 @@
{
"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."
}
@@ -1,93 +0,0 @@
{
"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."
}
@@ -1,227 +0,0 @@
{
"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."
}
@@ -1,453 +0,0 @@
{
"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."
}
@@ -1,33 +0,0 @@
//! 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,7 +88,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
seed: 0,
max_players: 8,
dedicated: false,
integrated: false,
},
);
let mut next_entity = 100u32;
@@ -1,271 +0,0 @@
//! 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,10 +1,7 @@
#![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, V},
};
use horizon_client::{Result, keybinds, options_file::OptionsFile};
use std::{fs, io::Write};
fn main() -> Result<()> {
@@ -17,16 +14,11 @@ fn main() -> Result<()> {
let mut first = true;
for path in names {
let name = path.file_name().unwrap().to_string_lossy().into_owned();
let mut file = if name == "absent.txt" {
let 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;
@@ -1,74 +0,0 @@
//! 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(())
}
@@ -1,47 +0,0 @@
//! 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(&centers, 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(())
}
@@ -1,40 +0,0 @@
//! 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(())
}
-58
View File
@@ -175,53 +175,6 @@ 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 {
@@ -1106,15 +1059,4 @@ mod tests {
assert_eq!(l[4], Some((1, 2.0)));
assert_eq!(l[1], Some((2, 2.0)));
}
#[test]
fn saved_dates_read_back_to_their_instant() {
for secs in [0, 951_782_400, 1_792_216_800, 4_107_542_399] {
assert_eq!(date_millis(&date_text(secs)), Some(secs * 1000));
}
assert_eq!(
date_millis("2026-10-17 07:30:00 +0200"),
date_millis("2026-10-17 05:30:00 +0000")
);
assert_eq!(date_millis("not a date"), None);
}
}
+1 -1
View File
@@ -245,7 +245,7 @@ impl Assets {
material_states,
sprite_size: sprites.size,
sprite_minimum_size: sprites.minimum_size,
texture_layers: u32::try_from(sprites.count())?,
texture_layers: u32::try_from(sprites.layers.len())?,
pixels: sprites.pixels,
model_catalog: models.catalog,
model_library,
+2 -52
View File
@@ -44,7 +44,6 @@ 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.
@@ -398,17 +397,8 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
c.position += step;
}
}
// 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);
for s in data.iter_mut() {
*s = s.clamp(-1.0, 1.0);
}
}
/// Why the game should not open an output, if it should not:
@@ -872,46 +862,6 @@ 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();
-95
View File
@@ -232,87 +232,6 @@ 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::*;
@@ -331,20 +250,6 @@ 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()));
-26
View File
@@ -199,14 +199,6 @@ 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 {
@@ -310,24 +302,6 @@ 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, false, out);
self.render_one(scene, *pos, kind, base, packed, lights, out);
}
}
/// One block entity at `pos` drawn under `base` with the given packed light
@@ -892,33 +892,12 @@ impl BlockEntityRenderer {
) {
let kind = self.kind(state);
let lights = diffuse_lights(scene.nether);
self.render_one(scene, pos, kind, base, packed, lights, false, out);
self.render_one(scene, pos, kind, base, packed, lights, 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
@@ -991,7 +970,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, false, out);
self.render_one(scene, [i32::MAX; 3], kind, base, packed, lights, out);
}
}
#[allow(clippy::too_many_arguments)]
@@ -1003,24 +982,11 @@ 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| {
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(name).map(|s| Paint {
sprite: s,
tint,
light: vertex_light(packed),
@@ -1092,7 +1058,7 @@ impl BlockEntityRenderer {
ChestPart::Left => "double_chest_left",
ChestPart::Right => "double_chest_right",
};
let Some(model) = self.models.get(model) else {
let (Some(model), Some(_)) = (self.models.get(model), sprite(texture)) else {
return;
};
let (open, other) = self.chest_openness(
@@ -1179,10 +1145,6 @@ 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) {
@@ -1711,138 +1673,6 @@ 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, &registry, &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(),
&registry,
);
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)]| {
+13 -236
View File
@@ -2,10 +2,7 @@
//! (`gameplay-facts.json`), indexed by registry state ID.
mod decode;
use crate::Result;
use horizon_world::{
level::{Behaviours, GameplayStateSource},
registry::{BlockRegistry, BlockStateId},
};
use horizon_world::registry::{BlockRegistry, BlockStateId};
use serde_json::Value;
use std::{
collections::HashMap,
@@ -110,178 +107,12 @@ 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)?;
@@ -294,6 +125,9 @@ 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())
@@ -387,13 +221,13 @@ impl BlockFacts {
outline,
outline_edges,
name: intern(&state.name),
emission: entry.emission.as_f64().unwrap_or(0.0) as u8,
dampening: entry.dampening.as_f64().unwrap_or(0.0) as u8,
emission: entry.emission as u8,
dampening: entry.dampening as u8,
skylight_down: entry.skylight_down,
shape_occlusion: entry.shape_occlusion,
can_occlude: entry.can_occlude,
solid_render: entry.solid_render,
hardness: entry.hardness.as_f64().unwrap_or(0.0) as f32,
hardness: entry.hardness as f32,
requires_tool: entry.requires_tool,
replaceable: entry.replaceable,
random_ticks: entry.random_ticks,
@@ -430,16 +264,12 @@ impl BlockFacts {
.ok_or("missing block behaviour class chain")?,
});
}
let extra = callback(entries)?;
Ok((
Self {
states,
air: registry
.default_state("minecraft:air")
.ok_or("missing air")?,
},
extra,
))
Ok(Self {
states,
air: registry
.default_state("minecraft:air")
.ok_or("missing air")?,
})
}
pub fn get(&self, id: BlockStateId) -> &StateFacts {
&self.states[id.0 as usize]
@@ -451,56 +281,3 @@ 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(""),
_ => "",
}
}
}
+3 -6
View File
@@ -155,20 +155,17 @@ object!(Sound<'a> {
});
object!(Entry<'a> {
id: Option<u64> = None => value(unsigned),
emission: Value = Value::Null => typed,
dampening: Value = Value::Null => typed,
emission: f64 = 0.0 => value(number),
dampening: f64 = 0.0 => value(number),
skylight_down: bool = false => value(flag),
shape_occlusion: bool = false => value(flag),
can_occlude: bool = false => value(flag),
solid_render: bool = false => value(flag),
hardness: Value = Value::Null => typed,
hardness: f64 = 0.0 => value(number),
requires_tool: bool = false => value(flag),
replaceable: bool = false => value(flag),
random_ticks: bool = false => value(flag),
block_entity: bool = false => value(flag),
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.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_eq!(e.emission, 4.5);
assert_eq!(e.dampening, 0.0);
assert_eq!(e.hardness.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);
-48
View File
@@ -41,35 +41,6 @@ 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::*;
@@ -78,7 +49,6 @@ mod tests {
fn slab_top() -> BakedModel {
BakedModel {
ambient_occlusion: true,
particle: None,
cube: None,
faces: vec![BakedFace {
layer: 0,
@@ -116,24 +86,6 @@ 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);
+1 -39
View File
@@ -298,9 +298,6 @@ 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 {
@@ -315,20 +312,11 @@ 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
}
@@ -353,14 +341,9 @@ 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| match (runs, client) {
(true, true) => default.max(mspt),
(true, false) => mspt,
(false, _) => default,
});
.advance_game_time(now_ms, |default| if runs { mspt } else { default });
ticks.clamp(0, 10) as u32
}
/// Restart pacing from now (after loading, so no backlog replays).
@@ -417,10 +400,6 @@ 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()
}
@@ -505,21 +484,4 @@ 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"
);
}
}
+1 -1
View File
@@ -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::tables(),
Limits::default(),
)?
.0
.tag;
-1
View File
@@ -8,7 +8,6 @@ pub mod block;
pub mod codec;
pub mod component;
pub mod item;
pub mod network;
pub mod packrat;
pub mod selector;
pub mod snbt;
@@ -1,404 +0,0 @@
//! 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);
}
}
}
-2
View File
@@ -132,8 +132,6 @@ 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)]
+44 -39
View File
@@ -74,13 +74,40 @@ 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) {
@@ -117,9 +144,6 @@ 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
@@ -143,17 +167,6 @@ 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
@@ -498,6 +511,19 @@ 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]
@@ -542,27 +568,6 @@ 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();
-41
View File
@@ -68,14 +68,6 @@ 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();
@@ -178,39 +170,6 @@ 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
-2
View File
@@ -185,8 +185,6 @@ 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")
-45
View File
@@ -301,20 +301,6 @@ 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
@@ -554,37 +540,6 @@ 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::*;
+2 -16
View File
@@ -68,7 +68,6 @@ 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,
@@ -109,7 +108,6 @@ pub struct Extras {
impl Default for Extras {
fn default() -> Self {
Self {
fullbright: false,
zoom: false,
zoom_key: "key.keyboard.z".into(),
zoom_level: 4,
@@ -157,7 +155,6 @@ 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" => {
@@ -201,8 +198,7 @@ impl Extras {
}
pub fn serialize(&self) -> String {
format!(
"fullbright:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n",
bool_text(self.fullbright),
"zoom:{}\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.zoom),
self.zoom_key,
self.zoom_level,
@@ -422,7 +418,6 @@ impl Cps {
/// The settings the Extras page shows, one per row.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Item {
Fullbright,
Zoom,
ZoomKey,
ZoomLevel,
@@ -450,8 +445,6 @@ pub enum Item {
pub fn rows() -> Vec<Result<Item, &'static str>> {
use Item::*;
vec![
Err("Lighting"),
Ok(Fullbright),
Err("Zoom"),
Ok(Zoom),
Ok(ZoomKey),
@@ -500,7 +493,6 @@ 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",
@@ -526,7 +518,6 @@ 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.",
@@ -565,7 +556,7 @@ impl Item {
pub fn cost(self) -> Option<crate::graphics_style::Cost> {
use crate::graphics_style::Cost;
match self {
Item::Fullbright | Item::Zoom | Item::Fps | Item::Keystrokes => Some(Cost::None),
Item::Zoom | Item::Fps | Item::Keystrokes => Some(Cost::None),
Item::Minimap => Some(Cost::Low),
Item::AutoReconnect | Item::MouseTweaks | Item::Sort => Some(Cost::None),
_ => None,
@@ -574,7 +565,6 @@ 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,
@@ -616,7 +606,6 @@ 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,
@@ -662,14 +651,11 @@ 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;
+3 -51
View File
@@ -1173,7 +1173,6 @@ 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_());
@@ -1208,11 +1207,11 @@ pub mod pause {
Kind::Primary,
true,
);
let lw = ui.button_width("World Options", true);
let lw = ui.button_width("Open to LAN", true);
ui.button(
act(9),
[x + bw + 8.0, y, lw, CONTROL_LARGE],
"World Options",
"Open to LAN",
Kind::Secondary,
true,
);
@@ -1236,11 +1235,7 @@ pub mod pause {
x,
y,
"Leave",
if remote {
"Disconnects from the server, then returns to the title."
} else {
"Saves the world, then returns to the title."
},
"Saves the world, then returns to the title.",
cw - t::ROW_LABEL - 220.0,
);
let qw = ui.button_width(quit_label, false);
@@ -1368,7 +1363,6 @@ pub mod pause {
world: &str,
quit_label: &str,
friends: &FriendsView,
remote: bool,
) -> Option<u8> {
let mut ui = Ui::dry(atlas, batch, (x, y));
draw(
@@ -1377,7 +1371,6 @@ 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);
@@ -1398,47 +1391,6 @@ 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"),
+105 -353
View File
@@ -68,10 +68,8 @@ 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;
@@ -114,7 +112,6 @@ mod scoreboard;
mod screens;
mod sculk;
mod sculk_fixture;
mod server_travel;
mod shader_packs;
mod shield;
mod sign_edit;
@@ -248,9 +245,8 @@ struct Options {
benchmark: Option<String>,
/// `--dev-mod PATH` (repeatable): mods always on and reloaded when rebuilt.
dev_mods: Vec<PathBuf>,
/// 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` or `HORIZON_SERVER_THREAD=1`: single player runs on
/// an integrated server thread (game/integrated.rs).
server_thread: bool,
/// Set on the integrated server's own options: it draws nothing, so it skips meshing.
integrated: bool,
@@ -305,10 +301,7 @@ impl Options {
dedicated: None,
benchmark: None,
dev_mods: Vec::new(),
// 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(),
server_thread: std::env::var("HORIZON_SERVER_THREAD").is_ok_and(|v| v == "1"),
integrated: false,
};
let mut session_file = std::env::var("HORIZON_SESSION_FILE").ok();
@@ -317,7 +310,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 --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."
"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."
);
return Ok(None);
}
@@ -331,7 +324,6 @@ 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()
@@ -974,21 +966,12 @@ struct Game<'a> {
/// `ClientAvatarState`: walked distance and view bob.
avatar: horizon_client::camera::AvatarBob,
flash: u32,
/// The head of the bed while sleeping; the visual timer also survives waking.
sleeping: Option<[i32; 3]>,
sleep_timer: u32,
/// Sleeping in a bed: (bed head position, ticks asleep).
sleeping: Option<([i32; 3], 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).
@@ -1104,8 +1087,6 @@ 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>,
@@ -1178,7 +1159,7 @@ struct Game<'a> {
/// The local player as a model (third person, the inventory).
player_model: Option<Mob>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Dim {
Overworld,
Nether,
@@ -1466,13 +1447,9 @@ 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());
// 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) {
.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 {
let n = &self.res.facts.get(state).name;
if n.contains("leaves") || n.contains("vine") {
0x48B518
@@ -1800,12 +1777,6 @@ 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) {
@@ -2914,7 +2885,6 @@ 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,
@@ -3121,7 +3091,6 @@ impl Game<'_> {
self.eye = 1.62;
self.previous_eye = 1.62;
self.clock.reset();
self.capture_loaded_world();
Ok(())
}
fn tick(&mut self) -> Result<()> {
@@ -3196,7 +3165,7 @@ impl Game<'_> {
if !self.streaming.is_lit(cx, cz) {
return Ok(());
}
let playing = self.screen == Screen::Playing && self.sleeping.is_none();
let playing = self.screen == Screen::Playing;
let held = |k| playing && self.keys.contains(&k);
let keys = Keys {
forward: held(KeyCode::KeyW),
@@ -3290,12 +3259,7 @@ 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;
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;
self.eye += (camera_eye.unwrap_or(player.dimensions.eye_height) - self.eye) * 0.5;
let crouching = player.pose == horizon_client::pose::Pose::Crouching;
self.tick_avatar(before, riding);
self.record_combat(&events);
@@ -3442,8 +3406,11 @@ impl Game<'_> {
}
if player.health.dead_or_dying() && self.screen != Screen::Dead {
// Inventory.dropAll: every stack thrown in a random direction.
let feet = [player.position.x, player.position.y, player.position.z];
let at = [feet[0], feet[1] + 1.32, feet[2]];
let at = [
player.position.x,
player.position.y + 1.32,
player.position.z,
];
let killer = self.combat_killer_kind();
self.death_message();
// ServerPlayer.die: deaths, and the time-since counters restart.
@@ -3459,10 +3426,6 @@ 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> =
@@ -3558,11 +3521,7 @@ impl Game<'_> {
});
horizon_client::spans::time("tick_particles", || self.tick_particles());
}
if self.right
&& self.use_delay == 0
&& self.screen == Screen::Playing
&& self.sleeping.is_none()
{
if self.right && self.use_delay == 0 && self.screen == Screen::Playing {
self.use_with_hand()?;
self.use_delay = 4;
}
@@ -3675,8 +3634,8 @@ impl Game<'_> {
}
/// `Minecraft.pause`: a single-player game with a pausing screen open.
fn pauses_game(&self) -> bool {
self.has_singleplayer_server()
&& !self.is_published()
(self.remote.is_none() || self.integrated.is_some())
&& self.lan.is_none()
&& self.title.is_none()
&& (matches!(
self.screen,
@@ -3689,15 +3648,6 @@ 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();
}
@@ -3716,13 +3666,6 @@ 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.
@@ -3900,9 +3843,7 @@ 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.
let horizon_pause = self.horizon_pause();
let show_hud = !self.ui.hide_gui && !horizon_pause;
if show_hud {
if !self.ui.hide_gui && !self.horizon_pause() {
self.draw_spyglass(&mut batch);
// Hud.extractCrosshair / extractItemHotbar / extractPlayerHealth /
// extractVehicleHealth and the experience bar, laid out by hud_layout.
@@ -3947,7 +3888,15 @@ impl Game<'_> {
self.draw_boss_bars(&mut batch);
self.draw_extras(&mut batch);
}
if show_hud {
// 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);
// The mount's hearts (and the jump bar that replaces the experience bar).
let (vehicle_hearts, jump_bar) = if spectator {
(0, false)
@@ -4019,26 +3968,16 @@ 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 show_hud
if !self.ui.hide_gui
&& 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);
}
if !horizon_pause {
self.draw_chat(&mut batch);
self.draw_tab_list(&mut batch);
}
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.
@@ -4397,7 +4336,14 @@ impl Game<'_> {
} else {
("Crafting", 97.0)
};
batch.text(atlas, title, ox + tx, oy + 6.0, grey, false);
batch.text(
atlas,
title,
ox + tx,
oy + if self.craft_size == 3 { 6.0 } else { 8.0 },
grey,
false,
);
if self.craft_size == 3 {
batch.text(atlas, "Inventory", ox + 8.0, oy + 72.0, grey, false);
}
@@ -5633,20 +5579,6 @@ 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
@@ -5884,38 +5816,6 @@ 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.
@@ -5932,11 +5832,7 @@ impl Game<'_> {
} else {
"minecraft:entity.player.attack.weak"
};
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);
}
self.audio.play(sound, from, 1.0, 1.0);
}
/// Player.attack with the attack-strength charge, crits and knockback.
fn attack(&mut self) -> bool {
@@ -5988,7 +5884,6 @@ 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
@@ -6048,11 +5943,7 @@ impl Game<'_> {
"minecraft:entity.player.attack.weak"
};
// Player.playServerSideSound: at the player.
if let Some(at) = sweep {
self.sweep_attack(at, from);
} else {
self.audio.play(sound, from, 1.0, 1.0);
}
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);
@@ -6393,8 +6284,6 @@ 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 =
@@ -6521,17 +6410,6 @@ 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();
@@ -6547,10 +6425,11 @@ impl Game<'_> {
_ => 1.0,
};
let destination = self.end_destination(target).unwrap_or(Vec3::new(
from.x * scale,
from.y,
from.z * scale,
player.position.x * scale,
player.position.y,
player.position.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);
@@ -6558,14 +6437,12 @@ 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();
@@ -6578,11 +6455,13 @@ 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(destination)
Ok(())
}
/// 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)> {
@@ -6752,8 +6631,6 @@ 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() {
@@ -7583,7 +7460,6 @@ 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
@@ -7772,15 +7648,7 @@ 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> {
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 assets = &self.pack.assets;
let mut renderer = pollster::block_on(Renderer::new(
window,
self.options.offscreen,
@@ -7790,12 +7658,12 @@ impl Game<'_> {
assets.texture_layers,
&assets.sprite_mips,
&assets.animations,
(&atlas.pixels, atlas.width, atlas.height),
(&pack.forcefield.2, pack.forcefield.0),
&pack.panorama,
self.renderer.as_ref(),
(&self.atlas.pixels, self.atlas.width, self.atlas.height),
(&self.pack.forcefield.2, self.pack.forcefield.0),
&self.pack.panorama,
))?;
renderer.hud.fire_animations = atlas
renderer.hud.fire_animations = self
.atlas
.fire_animations
.iter()
.map(|(rect, animation)| (*rect, animation.clone(), usize::MAX))
@@ -8059,23 +7927,13 @@ impl Game<'_> {
return;
}
self.message = Some(("Respawn point set".into(), Instant::now()));
self.stop_mining();
self.left = false;
self.right = false;
self.using = None;
self.sleeping = Some(head);
self.sleep_timer = 0;
self.sleeping = Some((head, 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) {
self.sleep_timer = tick_sleep_timer(self.sleep_timer, self.sleeping.is_some());
let Some(head) = self.sleeping else {
let Some((head, ticks)) = self.sleeping else {
return;
};
if self.keys.contains(&KeyCode::ShiftLeft)
@@ -8084,7 +7942,7 @@ impl Game<'_> {
.as_ref()
.is_none_or(|p| p.health.dead_or_dying())
{
self.wake_up();
self.sleeping = None;
return;
}
if let Some(p) = self.player.as_mut() {
@@ -8095,15 +7953,18 @@ impl Game<'_> {
);
p.velocity = Vec3::ZERO;
}
let ticks = ticks + 1;
// ServerLevel: once everyone has slept 100 ticks, skip to morning and clear weather.
if self.sleep_timer >= 100 {
if ticks >= 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.wake_up();
self.sleeping = None;
} else {
self.sleeping = Some((head, ticks));
}
}
fn creative_origin(batch: &HudBatch) -> (f32, f32) {
@@ -8203,7 +8064,7 @@ impl Game<'_> {
self.creative_screen.search_focused.elapsed().as_millis(),
);
}
let mut hovered: Option<ItemStack> = None;
let mut hovered: Option<String> = None;
if tab.inventory {
self.draw_player_preview(
batch,
@@ -8253,7 +8114,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.clone());
hovered = Some(stack.item.to_string());
}
}
}
@@ -8268,7 +8129,7 @@ impl Game<'_> {
&& self.cursor.1 >= y
&& self.cursor.1 < y + 16.0
{
hovered = Some(stack);
hovered = Some(stack.item.to_string());
}
}
let scrollable = self.creative_screen.rows(&self.pack.names) > 5;
@@ -8304,8 +8165,8 @@ impl Game<'_> {
} else if self.carried.is_none()
&& let Some(item) = hovered
{
let lines = self.stack_tooltip(&item);
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
let name = self.pack.names.item(&item);
self.tooltip(batch, &name, self.cursor.0, self.cursor.1);
}
self.draw_inventory_effects(batch, (ox, oy), Creative::WIDTH as i32);
}
@@ -8449,7 +8310,21 @@ impl Game<'_> {
self.ender_items = items.clone();
}
level.ender_items = self.ender_items.clone();
level.active_effects = self.potions.effects.saved();
level.active_effects = self
.potions
.effects
.list
.iter()
.map(|e| {
(
format!("minecraft:{}", e.id),
e.duration,
e.amplifier,
e.ambient,
e.visible,
)
})
.collect();
}
fn save_game(&mut self) -> Result<()> {
self.save_game_with(true)
@@ -8461,10 +8336,18 @@ impl Game<'_> {
if self.remote.is_some() {
return Ok(());
}
self.save_level(wait)?;
self.save_parked_levels(wait)?;
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_maps();
self.save_random_sequences();
self.save_raids();
self.save_fight();
self.save_wandering_trader();
self.save_portal_arrivals();
@@ -8481,20 +8364,6 @@ 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
@@ -8573,23 +8442,12 @@ 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);
// LevelExtractor.extractBlockOutline: model translucency controls draw order.
let mut outline_after_terrain = false;
// LevelRenderer.renderHitOutline: the outline shape's edges.
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
@@ -8739,7 +8597,6 @@ 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,
@@ -8917,18 +8774,6 @@ 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(());
}
@@ -9015,13 +8860,7 @@ impl Game<'_> {
}
_ => {}
},
// 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();
}
}
// key.swapOffhand: ServerboundPlayerActionPacket SWAP_ITEM_WITH_OFFHAND.
KeyCode::KeyF if self.screen == Screen::Playing => {
std::mem::swap(&mut self.offhand, &mut self.inventory[self.selected]);
self.using = None;
@@ -9059,9 +8898,6 @@ 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);
}
@@ -9391,7 +9227,6 @@ 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, _)) =
@@ -9528,21 +9363,6 @@ 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 {
@@ -9665,6 +9485,9 @@ 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)) =>
@@ -9771,6 +9594,7 @@ 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);
@@ -10179,13 +10003,9 @@ 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,
@@ -10266,7 +10086,6 @@ impl<'a> Game<'a> {
remote: None,
lan: None,
integrated: None,
serving: None,
world_options: None,
options_to_world_options: false,
command_block_screen: None,
@@ -10328,16 +10147,7 @@ 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"));
// 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 = packs::startup_root(&vanilla_root, &early_settings.resource_packs);
options.resources = mods::content_root(
&options.resources,
&early_settings.mods.enabled,
@@ -10349,7 +10159,6 @@ 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)?
}
@@ -10363,11 +10172,7 @@ 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 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 facts = BlockFacts::load(&root, assets.registry)?;
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());
@@ -10434,12 +10239,7 @@ 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_with_gameplay(
registry_owner.registry,
&root,
&behaviour_json,
&gameplay_projection,
)?,
behaviours: Behaviours::load(registry_owner.registry, &root)?,
mob_types: MobTypes::load(&root)?
.types
.into_iter()
@@ -10451,10 +10251,6 @@ 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
@@ -10469,10 +10265,6 @@ 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.
@@ -10580,17 +10372,6 @@ 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();
@@ -10625,35 +10406,6 @@ 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() {
+49 -166
View File
@@ -11,7 +11,7 @@ use horizon_client::{
};
use serde_json::Value;
use std::{
collections::{BTreeMap, BTreeSet, VecDeque},
collections::{BTreeMap, VecDeque},
sync::Arc,
time::Instant,
};
@@ -72,22 +72,13 @@ 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`.
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>,
last_inventory: u64,
}
/// The game's world for predicates.
struct View<'a, 'g>(&'a Game<'g>);
@@ -180,10 +171,29 @@ 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;
adv::date_text(secs)
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
)
}
impl Game<'_> {
/// `ItemPredicate.test` (the `item_filter` of a filtered item modifier).
@@ -207,30 +217,30 @@ pub(super) fn item_view(s: &ItemStack) -> ItemView {
}
impl Game<'_> {
fn adv_path(&self) -> Option<std::path::PathBuf> {
Some(super::stats::player_json(
&self.world_root,
"advancements",
self.level.player_uuid?,
))
}
/// 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()
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")),
)
}
pub(super) fn load_advancements(&mut self) {
if self.adv.loaded || self.remote.is_some() || self.no_player_here() {
if self.adv.loaded || self.remote.is_some() {
return;
}
self.adv.loaded = true;
self.advancement_tree();
if self.adv.tree.list.is_empty() {
self.adv.tree = Arc::new(Tree::load(&self.options.resources.join("data")));
}
let progress = self
.adv_path()
.and_then(|p| std::fs::read_to_string(p).ok())
@@ -244,7 +254,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() || self.no_player_here() {
if !self.adv.loaded || self.remote.is_some() {
return;
}
let Some(path) = self.adv_path() else {
@@ -366,7 +376,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() || self.no_player_here() {
if self.remote.is_some() {
return;
}
self.load_advancements();
@@ -393,27 +403,15 @@ 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]) {
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);
}
self.recipe_toast(unlocked);
for r in unlocked {
let r = r.clone();
self.trigger("minecraft:recipe_unlocked", move |c, _| {
@@ -455,8 +453,7 @@ impl Game<'_> {
}
}
if let Some(d) = &a.display {
// A network player's client shows its own toast.
if d.show_toast && !self.adv.quiet && self.lan_acting.is_none() {
if d.show_toast && !self.adv.quiet {
self.adv.toasts.push_back(Toast {
icon: d.icon.clone(),
frame: d.frame,
@@ -474,18 +471,10 @@ impl Game<'_> {
Frame::Challenge => "chat.type.advancement.challenge",
};
let name = adv_name(a);
// 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.system_message(Text::translate(
key,
&[Text::literal(super::chat::PLAYER_NAME), name],
));
}
}
self.recipe_unlocked(&unlocked);
@@ -831,7 +820,6 @@ 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);
}
@@ -843,7 +831,6 @@ 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 => {
@@ -856,110 +843,6 @@ 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,18 +163,11 @@ impl Game<'_> {
}
let tree = self.adv.tree.clone();
let kids = tree.children();
// 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(),
};
let pos = 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 {
@@ -191,12 +184,7 @@ impl Game<'_> {
};
};
let tree = &c.tree;
// 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]))
};
let visible = 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| {
+2 -37
View File
@@ -460,18 +460,12 @@ impl Game<'_> {
json!({
"screen": self.screen_label(),
"menu": self.title.as_ref().map(|_| self.title_value()),
"world": (self.title.is_none()).then(|| match &self.integrated {
Some(server) => server.world_name.clone(),
None => self.level.name.clone(),
}),
"world": (self.title.is_none()).then(|| 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)),
@@ -616,9 +610,6 @@ 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();
}
@@ -637,15 +628,6 @@ 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"
);
@@ -1494,13 +1476,6 @@ 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 }));
@@ -1786,21 +1761,11 @@ 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,
-4
View File
@@ -220,11 +220,7 @@ 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;
};
+6 -33
View File
@@ -10,18 +10,6 @@ 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},
@@ -390,10 +378,6 @@ 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() {
@@ -407,7 +391,7 @@ impl Game<'_> {
let b = HudBatch::new(size);
let m = Measure(&self.atlas.font);
let c = Commands {
dispatcher: chat_tree(&self.remote, &self.mods),
dispatcher: self.mods.dispatcher(),
registries: &self.ui.registries,
};
let mut screen = ChatScreen::new(
@@ -441,12 +425,6 @@ 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(
@@ -465,7 +443,7 @@ impl Game<'_> {
if let Some(shortcut) = self.edit_shortcut(event) {
let m = Measure(&self.atlas.font);
let c = Commands {
dispatcher: chat_tree(&self.remote, &self.mods),
dispatcher: self.mods.dispatcher(),
registries: &self.ui.registries,
};
if let Some(screen) = self.ui.chat_screen.as_mut() {
@@ -511,7 +489,7 @@ impl Game<'_> {
};
let m = Measure(&self.atlas.font);
let c = Commands {
dispatcher: chat_tree(&self.remote, &self.mods),
dispatcher: self.mods.dispatcher(),
registries: &self.ui.registries,
};
match screen.key(&m, &c, &self.ui.chat.recent, key, shift, ctrl) {
@@ -537,7 +515,7 @@ impl Game<'_> {
pub(super) fn chat_type(&mut self, text: &str) {
let m = Measure(&self.atlas.font);
let c = Commands {
dispatcher: chat_tree(&self.remote, &self.mods),
dispatcher: self.mods.dispatcher(),
registries: &self.ui.registries,
};
if let Some(screen) = self.ui.chat_screen.as_mut() {
@@ -547,11 +525,6 @@ 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 {
@@ -593,7 +566,7 @@ impl Game<'_> {
});
let m = Measure(&self.atlas.font);
let c = Commands {
dispatcher: chat_tree(&self.remote, &self.mods),
dispatcher: self.mods.dispatcher(),
registries: &self.ui.registries,
};
if let Some(screen) = self.ui.chat_screen.as_mut() {
@@ -638,7 +611,7 @@ impl Game<'_> {
};
let m = Measure(&self.atlas.font);
let c = Commands {
dispatcher: chat_tree(&self.remote, &self.mods),
dispatcher: self.mods.dispatcher(),
registries: &self.ui.registries,
};
screen.resize(&m, &c, batch.width as i32, batch.height as i32);
@@ -758,25 +758,18 @@ impl Game<'_> {
.and_then(|l| l.local_addr())
.map_or(25564, |a| i32::from(a.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]));
if let Some(lan) = &self.lan {
return Err(err(
"commands.publish.alreadyPublished.lan",
&[&lan.settings.port],
));
}
let settings = super::super::lan::LanSettings {
port: port as u16,
guest_commands: allow,
force_game_mode: false,
};
let started = if self.serving() {
self.publish(settings).is_ok()
} else {
self.lan_start(settings).is_ok()
};
if !started {
if self.lan_start(settings).is_err() {
return Err(err("commands.publish.failed", &[]));
}
let port_text = port.to_string();
@@ -792,13 +785,6 @@ 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", &[]));
}
+28 -64
View File
@@ -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 mods graphics graphics-advanced)",
"options [PAGE] open the options (video sound controls keybinds mouse chat accessibility language font packs 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,23 +54,12 @@ 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 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 Some(flight) = &self.flight else { return };
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];
@@ -90,9 +79,6 @@ 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();
@@ -353,14 +339,6 @@ 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.
@@ -391,14 +369,7 @@ impl Game<'_> {
ok()
}
"time" => {
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")?;
}
self.world.day_time = arg(1)?.parse()?;
ok()
}
"summon" => {
@@ -491,10 +462,8 @@ 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() {
@@ -506,10 +475,7 @@ impl Game<'_> {
}
"chat-log" => {
let n = words.get(1).map_or(Ok(10), |n| n.parse())?;
let lines = self
.remote_chat(n)
.unwrap_or_else(|| self.ui.chat.recent_lines(n));
Ok(Some(json!({ "chat": lines })))
Ok(Some(json!({ "chat": self.ui.chat.recent_lines(n) })))
}
"stats" => {
let summary = self.frame_stats.summary();
@@ -546,7 +512,6 @@ impl Game<'_> {
seconds: n(7)?.max(0.1),
raw: words.get(9) == Some(&"raw"),
reply: reply.clone(),
tick_ms: Vec::new(),
});
Ok(None)
}
@@ -584,20 +549,18 @@ impl Game<'_> {
self.leave_server(None)?;
ok()
}
"save" | "save-all" => {
"save" => {
self.require_world()?;
// `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)?;
}
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()?;
ok()
}
"dragon-hurt" => {
@@ -618,15 +581,6 @@ 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());
@@ -636,6 +590,18 @@ 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(());
@@ -749,9 +715,7 @@ impl Game<'_> {
"tridents": self.tridents.len(),
"rockets": self.rockets.len(),
"fight": self.dragon_status(),
// 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<_>>()),
"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<_>>(),
"riding": self.riding,
"using": self.using,
"render_distance": self.streaming.radius,
@@ -362,11 +362,3 @@ 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);
}
}
+7 -34
View File
@@ -1358,52 +1358,25 @@ 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 false;
return;
}
let Some(p) = self.player.as_ref() else {
return false;
return;
};
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 false;
return;
}
let active = self.potions.effects.get("minecraft:mining_fatigue");
if active.is_some_and(|e| e.amplifier >= 2 && e.duration >= 1200) {
return false;
return;
}
let at = [p.position.x, p.position.y, p.position.z];
self.give_player_effect("minecraft:mining_fatigue", 6000, 2);
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
self.audio
.play("minecraft:entity.elder_guardian.curse", Some(at), 1.0, 1.0);
self.creatures.elder_apparition = super::mob_blocks::APPARITION_TICKS;
}
/// 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) {
+42 -32
View File
@@ -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.has_singleplayer_server() && !g.is_published());
g.pause_menu = !(g.remote.is_none() && g.lan.is_none());
g.screen = Screen::Paused;
g.capture(false);
g.mining = None;
@@ -88,7 +88,8 @@ impl Game<'_> {
|| self.book.is_some()
|| (self.screen == Screen::Creative && self.creative_screen.tab().search)
|| self.recipe_search_focused()
|| self.station_typing();
|| self.station_typing()
|| self.remote_chat_open();
// 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 {
@@ -186,24 +187,27 @@ 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) = &self.remote else {
let Some(r) = &mut self.remote else {
// The integrated server answers within the tick.
if shown {
self.debug_charts.ping.log(0);
}
return;
};
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 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),
)
});
if let Some([bytes, sent, received]) = received {
self.debug_charts.log_bandwidth(bytes);
self.debug_charts.log_packets([sent, received]);
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 shown {
use horizon_protocol::packets::{play::Serverbound, status::Ping};
@@ -404,9 +408,27 @@ impl Game<'_> {
],
);
}
// 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);
// 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() {
let (target, status) = if self.clock.sprinting() {
("-".to_owned(), " (sprinting)")
} else if self.clock.stepping() {
@@ -419,22 +441,10 @@ impl Game<'_> {
} else {
(format!("{:.1}", 1000.0 / self.clock.rate()), "")
};
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
));
}
d.line(format!(
"Integrated server @ {:.1}/{target} ms{status}, {:.0} tx, {:.0} rx",
self.debug_charts.smoothed_tick_ms, rates.0, rates.1
));
}
let (left, right) = d.columns(&self.debug_chart_lines());
let atlas = &self.atlas;
+58 -122
View File
@@ -9,13 +9,11 @@
use super::{Game, Options, lan::LanSettings};
use horizon_client::{
Result,
lan::{LoginGate, NetConfig, NetLog, OnlineMode, hub::HubConfig},
lan::{LoginGate, NetConfig, 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,
@@ -59,26 +57,9 @@ 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,
@@ -92,7 +73,7 @@ fn log_from(thread: &str, level: &str, message: &str) {
});
let secs = now_millis().div_euclid(1000).rem_euclid(86_400);
println!(
"[{:02}:{:02}:{:02}] [{thread}/{level}]: {message}",
"[{:02}:{:02}:{:02}] [Server thread/{level}]: {message}",
secs / 3600,
secs / 60 % 60,
secs % 60
@@ -362,14 +343,6 @@ 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;
@@ -381,19 +354,6 @@ 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!(
@@ -405,21 +365,27 @@ impl Game<'_> {
}
),
);
if online.is_none() {
log("WARN", "**** SERVER IS RUNNING IN OFFLINE/INSECURE MODE!");
if props.online_mode {
// Authentication needs Mojang's session servers (an HTTPS client
// and RSA), which Horizon does not have yet: offline 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.",
"online-mode=true is not supported by Horizon yet: running in offline mode",
);
}
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;
@@ -485,12 +451,6 @@ 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,
@@ -503,7 +463,6 @@ impl Game<'_> {
seed: self.level.seed,
max_players: props.max_players.max(0) as usize,
dedicated: true,
integrated: false,
};
let settings = LanSettings {
port,
@@ -565,12 +524,18 @@ impl Game<'_> {
);
self.save_game()?;
}
if !paused && !state.autosave {
// `save-off`: the periodic save inside the tick is skipped.
self.ticks = self.ticks.max(1);
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()?;
}
// MinecraftServer.tickServer: while paused only the connections run.
self.tick_server(paused)?;
if idle_timeout > 0 {
self.kick_idle(idle_timeout);
}
@@ -611,13 +576,7 @@ impl Game<'_> {
let center = self
.lan
.as_ref()
.and_then(|h| {
h.hub
.guests
.iter()
.find(|g| h.hub.here(g))
.map(|g| g.chunk())
})
.and_then(|h| h.hub.guests.iter().find(|g| g.placed).map(|g| g.chunk()))
.or_else(|| {
self.spawn
.map(|s| ((s.x.floor() as i32) >> 4, (s.z.floor() as i32) >> 4))
@@ -663,36 +622,23 @@ impl Game<'_> {
host.hub.resend_commands();
}
/// 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
/// 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))
})
});
if found.is_none() {
self.console_reply(Text::translate("argument.player.unknown", &[]));
}
found
.map_or_else(
|| NameAndId::offline(name),
|g| NameAndId {
id: g.uuid,
name: g.name.clone(),
},
)
}
/// MinecraftServer.sendSystemMessage: what the console is told.
@@ -775,9 +721,7 @@ impl Game<'_> {
));
}
"op" | "deop" if !rest.is_empty() => {
let Some(user) = self.resolve(rest[0]) else {
return;
};
let user = self.resolve(rest[0]);
let level = 4;
let changed = {
let mut l = state.lists.lock().unwrap();
@@ -807,9 +751,7 @@ impl Game<'_> {
}
"whitelist" => self.console_whitelist(state, &rest),
"ban" if !rest.is_empty() => {
let Some(user) = self.resolve(rest[0]) else {
return;
};
let user = self.resolve(rest[0]);
let reason = (rest.len() > 1).then(|| rest[1..].join(" "));
let added = state.lists.lock().unwrap().bans.add(BanEntry::new(
user.clone(),
@@ -899,9 +841,7 @@ impl Game<'_> {
}
}
"pardon" if !rest.is_empty() => {
let Some(user) = self.resolve(rest[0]) else {
return;
};
let user = self.resolve(rest[0]);
let removed = state.lists.lock().unwrap().bans.remove(&user);
let key = if removed {
"commands.pardon.success"
@@ -967,10 +907,15 @@ impl Game<'_> {
}
}
"kick" if !rest.is_empty() => {
let Some(user) = self.online_player(rest[0]) else {
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 {
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 {
@@ -1050,9 +995,7 @@ impl Game<'_> {
self.console_reply(Text::translate("commands.whitelist.reloaded", &[]));
}
["add", name] | ["remove", name] => {
let Some(user) = self.resolve(name) else {
return;
};
let user = self.resolve(name);
let mut l = state.lists.lock().unwrap();
let (ok, key) = if rest[0] == "add" {
if l.white.contains(&user) {
@@ -1129,13 +1072,6 @@ 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(
+51 -74
View File
@@ -222,7 +222,7 @@ impl Game<'_> {
}
}
pub(super) fn block_name(&self, p: [i32; 3]) -> std::sync::Arc<str> {
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,11 +366,31 @@ impl Game<'_> {
if self.travelling.is_some() || p.health.dead_or_dying() {
return Ok(());
}
let (portal, gateway) = self.inside_end_portals(
p.position,
f64::from(p.dimensions.width),
f64::from(p.dimensions.height),
);
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]);
}
}
}
}
if portal {
if self.portals.player.cooldown == 0 {
let target = if self.dim == Dim::End {
@@ -397,38 +417,6 @@ 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 {
@@ -463,36 +451,10 @@ impl Game<'_> {
self.previous_eye = 1.62;
self.clock.reset();
}
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 {
let mut position = target;
if let Some(job) = self.end.job.take() {
position = self.resolve_gateway(job, target)?;
} else 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 {
@@ -511,13 +473,28 @@ impl Game<'_> {
}
}
// Direction.WEST.toYRot().
return Ok((target, Some(90.0)));
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);
}
// 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))
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(())
}
/// Dying away from the Overworld respawns at the world spawn there.
+3 -16
View File
@@ -167,10 +167,7 @@ impl Game<'_> {
Snapshot {
screen: self.screen_label(),
dimension: self.dim.id().to_string(),
chat: self
.remote
.as_ref()
.map_or(self.ui.chat.received, |r| r.chat_received),
chat: self.ui.chat.received,
counts: if world.is_some() {
self.inventory_counts()
} else {
@@ -192,18 +189,8 @@ impl Game<'_> {
if let Some(before) = &self.events.last
&& *before != now
{
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),
};
let new_chat = (now.chat - before.chat).min(100) as usize;
let lines = self.ui.chat.recent_lines(new_chat);
for event in diff(before, &now, &lines) {
self.events.push(self.ticks, event);
}
+1 -5
View File
@@ -58,12 +58,8 @@ 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 zoom and the keystroke overlay.
/// Mouse buttons while playing, for 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| {
host.hub.here(g)
g.placed
&& g.loaded
&& !g.dead
&& !g.creative()
+1 -7
View File
@@ -55,13 +55,7 @@ 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| {
if self.sleeping.is_some() {
0.2
} else {
p.dimensions.eye_height
}
});
let eye = self.player.as_ref().map(|p| p.dimensions.eye_height);
self.view.tick(eye, target);
}
/// The player's `deathTime` while dead or dying.
+2 -5
View File
@@ -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() && self.sleeping.is_none() {
if self.screen == Screen::Playing && self.title.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,10 +409,7 @@ impl HandWorld for GameHands<'_, '_> {
fn within_border(&mut self, _: [i32; 3]) -> bool {
true
}
fn attack_block(&mut self, pos: [i32; 3]) {
let result = self.game.attack_block(pos);
self.keep(result);
}
fn attack_block(&mut self, _: [i32; 3]) {}
fn destroy_progress(&mut self, pos: [i32; 3]) -> f32 {
self.game.destroy_progress(pos)
}
+40 -368
View File
@@ -2,34 +2,32 @@
//! 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.
//!
//! 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).
//! 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.
use super::{
Game, Options, PackResources, Resources,
Dim, Game, Options, PackResources, Resources,
lan::{LanSettings, Owner},
};
use horizon_client::{
Result,
audio::Audio,
hud::HudAtlas,
lan::{Lan, MAX_PLAYERS, NetConfig, hub::HubConfig},
lan::{MAX_PLAYERS, NetConfig, hub::HubConfig},
save::Level,
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, AtomicU16, AtomicU32, Ordering},
mpsc::{Receiver, Sender, channel, sync_channel},
atomic::{AtomicBool, Ordering},
mpsc::{Sender, channel},
},
thread::JoinHandle,
time::{Duration, Instant},
@@ -41,49 +39,18 @@ 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();
@@ -101,84 +68,6 @@ 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,
@@ -186,32 +75,19 @@ struct Launch {
seed: Option<i64>,
creative: bool,
create: Option<WorldOptions>,
/// The window's profile: the only one let in until the world is open to
/// the LAN, and the world's player.
/// The window's profile: the only one let in, 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.
pub(super) fn wanted(options: &Options, _world: &Path) -> bool {
options.server_thread && options.dedicated.is_none()
/// 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)
}
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(
@@ -234,7 +110,6 @@ 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.
@@ -252,7 +127,6 @@ 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())
@@ -263,8 +137,7 @@ impl Game<'_> {
options,
settings,
launch,
server_flags,
server_tasks,
&server_flags,
&ready_tx,
);
if let Err(e) = result {
@@ -272,17 +145,12 @@ impl Game<'_> {
let _ = ready_tx.send(Err(e.to_string()));
}
})?;
let mut handle = Integrated {
let handle = Integrated {
flags,
thread: Some(thread),
tasks,
world_name: String::new(),
};
let pipe = match ready.recv() {
Ok(Ok((pipe, world_name))) => {
handle.world_name = world_name;
pipe
}
Ok(Ok(pipe)) => pipe,
Ok(Err(e)) => return Err(e.into()),
Err(_) => return Err("the integrated server stopped".into()),
};
@@ -296,9 +164,8 @@ impl Game<'_> {
fn run_integrated(
&mut self,
launch: Launch,
flags: Arc<Flags>,
tasks: Receiver<Task>,
ready: &Sender<Ready>,
flags: &Flags,
ready: &Sender<std::result::Result<Pipe, String>>,
) -> Result<()> {
self.open_world(
&launch.folder,
@@ -311,8 +178,7 @@ impl Game<'_> {
let view_distance = self.settings.render_distance.clamp(2, 32);
let config = HubConfig {
default_game_mode: self.level.mode(),
// Only the owner until it is opened to the LAN.
guest_commands: false,
guest_commands: true,
force_game_mode: false,
hardcore: self.level.hardcore,
view_distance,
@@ -322,28 +188,23 @@ 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; the memory connection
// is not compressed (`Connection.isMemoryConnection`).
// No network until it is opened to the LAN.
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 && published.published.load(Ordering::Relaxed) == 0)
.then(|| translatable("multiplayer.disconnect.not_whitelisted", &[]))
(uuid != owner).then(|| translatable("multiplayer.disconnect.not_whitelisted", &[]))
})),
// IntegratedServer.initServer: the MOTD names the owner and the world.
..NetConfig::lan(
0,
format!("{} - {}", launch.player, self.level.name),
MAX_PLAYERS,
)
..NetConfig::lan(0, self.level.name.clone(), MAX_PLAYERS)
};
let settings = LanSettings {
port: 0,
guest_commands: false,
guest_commands: true,
force_game_mode: false,
};
self.lan_host(settings, net, config, None)?;
@@ -362,30 +223,18 @@ impl Game<'_> {
});
host.hub.lan.connect_local()?
};
self.serving = Some(Serving {
flags: flags.clone(),
tasks,
owner,
pending: None,
});
let _ = ready.send(Ok((pipe, self.level.name.clone())));
let _ = ready.send(Ok(pipe));
self.settle_spawn()?;
let mut next = Instant::now();
while !flags.stop.load(Ordering::Relaxed) {
self.run_tasks();
self.update_dedicated_streaming();
self.update_world();
if flags.paused.load(Ordering::Relaxed) {
// Only the connections run.
self.tick_server(true)?;
self.tick_lan();
} else {
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.tick()?;
}
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();
@@ -401,10 +250,6 @@ 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);
@@ -427,191 +272,18 @@ 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: Arc<Flags>,
tasks: Receiver<Task>,
ready: &Sender<Ready>,
flags: &Flags,
ready: &Sender<std::result::Result<Pipe, String>>,
) -> Result<()> {
// It draws nothing: only the font, which books and chat measure with.
let atlas = HudAtlas::fonts(&options.resources)?;
let atlas = HudAtlas::build(&options.resources)?;
let audio = Audio::silent(&options.resources);
let mut server = Game::new(res, pack, atlas, options, settings, audio)?;
server.run_integrated(launch, flags, tasks, ready)
server.run_integrated(launch, flags, ready)
}
+40 -235
View File
@@ -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, Copy, Debug)]
#[derive(Clone, Debug)]
pub struct LanSettings {
pub port: u16,
pub guest_commands: bool,
@@ -52,15 +52,11 @@ 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<super::Dim, HashMap<u32, (i32, bool)>>,
mob_state: 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
@@ -320,15 +316,6 @@ 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() {
@@ -350,58 +337,43 @@ impl Game<'_> {
self.lan_close(true);
Ok(Some(json!({ "ok": true })))
}
Some("status") => Ok(Some(self.lan_status())),
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 }),
})),
_ => 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<()> {
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.lan_start(settings)?;
let port = 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())],
@@ -436,7 +408,6 @@ 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))
@@ -506,8 +477,6 @@ 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(())
@@ -515,22 +484,6 @@ 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;
@@ -563,75 +516,6 @@ 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) {
@@ -643,7 +527,6 @@ 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);
}
}
@@ -656,37 +539,18 @@ 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 = effective;
host.hub.config.guest_commands = guest_commands;
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)
}
@@ -766,8 +630,6 @@ 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 {
@@ -795,19 +657,7 @@ impl Game<'_> {
tick_rate: self.clock.rate(),
tick_frozen: self.clock.frozen(),
};
// 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
host.hub.tick(&mut ctx)
};
if let Some(state) = &state
&& host.host_mode != state.game_mode
@@ -823,7 +673,6 @@ 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",
@@ -848,20 +697,11 @@ impl Game<'_> {
if let Some(pos) = guest.menu.as_ref().and_then(|m| m.pos)
&& !guest.spectator()
{
// 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.container_openers_changed(pos, false);
}
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",
@@ -886,15 +726,9 @@ 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);
}
@@ -909,7 +743,6 @@ 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;
@@ -932,28 +765,7 @@ 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,
@@ -967,9 +779,6 @@ 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`).
@@ -1024,11 +833,7 @@ impl Game<'_> {
let mut seen = HashMap::new();
for m in &self.mobs {
let dead = !m.alive();
let old = host
.mob_state
.get(&self.dim)
.and_then(|s| s.get(&m.id))
.copied();
let old = host.mob_state.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);
@@ -1038,7 +843,7 @@ impl Game<'_> {
}
seen.insert(m.id, (m.hurt_time, dead));
}
host.mob_state.insert(self.dim, seen);
host.mob_state = seen;
}
/// Keep the chunks around every guest loaded.
@@ -1047,7 +852,7 @@ impl Game<'_> {
.hub
.guests
.iter()
.filter(|g| host.hub.here(g))
.filter(|g| g.placed)
// 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))
+43 -407
View File
@@ -13,7 +13,6 @@ use horizon_client::{
items::ItemStack,
lan::{
guest::SPECTATOR,
hub::TickCtx,
menu::{Rules, Slots},
},
motion::{MotionEvent, MotionWorld, PlayerMotion},
@@ -37,11 +36,6 @@ 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,
@@ -49,13 +43,6 @@ 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);
@@ -105,11 +92,7 @@ impl Game<'_> {
let mut play = host.play.remove(&conn).unwrap_or_default();
let g = &mut host.hub.guests[i];
// The guest's body.
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);
let mut motion = g.motion.take()?;
motion.position = Vec3::new(g.position[0], g.position[1], g.position[2]);
motion.ground.on_ground = g.on_ground;
motion.yaw = g.yaw;
@@ -142,20 +125,13 @@ impl Game<'_> {
total: g.xp_total,
},
);
// 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);
let sneak = self.keys.contains(&KeyCode::ShiftLeft);
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);
@@ -177,15 +153,6 @@ 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);
@@ -214,23 +181,13 @@ impl Game<'_> {
play.swing = std::mem::replace(&mut self.swing, swing);
self.container = container;
self.screen = screen;
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;
if sneak {
self.keys.insert(KeyCode::ShiftLeft);
} else {
self.keys.remove(&KeyCode::ShiftLeft);
}
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
@@ -274,7 +231,6 @@ 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()
@@ -291,32 +247,6 @@ 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;
@@ -344,14 +274,6 @@ 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 {
@@ -428,41 +350,35 @@ 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 => {}
3 => {
host.hub.ack(conn, a.sequence);
self.guest_stop_digging(host, conn, a.pos);
}
2 => {
2 | 3 => {
host.hub.ack(conn, a.sequence);
let play = host.play.entry(conn).or_default();
play.digging = None;
play.mining = None;
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),
);
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),
);
}
}
4 | 5 => {
if !spectator {
@@ -479,15 +395,10 @@ impl Game<'_> {
}
});
}
// SWAP_ITEM_WITH_OFFHAND: the hands swap and the item in use stops
// (ServerGamePacketListenerImpl.handlePlayerAction), not for spectators.
7 if !spectator => {
7 => {
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;
}
}
_ => {}
},
@@ -563,7 +474,6 @@ 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];
@@ -597,70 +507,6 @@ 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()
@@ -698,17 +544,7 @@ 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();
@@ -833,32 +669,6 @@ 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();
@@ -873,14 +683,9 @@ 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()
@@ -888,7 +693,6 @@ 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,
@@ -948,18 +752,6 @@ 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;
@@ -967,55 +759,24 @@ impl Game<'_> {
g.saturation = 5.0;
g.sent_health = None;
g.motion = None;
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());
}
host.hub.respawn(i, spawn);
}
/// 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| host.hub.here(g) && g.loaded)
.filter(|g| g.placed && 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 || self.on_server_trip(conn) {
if host.hub.guests[gi].dead {
continue;
}
let play = host.play.entry(conn).or_default();
@@ -1045,49 +806,20 @@ 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, message }),
&C::PlayerCombatKill(play::PlayerCombatKill {
id: host.hub.guests[gi].entity_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);
}
}
}
@@ -1114,17 +846,12 @@ 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, motion) = (game.world.health_rules, game.motion);
let health = &motion.health;
let (rules, health) = (game.world.health_rules, &game.motion.health);
let Some(player) = game.player.as_mut() else {
return Vec::new();
};
@@ -1139,7 +866,6 @@ 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 {
@@ -1163,21 +889,9 @@ impl Game<'_> {
let _ = w.tick_environment_damage();
let _ = w.apply_fire_contact();
let _ = w.server_hazards_tick(in_wall);
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
let _ = w.player.peaceful_regeneration(rules);
let _ = w.player.food_tick(health, rules);
std::mem::take(&mut w.player.events)
.into_iter()
.filter_map(|e| match e {
MotionEvent::HurtSound(sound) => Some(sound),
@@ -1205,81 +919,6 @@ 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 {
@@ -1400,9 +1039,6 @@ 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;
+1 -153
View File
@@ -6,9 +6,8 @@
//! closes (`AbstractContainerMenu.clearContainer`).
use super::{Game, lan::LanHost};
use horizon_client::{
containers::BlockEntity,
items::ItemStack,
lan::{guest::Guest, menu::Slots},
lan::guest::Guest,
menu::{Env, Input, Kind, Menu},
};
use horizon_protocol::packets::play::{self, Clientbound as C};
@@ -21,9 +20,6 @@ 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.
@@ -157,154 +153,6 @@ 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);
+4 -7
View File
@@ -92,7 +92,7 @@ impl Game<'_> {
.hub
.guests
.iter()
.filter(|g| host.hub.here(g) && g.loaded && !g.dead)
.filter(|g| g.placed && 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| host.hub.here(g) && g.loaded && !g.dead && !g.spectator())
.filter(|g| g.placed && g.loaded && !g.dead && !g.spectator())
.map(|g| (Who::Mob(g.entity_id as u32), guest_bounds(g)))
.collect()
}
@@ -209,12 +209,9 @@ 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.iter().filter(|g| h.hub.here(g)));
let guests = self.lan.iter().flat_map(|h| &h.hub.guests);
guests
.filter(|g| g.loaded)
.filter(|g| g.placed && g.loaded)
.map(|g| {
let [x, y, z] = g.position;
Present {
+3 -10
View File
@@ -19,7 +19,6 @@ pub(super) struct Strike {
scale: f32,
crit: bool,
from: [f64; 3],
sweep: Option<[f64; 3]>,
}
impl Game<'_> {
@@ -32,7 +31,7 @@ impl Game<'_> {
host.hub
.guests
.iter()
.filter(|g| host.hub.here(g) && g.loaded && !g.dead && !g.spectator())
.filter(|g| g.placed && 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))
}
@@ -64,7 +63,7 @@ impl Game<'_> {
host.hub
.guests
.iter()
.find(|g| g.entity_id == id && g.conn != conn && host.hub.here(g) && !g.dead)
.find(|g| g.entity_id == id && g.conn != conn && g.placed && !g.dead)
.map(|g| Victim::Guest(g.conn))
}
@@ -74,7 +73,6 @@ 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
@@ -87,7 +85,6 @@ impl Game<'_> {
scale,
crit,
from,
sweep,
})
}
@@ -108,10 +105,6 @@ impl Game<'_> {
(true, false, true) => "minecraft:entity.player.attack.strong",
(true, false, false) => "minecraft:entity.player.attack.weak",
};
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);
}
self.audio.play(sound, Some(s.from), 1.0, 1.0);
}
}
+55 -300
View File
@@ -4,11 +4,9 @@
//! 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::{
@@ -17,12 +15,9 @@ 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,
@@ -55,7 +50,7 @@ enum View {
Restrictions,
GameRules,
}
#[derive(Clone, Copy, PartialEq)]
#[derive(Clone, PartialEq)]
struct Snapshot {
default_mode: i32,
personal_mode: i32,
@@ -113,36 +108,6 @@ 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);
@@ -210,13 +175,16 @@ 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.visible_buttons(w)
Self::buttons(w, self.top())
.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(
@@ -261,17 +229,6 @@ 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.
@@ -447,64 +404,30 @@ 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;
// 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 {
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() {
port.set_value(&p.to_string());
}
let mut options = WorldOptions {
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(),
default_mode: self.level.mode(),
personal_mode: self.mode_id(),
cheats: self.allow_commands(),
difficulty: self.ui.difficulty,
lan,
port,
hint,
guest_commands: state.guest_commands,
force: state.force,
hardcore: state.hardcore,
difficulty_locked: state.difficulty_locked,
locked: state.difficulty_locked || state.hardcore,
guest_commands,
force,
hardcore: self.level.hardcore,
difficulty_locked: self.level.difficulty_locked,
locked: self.level.difficulty_locked || self.level.hardcore,
scroll: 0.0,
view: View::Options,
rules: None,
@@ -535,10 +458,7 @@ impl Game<'_> {
pub(super) fn world_options_escape(&mut self) {
match self.world_options.as_mut() {
Some(o) if o.view == View::GameRules => {
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) {
if let Some(exit) = o.rules.as_mut().and_then(RulesScreen::escape) {
self.close_game_rules(exit);
}
}
@@ -558,20 +478,6 @@ 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;
}
@@ -597,16 +503,6 @@ 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,
);
}
}
}
@@ -689,7 +585,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, o.scroll_limit(h));
o.scroll = o.scroll.clamp(0.0, WorldOptions::max_scroll(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);
@@ -711,9 +607,6 @@ 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);
@@ -721,7 +614,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 o.visible_buttons(w) {
for (b, bx, by, bw) in WorldOptions::buttons(w, top) {
let active = o.active(b);
let hover = active && inside && ui::hovered(cursor, bx, by, bw, 20.0);
if b == Button::Lock {
@@ -739,34 +632,32 @@ impl Game<'_> {
}
// The port box beside the LAN button: editable only with LAN on,
// its hint the port to use (setPortError colours the text).
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)
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())
} else {
rgba(0xDF, 0x50, 0x50, 255)
Text::translate("lanServer.port", &[])
};
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,
);
}
// 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(
@@ -775,7 +666,7 @@ impl Game<'_> {
(w / 2.0).floor() + 166.0,
HEADER,
h - FOOTER,
o.content_height(),
CONTENT,
o.scroll.trunc(),
);
ui::separators(batch, atlas, HEADER, h - FOOTER, true);
@@ -791,7 +682,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, o.scroll_limit(h));
o.scroll = (o.scroll - delta * 10.0).clamp(0.0, WorldOptions::max_scroll(h));
}
Some(o) => {
if let Some(rules) = o.rules.as_mut() {
@@ -864,8 +755,7 @@ impl Game<'_> {
}
}
let inside = self.cursor.1 >= HEADER && self.cursor.1 < h - FOOTER;
o.port.focused = !o.remote
&& o.lan
o.port.focused = o.lan
&& inside
&& ui::hovered(
self.cursor,
@@ -894,12 +784,7 @@ impl Game<'_> {
Some(Button::Lock) => o.locked = !o.locked,
Some(Button::Restrictions) => o.view = View::Restrictions,
Some(Button::GameRules) => {
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.rules = Some(RulesScreen::new(&self.ui.rules));
o.view = View::GameRules;
}
Some(Button::Lan) => {
@@ -923,7 +808,7 @@ impl Game<'_> {
fn apply_and_close_world_options(&mut self, o: WorldOptions) {
let wanted = o.snapshot();
let before = o.initial;
let before = o.initial.clone();
self.world_options = Some(o);
self.apply_world_options(&before, &wanted);
self.close_world_options();
@@ -1006,60 +891,7 @@ 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}")));
@@ -1072,15 +904,10 @@ impl Game<'_> {
self.set_default_game_mode(wanted.default_mode);
}
if wanted.personal_mode != before.personal_mode {
if self.serving() {
self.set_owner_mode(wanted.personal_mode);
} else {
self.set_mode_id(wanted.personal_mode);
}
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;
@@ -1108,8 +935,6 @@ mod tests {
fn options(cheats: bool, hardcore: bool) -> WorldOptions {
let mut o = WorldOptions {
remote: false,
gamemaster: cheats,
default_mode: 1,
personal_mode: 1,
cheats,
@@ -1163,8 +988,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!(options(true, false).scroll_limit(240.0), 44.0);
assert_eq!(options(true, false).scroll_limit(400.0), 0.0);
assert_eq!(WorldOptions::max_scroll(240.0), 44.0);
assert_eq!(WorldOptions::max_scroll(400.0), 0.0);
}
#[test]
@@ -1208,74 +1033,4 @@ 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 && host.hub.here(g))
.find(|g| g.conn == conn)
.map(|g| uuid_words(g.uuid))
else {
continue;
@@ -1,95 +0,0 @@
//! 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);
}
}
}
}
-350
View File
@@ -1,350 +0,0 @@
//! 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)
}
}
+16 -84
View File
@@ -92,13 +92,13 @@ impl Game<'_> {
}
}
/// `ServerLevel.isAllowedToEnterPortal`.
pub(super) fn may_enter(&self, target: Dim) -> bool {
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`).
pub(super) fn portal_plan(
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`.
pub(super) fn nether_exit_portal(&mut self, plan: &Plan) -> Option<FoundRectangle> {
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.
pub(super) fn arrival(&self, plan: &Plan, exit: FoundRectangle) -> (Vec3, f32) {
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,16 +312,8 @@ impl Game<'_> {
self.portals.travel = Some(plan);
self.end_portal_travel = false;
self.enter_dimension(plan.dim)?;
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 {
// The destination is all on disk now: load around the portal its
// points of interest lead to, or the approximate exit.
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) };
@@ -335,11 +327,14 @@ impl Game<'_> {
max_section,
);
let centre = found.unwrap_or(plan.approximate);
Vec3::new(
f64::from(centre.x) + 0.5,
f64::from(centre.y),
f64::from(centre.z) + 0.5,
)
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(())
}
/// The player's arrival once the destination has loaded around the
/// approximate exit: the waiting entities first, then the exit portal
@@ -486,10 +481,9 @@ 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, &mut portal_sections) {
if let Some(pos) = self.swept_portal(from, to, width, height) {
inside.push((id, pos));
}
}
@@ -503,14 +497,7 @@ 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,
portal_sections: &mut horizon_client::fast_hash::FxHashMap<(i32, i32, i32), bool>,
) -> Option<BlockPos> {
fn swept_portal(&self, from: Vec3, to: Vec3, width: f32, height: f32) -> 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),
@@ -521,13 +508,6 @@ 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,
@@ -549,54 +529,6 @@ 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) {
+8 -5
View File
@@ -186,11 +186,16 @@ 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.selected_key.is_some() && self.ui.options.page() == Page::KeyBinds
}
pub(super) fn options_bind_mouse(&mut self, button: MouseButton) -> Result<()> {
let events = self.ui.options.bind_mouse(&mut self.settings, button);
self.handle_option_events(events)
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(())
}
pub(super) fn handle_option_events(&mut self, events: Vec<Event>) -> Result<()> {
for e in events {
@@ -201,7 +206,6 @@ 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) {
@@ -240,7 +244,6 @@ 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);
+6 -37
View File
@@ -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]) -> std::io::Result<PathBuf> {
pub fn startup_root(vanilla: &Path, selected: &[String]) -> PathBuf {
let all = packs::discover(vanilla, &packs_dir());
let enabled = packs::enabled(&all, selected);
packs::resource_root(vanilla, &enabled, &cache_dir())
@@ -104,18 +104,9 @@ impl Game<'_> {
if self.ui.vanilla_root.as_os_str().is_empty() {
self.ui.vanilla_root = vanilla.clone();
}
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 {
let root = startup_root(&vanilla, &self.settings.resource_packs);
if let Err(e) = self.reload_resources(&root) {
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<()> {
@@ -130,35 +121,12 @@ 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
@@ -174,8 +142,9 @@ 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(renderer) = renderer {
self.renderer = Some(renderer);
if let Some(window) = self.window.clone() {
self.renderer = None;
self.renderer = Some(self.new_renderer(window)?);
self.apply_ui_settings();
}
self.ui.chat.rescale(&self.atlas);
+7
View File
@@ -365,6 +365,13 @@ 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)
}
+11 -7
View File
@@ -31,7 +31,7 @@ use winit::{
dpi::PhysicalPosition,
event::{DeviceId, ElementState, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent},
event_loop::ActiveEventLoop,
keyboard::KeyCode,
keyboard::{KeyCode, NamedKey},
};
/// D-pad auto-repeat: the first step at once, the next after a pause.
@@ -859,6 +859,7 @@ 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
@@ -889,7 +890,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.screen == Screen::Chat || self.sign_edit.is_some() || self.remote_chat_open())
{
self.pad.menu.osk_dismissed = true;
self.pad_open_keyboard();
@@ -900,10 +901,10 @@ impl Game<'_> {
/// the field that has focus.
fn pad_osk_output(&mut self, out: osk::Output) {
use osk::Output;
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),
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),
Output::Close => {
self.pad.menu.osk = None;
self.pad.menu.osk_dismissed = true;
@@ -932,6 +933,8 @@ 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);
@@ -982,7 +985,8 @@ impl Game<'_> {
.menu
.focus
.is_some_and(|f| f.center().1 > batch.height / 2.0)
|| self.screen == Screen::Chat;
|| self.screen == Screen::Chat
|| self.remote_chat_open();
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();
+8 -73
View File
@@ -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,15 +313,6 @@ 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()
@@ -387,8 +378,6 @@ 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.
@@ -438,7 +427,7 @@ impl Game<'_> {
}
/// `extractEntityInInventoryFollowsMouse`: the player in the box
/// `[x0, x1) x [y0, y1)`, `size` pixels per block, turned toward the
/// cursor; drawn with a depth buffer and back-face culling.
/// cursor; drawn as depth-sorted, back-face-culled quads.
#[allow(clippy::too_many_arguments)]
pub(super) fn draw_player_preview(
&self,
@@ -506,9 +495,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_3d(p, uv, color);
batch.quad(p, uv, color);
}
batch.depth_since(start, [x0, y0, x1, y1]);
batch.clip_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) {
@@ -581,7 +570,7 @@ fn player_preview_quads(
gui: glam::Mat4,
fire: Option<(f32, f32)>,
sprites: &std::collections::HashMap<String, horizon_client::hud::Rect>,
) -> Vec<(f32, [[f32; 3]; 4], [[f32; 2]; 4], u32)> {
) -> Vec<(f32, [[f32; 2]; 4], [[f32; 2]; 4], u32)> {
let flame_start = verts.len();
if let Some((width, height)) = fire {
horizon_client::fire_render::flames(
@@ -595,7 +584,7 @@ fn player_preview_quads(
&mut verts,
);
}
let mut quads: Vec<(f32, [[f32; 3]; 4], [[f32; 2]; 4], u32)> = Vec::new();
let mut quads: Vec<(f32, [[f32; 2]; 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)));
@@ -617,12 +606,12 @@ fn player_preview_quads(
let depth = p.iter().map(|v| v.z).sum::<f32>() / 4.0;
quads.push((
depth,
p.map(|v| v.to_array()),
p.map(|v| [v.x, v.y]),
std::array::from_fn(|i| q[i].uv),
color,
));
}
// Keep far-to-near order for translucent faces; opaque overlap uses depth.
// Far (low z) first: the viewer is on the +z side.
quads.sort_by(|a, b| a.0.total_cmp(&b.0));
quads
}
@@ -630,60 +619,6 @@ 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 {
+37 -78
View File
@@ -49,14 +49,6 @@ 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 {
@@ -71,14 +63,6 @@ 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)> {
@@ -290,16 +274,8 @@ impl Game<'_> {
}
}
}
/// The local player's effects (`tick_mob_effects`), then the potions
/// in flight, which a server without a local player still has.
/// `LivingEntity.tickEffects`, attribute modifiers and the status flags.
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;
};
@@ -318,20 +294,6 @@ 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()
@@ -384,6 +346,7 @@ 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);
@@ -467,41 +430,6 @@ 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" {
@@ -562,9 +490,34 @@ impl Game<'_> {
let Some(def) = stack.potion().and_then(brewing::potion) else {
return;
};
// 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));
// 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));
}
}
}
}
}
// Mobs take the instant effects (undead invert healing and harming).
let undead = &self.res.stations.enchantments.entity_tags;
let mut hits = Vec::new();
@@ -653,7 +606,13 @@ impl Game<'_> {
/// Effects saved in level.dat (`Player.active_effects`).
pub(super) fn restore_effects(&mut self) {
self.potions = PotionState::default();
self.potions.effects = PlayerEffects::from_saved(&self.level.active_effects);
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);
}
}
}
pub(super) fn effects_json(&self) -> serde_json::Value {
serde_json::json!({
+7 -18
View File
@@ -39,17 +39,6 @@ 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)
@@ -624,17 +613,17 @@ impl Game<'_> {
raider::state_mut(m).ticks_outside_raid = ticks;
}
}
// 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() {
Action::Victory { heroes, amplifier } => {
let me = self.level.player_uuid;
if me.is_some_and(|u| heroes.contains(&u)) && !self.spectator() {
let mut effect =
EffectInstance::new("hero_of_the_village", raid::HERO_DURATION, amplifier);
effect.visible = false;
g.potions.effects.add(effect);
g.stats.custom("raid_win", 1);
g.trigger_player("minecraft:hero_of_the_village");
self.potions.effects.add(effect);
self.stats.custom("raid_win", 1);
self.trigger_player("minecraft:hero_of_the_village");
}
}),
}
}
}
/// `Raid.playSound`: the horn, 13 blocks away from the player toward the
+1 -122
View File
@@ -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.station.is_some() {
if self.screen != Screen::Inventory || self.remote.is_some() || self.station.is_some() {
return None;
}
if self.mount_active() {
@@ -262,33 +262,6 @@ 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, _| {
@@ -301,10 +274,6 @@ 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;
@@ -691,10 +660,6 @@ 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).
@@ -835,18 +800,6 @@ 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;
@@ -875,80 +828,6 @@ 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.
+133 -493
View File
@@ -68,7 +68,6 @@ struct Lerp {
pub(super) struct Remote {
pub session: Session,
pub brand: String,
builtin: Arc<Builtin>,
lang: Arc<Language>,
registries: Arc<Dynamic>,
@@ -89,11 +88,10 @@ pub(super) struct Remote {
was_sprinting: bool,
carried: Option<usize>,
loaded_sent: bool,
/// 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)>,
/// 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,
/// Open container: (window id, menu name).
menu: Option<(i32, String)>,
/// The open menu's title as the server named it.
@@ -107,10 +105,7 @@ pub(super) struct Remote {
health: (f32, i32, f32),
game_mode: i32,
pub chat: Vec<(String, Instant)>,
/// 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>,
pub chat_input: Option<String>,
action_bar: Option<(String, Instant)>,
players: HashMap<u128, String>,
/// `ClientPacketListener.playerInfoMap` as player-list entries, and
@@ -135,8 +130,6 @@ 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 {
@@ -209,6 +202,7 @@ 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 => {
@@ -253,7 +247,6 @@ impl Game<'_> {
progress: 0.0,
total: 0,
};
self.ui.registries.permission = 0;
self.screen = Screen::Playing;
self.title = None;
self.dim = Dim::Overworld;
@@ -263,7 +256,6 @@ impl Game<'_> {
self.apply_settings();
self.remote = Some(Remote {
session,
brand: "unknown".into(),
builtin: Arc::new(builtin),
lang,
registries: Arc::new(Dynamic::default()),
@@ -284,7 +276,8 @@ impl Game<'_> {
was_sprinting: false,
carried: None,
loaded_sent: false,
predictions: HashMap::new(),
local: is_local,
packet_rates: Default::default(),
menu: None,
menu_title: None,
state_id: 0,
@@ -295,8 +288,7 @@ impl Game<'_> {
health: (20.0, 20, 5.0),
game_mode: 0,
chat: Vec::new(),
chat_received: 0,
commands: None,
chat_input: None,
action_bar: None,
players: HashMap::new(),
tab_players: HashMap::new(),
@@ -314,13 +306,7 @@ 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(())
}
@@ -343,9 +329,6 @@ 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();
@@ -404,31 +387,6 @@ 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
@@ -464,53 +422,16 @@ impl Game<'_> {
}
Some(stack)
}
pub(super) fn chat_line(&mut self, text: String) {
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()?;
@@ -542,11 +463,6 @@ 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() {
@@ -600,9 +516,12 @@ 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 = Dim::from_id(&spawn.dimension);
self.dim = if spawn.dimension == "minecraft:the_nether" {
Dim::Nether
} else {
Dim::Overworld
};
if !same {
let mut streaming = Streaming::new(
Arc::new(NoTerrain),
@@ -629,8 +548,6 @@ 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();
@@ -707,128 +624,6 @@ 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();
@@ -848,42 +643,6 @@ 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])
@@ -994,8 +753,6 @@ 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 {
@@ -1055,7 +812,6 @@ 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;
@@ -1071,8 +827,6 @@ 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() {
@@ -1095,15 +849,6 @@ 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);
@@ -1119,8 +864,6 @@ 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),
@@ -1143,7 +886,7 @@ impl Game<'_> {
chunks_per_tick: 64.0,
}));
}
C::BlockUpdate(p) => self.server_block(p.pos, p.state),
C::BlockUpdate(p) => self.set_block(p.pos, p.state),
C::SectionBlocksUpdate(p) => {
let [sx, sy, sz] = p.section;
for change in p.changes {
@@ -1153,10 +896,10 @@ impl Game<'_> {
sy * 16 + (local & 15),
sz * 16 + (local >> 4 & 15),
];
self.server_block(pos, (change >> 12) as i32);
self.set_block(pos, (change >> 12) as i32);
}
}
C::BlockChangedAck(p) => self.end_predictions(p.sequence),
C::BlockChangedAck(_) => {}
// ClientLevel.blockEvent: BlockEntity.triggerEvent (chest lids,
// shulker boxes, bells) when the block is still the one it names.
C::BlockEvent(p) => {
@@ -1312,20 +1055,6 @@ 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
@@ -1398,13 +1127,6 @@ 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
@@ -1418,21 +1140,7 @@ impl Game<'_> {
m.health = 0.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::Animate(p) if p.action == 0 => {}
C::SetHealth(p) => {
if let Some(r) = self.remote.as_mut() {
r.health = (p.health, p.food, p.saturation);
@@ -1450,16 +1158,9 @@ 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 {
@@ -1494,21 +1195,6 @@ 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);
@@ -1566,8 +1252,6 @@ 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();
@@ -1587,10 +1271,6 @@ 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 {
@@ -1599,15 +1279,6 @@ 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,
@@ -1788,68 +1459,6 @@ 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
@@ -2182,15 +1791,10 @@ impl Game<'_> {
/// One client tick while connected (Minecraft.tick with a ClientLevel).
pub(super) fn tick_remote(&mut self) -> Result<()> {
self.ticks += 1;
// 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;
}
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;
@@ -2201,12 +1805,11 @@ impl Game<'_> {
}
// ClientLevel.tick: the border's size change.
self.border.tick();
self.tick_remote_entities(running);
self.tick_remote_entities();
// 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(());
@@ -2224,13 +1827,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 playing = self.screen == Screen::Playing;
let chatting = self.remote.as_ref().is_some_and(|r| r.chat_input.is_some());
let playing = self.screen == Screen::Playing && !chatting;
let held = |k| playing && self.keys.contains(&k);
let keys = Keys {
forward: held(KeyCode::KeyW),
@@ -2311,18 +1914,12 @@ impl Game<'_> {
self.send(S::ClientTickEnd(Empty));
Ok(())
}
/// ClientLevel.tickEntities; a frozen level only ticks the players
/// (`TickRateManager.isEntityFrozen`).
fn tick_remote_entities(&mut self, running: bool) {
fn tick_remote_entities(&mut self) {
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;
@@ -2360,7 +1957,7 @@ impl Game<'_> {
horizon_client::mobs::rabbit::post_tick(m);
}
}
for d in self.dropped.iter_mut().filter(|_| running) {
for d in &mut self.dropped {
d.previous = d.position;
d.age += 1;
if let Some(l) = lerps.get_mut(&(d.id as i32))
@@ -2500,7 +2097,8 @@ impl Game<'_> {
if r.destroy_delay > 0 {
r.destroy_delay -= 1;
}
if !self.left || self.screen != Screen::Playing {
let chatting = r.chat_input.is_some();
if !self.left || self.screen != Screen::Playing || chatting {
self.stop_remote_mining();
self.mining = None;
return;
@@ -2517,12 +2115,11 @@ 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, seq);
self.predict_break(hit.pos, state);
self.send(S::PlayerAction(play::PlayerAction {
action: 0,
pos: hit.pos,
@@ -2571,7 +2168,7 @@ impl Game<'_> {
sequence: seq,
}));
if progress >= 1.0 {
self.predict_break(hit.pos, state, seq);
self.predict_break(hit.pos, state);
if let Some(r) = self.remote.as_mut() {
r.destroy_delay = 5;
}
@@ -2608,7 +2205,7 @@ impl Game<'_> {
self.crack_particle(pos, hit.face, state);
if done >= 1.0 {
let seq = self.next_sequence();
self.predict_break(pos, state, seq);
self.predict_break(pos, state);
self.send(S::PlayerAction(play::PlayerAction {
action: 3,
pos,
@@ -2640,14 +2237,7 @@ impl Game<'_> {
}
}
/// MultiPlayerGameMode.destroyBlock: the predicted break.
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));
}
fn predict_break(&mut self, pos: [i32; 3], state: BlockStateId) {
let s = self.res.facts.get(state).sounds.clone();
let at = [
f64::from(pos[0]) + 0.5,
@@ -2669,22 +2259,7 @@ 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))
@@ -2745,14 +2320,13 @@ impl Game<'_> {
}
/// Left click: attack the targeted entity (MultiPlayerGameMode.attack).
pub(super) fn remote_mouse(&mut self, button: MouseButton, pressed: bool) -> Result<()> {
if self.screen == Screen::Chat {
if self.remote.as_ref().is_some_and(|r| r.chat_input.is_some()) {
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();
@@ -2805,8 +2379,14 @@ impl Game<'_> {
}
match key {
KeyCode::KeyT | KeyCode::Slash | KeyCode::Enter => {
// ChatScreen, over the server's command tree.
self.open_chat(if key == KeyCode::Slash { "/" } else { "" });
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);
Ok(true)
}
KeyCode::KeyQ => {
@@ -2843,6 +2423,84 @@ 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();
@@ -2870,17 +2528,6 @@ 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
@@ -3185,11 +2832,11 @@ impl Game<'_> {
let Some(r) = &self.remote else { return };
let atlas = &self.atlas;
let (w, h) = (batch.width, batch.height);
let open = self.screen == Screen::Chat;
let open = r.chat_input.is_some();
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 | Screen::Chat) {
let shown = if matches!(self.screen, Screen::Playing | Screen::Dead) {
if open { 20 } else { 10 }
} else {
0
@@ -3217,6 +2864,16 @@ 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)
{
@@ -3291,20 +2948,3 @@ 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);
}
}
+14 -23
View File
@@ -303,33 +303,20 @@ 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();
@@ -600,7 +587,9 @@ 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,
@@ -746,10 +735,12 @@ mod tab_list_tests {
conn: uuid as u32,
uuid,
name: name.into(),
properties: Vec::new(),
info: ClientInformation::new(8),
brand: "test".into(),
sender: PlayerSender::new(tx, Arc::default()),
sender: PlayerSender {
tx,
pending: Arc::default(),
},
latency: Arc::new(AtomicI32::new(42)),
address: "127.0.0.1:25565".parse().unwrap(),
},
+15 -59
View File
@@ -8,8 +8,7 @@ use horizon_client::{
};
/// PauseScreen buttons: (x, y, width, label key, action id, active).
/// 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)> {
fn pause_buttons(w: f32, h: f32) -> 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();
@@ -27,21 +26,9 @@ fn pause_buttons(w: f32, h: f32, remote: bool) -> Vec<(f32, f32, f32, &'static s
9,
true,
),
(
x0 + 4.0,
y0 + 146.0,
204.0,
if remote {
"menu.disconnect"
} else {
"menu.returnToMenu"
},
2,
true,
),
(x0 + 4.0, y0 + 146.0, 204.0, "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;
@@ -65,10 +52,6 @@ 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
@@ -77,14 +60,17 @@ impl Game<'_> {
&& horizon_client::ui_theme::horizon()
}
fn pause_labels(&self) -> (String, &'static str) {
if let Some(server) = &self.integrated {
return (server.world_name.clone(), "Save and quit");
}
if self.external_server() {
("Multiplayer".to_owned(), "Disconnect")
let world = if self.remote.is_some() {
"Multiplayer".to_owned()
} else {
(self.level.name.clone(), "Save and quit")
}
self.level.name.clone()
};
let quit = if self.remote.is_some() {
"Disconnect"
} else {
"Save and quit"
};
(world, quit)
}
pub(super) fn draw_pause_menu(&mut self, batch: &mut HudBatch) {
if horizon_client::ui_theme::horizon() {
@@ -93,14 +79,7 @@ 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,
self.external_server(),
);
horizon_client::front::pause::draw(&mut ui, &world, quit, opened, &friends);
ui.finish();
return;
}
@@ -117,7 +96,7 @@ impl Game<'_> {
u32::MAX,
true,
);
for (x, y, bw, key, _, active) in pause_buttons(w, h, self.external_server()) {
for (x, y, bw, key, _, active) in pause_buttons(w, h) {
let hover = active && ui::hovered(self.cursor, x, y, bw, 20.0);
ui::button(
batch,
@@ -163,10 +142,9 @@ impl Game<'_> {
&world,
quit,
&self.friends_view(),
self.external_server(),
)
} else {
pause_buttons(w, h, self.external_server())
pause_buttons(w, h)
.into_iter()
.find(|b| b.5 && ui::hovered(self.cursor, b.0, b.1, b.2, 20.0))
.map(|b| b.4)
@@ -285,12 +263,6 @@ 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;
@@ -326,19 +298,3 @@ 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));
}
}
}
-20
View File
@@ -80,15 +80,6 @@ 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)]
@@ -382,14 +373,6 @@ 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;
@@ -1180,9 +1163,6 @@ 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;
}
@@ -1,271 +0,0 @@
//! 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 -77
View File
@@ -6,7 +6,6 @@
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;
@@ -16,7 +15,6 @@ use horizon_world::{
LevelEvent,
removal::{self, PreRemove},
},
nbt::{JavaString, Tag},
registry::BlockStateId,
};
use std::collections::HashMap;
@@ -82,37 +80,14 @@ impl Pending {
stack.data_mut().pot_decorations = Some(Box::new(sides));
}
if stack.item.ends_with("_banner")
&& let Some(Banner {
layers,
name,
components,
}) = self.banner.remove(&pos)
&& let Some(Banner { layers, name }) = self.banner.remove(&pos)
{
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());
}
let data = stack.data_mut();
data.banner_patterns = layers;
if name.is_some() {
data.custom_name = name;
}
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)));
stack.tidy();
}
}
/// Remember a decorated pot's sherds from its saved block entity.
@@ -578,49 +553,3 @@ 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()))
);
}
}
}
+13 -33
View File
@@ -1860,10 +1860,18 @@ 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 rarity = horizon_client::item_tooltip::rarity(
stack,
props.map_or("common", |p| p.rarity.as_str()),
);
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 color = match rarity {
"uncommon" => rgba(255, 255, 85, 255),
"rare" => rgba(85, 255, 255, 255),
@@ -1944,7 +1952,7 @@ impl Game<'_> {
));
}
// BannerPatternLayers.addToTooltip: up to six layer names.
for layer in horizon_client::banner::tooltip_layers(stack).iter().take(6) {
for layer in horizon_client::banner::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)));
}
@@ -1969,34 +1977,6 @@ 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
+46 -98
View File
@@ -3,12 +3,8 @@
//! 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, BTreeSet},
path::{Path, PathBuf},
};
use std::collections::BTreeMap;
/// `stats[type][key]`, with `type` and `key` namespaced ids.
#[derive(Default)]
@@ -20,8 +16,6 @@ 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) {
@@ -34,8 +28,6 @@ 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
@@ -48,51 +40,52 @@ 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);
}
/// `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();
}
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.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()
/// `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;
};
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(text) = std::fs::read_to_string(path) else {
return;
};
if let Some(all) = json.get("stats").and_then(|s| s.as_object()) {
for (ty, entries) in all {
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 {
let Some(entries) = entries.as_object() else {
continue;
};
let map = stats.values.entry(ty.clone()).or_default();
let map = self.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);
@@ -100,11 +93,17 @@ impl Stats {
}
}
}
stats
}
/// `ServerStatsCounter.save`.
pub(super) fn write(&self, path: &Path) {
pub(super) fn save_stats(&self) {
if !self.stats.loaded || self.remote.is_some() {
return;
}
let Some(path) = self.stats_path() else {
return;
};
let stats: serde_json::Map<String, serde_json::Value> = self
.stats
.values
.iter()
.filter(|(_, m)| !m.is_empty())
@@ -125,55 +124,9 @@ impl Stats {
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;
};
@@ -184,26 +137,21 @@ impl Game<'_> {
s.custom("total_world_time", 1);
if alive {
s.custom("time_since_death", 1);
if !sleeping {
if self.sleeping.is_none() {
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,12 +173,6 @@ 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;
}
+14 -7
View File
@@ -6,7 +6,6 @@ use horizon_client::{
hud::HudBatch,
hud_layout,
hud_overlays::HeldName,
item_tooltip,
text::{self, Text},
};
@@ -25,14 +24,22 @@ impl Game<'_> {
fn held_name(&self) -> Option<HeldName> {
let stack = self.inventory.get(self.selected)?.as_ref()?;
let custom = stack.custom_name();
let default_rarity = self.item_rarity(&stack.item);
let rarity = item_tooltip::rarity(stack, &default_rarity);
// 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,
};
}
Some(HeldName {
item: stack.item.to_string(),
hover_name: Text::literal(item_tooltip::name_line(stack, &|key| {
self.pack.names.get(key).map(str::to_owned)
})),
rarity_color: match rarity {
hover_name: Text::literal(
custom.map_or_else(|| self.pack.names.item(&stack.item), str::to_owned),
),
rarity_color: match rarity.as_str() {
"uncommon" => text::YELLOW,
"rare" => text::AQUA,
"epic" => text::LIGHT_PURPLE,
+2 -8
View File
@@ -28,7 +28,7 @@ use horizon_client::{
menu::Input,
stations::hover_name,
};
use winit::{event::MouseButton, keyboard::KeyCode};
use winit::keyboard::KeyCode;
/// A sort button's side in GUI pixels.
const SORT_BUTTON: f32 = 9.0;
@@ -377,14 +377,8 @@ 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 || e.sort_key != name || self.recipe_search_focused() {
if !e.sort || keybinds::key_code(&e.sort_key) != Some(key) || self.recipe_search_focused() {
return false;
}
let buttons = self.sort_buttons();
-25
View File
@@ -143,15 +143,6 @@ 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,
@@ -259,22 +250,6 @@ 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();
+156 -401
View File
@@ -64,7 +64,6 @@ mod pack_stages;
pub mod panorama;
mod resident;
mod timing;
pub mod translucency;
pub(crate) mod upload;
mod world_post;
@@ -99,8 +98,6 @@ 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;
@@ -136,8 +133,6 @@ 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.
@@ -227,7 +222,6 @@ 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`.
@@ -296,13 +290,7 @@ pub struct Renderer {
group: wgpu::BindGroup,
globals: wgpu::Buffer,
lightmap: 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,
sprite_texture: wgpu::Texture,
mip_levels: u32,
/// The mipmap levels sampled (the Mipmap Levels option), at most `mip_levels - 1`.
mipmaps: u32,
@@ -323,9 +311,6 @@ 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,
@@ -337,8 +322,8 @@ pub struct Renderer {
/// `DrawIndexedIndirectArgs` words: index count, instance count, first
/// index, base vertex, first instance.
draws: Vec<[u32; 5]>,
/// Vertex arena page and optional sorted index arena page of each draw.
draw_pages: Vec<(u32, Option<u32>)>,
/// The arena page of each of `draws`.
draw_pages: Vec<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).
@@ -363,87 +348,12 @@ 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,
@@ -544,14 +454,8 @@ impl Renderer {
hud_atlas: (&[u8], u32, u32),
forcefield: (&[u8], u32),
panorama_faces: &panorama::Faces,
previous: Option<&Self>,
) -> Result<Self> {
// 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(),
);
let instance = wgpu::Instance::new(instance_descriptor());
// 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 {
@@ -559,18 +463,15 @@ impl Renderer {
} else {
Some(instance.create_surface(window.clone())?)
};
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 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 info = adapter.get_info();
log::info!(
"Horizon renderer: {} ({:?}, {:?})",
@@ -593,40 +494,22 @@ impl Renderer {
),
);
let limits = adapter.limits();
let split = sprite_split();
if layers > split * SPRITE_ARRAYS {
if limits.max_texture_array_layers < layers {
return Err(format!(
"{layers} block textures; Horizon draws at most {}",
split * SPRITE_ARRAYS
"GPU supports {} texture layers; {layers} required",
limits.max_texture_array_layers
)
.into());
}
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 (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?;
let size = window.inner_size();
let (config, present_modes) = if let Some(surface) = &surface {
let mut config = surface
@@ -645,16 +528,11 @@ impl Renderer {
if caps.usages.contains(wgpu::TextureUsages::COPY_SRC) {
config.usage |= wgpu::TextureUsages::COPY_SRC;
}
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
}
});
config.present_mode = 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 {
@@ -687,19 +565,6 @@ 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: &[
@@ -740,9 +605,6 @@ 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 {
@@ -815,10 +677,7 @@ impl Renderer {
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some(fragment),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &sprite_constants,
..Default::default()
},
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: config.format.remove_srgb_suffix(),
blend,
@@ -848,7 +707,7 @@ impl Renderer {
&terrain_buffers,
tri,
Some(wgpu::Face::Back),
true,
false,
true,
Some(alpha_blend),
);
@@ -881,7 +740,7 @@ impl Renderer {
&line_buffers,
wgpu::PrimitiveTopology::TriangleStrip,
None,
true,
false,
true,
Some(alpha_blend),
);
@@ -925,62 +784,30 @@ impl Renderer {
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
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)"),
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"),
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
width: sprite_size,
height: sprite_size,
depth_or_array_layers: layers,
},
mip_level_count: 1,
mip_level_count: mip_levels,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
})
.create_view(&wgpu::TextureViewDescriptor {
dimension: Some(wgpu::TextureViewDimension::D2Array),
..Default::default()
});
let sprite_views = sprite_views(&sprite_textures, &sprite_unused, None);
},
wgpu::util::TextureDataOrder::LayerMajor,
&mips,
);
let sprite_view = sprite_texture.create_view(&wgpu::TextureViewDescriptor {
dimension: Some(wgpu::TextureViewDimension::D2Array),
..Default::default()
});
let sprite_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Block sprites"),
address_mode_u: wgpu::AddressMode::Repeat,
@@ -1011,26 +838,58 @@ impl Renderer {
min_filter: wgpu::FilterMode::Linear,
..Default::default()
});
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 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 index_quads = 1 << 16;
let indices = Self::quad_indices(&device, index_quads);
let instance_capacity = 4096;
@@ -1645,7 +1504,6 @@ 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,
@@ -1665,7 +1523,6 @@ impl Renderer {
);
Ok(Self {
instance,
adapter,
surface,
offscreen,
in_flight: std::collections::VecDeque::new(),
@@ -1718,9 +1575,7 @@ impl Renderer {
group,
globals,
lightmap,
sprite_textures,
sprite_unused,
sprite_split: split,
sprite_texture,
mip_levels,
mipmaps: mip_levels - 1,
terrain_layout: bindings,
@@ -1739,9 +1594,6 @@ impl Renderer {
depth,
sections: resident::Resident::default(),
arena,
sorted_indices,
translucent_sorter: translucency::Sorter::default(),
translucency_cursor: 0,
indirect,
indirect_capacity: 1024,
multi_draw,
@@ -1764,7 +1616,6 @@ impl Renderer {
screenshot: None,
screenshot_result: None,
gamma: 0.5,
fullbright: false,
animated_textures: true,
present_modes,
})
@@ -1805,22 +1656,6 @@ 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 {
@@ -1925,22 +1760,7 @@ impl Renderer {
self.indices = Self::quad_indices(&self.device, self.index_quads);
}
let vertices = (mesh.solid.len() + mesh.translucent.len()) as u64;
// 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 (page, start) = self.arena.alloc(&self.device, &self.queue, vertices);
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() {
@@ -1961,15 +1781,6 @@ 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,
},
);
}
@@ -1980,19 +1791,17 @@ 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, index_page) = self.draw_pages[i];
let page = self.draw_pages[i];
let run = self.draw_pages[i..end]
.iter()
.take_while(|&&p| p == (page, index_page))
.take_while(|&&p| p == 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(d[2]..d[2] + d[0], d[3] as i32, d[4]..d[4] + 1);
pass.draw_indexed(0..d[0], d[3] as i32, d[4]..d[4] + 1);
}
}
i += run;
@@ -2000,10 +1809,6 @@ 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);
}
@@ -2016,8 +1821,6 @@ 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) {
@@ -2061,7 +1864,7 @@ impl Renderer {
pub fn set_mipmap_levels(&mut self, levels: u32) {
let levels = sprite_mipmap_limit(
levels,
self.sprite_textures[0].width(),
self.sprite_texture.width(),
self.sprite_minimum_size,
self.mip_levels,
);
@@ -2069,17 +1872,39 @@ impl Renderer {
return;
}
self.mipmaps = levels;
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,
);
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),
},
],
});
}
pub fn view_projection(&self, frame: &Frame) -> Mat4 {
self.projection(
@@ -2147,9 +1972,6 @@ 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 {
@@ -2179,12 +2001,12 @@ impl Renderer {
self.uploads.texture(
encoder,
wgpu::TexelCopyTextureInfo {
texture: &self.sprite_textures[(layer / self.sprite_split) as usize],
texture: &self.sprite_texture,
mip_level: level,
origin: wgpu::Origin3d {
x: 0,
y: 0,
z: layer % self.sprite_split,
z: layer,
},
aspect: wgpu::TextureAspect::All,
},
@@ -2196,8 +2018,6 @@ impl Renderer {
);
}
pub fn render(&mut self, frame: &Frame) -> Result<bool> {
self.arena.trim();
self.sorted_indices.trim();
if self.suspended {
return Ok(false);
}
@@ -2219,7 +2039,9 @@ impl Renderer {
return Ok(false);
}
Some(wgpu::CurrentSurfaceTexture::Lost) => {
self.recreate_surface()?;
let surface = self.instance.create_surface(self.window.clone())?;
surface.configure(&self.device, &self.config);
self.surface = Some(surface);
return Ok(false);
}
Some(wgpu::CurrentSurfaceTexture::Validation) => {
@@ -2395,7 +2217,7 @@ impl Renderer {
visible.clear();
self.sections.visible(
camera,
(frame.distance / 16.0) as i32,
frame.distance,
&view,
|k| !culling || reachable.contains(k),
&mut visible,
@@ -2441,51 +2263,19 @@ 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, None));
self.draw_pages.push(s.page);
});
}
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_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 s = self.sections.get(v.slot);
if s.translucent > 0 {
let first = s.start + u64::from(s.solid) * 4;
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.draws
.push([s.translucent * 6, 1, 0, first as u32, i as u32]);
self.draw_pages.push(s.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 {
@@ -2747,7 +2537,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 && !frame.outline_after_terrain {
if outline_edges > 0 {
pass.set_pipeline(&self.lines);
pass.set_vertex_buffer(0, self.outline.slice(..));
pass.draw(0..4, 0..outline_edges as u32);
@@ -2766,14 +2556,6 @@ 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);
@@ -2994,7 +2776,6 @@ impl Renderer {
&mut self.uploads,
&mut encoder,
&view_target,
&self.depth,
frame.hud,
[self.config.width, self.config.height],
);
@@ -3507,39 +3288,13 @@ 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 = round_unorm8((a * (1.0 - t) + b * t) * 255.0);
*c = ((a * (1.0 - t) + b * t) * 255.0).round() as u8;
}
}
/// `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");
+40 -291
View File
@@ -49,79 +49,35 @@ 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<Option<Page>>,
pages: Vec<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::new(),
pages: vec![Page {
buffer: Self::create(device, stride, capacity),
capacity,
free: FreeList::new(capacity),
}],
stride,
page_limit,
initial_capacity: capacity,
usage,
spilled: false,
#[cfg(test)]
video_memory_full: 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)
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,
})
}
/// The rounded length a request of `vertices` occupies.
@@ -130,97 +86,55 @@ impl Arena {
}
/// Bytes of every page's buffer.
pub fn bytes(&self) -> u64 {
self.pages.iter().flatten().map(|p| p.capacity).sum::<u64>() * self.stride
self.pages.iter().map(|p| p.capacity).sum::<u64>() * self.stride
}
pub fn buffer(&self, page: u32) -> &wgpu::Buffer {
&self.pages[page as usize]
.as_ref()
.expect("live arena page")
.buffer
&self.pages[page as usize].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; `None` when no memory is left for it.
/// free is large enough.
pub fn alloc(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
vertices: u64,
) -> Option<(u32, u64)> {
) -> (u32, u64) {
let len = Self::span(vertices).min(self.page_limit);
for (i, page) in self.pages.iter_mut().enumerate() {
if let Some(page) = page
&& let Some(start) = page.free.take(len)
{
return Some((i as u32, start));
if let Some(start) = page.free.take(len) {
return (i as u32, start);
}
}
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 last = self.pages.len() - 1;
let current = self.pages[last].capacity;
if current < self.page_limit {
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)
&& let Some(start) = self.pages[last].as_mut().unwrap().free.take(len)
{
return Some((last as u32, start));
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);
}
}
}
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 capacity = (1 << 20u64).max(len).min(self.page_limit);
let mut free = FreeList::new(capacity);
let start = free.take(len).expect("a new page fits one section");
let page = Page {
buffer,
self.pages.push(Page {
buffer: Self::create(device, self.stride, capacity),
capacity,
free,
};
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))
});
((self.pages.len() - 1) 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]
.as_mut()
.expect("live arena page")
.free
.give(start, len);
self.pages[page as usize].free.give(start, len);
}
/// 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 {
fn grow(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, page: usize, capacity: u64) {
let capacity = capacity.next_multiple_of(GRAIN).min(self.page_limit);
let Some(buffer) = self.create(device, capacity) else {
return false;
};
let old = self.pages[page].as_mut().expect("live arena page");
let buffer = Self::create(device, self.stride, capacity);
let old = &mut self.pages[page];
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("Grow section arena"),
});
@@ -231,124 +145,18 @@ impl Arena {
old.buffer = buffer;
old.capacity = capacity;
old.free.give(previous, capacity - previous);
true
}
/// Reclaim after an upload batch, so replacing a section can reuse its range.
pub fn trim(&mut self) {
for page in &mut self.pages {
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();
for page in &mut self.pages {
page.free = FreeList::new(page.capacity);
}
}
}
/// `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);
@@ -373,63 +181,4 @@ 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)));
}
}
+28 -33
View File
@@ -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, streaming::in_view_distance};
use crate::fast_hash::FxHashMap;
use glam::{DVec3, Vec3};
/// One chunk column's resident sections.
@@ -58,9 +58,6 @@ 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();
@@ -152,28 +149,26 @@ fn origin(key: SectionKey, camera: DVec3) -> DVec3 {
) - camera
}
/// 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) {
/// 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 {
return None;
}
let origin = origin(key, camera);
let min = origin.as_vec3();
if !frustum.intersects(min, min + Vec3::splat(16.0)) {
return None;
}
Some(((origin + DVec3::splat(8.0)).length_squared(), min))
Some((center.length_squared(), min))
}
impl<T> Resident<T> {
@@ -183,22 +178,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. `radius` is the
/// render distance in chunks.
/// the frustum rejects holds no section it would accept.
pub fn visible(
&self,
camera: DVec3,
radius: i32,
distance: f32,
frustum: &Frustum,
reachable: impl Fn(SectionKey) -> bool,
out: &mut Vec<Visible>,
) {
let from = camera_column(camera);
for column in &self.columns {
if !in_view_distance(from, radius, column.x, column.z) {
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 {
continue;
}
let min = origin((column.x, column.low, column.z), camera).as_vec3();
let min = low.as_vec3();
let top = origin((column.x, column.high, column.z), camera).as_vec3();
if !frustum.intersects(min, top + Vec3::splat(16.0)) {
continue;
@@ -208,7 +203,7 @@ impl<T> Resident<T> {
if !reachable(key) {
continue;
}
if let Some((d, o)) = in_view(key, camera, radius, frustum) {
if let Some((d, o)) = in_view(key, camera, distance, frustum) {
out.push(Visible {
distance: d.to_bits(),
key,
@@ -248,20 +243,20 @@ mod tests {
(DVec3::new(8.0, 64.0, 8.0), 45.0, 89.0),
];
for (camera, yaw, pitch) in cameras {
for radius in [2, 6, 10] {
for distance in [32.0, 96.0, 160.0] {
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, radius, &frustum, reachable, &mut fast);
r.visible(camera, distance, &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, radius, &frustum).map(|(d, o)| (*k, d, o.to_array()))
in_view(*k, camera, distance, &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)));
@@ -273,7 +268,7 @@ mod tests {
})
.collect();
assert!(!slow.is_empty());
assert_eq!(fast, slow, "{camera} {yaw} {pitch} {radius}");
assert_eq!(fast, slow, "{camera} {yaw} {pitch} {distance}");
}
}
}
@@ -361,7 +356,7 @@ mod tests {
let t = Instant::now();
keys.clear();
keys.extend(map.keys().filter_map(|&k| {
in_view(k, camera, 32, &frustum).map(|(d, o)| (k, d, o.to_array()))
in_view(k, camera, 512.0, &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 {
@@ -370,7 +365,7 @@ mod tests {
old = old.min(t.elapsed());
let t = Instant::now();
list.clear();
r.visible(camera, 32, &frustum, |_| true, &mut list);
r.visible(camera, 512.0, &frustum, |_| true, &mut list);
for v in &list {
sum += i64::from(r.get(v.slot)[1] + r.get(v.slot)[0]);
}
@@ -1,119 +0,0 @@
//! 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(&centers, [0.5, 0.5, -2.0])[0], 4);
assert_eq!(sorter.sort(&centers, [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()
);
}
}
+61 -254
View File
@@ -139,74 +139,19 @@ 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();
for folder in folders {
walk(&textures.join(folder), &mut files);
}
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);
files.retain(|p| {
let s = p.to_string_lossy();
!s.contains("title/background") && !s.contains("realms") && !s.contains("presets")
@@ -242,9 +187,7 @@ impl HudAtlas {
.flat_map(|h| h.join().unwrap_or_default())
.collect()
});
if full {
images.extend(atlas_sprites(root, &images));
}
images.extend(atlas_sprites(root, &images));
// The title screen's "JAVA EDITION" banner reads "RUST EDITION".
if let Some(edition) = images
.iter_mut()
@@ -255,9 +198,36 @@ impl HudAtlas {
edition.3 = pixels;
}
images.push(("horizon/white".into(), 4, 4, vec![255; 64]));
if full {
Self::slots(&mut images);
// 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]));
}
// 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());
@@ -349,7 +319,6 @@ 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);
@@ -361,10 +330,10 @@ pub struct HudBatch {
pub height: f32,
/// Glyph pixels to upload into the atlas before drawing.
pub patches: Vec<crate::font::Patch>,
/// Vertex ranges with another blend or depth/scissor state, in order.
/// Vertex ranges drawn with another blend than alpha blending, in order.
pub blends: Vec<(std::ops::Range<u32>, Blend)>,
}
/// How a run of HUD vertices blends and tests depth.
/// How a run of HUD vertices blends with what is under it.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Blend {
/// `BlendFunction.TRANSLUCENT`.
@@ -373,8 +342,6 @@ 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)
@@ -463,44 +430,9 @@ 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]],
@@ -637,7 +569,6 @@ impl HudBatch {
pos: p[i],
uv,
color: c[i],
depth: 0.0,
});
}
}
@@ -798,7 +729,6 @@ 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,
@@ -878,8 +808,8 @@ impl HudRenderer {
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
let attributes = wgpu::vertex_attr_array![0=>Float32x2,1=>Float32x2,2=>Unorm8x4,3=>Float32];
let create = |label, blend, fragment_entry, depth: bool| {
let attributes = wgpu::vertex_attr_array![0=>Float32x2,1=>Float32x2,2=>Unorm8x4];
let create = |label, blend, fragment_entry| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(&pipeline_layout),
@@ -894,13 +824,7 @@ impl HudRenderer {
})],
},
primitive: Default::default(),
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(),
}),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
@@ -916,13 +840,7 @@ impl HudRenderer {
cache: None,
})
};
let pipeline = create("HUD", wgpu::BlendState::ALPHA_BLENDING, "fs_main", false);
let depth_pipeline = create(
"HUD model",
wgpu::BlendState::ALPHA_BLENDING,
"fs_main",
true,
);
let pipeline = create("HUD", wgpu::BlendState::ALPHA_BLENDING, "fs_main");
// BlendFunction.INVERT: ONE_MINUS_DST_COLOR, ONE_MINUS_SRC_COLOR,
// ONE, ZERO.
let invert_pipeline = create(
@@ -940,7 +858,6 @@ impl HudRenderer {
},
},
"fs_crosshair",
false,
);
// BlendFunction.ADDITIVE: ONE, ONE.
let add = wgpu::BlendComponent {
@@ -955,7 +872,6 @@ impl HudRenderer {
alpha: add,
},
"fs_main",
false,
);
let capacity = 1 << 16;
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
@@ -970,7 +886,6 @@ impl HudRenderer {
pipeline,
invert_pipeline,
additive_pipeline,
depth_pipeline,
group,
uniform,
buffer,
@@ -1020,7 +935,6 @@ impl HudRenderer {
uploads: &mut crate::gfx::upload::Uploads,
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::TextureView,
depth: &wgpu::TextureView,
batch: &HudBatch,
size: [u32; 2],
) {
@@ -1051,7 +965,7 @@ impl HudRenderer {
);
}
}
if batch.vertices.is_empty() || size.contains(&0) {
if batch.vertices.is_empty() {
return;
}
let count = batch.vertices.len() as u64;
@@ -1087,55 +1001,20 @@ impl HudRenderer {
store: wgpu::StoreOp::Store,
},
})];
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()
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,
});
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;
pass.draw(range, 0..1);
}
}
}
@@ -1144,43 +1023,6 @@ 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};
@@ -1250,41 +1092,6 @@ 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();
+2 -2
View File
@@ -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>, @location(3) depth: f32) -> Out {
@vertex fn vs_main(@location(0) pos: vec2<f32>, @location(1) uv: vec2<f32>, @location(2) color: vec4<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, depth, 1.0);
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.uv = uv;
out.color = color;
return out;
+18 -203
View File
@@ -1,9 +1,8 @@
//! 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},
};
//! 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};
/// `EquipmentSlotGroup` in declaration order, the order tooltips list them in.
const SLOT_GROUPS: [&str; 11] = [
@@ -26,7 +25,6 @@ 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 {
@@ -34,9 +32,7 @@ fn modifier_line(
0 => 1.0,
_ => 100.0,
};
let (kind, shown) = if total {
("equals", amount * scale)
} else if amount > 0.0 {
let (kind, shown) = if amount > 0.0 {
("plus", amount * scale)
} else if amount < 0.0 {
("take", -amount * scale)
@@ -51,27 +47,22 @@ fn modifier_line(
)
}
/// `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)> {
/// `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> {
let mut lines = Vec::new();
for group in SLOT_GROUPS {
let mut first = true;
for (attribute, modifier, amount, operation, slot) in modifiers {
for (attribute, _, amount, operation, slot) in &info.modifiers {
if slot != group {
continue;
}
if std::mem::take(&mut first) {
lines.push((String::new(), u32::MAX));
lines.push((
lines.push(String::new());
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,
@@ -79,27 +70,7 @@ pub fn attribute_lines(
_ => 0,
};
let path = attribute.strip_prefix("minecraft:").unwrap_or(attribute);
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.extend(modifier_line(translate, path, *amount, id));
}
}
lines
@@ -131,10 +102,6 @@ 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") {
@@ -153,24 +120,6 @@ 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(
@@ -262,7 +211,6 @@ pub fn detail_lines(
attribute,
amount * (*amplifier + 1) as f64,
operation,
false,
)
})
.collect();
@@ -294,11 +242,7 @@ pub fn detail_lines(
lines.push(text("item.dyed"));
}
if let Some(info) = info {
lines.extend(
attribute_lines(&info.modifiers, None, translate)
.into_iter()
.map(|(line, _)| line),
);
lines.extend(attribute_lines(info, translate));
}
lines
}
@@ -348,26 +292,6 @@ 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);
@@ -411,115 +335,6 @@ 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| {
@@ -534,13 +349,13 @@ mod tests {
)
};
assert_eq!(
modifier_line(&lang, "movement_speed", 0.2, 2, false).as_deref(),
modifier_line(&lang, "movement_speed", 0.2, 2).as_deref(),
Some("+20% Speed")
);
assert_eq!(
modifier_line(&lang, "movement_speed", -0.6, 2, false).as_deref(),
modifier_line(&lang, "movement_speed", -0.6, 2).as_deref(),
Some("-60% Speed")
);
assert_eq!(modifier_line(&lang, "movement_speed", 0.0, 2, false), None);
assert_eq!(modifier_line(&lang, "movement_speed", 0.0, 2), None);
}
}
+6 -14
View File
@@ -274,20 +274,16 @@ 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,
namespace: &str,
raw: &mut HashMap<String, Vec<String>>,
) -> Result<()> {
fn walk(dir: &Path, base: &Path, 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, namespace, raw)?;
walk(&p, base, raw)?;
} else if p.extension().is_some_and(|e| e == "json") {
let name = format!(
"{namespace}:{}",
"minecraft:{}",
p.strip_prefix(base)?
.with_extension("")
.to_string_lossy()
@@ -305,12 +301,8 @@ pub(crate) fn load_tag_lists(root: &Path, kind: &str) -> Result<HashMap<String,
}
Ok(())
}
// 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)?;
}
if dir.exists() {
walk(&dir, &dir, &mut raw)?;
}
fn resolve(name: &str, raw: &HashMap<String, Vec<String>>, out: &mut Vec<String>, depth: u32) {
if depth > 32 {
-49
View File
@@ -373,60 +373,11 @@ 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();
+66 -212
View File
@@ -4,14 +4,11 @@
//! ClientboundRecipeBookAddPacket) and the advancements the player may see go
//! out in ClientboundUpdateAdvancementsPacket (PlayerAdvancements.flushDirty).
use super::data::{SyncData, json_to_nbt};
use crate::{
advancements::{Progress, Tree},
recipe_book::{RecipeIndex, Settings},
};
use crate::{advancements::Tree, recipe_book::RecipeIndex};
use horizon_protocol::buf::Writer;
use serde_json::Value;
use std::{
collections::{BTreeMap, BTreeSet, HashMap},
collections::{BTreeMap, HashMap},
path::Path,
};
@@ -57,16 +54,8 @@ impl ServerAdvancements {
}
}
/// 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 {
/// The first tick of a player without advancements.
pub fn first_tick(&self, sync: &SyncData, now_millis: i64) -> 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() {
@@ -88,24 +77,25 @@ 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, NOTIFY_AND_HIGHLIGHT));
entries.extend(self.book_entries(id, sync));
}
}
let recipe_book_add = (!entries.is_empty()).then(|| book_add(entries, false));
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
});
// 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();
@@ -113,14 +103,60 @@ impl ServerAdvancements {
w.bool(true); // the first packet resets the client's advancements
w.varint(shown.len() as i32);
for &i in &shown {
self.write_advancement(&mut w, i, sync);
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);
}
w.varint(0); // nothing removed
w.varint(shown.len() as i32);
for &i in &shown {
self.write_progress(&mut w, i, |c| {
obtained[i].contains(&c).then_some(now_millis)
});
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);
}
}
}
w.bool(true); // show the advancements
FirstTick {
@@ -129,176 +165,9 @@ 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, flags: i8) -> Vec<Vec<u8>> {
fn book_entries(&self, id: &str, sync: &SyncData) -> Vec<Vec<u8>> {
let Some(r) = self.recipes.get(id) else {
return Vec::new();
};
@@ -376,27 +245,12 @@ 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(if notify { flags } else { flags & !1 });
w.i8(i8::from(notify) | 2);
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
+64 -376
View File
@@ -90,78 +90,7 @@ 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.
@@ -214,106 +143,8 @@ 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))
@@ -339,17 +170,11 @@ impl ChunkEncoder {
}
f & ENTITY != 0
};
// 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 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| {
@@ -364,14 +189,7 @@ impl ChunkEncoder {
horizon_protocol::chunk::Section {
non_empty,
fluids,
states: match uniform {
Some(v) => Paletted {
bits: 0,
palette: vec![v as i32],
data: Vec::new(),
},
None => Paletted::from_values(&values, Strategy::Blocks, 16),
},
states: Paletted::from_values(&values, Strategy::Blocks, 16),
biomes: Paletted::from_values(&biome_values, Strategy::Biomes, self.biome_bits),
},
entities,
@@ -382,8 +200,13 @@ 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[self.sections.clone()].iter().enumerate() {
if self.cached(s, |e| e.non_air) {
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 {
bits |= 1 << i;
}
}
@@ -401,8 +224,8 @@ impl ChunkEncoder {
}
}
}
let mut packet = self.encode_blocks(chunk, &world.block_entities);
packet.light = self.light_data(chunk, around);
let mut packet = self.encode(chunk, &world.block_entities);
packet.light = light_data(chunk, around);
packet
}
@@ -411,27 +234,10 @@ impl ChunkEncoder {
chunk: &Chunk,
block_entities: &HashMap<(i32, i32, i32), BlockEntity>,
) -> LevelChunkWithLight {
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 sections = Vec::with_capacity(SECTIONS);
let mut entity_infos = Vec::new();
for (i, s) in chunk
.sections
.iter()
.enumerate()
.skip(self.sections.start)
.take(self.sections.len())
{
let (encoded, has_entities) = self.section_wire(s);
for (i, s) in chunk.sections.iter().enumerate() {
let (encoded, has_entities) = self.section(s);
if has_entities {
for y in 0..16 {
for z in 0..16 {
@@ -455,7 +261,7 @@ impl ChunkEncoder {
}
}
}
buffer.extend_from_slice(&encoded);
sections.push(encoded);
}
// A loaded chunk's block entities enter its maps in the save's order
// (ChunkAccess.pendingBlockEntities, then LevelChunk.blockEntities):
@@ -487,46 +293,14 @@ impl ChunkEncoder {
heightmaps: self
.tables
.as_ref()
.map(|t| self.heightmaps(chunk, t))
.map(|t| crate::save::client_heightmaps(chunk, t))
.unwrap_or_default(),
buffer,
buffer: ChunkData::from_sections(&sections),
block_entities: entity_infos,
},
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(),
},
light: light_data(chunk, u64::MAX),
}
}
/// 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
@@ -599,18 +373,7 @@ 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.
#[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();
fn light_data(chunk: &Chunk, around: u64) -> LightData {
let mut data = LightData {
sky_mask: Vec::new(),
block_mask: Vec::new(),
@@ -620,27 +383,55 @@ fn light_data_with(
block: Vec::new(),
};
// Light section b holds chunk section b - 1.
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 {
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 {
if !exists(b) {
continue;
}
let layers = match b {
let light = match b {
// Below the world nothing is lit; above it the sky is full.
0 => Arc::new(Layers::of(&Light::Uniform(0))),
_ if b == n + 1 => Arc::new(Layers::of(&Light::Uniform(0xF0))),
_ => section(&sections[b - 1]),
0 => &Light::Uniform(0),
_ if b == SECTIONS + 1 => &full_sky,
_ => &chunk.sections[b - 1].light,
};
if layers.sky_any {
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 {
set_bit(&mut data.sky_mask, b);
data.sky.push(layers.sky.clone());
data.sky.push(sky);
} else {
set_bit(&mut data.empty_sky_mask, b);
}
if layers.block_any {
if block_any {
set_bit(&mut data.block_mask, b);
data.block.push(layers.block.clone());
data.block.push(block);
} else {
set_bit(&mut data.empty_block_mask, b);
}
@@ -669,119 +460,16 @@ 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.sections, 0).sky.is_empty());
let data = light_data(&chunk.sections, u64::MAX);
assert!(light_data(&chunk, 0).sky.is_empty());
let data = light_data(&chunk, 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
));
}
}
+19 -279
View File
@@ -1,5 +1,4 @@
//! One accepted connection: handshake, status ping, login (offline, or online
//! with encryption and the session service's verdict) and the
//! One accepted connection: handshake, status ping, offline-mode login and the
//! configuration phase (ServerStatusPacketListenerImpl,
//! ServerLoginPacketListenerImpl, ServerConfigurationPacketListenerImpl). A
//! player that completes configuration continues in the play state: the
@@ -13,7 +12,7 @@ use super::{
use horizon_protocol::{
PROTOCOL_VERSION,
connection::{ConnReader, ConnWriter, Connection},
crypto::{offline_uuid, server_hash, uuid_string},
crypto::offline_uuid,
packets::{
Empty,
configuration::{self as cfg, ClientInformation, KnownPack},
@@ -22,11 +21,10 @@ use horizon_protocol::{
text::translatable,
};
use std::{
cell::Cell,
net::TcpStream,
sync::{
Arc, Mutex, PoisonError,
atomic::{AtomicI32, AtomicI64, AtomicU32, AtomicUsize, Ordering},
Arc,
atomic::{AtomicI32, AtomicI64, AtomicUsize, Ordering},
mpsc::{Receiver, RecvTimeoutError, Sender, channel},
},
time::{Duration, Instant},
@@ -124,64 +122,16 @@ 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);
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));
let _ = self.tx.send(Out::Packet(body));
}
}
@@ -355,145 +305,6 @@ 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,
@@ -512,36 +323,14 @@ fn serve_login(
);
return Ok(());
}
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.
// Offline mode: the profile is derived from the name (PlayerList.canPlayerLogin
// then rejects duplicates and a full server).
let uuid = offline_uuid(&hello.name);
if let Some(reason) = shared.try_admit(address, uuid, &hello.name) {
let text = refuse(&mut c, shared, "Server thread", &user, &reason);
lost(shared, &user, &text);
login_disconnect(&mut c, &reason);
return Ok(());
}
let result = login_and_configure(&mut c, shared, uuid, &hello.name, properties);
let result = login_and_configure(&mut c, shared, uuid, &hello.name);
match result {
Ok(Some(parts)) => {
play(c, shared, parts, address);
@@ -553,9 +342,6 @@ fn serve_login(
}
Err(err) => {
shared.release(uuid);
// ServerConfigurationPacketListenerImpl.onDisconnect.
let profile = format!("{} ({})", hello.name, uuid_string(uuid));
lost(shared, &profile, "Disconnected");
Err(err)
}
}
@@ -564,7 +350,6 @@ fn serve_login(
struct Parts {
uuid: u128,
name: String,
properties: Vec<login::Property>,
info: ClientInformation,
brand: String,
}
@@ -574,12 +359,10 @@ 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 && !c.is_memory() {
if threshold >= 0 {
c.send(&login::Clientbound::Compression(login::Compression { threshold }).encode())
.map_err(e)?;
c.set_compression(threshold);
@@ -589,7 +372,7 @@ fn login_and_configure(
profile: login::GameProfile {
uuid,
name: name.to_owned(),
properties: properties.clone(),
properties: Vec::new(),
},
session: uuid,
})
@@ -668,7 +451,6 @@ fn login_and_configure(
Ok(Some(Parts {
uuid,
name: name.to_owned(),
properties,
info,
brand,
}))
@@ -683,7 +465,10 @@ 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::new(tx.clone(), pending.clone());
let sender = PlayerSender {
tx: tx.clone(),
pending: pending.clone(),
};
let writer_thread = {
let (alive, latency, pending) = (alive.clone(), latency.clone(), pending.clone());
std::thread::Builder::new()
@@ -695,7 +480,6 @@ 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,
@@ -759,18 +543,13 @@ 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)) => {
if !write(body) {
pending.fetch_sub(body.len(), Ordering::Relaxed);
if writer.send(&body).is_err() {
break;
}
}
Ok(Out::Packets(bodies)) => {
if !bodies.into_iter().all(&mut write) {
if pending.load(Ordering::Relaxed) > MAX_PENDING {
break;
}
}
@@ -814,42 +593,3 @@ 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