Merge origin/main into revert-ram-limit (#6652)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-11-04 17:37:25 +01:00
commit d571d80a06
298 files changed
+19275 -8702

No files matched your search

+2
View File
@@ -16,6 +16,8 @@ are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
The GLSL frontend also resets the common type for each comma-separated declarator so one
variable's array specifier does not change later variables.
It also reads a struct member outside a constant expression through a pointer to the member,
not out of a loaded copy of the whole struct, so a member an atomic function uses can be read.
## libmimalloc-sys and mimalloc
+870
View File
@@ -0,0 +1,870 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-04T13:00:15Z",
"app": {
"name": "horizon-client",
"version": "nightly-2026-11-04-67dad0d",
"commit": "67dad0d"
},
"hardware": {
"os": "linux",
"arch": "x86_64",
"cpu_threads": 24,
"os_version": "NixOS 26.05 (Yarara) (kernel 6.18.57)",
"cpu": "13th Gen Intel(R) Core(TM) i7-13700KF",
"ram_mb": 31904,
"gpu": {
"name": "NVIDIA GeForce RTX 4080",
"vendor": "nvidia",
"driver": "NVIDIA 595.71.05",
"backend": "vulkan",
"kind": "discrete"
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 12,
"simulation_distance": 12,
"vsync": false,
"offscreen": false,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 28.408,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 66658.183,
"stdev": 61839.502,
"min": 1827.42,
"max": 124993.694,
"ci95": 153630.237
},
"startup.world_ms": {
"n": 3,
"mean": 74754.411,
"stdev": 59189.587,
"min": 13600.651,
"max": 131761.773,
"ci95": 147046.952
},
"chunks.village.load_ms": {
"n": 3,
"mean": 5571.499,
"stdev": 1900.32,
"min": 3378.38,
"max": 6730.394,
"ci95": 4721.037
},
"chunks.village.meshed_count": {
"n": 3,
"mean": 15287.0,
"stdev": 527.501,
"min": 14788.0,
"max": 15839.0,
"ci95": 1310.49
},
"fps.village.avg_fps": {
"n": 3,
"mean": 2958.557,
"stdev": 376.543,
"min": 2526.453,
"max": 3216.434,
"ci95": 935.46
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 368.457,
"stdev": 90.668,
"min": 300.631,
"max": 471.438,
"ci95": 225.25
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 91.04,
"stdev": 40.506,
"min": 46.124,
"max": 124.796,
"ci95": 100.63
},
"memory.village.gpu_mb": {
"n": 3,
"mean": 581.0,
"stdev": 48.0,
"min": 533.0,
"max": 629.0,
"ci95": 119.248
},
"memory.village.rss_mb": {
"n": 3,
"mean": 1277.531,
"stdev": 25.546,
"min": 1253.879,
"max": 1304.623,
"ci95": 63.466
},
"tick.village.client_ms": {
"n": 3,
"mean": 0.529,
"stdev": 0.127,
"min": 0.396,
"max": 0.649,
"ci95": 0.315
},
"cpu.village.p50_ms": {
"n": 3,
"mean": 0.185,
"stdev": 0.034,
"min": 0.162,
"max": 0.224,
"ci95": 0.085
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 3318.3,
"stdev": 911.608,
"min": 2266.627,
"max": 3883.075,
"ci95": 2264.743
},
"chunks.forest.meshed_count": {
"n": 3,
"mean": 16565.333,
"stdev": 172.755,
"min": 16367.0,
"max": 16683.0,
"ci95": 429.182
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 2333.626,
"stdev": 569.124,
"min": 1745.365,
"max": 2881.457,
"ci95": 1413.897
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 336.615,
"stdev": 185.666,
"min": 208.133,
"max": 549.487,
"ci95": 461.257
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 160.734,
"stdev": 127.586,
"min": 39.777,
"max": 294.05,
"ci95": 316.967
},
"memory.forest.gpu_mb": {
"n": 3,
"mean": 666.333,
"stdev": 24.44,
"min": 645.0,
"max": 693.0,
"ci95": 60.718
},
"memory.forest.rss_mb": {
"n": 3,
"mean": 1346.816,
"stdev": 42.279,
"min": 1305.547,
"max": 1390.037,
"ci95": 105.035
},
"tick.forest.client_ms": {
"n": 3,
"mean": 0.669,
"stdev": 0.084,
"min": 0.598,
"max": 0.761,
"ci95": 0.208
},
"cpu.forest.p50_ms": {
"n": 3,
"mean": 0.222,
"stdev": 0.032,
"min": 0.197,
"max": 0.258,
"ci95": 0.079
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 2463.434,
"stdev": 1056.0,
"min": 1527.516,
"max": 3608.283,
"ci95": 2623.462
},
"chunks.ocean.meshed_count": {
"n": 3,
"mean": 14226.667,
"stdev": 307.077,
"min": 14038.0,
"max": 14581.0,
"ci95": 762.883
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 2192.874,
"stdev": 1320.904,
"min": 673.829,
"max": 3071.415,
"ci95": 3281.573
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 345.406,
"stdev": 294.904,
"min": 8.977,
"max": 559.232,
"ci95": 732.642
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 172.573,
"stdev": 149.386,
"min": 1.038,
"max": 274.089,
"ci95": 371.125
},
"memory.ocean.gpu_mb": {
"n": 3,
"mean": 629.0,
"stdev": 32.0,
"min": 597.0,
"max": 661.0,
"ci95": 79.499
},
"memory.ocean.rss_mb": {
"n": 3,
"mean": 1352.223,
"stdev": 51.951,
"min": 1301.492,
"max": 1405.314,
"ci95": 129.065
},
"tick.ocean.client_ms": {
"n": 3,
"mean": 0.512,
"stdev": 0.099,
"min": 0.406,
"max": 0.603,
"ci95": 0.246
},
"cpu.ocean.p50_ms": {
"n": 3,
"mean": 0.178,
"stdev": 0.038,
"min": 0.135,
"max": 0.206,
"ci95": 0.094
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 6260.675,
"stdev": 3077.348,
"min": 2976.342,
"max": 9077.55,
"ci95": 7645.173
},
"chunks.mountain.meshed_count": {
"n": 3,
"mean": 16015.333,
"stdev": 209.872,
"min": 15826.0,
"max": 16241.0,
"ci95": 521.393
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 1412.095,
"stdev": 962.145,
"min": 714.773,
"max": 2509.775,
"ci95": 2390.293
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 182.479,
"stdev": 177.563,
"min": 13.364,
"max": 367.429,
"ci95": 441.126
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 88.451,
"stdev": 87.578,
"min": 1.526,
"max": 176.667,
"ci95": 217.572
},
"memory.mountain.gpu_mb": {
"n": 3,
"mean": 799.667,
"stdev": 18.475,
"min": 789.0,
"max": 821.0,
"ci95": 45.899
},
"memory.mountain.rss_mb": {
"n": 3,
"mean": 1491.572,
"stdev": 36.719,
"min": 1459.775,
"max": 1531.76,
"ci95": 91.221
},
"tick.mountain.client_ms": {
"n": 3,
"mean": 0.697,
"stdev": 0.155,
"min": 0.602,
"max": 0.876,
"ci95": 0.386
},
"cpu.mountain.p50_ms": {
"n": 3,
"mean": 0.325,
"stdev": 0.115,
"min": 0.239,
"max": 0.456,
"ci95": 0.286
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 1545.896,
"stdev": 30.591,
"min": 1524.965,
"max": 1581.004,
"ci95": 75.999
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 1348.389,
"stdev": 52.5,
"min": 1296.172,
"max": 1401.168,
"ci95": 130.428
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 37594,
"seconds": 12.0,
"avg_fps": 3132.783,
"low_1pct_fps": 471.438,
"low_01pct_fps": 124.796,
"p50_ms": 0.271,
"p95_ms": 0.494,
"p99_ms": 1.127,
"p999_ms": 2.15,
"max_ms": 155.204,
"cpu_p50_ms": 0.168,
"gpu_p50_ms": 0.104,
"histogram": [
37151,
394,
30,
8,
5,
2,
2,
0,
0,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
1,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 38598,
"seconds": 12.0,
"avg_fps": 3216.434,
"low_1pct_fps": 300.631,
"low_01pct_fps": 46.124,
"p50_ms": 0.267,
"p95_ms": 0.446,
"p99_ms": 1.034,
"p999_ms": 1.941,
"max_ms": 493.993,
"cpu_p50_ms": 0.162,
"gpu_p50_ms": 0.104,
"histogram": [
38177,
387,
16,
8,
4,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
0,
1,
1,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 30318,
"seconds": 12.0,
"avg_fps": 2526.453,
"low_1pct_fps": 333.302,
"low_01pct_fps": 102.199,
"p50_ms": 0.305,
"p95_ms": 0.757,
"p99_ms": 1.69,
"p999_ms": 3.539,
"max_ms": 170.856,
"cpu_p50_ms": 0.224,
"gpu_p50_ms": 0.106,
"histogram": [
29368,
759,
139,
35,
11,
3,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 34578,
"seconds": 12.0,
"avg_fps": 2881.457,
"low_1pct_fps": 549.487,
"low_01pct_fps": 294.05,
"p50_ms": 0.32,
"p95_ms": 0.483,
"p99_ms": 1.213,
"p999_ms": 2.5,
"max_ms": 5.358,
"cpu_p50_ms": 0.197,
"gpu_p50_ms": 0.154,
"histogram": [
34119,
379,
64,
9,
5,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 28489,
"seconds": 12.0,
"avg_fps": 2374.055,
"low_1pct_fps": 208.133,
"low_01pct_fps": 39.777,
"p50_ms": 0.313,
"p95_ms": 0.937,
"p99_ms": 1.863,
"p999_ms": 4.318,
"max_ms": 571.48,
"cpu_p50_ms": 0.211,
"gpu_p50_ms": 0.151,
"histogram": [
27193,
1071,
140,
51,
19,
9,
1,
2,
1,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 20945,
"seconds": 12.0,
"avg_fps": 1745.365,
"low_1pct_fps": 252.226,
"low_01pct_fps": 148.375,
"p50_ms": 0.343,
"p95_ms": 1.505,
"p99_ms": 2.758,
"p999_ms": 5.341,
"max_ms": 11.642,
"cpu_p50_ms": 0.258,
"gpu_p50_ms": 0.162,
"histogram": [
18145,
2293,
337,
106,
38,
13,
6,
5,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 36856,
"seconds": 12.0,
"avg_fps": 3071.415,
"low_1pct_fps": 559.232,
"low_01pct_fps": 274.089,
"p50_ms": 0.286,
"p95_ms": 0.492,
"p99_ms": 1.179,
"p999_ms": 2.711,
"max_ms": 6.718,
"cpu_p50_ms": 0.135,
"gpu_p50_ms": 0.127,
"histogram": [
36366,
405,
60,
13,
10,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 34001,
"seconds": 12.0,
"avg_fps": 2833.377,
"low_1pct_fps": 468.008,
"low_01pct_fps": 242.591,
"p50_ms": 0.295,
"p95_ms": 0.589,
"p99_ms": 1.41,
"p999_ms": 3.048,
"max_ms": 7.7,
"cpu_p50_ms": 0.206,
"gpu_p50_ms": 0.126,
"histogram": [
33314,
544,
106,
23,
10,
2,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 8086,
"seconds": 12.0,
"avg_fps": 673.829,
"low_1pct_fps": 8.977,
"low_01pct_fps": 1.038,
"p50_ms": 0.302,
"p95_ms": 0.665,
"p99_ms": 2.495,
"p999_ms": 15.716,
"max_ms": 4849.364,
"cpu_p50_ms": 0.194,
"gpu_p50_ms": 0.128,
"histogram": [
7808,
166,
50,
26,
8,
2,
3,
5,
2,
5,
1,
2,
2,
0,
0,
0,
0,
0,
0,
1,
1,
1,
1,
2
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 12141,
"seconds": 12.0,
"avg_fps": 1011.738,
"low_1pct_fps": 166.643,
"low_01pct_fps": 87.16,
"p50_ms": 0.641,
"p95_ms": 2.642,
"p99_ms": 4.468,
"p999_ms": 7.821,
"max_ms": 22.536,
"cpu_p50_ms": 0.456,
"gpu_p50_ms": 0.138,
"histogram": [
7840,
2996,
866,
278,
87,
38,
16,
11,
2,
5,
0,
0,
0,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 30118,
"seconds": 12.0,
"avg_fps": 2509.775,
"low_1pct_fps": 367.429,
"low_01pct_fps": 176.667,
"p50_ms": 0.316,
"p95_ms": 0.771,
"p99_ms": 1.931,
"p999_ms": 4.2,
"max_ms": 14.633,
"cpu_p50_ms": 0.239,
"gpu_p50_ms": 0.127,
"histogram": [
29101,
775,
173,
38,
16,
8,
4,
1,
0,
1,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 8577,
"seconds": 12.0,
"avg_fps": 714.773,
"low_1pct_fps": 13.364,
"low_01pct_fps": 1.526,
"p50_ms": 0.416,
"p95_ms": 2.257,
"p99_ms": 4.36,
"p999_ms": 212.217,
"max_ms": 1051.428,
"cpu_p50_ms": 0.28,
"gpu_p50_ms": 0.132,
"histogram": [
7017,
978,
377,
111,
39,
24,
10,
6,
1,
0,
2,
2,
0,
0,
0,
0,
0,
0,
1,
0,
1,
2,
4,
2
]
}
],
"label": "horizon-desktop"
}
+870
View File
@@ -0,0 +1,870 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-04T14:47:19Z",
"app": {
"name": "horizon-client",
"version": "nightly-2026-11-04-67dad0d",
"commit": "67dad0d"
},
"hardware": {
"os": "linux",
"arch": "x86_64",
"cpu_threads": 24,
"os_version": "NixOS 26.05 (Yarara) (kernel 6.18.57)",
"cpu": "13th Gen Intel(R) Core(TM) i7-13700KF",
"ram_mb": 31904,
"gpu": {
"name": "NVIDIA GeForce RTX 4080",
"vendor": "nvidia",
"driver": "NVIDIA 595.71.05",
"backend": "vulkan",
"kind": "discrete"
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 32,
"simulation_distance": 12,
"vsync": false,
"offscreen": false,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 29.665,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 45386.16,
"stdev": 38970.921,
"min": 1077.276,
"max": 74343.156,
"ci95": 96816.948
},
"startup.world_ms": {
"n": 3,
"mean": 86585.608,
"stdev": 71331.593,
"min": 11201.581,
"max": 153020.143,
"ci95": 177211.803
},
"chunks.village.load_ms": {
"n": 3,
"mean": 11082.587,
"stdev": 5351.291,
"min": 7620.454,
"max": 17246.096,
"ci95": 13294.417
},
"chunks.village.meshed_count": {
"n": 3,
"mean": 48440.333,
"stdev": 656.16,
"min": 48060.0,
"max": 49198.0,
"ci95": 1630.124
},
"fps.village.avg_fps": {
"n": 3,
"mean": 853.775,
"stdev": 345.646,
"min": 524.913,
"max": 1214.059,
"ci95": 858.701
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 130.235,
"stdev": 117.39,
"min": 9.024,
"max": 243.388,
"ci95": 291.635
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 70.65,
"stdev": 68.628,
"min": 1.125,
"max": 138.345,
"ci95": 170.496
},
"memory.village.gpu_mb": {
"n": 3,
"mean": 2202.333,
"stdev": 33.307,
"min": 2165.0,
"max": 2229.0,
"ci95": 82.745
},
"memory.village.rss_mb": {
"n": 3,
"mean": 1652.647,
"stdev": 73.452,
"min": 1568.063,
"max": 1700.344,
"ci95": 182.478
},
"tick.village.client_ms": {
"n": 3,
"mean": 0.493,
"stdev": 0.169,
"min": 0.319,
"max": 0.657,
"ci95": 0.42
},
"cpu.village.p50_ms": {
"n": 3,
"mean": 0.692,
"stdev": 0.207,
"min": 0.531,
"max": 0.925,
"ci95": 0.513
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 15019.921,
"stdev": 12855.4,
"min": 5613.942,
"max": 29668.11,
"ci95": 31937.162
},
"chunks.forest.meshed_count": {
"n": 3,
"mean": 80224.667,
"stdev": 678.709,
"min": 79626.0,
"max": 80962.0,
"ci95": 1686.142
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 898.208,
"stdev": 446.3,
"min": 382.944,
"max": 1163.61,
"ci95": 1108.759
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 178.513,
"stdev": 107.096,
"min": 54.933,
"max": 244.255,
"ci95": 266.064
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 103.369,
"stdev": 78.403,
"min": 12.974,
"max": 152.87,
"ci95": 194.779
},
"memory.forest.gpu_mb": {
"n": 3,
"mean": 2201.667,
"stdev": 34.429,
"min": 2163.0,
"max": 2229.0,
"ci95": 85.532
},
"memory.forest.rss_mb": {
"n": 3,
"mean": 1809.063,
"stdev": 93.103,
"min": 1707.98,
"max": 1891.305,
"ci95": 231.3
},
"tick.forest.client_ms": {
"n": 3,
"mean": 0.585,
"stdev": 0.147,
"min": 0.493,
"max": 0.754,
"ci95": 0.365
},
"cpu.forest.p50_ms": {
"n": 3,
"mean": 0.989,
"stdev": 0.725,
"min": 0.57,
"max": 1.826,
"ci95": 1.802
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 12399.047,
"stdev": 15052.073,
"min": 3174.115,
"max": 29768.486,
"ci95": 37394.441
},
"chunks.ocean.meshed_count": {
"n": 3,
"mean": 64737.0,
"stdev": 16149.666,
"min": 46089.0,
"max": 74089.0,
"ci95": 40121.232
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 735.22,
"stdev": 601.75,
"min": 108.108,
"max": 1307.912,
"ci95": 1494.952
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 134.654,
"stdev": 147.391,
"min": 1.252,
"max": 292.881,
"ci95": 366.169
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 62.062,
"stdev": 66.294,
"min": 0.202,
"max": 132.042,
"ci95": 164.697
},
"memory.ocean.gpu_mb": {
"n": 3,
"mean": 1942.333,
"stdev": 251.725,
"min": 1657.0,
"max": 2133.0,
"ci95": 625.369
},
"memory.ocean.rss_mb": {
"n": 3,
"mean": 1812.661,
"stdev": 102.065,
"min": 1697.912,
"max": 1893.313,
"ci95": 253.564
},
"tick.ocean.client_ms": {
"n": 3,
"mean": 0.421,
"stdev": 0.227,
"min": 0.193,
"max": 0.646,
"ci95": 0.563
},
"cpu.ocean.p50_ms": {
"n": 3,
"mean": 0.513,
"stdev": 0.194,
"min": 0.303,
"max": 0.686,
"ci95": 0.483
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 31815.539,
"stdev": 36758.245,
"min": 9582.166,
"max": 74243.988,
"ci95": 91319.913
},
"chunks.mountain.meshed_count": {
"n": 3,
"mean": 90876.333,
"stdev": 1525.588,
"min": 89793.0,
"max": 92621.0,
"ci95": 3790.075
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 712.657,
"stdev": 252.815,
"min": 450.565,
"max": 955.041,
"ci95": 628.077
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 117.93,
"stdev": 85.922,
"min": 24.389,
"max": 193.34,
"ci95": 213.46
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 72.407,
"stdev": 65.468,
"min": 3.577,
"max": 133.895,
"ci95": 162.646
},
"memory.mountain.gpu_mb": {
"n": 3,
"mean": 2783.667,
"stdev": 48.881,
"min": 2741.0,
"max": 2837.0,
"ci95": 121.436
},
"memory.mountain.rss_mb": {
"n": 3,
"mean": 1931.359,
"stdev": 106.681,
"min": 1808.184,
"max": 1994.242,
"ci95": 265.031
},
"tick.mountain.client_ms": {
"n": 3,
"mean": 1.03,
"stdev": 0.685,
"min": 0.564,
"max": 1.817,
"ci95": 1.702
},
"cpu.mountain.p50_ms": {
"n": 3,
"mean": 1.025,
"stdev": 0.315,
"min": 0.765,
"max": 1.376,
"ci95": 0.783
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 2051.333,
"stdev": 147.151,
"min": 1884.176,
"max": 2161.316,
"ci95": 365.573
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 1810.92,
"stdev": 94.623,
"min": 1707.342,
"max": 1892.828,
"ci95": 235.075
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 6301,
"seconds": 12.004,
"avg_fps": 524.913,
"low_1pct_fps": 9.024,
"low_01pct_fps": 1.125,
"p50_ms": 0.555,
"p95_ms": 2.072,
"p99_ms": 4.067,
"p999_ms": 282.455,
"max_ms": 3525.74,
"cpu_p50_ms": 0.531,
"gpu_p50_ms": 0.218,
"histogram": [
4878,
1060,
217,
86,
19,
15,
6,
2,
5,
0,
1,
2,
0,
0,
0,
0,
0,
0,
0,
2,
0,
4,
3,
1
]
},
{
"phase": "village",
"repeat": 1,
"frames": 9869,
"seconds": 12.001,
"avg_fps": 822.353,
"low_1pct_fps": 138.293,
"low_01pct_fps": 72.479,
"p50_ms": 0.976,
"p95_ms": 2.871,
"p99_ms": 5.065,
"p999_ms": 9.942,
"max_ms": 23.337,
"cpu_p50_ms": 0.925,
"gpu_p50_ms": 0.245,
"histogram": [
5262,
3404,
771,
248,
81,
49,
23,
11,
12,
3,
0,
3,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 14570,
"seconds": 12.001,
"avg_fps": 1214.059,
"low_1pct_fps": 243.388,
"low_01pct_fps": 138.345,
"p50_ms": 0.655,
"p95_ms": 1.486,
"p99_ms": 2.883,
"p999_ms": 5.366,
"max_ms": 26.145,
"cpu_p50_ms": 0.62,
"gpu_p50_ms": 0.239,
"histogram": [
11502,
2683,
257,
77,
25,
23,
1,
1,
0,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 4595,
"seconds": 11.999,
"avg_fps": 382.944,
"low_1pct_fps": 54.933,
"low_01pct_fps": 12.974,
"p50_ms": 2.269,
"p95_ms": 5.193,
"p99_ms": 8.719,
"p999_ms": 21.933,
"max_ms": 243.247,
"cpu_p50_ms": 1.826,
"gpu_p50_ms": 0.502,
"histogram": [
273,
1654,
1395,
836,
172,
122,
52,
38,
26,
10,
7,
5,
0,
2,
1,
0,
0,
0,
1,
0,
1,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 13964,
"seconds": 12.001,
"avg_fps": 1163.61,
"low_1pct_fps": 244.255,
"low_01pct_fps": 152.87,
"p50_ms": 0.682,
"p95_ms": 1.524,
"p99_ms": 2.877,
"p999_ms": 5.801,
"max_ms": 7.977,
"cpu_p50_ms": 0.57,
"gpu_p50_ms": 0.467,
"histogram": [
11067,
2532,
235,
82,
23,
16,
6,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 13777,
"seconds": 12.0,
"avg_fps": 1148.071,
"low_1pct_fps": 236.351,
"low_01pct_fps": 144.264,
"p50_ms": 0.691,
"p95_ms": 1.637,
"p99_ms": 3.131,
"p999_ms": 5.794,
"max_ms": 9.301,
"cpu_p50_ms": 0.57,
"gpu_p50_ms": 0.465,
"histogram": [
10826,
2511,
284,
106,
23,
16,
5,
5,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 1224,
"seconds": 11.322,
"avg_fps": 108.108,
"low_1pct_fps": 1.252,
"low_01pct_fps": 0.202,
"p50_ms": 0.439,
"p95_ms": 3.015,
"p99_ms": 7.359,
"p999_ms": 2880.23,
"max_ms": 7005.436,
"cpu_p50_ms": 0.303,
"gpu_p50_ms": 0.258,
"histogram": [
1081,
31,
49,
10,
3,
7,
23,
12,
4,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
0,
2
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 15695,
"seconds": 12.0,
"avg_fps": 1307.912,
"low_1pct_fps": 292.881,
"low_01pct_fps": 132.042,
"p50_ms": 0.624,
"p95_ms": 1.219,
"p99_ms": 2.173,
"p999_ms": 4.841,
"max_ms": 16.764,
"cpu_p50_ms": 0.551,
"gpu_p50_ms": 0.401,
"histogram": [
13558,
1932,
140,
37,
14,
6,
3,
1,
1,
1,
0,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 9476,
"seconds": 12.0,
"avg_fps": 789.641,
"low_1pct_fps": 109.83,
"low_01pct_fps": 53.941,
"p50_ms": 0.802,
"p95_ms": 3.406,
"p99_ms": 5.844,
"p999_ms": 14.977,
"max_ms": 22.481,
"cpu_p50_ms": 0.686,
"gpu_p50_ms": 0.403,
"histogram": [
5819,
2151,
787,
455,
112,
65,
36,
18,
7,
7,
7,
5,
4,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 5352,
"seconds": 11.878,
"avg_fps": 450.565,
"low_1pct_fps": 24.389,
"low_01pct_fps": 3.577,
"p50_ms": 1.431,
"p95_ms": 4.548,
"p99_ms": 7.601,
"p999_ms": 96.086,
"max_ms": 714.027,
"cpu_p50_ms": 1.376,
"gpu_p50_ms": 0.398,
"histogram": [
1412,
2323,
811,
460,
138,
84,
49,
34,
13,
9,
8,
2,
1,
1,
0,
1,
0,
0,
1,
2,
1,
1,
1,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 8789,
"seconds": 12.001,
"avg_fps": 732.365,
"low_1pct_fps": 136.06,
"low_01pct_fps": 79.749,
"p50_ms": 0.972,
"p95_ms": 3.329,
"p99_ms": 5.68,
"p999_ms": 9.7,
"max_ms": 18.289,
"cpu_p50_ms": 0.935,
"gpu_p50_ms": 0.383,
"histogram": [
4529,
3007,
715,
287,
120,
61,
37,
16,
12,
1,
1,
2,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 11461,
"seconds": 12.001,
"avg_fps": 955.041,
"low_1pct_fps": 193.34,
"low_01pct_fps": 133.895,
"p50_ms": 0.787,
"p95_ms": 2.026,
"p99_ms": 3.953,
"p999_ms": 6.511,
"max_ms": 9.761,
"cpu_p50_ms": 0.765,
"gpu_p50_ms": 0.379,
"histogram": [
7754,
3114,
328,
165,
46,
33,
14,
5,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
}
],
"label": "horizon-desktop"
}
+854
View File
@@ -0,0 +1,854 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-04T12:44:40Z",
"app": {
"name": "horizon-client",
"version": "nightly-2026-11-04-67dad0d",
"commit": "67dad0d"
},
"hardware": {
"os": "macos",
"arch": "aarch64",
"cpu_threads": 14,
"os_version": "27.0.1",
"cpu": "Apple M4 Pro",
"ram_mb": 65536,
"gpu": {
"name": "Apple M4 Pro",
"vendor": "",
"driver": "",
"backend": "metal",
"kind": "integrated"
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 12,
"simulation_distance": 12,
"vsync": false,
"offscreen": true,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 17.39,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 1516.155,
"stdev": 34.227,
"min": 1476.782,
"max": 1538.813,
"ci95": 85.032
},
"startup.world_ms": {
"n": 3,
"mean": 4080.9,
"stdev": 748.712,
"min": 3273.516,
"max": 4752.301,
"ci95": 1860.054
},
"chunks.village.load_ms": {
"n": 3,
"mean": 3363.749,
"stdev": 224.552,
"min": 3105.622,
"max": 3514.059,
"ci95": 557.864
},
"chunks.village.meshed_count": {
"n": 3,
"mean": 15473.667,
"stdev": 339.602,
"min": 15123.0,
"max": 15801.0,
"ci95": 843.686
},
"fps.village.avg_fps": {
"n": 3,
"mean": 999.753,
"stdev": 41.901,
"min": 970.527,
"max": 1047.758,
"ci95": 104.095
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 336.821,
"stdev": 17.834,
"min": 318.068,
"max": 353.566,
"ci95": 44.306
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 263.782,
"stdev": 31.556,
"min": 227.767,
"max": 286.582,
"ci95": 78.395
},
"memory.village.rss_mb": {
"n": 3,
"mean": 1147.654,
"stdev": 31.82,
"min": 1116.875,
"max": 1180.422,
"ci95": 79.052
},
"tick.village.client_ms": {
"n": 3,
"mean": 0.414,
"stdev": 0.036,
"min": 0.373,
"max": 0.44,
"ci95": 0.089
},
"cpu.village.p50_ms": {
"n": 3,
"mean": 0.52,
"stdev": 0.071,
"min": 0.44,
"max": 0.577,
"ci95": 0.177
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 3685.368,
"stdev": 844.43,
"min": 2932.117,
"max": 4598.204,
"ci95": 2097.85
},
"chunks.forest.meshed_count": {
"n": 3,
"mean": 16795.333,
"stdev": 437.802,
"min": 16325.0,
"max": 17191.0,
"ci95": 1087.648
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 592.615,
"stdev": 20.339,
"min": 580.401,
"max": 616.094,
"ci95": 50.529
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 250.069,
"stdev": 31.227,
"min": 217.065,
"max": 279.149,
"ci95": 77.58
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 151.684,
"stdev": 77.064,
"min": 64.033,
"max": 208.811,
"ci95": 191.454
},
"memory.forest.rss_mb": {
"n": 3,
"mean": 1064.344,
"stdev": 148.185,
"min": 901.203,
"max": 1190.609,
"ci95": 368.142
},
"tick.forest.client_ms": {
"n": 3,
"mean": 0.424,
"stdev": 0.003,
"min": 0.422,
"max": 0.427,
"ci95": 0.007
},
"cpu.forest.p50_ms": {
"n": 3,
"mean": 0.617,
"stdev": 0.016,
"min": 0.599,
"max": 0.629,
"ci95": 0.039
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 3594.311,
"stdev": 1381.026,
"min": 2011.153,
"max": 4551.541,
"ci95": 3430.936
},
"chunks.ocean.meshed_count": {
"n": 3,
"mean": 14527.0,
"stdev": 319.629,
"min": 14166.0,
"max": 14774.0,
"ci95": 794.068
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 789.003,
"stdev": 25.076,
"min": 766.83,
"max": 816.216,
"ci95": 62.297
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 276.887,
"stdev": 51.785,
"min": 217.373,
"max": 311.665,
"ci95": 128.651
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 169.278,
"stdev": 29.119,
"min": 137.156,
"max": 193.944,
"ci95": 72.341
},
"memory.ocean.rss_mb": {
"n": 3,
"mean": 982.995,
"stdev": 287.819,
"min": 651.109,
"max": 1164.063,
"ci95": 715.039
},
"tick.ocean.client_ms": {
"n": 3,
"mean": 0.397,
"stdev": 0.028,
"min": 0.38,
"max": 0.429,
"ci95": 0.069
},
"cpu.ocean.p50_ms": {
"n": 3,
"mean": 0.569,
"stdev": 0.045,
"min": 0.533,
"max": 0.62,
"ci95": 0.112
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 5682.993,
"stdev": 1272.063,
"min": 4476.873,
"max": 7012.069,
"ci95": 3160.234
},
"chunks.mountain.meshed_count": {
"n": 3,
"mean": 15640.0,
"stdev": 204.105,
"min": 15507.0,
"max": 15875.0,
"ci95": 507.067
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 716.475,
"stdev": 8.139,
"min": 707.613,
"max": 723.617,
"ci95": 20.221
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 313.736,
"stdev": 3.759,
"min": 311.007,
"max": 318.023,
"ci95": 9.338
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 270.042,
"stdev": 19.572,
"min": 247.802,
"max": 284.642,
"ci95": 48.624
},
"memory.mountain.rss_mb": {
"n": 3,
"mean": 997.474,
"stdev": 231.733,
"min": 790.266,
"max": 1247.703,
"ci95": 575.704
},
"tick.mountain.client_ms": {
"n": 3,
"mean": 0.385,
"stdev": 0.023,
"min": 0.359,
"max": 0.401,
"ci95": 0.057
},
"cpu.mountain.p50_ms": {
"n": 3,
"mean": 0.609,
"stdev": 0.044,
"min": 0.56,
"max": 0.645,
"ci95": 0.109
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 1213.255,
"stdev": 81.897,
"min": 1136.078,
"max": 1299.172,
"ci95": 203.461
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 1034.279,
"stdev": 188.802,
"min": 816.453,
"max": 1150.961,
"ci95": 469.049
},
"memory.footprint_peak_mb": {
"n": 3,
"mean": 2167.747,
"stdev": 14.039,
"min": 2153.721,
"max": 2181.799,
"ci95": 34.878
},
"memory.footprint_steady_mb": {
"n": 3,
"mean": 1889.468,
"stdev": 24.444,
"min": 1862.799,
"max": 1910.807,
"ci95": 60.727
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 12573,
"seconds": 12.0,
"avg_fps": 1047.758,
"low_1pct_fps": 353.566,
"low_01pct_fps": 286.582,
"p50_ms": 0.921,
"p95_ms": 1.077,
"p99_ms": 2.675,
"p999_ms": 2.946,
"max_ms": 5.36,
"cpu_p50_ms": 0.44,
"gpu_p50_ms": 0.914,
"histogram": [
11056,
1291,
218,
6,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 11646,
"seconds": 12.0,
"avg_fps": 970.527,
"low_1pct_fps": 318.068,
"low_01pct_fps": 227.767,
"p50_ms": 0.973,
"p95_ms": 1.264,
"p99_ms": 2.805,
"p999_ms": 3.808,
"max_ms": 5.305,
"cpu_p50_ms": 0.577,
"gpu_p50_ms": 0.969,
"histogram": [
7035,
4334,
230,
39,
6,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 11771,
"seconds": 11.999,
"avg_fps": 980.973,
"low_1pct_fps": 338.828,
"low_01pct_fps": 276.996,
"p50_ms": 0.985,
"p95_ms": 1.157,
"p99_ms": 2.779,
"p999_ms": 3.068,
"max_ms": 4.308,
"cpu_p50_ms": 0.542,
"gpu_p50_ms": 0.98,
"histogram": [
6880,
4663,
208,
18,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 7394,
"seconds": 12.001,
"avg_fps": 616.094,
"low_1pct_fps": 253.993,
"low_01pct_fps": 182.207,
"p50_ms": 1.565,
"p95_ms": 1.915,
"p99_ms": 3.329,
"p999_ms": 4.661,
"max_ms": 7.32,
"cpu_p50_ms": 0.599,
"gpu_p50_ms": 1.551,
"histogram": [
82,
6988,
144,
162,
14,
2,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 6965,
"seconds": 12.0,
"avg_fps": 580.401,
"low_1pct_fps": 217.065,
"low_01pct_fps": 64.033,
"p50_ms": 1.654,
"p95_ms": 1.983,
"p99_ms": 3.233,
"p999_ms": 3.893,
"max_ms": 81.863,
"cpu_p50_ms": 0.629,
"gpu_p50_ms": 1.645,
"histogram": [
11,
6622,
153,
174,
4,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 6977,
"seconds": 12.001,
"avg_fps": 581.35,
"low_1pct_fps": 279.149,
"low_01pct_fps": 208.811,
"p50_ms": 1.659,
"p95_ms": 2.006,
"p99_ms": 3.273,
"p999_ms": 3.982,
"max_ms": 5.889,
"cpu_p50_ms": 0.623,
"gpu_p50_ms": 1.653,
"histogram": [
17,
6606,
164,
186,
1,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 9793,
"seconds": 11.998,
"avg_fps": 816.216,
"low_1pct_fps": 301.624,
"low_01pct_fps": 176.734,
"p50_ms": 1.171,
"p95_ms": 1.499,
"p99_ms": 2.928,
"p999_ms": 3.333,
"max_ms": 23.472,
"cpu_p50_ms": 0.533,
"gpu_p50_ms": 1.163,
"histogram": [
259,
9293,
182,
56,
2,
0,
0,
0,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 9202,
"seconds": 12.0,
"avg_fps": 766.83,
"low_1pct_fps": 217.373,
"low_01pct_fps": 137.156,
"p50_ms": 1.226,
"p95_ms": 1.912,
"p99_ms": 3.396,
"p999_ms": 6.003,
"max_ms": 13.562,
"cpu_p50_ms": 0.62,
"gpu_p50_ms": 1.219,
"histogram": [
635,
8130,
261,
124,
27,
15,
7,
2,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 9408,
"seconds": 12.001,
"avg_fps": 783.964,
"low_1pct_fps": 311.665,
"low_01pct_fps": 193.944,
"p50_ms": 1.22,
"p95_ms": 1.541,
"p99_ms": 2.865,
"p999_ms": 3.298,
"max_ms": 13.861,
"cpu_p50_ms": 0.555,
"gpu_p50_ms": 1.214,
"histogram": [
100,
9066,
204,
34,
2,
0,
1,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 8618,
"seconds": 12.0,
"avg_fps": 718.194,
"low_1pct_fps": 312.177,
"low_01pct_fps": 277.681,
"p50_ms": 1.338,
"p95_ms": 1.664,
"p99_ms": 3.068,
"p999_ms": 3.364,
"max_ms": 4.008,
"cpu_p50_ms": 0.622,
"gpu_p50_ms": 1.329,
"histogram": [
62,
8301,
130,
125,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 8492,
"seconds": 12.001,
"avg_fps": 707.613,
"low_1pct_fps": 311.007,
"low_01pct_fps": 284.642,
"p50_ms": 1.35,
"p95_ms": 1.707,
"p99_ms": 3.08,
"p999_ms": 3.329,
"max_ms": 3.796,
"cpu_p50_ms": 0.645,
"gpu_p50_ms": 1.342,
"histogram": [
32,
8216,
111,
133,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 8684,
"seconds": 12.001,
"avg_fps": 723.617,
"low_1pct_fps": 318.023,
"low_01pct_fps": 247.802,
"p50_ms": 1.318,
"p95_ms": 1.699,
"p99_ms": 2.875,
"p999_ms": 3.573,
"max_ms": 5.186,
"cpu_p50_ms": 0.56,
"gpu_p50_ms": 1.306,
"histogram": [
46,
8389,
199,
49,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
}
],
"label": "horizon-macmini"
}
+854
View File
@@ -0,0 +1,854 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-04T13:17:26Z",
"app": {
"name": "horizon-client",
"version": "nightly-2026-11-04-67dad0d",
"commit": "67dad0d"
},
"hardware": {
"os": "macos",
"arch": "aarch64",
"cpu_threads": 14,
"os_version": "27.0.1",
"cpu": "Apple M4 Pro",
"ram_mb": 65536,
"gpu": {
"name": "Apple M4 Pro",
"vendor": "",
"driver": "",
"backend": "metal",
"kind": "integrated"
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 32,
"simulation_distance": 12,
"vsync": false,
"offscreen": true,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 17.712,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 1528.444,
"stdev": 181.21,
"min": 1319.58,
"max": 1643.783,
"ci95": 450.188
},
"startup.world_ms": {
"n": 3,
"mean": 12368.716,
"stdev": 6475.37,
"min": 7168.429,
"max": 19621.62,
"ci95": 16087.01
},
"chunks.village.load_ms": {
"n": 3,
"mean": 6926.208,
"stdev": 3712.691,
"min": 4208.555,
"max": 11156.419,
"ci95": 9223.581
},
"chunks.village.meshed_count": {
"n": 3,
"mean": 46790.333,
"stdev": 1381.89,
"min": 45236.0,
"max": 47880.0,
"ci95": 3433.082
},
"fps.village.avg_fps": {
"n": 3,
"mean": 453.505,
"stdev": 14.384,
"min": 441.517,
"max": 469.455,
"ci95": 35.735
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 222.897,
"stdev": 10.449,
"min": 212.173,
"max": 233.048,
"ci95": 25.96
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 173.55,
"stdev": 31.798,
"min": 142.967,
"max": 206.437,
"ci95": 78.996
},
"memory.village.rss_mb": {
"n": 3,
"mean": 1544.565,
"stdev": 26.422,
"min": 1516.75,
"max": 1569.328,
"ci95": 65.64
},
"tick.village.client_ms": {
"n": 3,
"mean": 0.528,
"stdev": 0.018,
"min": 0.511,
"max": 0.548,
"ci95": 0.045
},
"cpu.village.p50_ms": {
"n": 3,
"mean": 1.496,
"stdev": 0.049,
"min": 1.453,
"max": 1.549,
"ci95": 0.121
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 8297.116,
"stdev": 689.909,
"min": 7716.447,
"max": 9059.776,
"ci95": 1713.966
},
"chunks.forest.meshed_count": {
"n": 3,
"mean": 78617.667,
"stdev": 494.532,
"min": 78228.0,
"max": 79174.0,
"ci95": 1228.586
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 216.265,
"stdev": 6.372,
"min": 209.501,
"max": 222.156,
"ci95": 15.831
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 137.027,
"stdev": 3.787,
"min": 133.07,
"max": 140.617,
"ci95": 9.408
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 114.683,
"stdev": 7.985,
"min": 106.585,
"max": 122.551,
"ci95": 19.839
},
"memory.forest.rss_mb": {
"n": 3,
"mean": 1420.638,
"stdev": 247.812,
"min": 1266.047,
"max": 1706.469,
"ci95": 615.649
},
"tick.forest.client_ms": {
"n": 3,
"mean": 0.423,
"stdev": 0.059,
"min": 0.355,
"max": 0.458,
"ci95": 0.148
},
"cpu.forest.p50_ms": {
"n": 3,
"mean": 1.605,
"stdev": 0.269,
"min": 1.295,
"max": 1.779,
"ci95": 0.668
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 8354.659,
"stdev": 2877.749,
"min": 5454.947,
"max": 11209.93,
"ci95": 7149.301
},
"chunks.ocean.meshed_count": {
"n": 3,
"mean": 74315.667,
"stdev": 63.106,
"min": 74246.0,
"max": 74369.0,
"ci95": 156.776
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 274.828,
"stdev": 4.993,
"min": 270.041,
"max": 280.004,
"ci95": 12.404
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 165.765,
"stdev": 7.21,
"min": 157.538,
"max": 170.985,
"ci95": 17.912
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 140.185,
"stdev": 9.74,
"min": 129.976,
"max": 149.377,
"ci95": 24.198
},
"memory.ocean.rss_mb": {
"n": 3,
"mean": 1306.578,
"stdev": 97.011,
"min": 1199.094,
"max": 1387.641,
"ci95": 241.007
},
"tick.ocean.client_ms": {
"n": 3,
"mean": 0.416,
"stdev": 0.029,
"min": 0.392,
"max": 0.448,
"ci95": 0.071
},
"cpu.ocean.p50_ms": {
"n": 3,
"mean": 1.636,
"stdev": 0.121,
"min": 1.496,
"max": 1.708,
"ci95": 0.301
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 17124.873,
"stdev": 3461.08,
"min": 13599.217,
"max": 20517.544,
"ci95": 8598.494
},
"chunks.mountain.meshed_count": {
"n": 3,
"mean": 91909.0,
"stdev": 815.603,
"min": 91157.0,
"max": 92776.0,
"ci95": 2026.235
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 242.657,
"stdev": 3.405,
"min": 240.66,
"max": 246.588,
"ci95": 8.459
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 110.95,
"stdev": 14.622,
"min": 99.907,
"max": 127.532,
"ci95": 36.325
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 51.329,
"stdev": 28.253,
"min": 30.391,
"max": 83.465,
"ci95": 70.191
},
"memory.mountain.rss_mb": {
"n": 3,
"mean": 1451.688,
"stdev": 127.471,
"min": 1310.969,
"max": 1559.43,
"ci95": 316.681
},
"tick.mountain.client_ms": {
"n": 3,
"mean": 0.614,
"stdev": 0.017,
"min": 0.594,
"max": 0.627,
"ci95": 0.043
},
"cpu.mountain.p50_ms": {
"n": 3,
"mean": 2.233,
"stdev": 0.05,
"min": 2.194,
"max": 2.289,
"ci95": 0.124
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 1697.984,
"stdev": 29.735,
"min": 1667.406,
"max": 1726.797,
"ci95": 73.871
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 1428.19,
"stdev": 120.658,
"min": 1333.0,
"max": 1563.891,
"ci95": 299.756
},
"memory.footprint_peak_mb": {
"n": 3,
"mean": 4921.753,
"stdev": 30.436,
"min": 4890.971,
"max": 4951.831,
"ci95": 75.613
},
"memory.footprint_steady_mb": {
"n": 3,
"mean": 4079.43,
"stdev": 10.978,
"min": 4068.049,
"max": 4089.956,
"ci95": 27.273
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 5634,
"seconds": 12.001,
"avg_fps": 469.455,
"low_1pct_fps": 223.469,
"low_01pct_fps": 171.246,
"p50_ms": 2.054,
"p95_ms": 2.492,
"p99_ms": 3.993,
"p999_ms": 5.425,
"max_ms": 6.645,
"cpu_p50_ms": 1.453,
"gpu_p50_ms": 1.965,
"histogram": [
0,
2082,
3324,
193,
24,
9,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 5395,
"seconds": 12.001,
"avg_fps": 449.543,
"low_1pct_fps": 233.048,
"low_01pct_fps": 206.437,
"p50_ms": 2.155,
"p95_ms": 2.611,
"p99_ms": 4.068,
"p999_ms": 4.456,
"max_ms": 5.305,
"cpu_p50_ms": 1.549,
"gpu_p50_ms": 2.043,
"histogram": [
0,
902,
4255,
206,
29,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 5298,
"seconds": 12.0,
"avg_fps": 441.517,
"low_1pct_fps": 212.173,
"low_01pct_fps": 142.967,
"p50_ms": 2.171,
"p95_ms": 3.249,
"p99_ms": 4.138,
"p999_ms": 5.591,
"max_ms": 9.676,
"cpu_p50_ms": 1.485,
"gpu_p50_ms": 2.092,
"histogram": [
0,
709,
4314,
229,
36,
6,
2,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 2606,
"seconds": 12.002,
"avg_fps": 217.137,
"low_1pct_fps": 140.617,
"low_01pct_fps": 106.585,
"p50_ms": 4.471,
"p95_ms": 5.926,
"p99_ms": 6.575,
"p999_ms": 7.735,
"max_ms": 12.187,
"cpu_p50_ms": 1.74,
"gpu_p50_ms": 4.238,
"histogram": [
0,
0,
0,
221,
2094,
172,
110,
8,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 2514,
"seconds": 12.0,
"avg_fps": 209.501,
"low_1pct_fps": 137.393,
"low_01pct_fps": 122.551,
"p50_ms": 4.627,
"p95_ms": 6.304,
"p99_ms": 6.93,
"p999_ms": 7.971,
"max_ms": 8.506,
"cpu_p50_ms": 1.779,
"gpu_p50_ms": 4.409,
"histogram": [
0,
0,
2,
35,
2097,
200,
162,
17,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 2667,
"seconds": 12.005,
"avg_fps": 222.156,
"low_1pct_fps": 133.07,
"low_01pct_fps": 114.913,
"p50_ms": 4.37,
"p95_ms": 5.756,
"p99_ms": 6.742,
"p999_ms": 8.444,
"max_ms": 8.984,
"cpu_p50_ms": 1.295,
"gpu_p50_ms": 4.152,
"histogram": [
0,
0,
0,
440,
1978,
144,
85,
16,
4,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 3294,
"seconds": 12.003,
"avg_fps": 274.44,
"low_1pct_fps": 157.538,
"low_01pct_fps": 129.976,
"p50_ms": 3.521,
"p95_ms": 4.814,
"p99_ms": 5.592,
"p999_ms": 7.491,
"max_ms": 7.868,
"cpu_p50_ms": 1.703,
"gpu_p50_ms": 3.277,
"histogram": [
0,
4,
55,
2967,
130,
122,
10,
6,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 3241,
"seconds": 12.002,
"avg_fps": 270.041,
"low_1pct_fps": 168.771,
"low_01pct_fps": 141.201,
"p50_ms": 3.578,
"p95_ms": 4.942,
"p99_ms": 5.631,
"p999_ms": 6.498,
"max_ms": 7.601,
"cpu_p50_ms": 1.708,
"gpu_p50_ms": 3.323,
"histogram": [
0,
2,
8,
2970,
110,
144,
5,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 3360,
"seconds": 12.0,
"avg_fps": 280.004,
"low_1pct_fps": 170.985,
"low_01pct_fps": 149.377,
"p50_ms": 3.477,
"p95_ms": 4.678,
"p99_ms": 5.462,
"p999_ms": 6.626,
"max_ms": 6.788,
"cpu_p50_ms": 1.496,
"gpu_p50_ms": 3.23,
"histogram": [
0,
1,
5,
3125,
129,
90,
10,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 2888,
"seconds": 12.0,
"avg_fps": 240.66,
"low_1pct_fps": 127.532,
"low_01pct_fps": 83.465,
"p50_ms": 3.984,
"p95_ms": 5.171,
"p99_ms": 6.823,
"p999_ms": 9.168,
"max_ms": 15.341,
"cpu_p50_ms": 2.194,
"gpu_p50_ms": 3.786,
"histogram": [
0,
0,
4,
2048,
638,
144,
33,
16,
3,
1,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 2889,
"seconds": 12.001,
"avg_fps": 240.722,
"low_1pct_fps": 99.907,
"low_01pct_fps": 40.132,
"p50_ms": 3.961,
"p95_ms": 5.706,
"p99_ms": 7.364,
"p999_ms": 12.597,
"max_ms": 49.156,
"cpu_p50_ms": 2.289,
"gpu_p50_ms": 3.802,
"histogram": [
0,
5,
126,
1859,
599,
194,
72,
22,
8,
1,
2,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 2959,
"seconds": 12.0,
"avg_fps": 246.588,
"low_1pct_fps": 105.411,
"low_01pct_fps": 30.391,
"p50_ms": 3.851,
"p95_ms": 5.353,
"p99_ms": 6.248,
"p999_ms": 9.05,
"max_ms": 54.378,
"cpu_p50_ms": 2.216,
"gpu_p50_ms": 3.667,
"histogram": [
0,
2,
58,
2196,
462,
196,
32,
10,
1,
0,
0,
0,
0,
0,
0,
1,
0,
1,
0,
0,
0,
0,
0,
0
]
}
],
"label": "horizon-macmini"
}
+38
View File
@@ -0,0 +1,38 @@
# Horizon Nightly 2026-11-04 vs vanilla 26.3 on the Mac mini and the desktop (2026-11-04)
The runs behind playhorizon.dev/benchmarks as of 2026-11-04 (horizon-rust/horizon-site#58): `tools/perf_bench.py`
reports, render distance 12 and 32, three games each, every game from a fresh world (seed 1234), 1280x720, VSync off. Files:
`{horizon,vanilla}-macmini-rd{12,32}.json` and `{horizon,vanilla}-desktop-rd{12,32}.json` next to this page.
## Builds
- **Horizon:** the Nightly 2026-11-04 release bundle (67dad0d), as the launcher installs it: macOS arm64 on the Mac,
Linux x86_64 on the desktop. Its resources come from the bundle's own `horizon-prepare`. perf_bench ran from main
a55b9a2e52 and wrote that checkout's crate version and commit; `app.version` and `app.commit` were set to the bundle's by hand.
- **Vanilla:** the 26.3 client jar with Fabric Loader and the driver mod, Java 25, the official launcher's JVM flags.
## Machines and conditions
- **Mac mini, Apple M4 Pro (14 cores, 20-core GPU), macOS.** The session was locked, so Horizon ran `--offscreen`: a locked
session gives a window no frames, and windowed Horizon measured 0 FPS. Vanilla drew into its window. Offscreen skips
presenting, which favours Horizon, so its averages aren't held at the 144 Hz display as they were on 2026-10-22. Each game
started at a 1-minute load of 17-18 (the run waited for < 20); during the runs the load rose to 30-50.
- **Desktop, RTX 4080 + i7-13700KF, NixOS, NVIDIA 595.** Both games drew into a headless sway rendering on the RTX 4080 (never
on its screen), both through Wayland on Vulkan. Vanilla went through SDL's Wayland driver: through Xwayland each of its
frames took about 2 s on this day. The machine also runs CI and agents' builds: each game started at a load of 28-30 on
24 threads (the run waited for < 30) and the load rose to 40-70 during the runs. Vanilla at RD 32 crashed twice under that
load (`GPU timeout attempting to acquire next frame`) before a third series finished. Start-up times are inflated by the
load and by a cold disk on each series' first start (start → world up to 130-235 s).
- **Order:** RD 12 ran Horizon then vanilla; RD 32 ran vanilla then Horizon.
## Results (average over the flights of the three games; FPS average / 1% low)
| Machine | RD | Horizon | Vanilla | Horizon ÷ vanilla (avg, 1% low) |
|---|---|---|---|---|
| Mac mini | 12 | 774 / 294 (offscreen) | 132 / 27 | 5.9x, 10.9x |
| Mac mini | 32 | 297 / 159 (offscreen) | 68 / 16 | 4.4x, 9.9x |
| Desktop | 12 | 2,224 / 308 | 1,824 / 201 | 1.2x, 1.5x |
| Desktop | 32 | 800 / 140 | 696 / 64 | 1.15x, 2.2x |
Neither machine was idle, so these say how both games behave on busy machines. They are not clean ratios; for those, see
`windows-9800x3d-5080-2026-10-29.md`. The scripts and logs are in horizon-parity `shots/site-bench-2026-11-04/`.
+787
View File
@@ -0,0 +1,787 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-04T13:28:55Z",
"app": {
"name": "minecraft",
"version": "26.3",
"mods": [
{
"name": "fabric-loader",
"version": "0.19.5"
}
]
},
"hardware": {
"os": "linux",
"arch": "x86_64",
"cpu_threads": 24,
"os_version": "NixOS 26.05 (Yarara) (kernel 6.18.57)",
"cpu": "13th Gen Intel(R) Core(TM) i7-13700KF",
"ram_mb": 31904,
"gpu": {
"name": "NVIDIA GeForce RTX 4080",
"vendor": "NVIDIA",
"driver": "1.4.329 NVIDIA 595.71.05",
"backend": "vulkan",
"kind": ""
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 12,
"simulation_distance": 12,
"vsync": false,
"offscreen": false,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 28.788,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 121075.467,
"stdev": 30646.429,
"min": 93272.034,
"max": 153935.834,
"ci95": 76136.094
},
"startup.world_ms": {
"n": 3,
"mean": 191063.8,
"stdev": 34368.16,
"min": 153895.533,
"max": 221692.034,
"ci95": 85382.134
},
"chunks.village.load_ms": {
"n": 3,
"mean": 18724.322,
"stdev": 9639.048,
"min": 13107.549,
"max": 29854.376,
"ci95": 23946.655
},
"fps.village.avg_fps": {
"n": 3,
"mean": 2161.532,
"stdev": 287.109,
"min": 1830.069,
"max": 2332.81,
"ci95": 713.276
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 162.575,
"stdev": 29.799,
"min": 136.023,
"max": 194.804,
"ci95": 74.03
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 27.752,
"stdev": 3.186,
"min": 24.403,
"max": 30.746,
"ci95": 7.916
},
"memory.village.gpu_mb": {
"n": 3,
"mean": 13.0,
"stdev": 0.0,
"min": 13.0,
"max": 13.0,
"ci95": 0.0
},
"memory.village.rss_mb": {
"n": 3,
"mean": 2902.054,
"stdev": 158.708,
"min": 2747.244,
"max": 3064.393,
"ci95": 394.285
},
"tick.village.client_ms": {
"n": 3,
"mean": 0.691,
"stdev": 0.097,
"min": 0.601,
"max": 0.794,
"ci95": 0.241
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 8852.332,
"stdev": 530.809,
"min": 8350.811,
"max": 9408.241,
"ci95": 1318.709
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 1841.581,
"stdev": 1186.781,
"min": 471.209,
"max": 2529.847,
"ci95": 2948.365
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 255.087,
"stdev": 217.128,
"min": 5.798,
"max": 402.877,
"ci95": 539.419
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 92.882,
"stdev": 81.722,
"min": 0.61,
"max": 156.13,
"ci95": 203.024
},
"memory.forest.gpu_mb": {
"n": 3,
"mean": 13.0,
"stdev": 0.0,
"min": 13.0,
"max": 13.0,
"ci95": 0.0
},
"memory.forest.rss_mb": {
"n": 3,
"mean": 3008.178,
"stdev": 149.07,
"min": 2875.082,
"max": 3169.256,
"ci95": 370.34
},
"tick.forest.client_ms": {
"n": 3,
"mean": 0.687,
"stdev": 0.132,
"min": 0.562,
"max": 0.825,
"ci95": 0.327
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 21215.745,
"stdev": 22821.547,
"min": 8034.077,
"max": 47567.797,
"ci95": 56696.441
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 1525.483,
"stdev": 1472.651,
"min": 633.984,
"max": 3225.275,
"ci95": 3658.563
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 191.398,
"stdev": 272.942,
"min": 10.346,
"max": 505.335,
"ci95": 678.08
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 49.506,
"stdev": 66.145,
"min": 1.104,
"max": 124.874,
"ci95": 164.326
},
"memory.ocean.gpu_mb": {
"n": 3,
"mean": 13.0,
"stdev": 0.0,
"min": 13.0,
"max": 13.0,
"ci95": 0.0
},
"memory.ocean.rss_mb": {
"n": 3,
"mean": 3062.136,
"stdev": 128.594,
"min": 2962.541,
"max": 3207.313,
"ci95": 319.472
},
"tick.ocean.client_ms": {
"n": 3,
"mean": 1.029,
"stdev": 0.573,
"min": 0.595,
"max": 1.678,
"ci95": 1.423
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 33255.15,
"stdev": 20451.99,
"min": 16655.144,
"max": 56102.104,
"ci95": 50809.661
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 1769.12,
"stdev": 996.863,
"min": 818.512,
"max": 2806.557,
"ci95": 2476.544
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 195.916,
"stdev": 195.285,
"min": 11.586,
"max": 400.567,
"ci95": 485.154
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 52.202,
"stdev": 51.208,
"min": 1.461,
"max": 103.864,
"ci95": 127.217
},
"memory.mountain.gpu_mb": {
"n": 3,
"mean": 13.0,
"stdev": 0.0,
"min": 13.0,
"max": 13.0,
"ci95": 0.0
},
"memory.mountain.rss_mb": {
"n": 3,
"mean": 2957.948,
"stdev": 340.935,
"min": 2586.504,
"max": 3256.625,
"ci95": 846.998
},
"tick.mountain.client_ms": {
"n": 3,
"mean": 1.436,
"stdev": 1.406,
"min": 0.462,
"max": 3.048,
"ci95": 3.494
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 3107.953,
"stdev": 141.888,
"min": 3020.105,
"max": 3271.645,
"ci95": 352.498
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 2985.317,
"stdev": 210.327,
"min": 2761.164,
"max": 3178.35,
"ci95": 522.523
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 21963,
"seconds": 12.001,
"avg_fps": 1830.069,
"low_1pct_fps": 136.023,
"low_01pct_fps": 30.746,
"p50_ms": 0.331,
"p95_ms": 1.153,
"p99_ms": 3.001,
"p999_ms": 9.997,
"max_ms": 326.36,
"histogram": [
20336,
1238,
167,
108,
40,
23,
12,
10,
8,
7,
5,
1,
2,
1,
2,
0,
1,
1,
0,
0,
0,
1,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 27994,
"seconds": 12.0,
"avg_fps": 2332.81,
"low_1pct_fps": 194.804,
"low_01pct_fps": 28.107,
"p50_ms": 0.321,
"p95_ms": 0.771,
"p99_ms": 1.356,
"p999_ms": 3.836,
"max_ms": 331.931,
"histogram": [
27270,
652,
34,
13,
5,
2,
3,
4,
3,
2,
0,
1,
0,
0,
0,
0,
1,
0,
1,
1,
0,
2,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 27861,
"seconds": 12.0,
"avg_fps": 2321.717,
"low_1pct_fps": 156.898,
"low_01pct_fps": 24.403,
"p50_ms": 0.281,
"p95_ms": 0.852,
"p99_ms": 1.73,
"p999_ms": 6.331,
"max_ms": 301.991,
"histogram": [
26932,
726,
124,
42,
4,
4,
3,
2,
9,
2,
3,
1,
1,
1,
0,
1,
1,
0,
2,
0,
2,
1,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 30359,
"seconds": 12.0,
"avg_fps": 2529.847,
"low_1pct_fps": 356.586,
"low_01pct_fps": 121.906,
"p50_ms": 0.353,
"p95_ms": 0.563,
"p99_ms": 1.405,
"p999_ms": 4.444,
"max_ms": 20.302,
"histogram": [
29756,
460,
57,
52,
11,
5,
3,
5,
2,
4,
1,
0,
2,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 5781,
"seconds": 12.268,
"avg_fps": 471.209,
"low_1pct_fps": 5.798,
"low_01pct_fps": 0.61,
"p50_ms": 0.359,
"p95_ms": 0.77,
"p99_ms": 1.78,
"p999_ms": 20.826,
"max_ms": 5834.37,
"histogram": [
5586,
158,
12,
11,
0,
1,
3,
2,
0,
0,
2,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
2,
3
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 30285,
"seconds": 12.0,
"avg_fps": 2523.687,
"low_1pct_fps": 402.877,
"low_01pct_fps": 156.13,
"p50_ms": 0.351,
"p95_ms": 0.539,
"p99_ms": 1.574,
"p999_ms": 3.683,
"max_ms": 16.022,
"histogram": [
29666,
490,
74,
35,
7,
2,
2,
1,
1,
5,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 11685,
"seconds": 16.293,
"avg_fps": 717.19,
"low_1pct_fps": 10.346,
"low_01pct_fps": 1.104,
"p50_ms": 0.299,
"p95_ms": 1.119,
"p99_ms": 2.568,
"p999_ms": 12.054,
"max_ms": 5250.537,
"histogram": [
10993,
502,
102,
39,
14,
8,
6,
4,
4,
1,
1,
2,
0,
2,
0,
0,
0,
0,
1,
0,
0,
2,
0,
4
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 38704,
"seconds": 12.0,
"avg_fps": 3225.275,
"low_1pct_fps": 505.335,
"low_01pct_fps": 124.874,
"p50_ms": 0.283,
"p95_ms": 0.36,
"p99_ms": 0.909,
"p999_ms": 2.015,
"max_ms": 132.801,
"histogram": [
38385,
279,
25,
3,
2,
0,
1,
0,
2,
4,
1,
0,
0,
1,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 7609,
"seconds": 12.002,
"avg_fps": 633.984,
"low_1pct_fps": 58.513,
"low_01pct_fps": 22.539,
"p50_ms": 1.141,
"p95_ms": 4.438,
"p99_ms": 9.113,
"p999_ms": 27.496,
"max_ms": 64.424,
"histogram": [
3452,
2398,
956,
368,
143,
96,
61,
46,
20,
29,
6,
10,
6,
7,
7,
0,
1,
3,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 10168,
"seconds": 12.423,
"avg_fps": 818.512,
"low_1pct_fps": 11.586,
"low_01pct_fps": 1.461,
"p50_ms": 0.267,
"p95_ms": 0.892,
"p99_ms": 3.176,
"p999_ms": 198.757,
"max_ms": 1882.64,
"histogram": [
9733,
205,
121,
49,
12,
9,
8,
2,
2,
0,
1,
2,
1,
3,
0,
3,
2,
0,
0,
2,
4,
5,
1,
3
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 33679,
"seconds": 12.0,
"avg_fps": 2806.557,
"low_1pct_fps": 400.567,
"low_01pct_fps": 103.864,
"p50_ms": 0.324,
"p95_ms": 0.411,
"p99_ms": 1.167,
"p999_ms": 3.508,
"max_ms": 83.572,
"histogram": [
33272,
315,
46,
22,
2,
1,
4,
2,
9,
1,
2,
2,
0,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 20188,
"seconds": 12.0,
"avg_fps": 1682.292,
"low_1pct_fps": 175.594,
"low_01pct_fps": 51.28,
"p50_ms": 0.374,
"p95_ms": 1.37,
"p99_ms": 2.821,
"p999_ms": 8.947,
"max_ms": 53.494,
"histogram": [
18109,
1592,
314,
83,
36,
19,
7,
6,
6,
1,
3,
1,
4,
2,
3,
0,
1,
1,
0,
0,
0,
0,
0,
0
]
}
],
"label": "vanilla-desktop"
}
+787
View File
@@ -0,0 +1,787 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-04T15:46:25Z",
"app": {
"name": "minecraft",
"version": "26.3",
"mods": [
{
"name": "fabric-loader",
"version": "0.19.5"
}
]
},
"hardware": {
"os": "linux",
"arch": "x86_64",
"cpu_threads": 24,
"os_version": "NixOS 26.05 (Yarara) (kernel 6.18.57)",
"cpu": "13th Gen Intel(R) Core(TM) i7-13700KF",
"ram_mb": 31904,
"gpu": {
"name": "NVIDIA GeForce RTX 4080",
"vendor": "NVIDIA",
"driver": "1.4.329 NVIDIA 595.71.05",
"backend": "vulkan",
"kind": ""
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 32,
"simulation_distance": 12,
"vsync": false,
"offscreen": false,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 28.799,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 112742.179,
"stdev": 27885.804,
"min": 81110.692,
"max": 133773.748,
"ci95": 69277.769
},
"startup.world_ms": {
"n": 3,
"mean": 235049.179,
"stdev": 31461.958,
"min": 203486.748,
"max": 266409.692,
"ci95": 78162.144
},
"chunks.village.load_ms": {
"n": 3,
"mean": 79828.05,
"stdev": 109107.365,
"min": 16685.972,
"max": 205814.266,
"ci95": 271059.597
},
"fps.village.avg_fps": {
"n": 3,
"mean": 525.667,
"stdev": 390.505,
"min": 101.073,
"max": 869.433,
"ci95": 970.146
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 44.209,
"stdev": 39.491,
"min": 1.644,
"max": 79.658,
"ci95": 98.109
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 10.635,
"stdev": 9.759,
"min": 0.686,
"max": 20.192,
"ci95": 24.244
},
"memory.village.gpu_mb": {
"n": 3,
"mean": 34.333,
"stdev": 36.95,
"min": 13.0,
"max": 77.0,
"ci95": 91.797
},
"memory.village.rss_mb": {
"n": 3,
"mean": 3296.229,
"stdev": 296.464,
"min": 2956.234,
"max": 3500.773,
"ci95": 736.517
},
"tick.village.client_ms": {
"n": 3,
"mean": 1.23,
"stdev": 0.449,
"min": 0.897,
"max": 1.74,
"ci95": 1.114
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 61172.527,
"stdev": 17434.762,
"min": 46779.931,
"max": 80559.429,
"ci95": 43313.846
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 599.408,
"stdev": 492.96,
"min": 64.173,
"max": 1034.813,
"ci95": 1224.68
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 61.923,
"stdev": 56.534,
"min": 0.737,
"max": 112.221,
"ci95": 140.449
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 15.771,
"stdev": 14.11,
"min": 0.098,
"max": 27.463,
"ci95": 35.054
},
"memory.forest.gpu_mb": {
"n": 3,
"mean": 34.333,
"stdev": 36.95,
"min": 13.0,
"max": 77.0,
"ci95": 91.797
},
"memory.forest.rss_mb": {
"n": 3,
"mean": 3278.964,
"stdev": 301.465,
"min": 2947.268,
"max": 3536.27,
"ci95": 748.941
},
"tick.forest.client_ms": {
"n": 3,
"mean": 5.908,
"stdev": 8.813,
"min": 0.693,
"max": 16.084,
"ci95": 21.895
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 61762.098,
"stdev": 3569.17,
"min": 57919.097,
"max": 64972.973,
"ci95": 8867.026
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 1175.044,
"stdev": 131.94,
"min": 1093.801,
"max": 1327.281,
"ci95": 327.785
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 119.758,
"stdev": 68.419,
"min": 77.252,
"max": 198.683,
"ci95": 169.976
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 24.224,
"stdev": 18.982,
"min": 12.308,
"max": 46.114,
"ci95": 47.159
},
"memory.ocean.gpu_mb": {
"n": 3,
"mean": 34.333,
"stdev": 36.95,
"min": 13.0,
"max": 77.0,
"ci95": 91.797
},
"memory.ocean.rss_mb": {
"n": 3,
"mean": 3275.349,
"stdev": 317.864,
"min": 2924.113,
"max": 3543.23,
"ci95": 789.681
},
"tick.ocean.client_ms": {
"n": 3,
"mean": 0.654,
"stdev": 0.066,
"min": 0.579,
"max": 0.699,
"ci95": 0.163
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 113439.529,
"stdev": 64746.162,
"min": 55046.887,
"max": 183068.386,
"ci95": 160851.363
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 485.832,
"stdev": 317.254,
"min": 121.312,
"max": 699.617,
"ci95": 788.165
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 28.612,
"stdev": 23.334,
"min": 2.229,
"max": 46.542,
"ci95": 57.97
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 4.56,
"stdev": 3.408,
"min": 0.75,
"max": 7.318,
"ci95": 8.467
},
"memory.mountain.gpu_mb": {
"n": 3,
"mean": 34.333,
"stdev": 36.95,
"min": 13.0,
"max": 77.0,
"ci95": 91.797
},
"memory.mountain.rss_mb": {
"n": 3,
"mean": 3248.708,
"stdev": 480.962,
"min": 2700.645,
"max": 3600.484,
"ci95": 1194.873
},
"tick.mountain.client_ms": {
"n": 3,
"mean": 1.832,
"stdev": 2.076,
"min": 0.618,
"max": 4.229,
"ci95": 5.157
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 3371.958,
"stdev": 280.347,
"min": 3059.926,
"max": 3602.613,
"ci95": 696.476
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 3291.249,
"stdev": 316.441,
"min": 2935.975,
"max": 3542.848,
"ci95": 786.147
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 1248,
"seconds": 12.348,
"avg_fps": 101.073,
"low_1pct_fps": 1.644,
"low_01pct_fps": 0.686,
"p50_ms": 1.225,
"p95_ms": 11.161,
"p99_ms": 226.597,
"p999_ms": 1457.605,
"max_ms": 1457.874,
"histogram": [
548,
232,
97,
118,
51,
49,
24,
39,
16,
17,
13,
6,
3,
2,
5,
3,
2,
2,
4,
2,
3,
8,
1,
3
]
},
{
"phase": "village",
"repeat": 1,
"frames": 10435,
"seconds": 12.002,
"avg_fps": 869.433,
"low_1pct_fps": 79.658,
"low_01pct_fps": 20.192,
"p50_ms": 0.813,
"p95_ms": 2.537,
"p99_ms": 5.685,
"p999_ms": 13.292,
"max_ms": 351.251,
"histogram": [
7598,
2071,
393,
171,
55,
53,
22,
24,
20,
10,
9,
2,
2,
1,
3,
0,
0,
0,
0,
0,
0,
1,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 7278,
"seconds": 12.0,
"avg_fps": 606.495,
"low_1pct_fps": 51.326,
"low_01pct_fps": 11.026,
"p50_ms": 1.105,
"p95_ms": 3.854,
"p99_ms": 7.067,
"p999_ms": 24.484,
"max_ms": 443.95,
"histogram": [
3206,
2621,
800,
360,
112,
71,
34,
26,
12,
13,
3,
5,
2,
7,
2,
1,
1,
0,
1,
0,
0,
1,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 975,
"seconds": 15.193,
"avg_fps": 64.173,
"low_1pct_fps": 0.737,
"low_01pct_fps": 0.098,
"p50_ms": 0.864,
"p95_ms": 5.083,
"p99_ms": 77.689,
"p999_ms": 10225.237,
"max_ms": 10225.237,
"histogram": [
680,
99,
104,
12,
30,
13,
8,
4,
2,
3,
2,
1,
1,
3,
0,
1,
0,
1,
2,
3,
1,
2,
1,
2
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 8391,
"seconds": 12.0,
"avg_fps": 699.237,
"low_1pct_fps": 72.811,
"low_01pct_fps": 19.752,
"p50_ms": 1.087,
"p95_ms": 2.927,
"p99_ms": 5.909,
"p999_ms": 15.268,
"max_ms": 252.557,
"histogram": [
3342,
3998,
656,
231,
56,
26,
15,
19,
12,
12,
11,
6,
1,
3,
1,
0,
0,
1,
0,
0,
0,
1,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 12418,
"seconds": 12.0,
"avg_fps": 1034.813,
"low_1pct_fps": 112.221,
"low_01pct_fps": 27.463,
"p50_ms": 0.837,
"p95_ms": 1.394,
"p99_ms": 3.491,
"p999_ms": 13.217,
"max_ms": 245.59,
"histogram": [
11155,
1004,
100,
64,
29,
16,
16,
10,
2,
6,
5,
3,
4,
2,
0,
1,
0,
0,
0,
0,
1,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 13126,
"seconds": 12.0,
"avg_fps": 1093.801,
"low_1pct_fps": 77.252,
"low_01pct_fps": 12.308,
"p50_ms": 0.714,
"p95_ms": 1.402,
"p99_ms": 2.875,
"p999_ms": 12.067,
"max_ms": 238.158,
"histogram": [
11605,
1207,
191,
54,
17,
11,
7,
12,
4,
4,
2,
3,
2,
1,
0,
0,
0,
0,
1,
2,
3,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 15928,
"seconds": 12.0,
"avg_fps": 1327.281,
"low_1pct_fps": 198.683,
"low_01pct_fps": 46.114,
"p50_ms": 0.67,
"p95_ms": 1.172,
"p99_ms": 2.111,
"p999_ms": 7.067,
"max_ms": 193.743,
"histogram": [
14891,
856,
102,
42,
7,
6,
8,
6,
0,
5,
3,
1,
0,
0,
0,
0,
0,
0,
0,
0,
1,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 13249,
"seconds": 12.0,
"avg_fps": 1104.051,
"low_1pct_fps": 83.338,
"low_01pct_fps": 14.249,
"p50_ms": 0.711,
"p95_ms": 1.403,
"p99_ms": 3.046,
"p999_ms": 10.625,
"max_ms": 451.869,
"histogram": [
11824,
1165,
124,
58,
13,
16,
11,
14,
7,
7,
3,
2,
1,
0,
0,
0,
1,
0,
0,
1,
1,
1,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 1469,
"seconds": 12.109,
"avg_fps": 121.312,
"low_1pct_fps": 2.229,
"low_01pct_fps": 0.75,
"p50_ms": 2.12,
"p95_ms": 13.173,
"p99_ms": 125.189,
"p999_ms": 727.045,
"max_ms": 1939.723,
"histogram": [
196,
440,
438,
136,
50,
36,
24,
25,
22,
17,
17,
13,
6,
11,
8,
4,
1,
4,
4,
4,
3,
7,
2,
1
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 8396,
"seconds": 12.001,
"avg_fps": 699.617,
"low_1pct_fps": 46.542,
"low_01pct_fps": 7.318,
"p50_ms": 1.022,
"p95_ms": 2.433,
"p99_ms": 5.528,
"p999_ms": 13.993,
"max_ms": 305.464,
"histogram": [
3248,
4509,
361,
113,
52,
37,
21,
26,
14,
4,
3,
2,
0,
0,
0,
0,
0,
0,
1,
1,
2,
2,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 7639,
"seconds": 12.0,
"avg_fps": 636.566,
"low_1pct_fps": 37.064,
"low_01pct_fps": 5.612,
"p50_ms": 1.023,
"p95_ms": 3.0,
"p99_ms": 7.11,
"p999_ms": 17.521,
"max_ms": 268.877,
"histogram": [
3051,
3794,
413,
158,
66,
43,
35,
21,
31,
12,
3,
4,
1,
1,
0,
0,
0,
0,
0,
0,
4,
2,
0,
0
]
}
],
"label": "vanilla-desktop"
}
+771
View File
@@ -0,0 +1,771 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-04T12:54:29Z",
"app": {
"name": "minecraft",
"version": "26.3",
"mods": [
{
"name": "fabric-loader",
"version": "0.19.5"
}
]
},
"hardware": {
"os": "macos",
"arch": "aarch64",
"cpu_threads": 14,
"os_version": "27.0.1",
"cpu": "Apple M4 Pro",
"ram_mb": 65536,
"gpu": {
"name": "Apple M4 Pro",
"vendor": "Apple",
"driver": "4.1 Metal - 91.7",
"backend": "opengl",
"kind": ""
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 12,
"simulation_distance": 12,
"vsync": false,
"offscreen": false,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 17.628,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 12652.182,
"stdev": 3231.342,
"min": 9526.004,
"max": 15979.309,
"ci95": 8027.746
},
"startup.world_ms": {
"n": 3,
"mean": 31329.516,
"stdev": 10016.129,
"min": 20777.004,
"max": 40705.309,
"ci95": 24883.451
},
"chunks.village.load_ms": {
"n": 3,
"mean": 12623.084,
"stdev": 4958.724,
"min": 7251.986,
"max": 17026.894,
"ci95": 12319.147
},
"fps.village.avg_fps": {
"n": 3,
"mean": 92.729,
"stdev": 58.527,
"min": 28.713,
"max": 143.496,
"ci95": 145.402
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 16.52,
"stdev": 10.949,
"min": 4.001,
"max": 24.308,
"ci95": 27.2
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 11.515,
"stdev": 7.155,
"min": 3.549,
"max": 17.394,
"ci95": 17.774
},
"memory.village.rss_mb": {
"n": 3,
"mean": 1834.469,
"stdev": 329.198,
"min": 1455.688,
"max": 2051.516,
"ci95": 817.839
},
"tick.village.client_ms": {
"n": 3,
"mean": 1.903,
"stdev": 1.114,
"min": 1.131,
"max": 3.18,
"ci95": 2.767
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 14590.694,
"stdev": 3260.615,
"min": 10942.613,
"max": 17221.108,
"ci95": 8100.47
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 106.817,
"stdev": 104.558,
"min": 43.857,
"max": 227.513,
"ci95": 259.759
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 23.0,
"stdev": 26.394,
"min": 7.338,
"max": 53.473,
"ci95": 65.57
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 14.841,
"stdev": 15.773,
"min": 5.157,
"max": 33.042,
"ci95": 39.186
},
"memory.forest.rss_mb": {
"n": 3,
"mean": 1814.245,
"stdev": 154.974,
"min": 1635.453,
"max": 1910.125,
"ci95": 385.007
},
"tick.forest.client_ms": {
"n": 3,
"mean": 1.452,
"stdev": 0.542,
"min": 0.826,
"max": 1.777,
"ci95": 1.346
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 13309.049,
"stdev": 8088.157,
"min": 6394.403,
"max": 22203.194,
"ci95": 20093.716
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 164.405,
"stdev": 141.4,
"min": 62.162,
"max": 325.77,
"ci95": 351.285
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 36.242,
"stdev": 37.051,
"min": 14.736,
"max": 79.024,
"ci95": 92.047
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 20.898,
"stdev": 17.899,
"min": 8.855,
"max": 41.466,
"ci95": 44.468
},
"memory.ocean.rss_mb": {
"n": 3,
"mean": 1724.453,
"stdev": 347.692,
"min": 1323.5,
"max": 1942.734,
"ci95": 863.784
},
"tick.ocean.client_ms": {
"n": 3,
"mean": 0.866,
"stdev": 0.248,
"min": 0.58,
"max": 1.03,
"ci95": 0.617
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 17989.43,
"stdev": 3969.295,
"min": 13444.423,
"max": 20774.261,
"ci95": 9861.07
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 162.084,
"stdev": 160.057,
"min": 68.44,
"max": 346.896,
"ci95": 397.635
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 33.74,
"stdev": 36.146,
"min": 11.158,
"max": 75.429,
"ci95": 89.798
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 17.194,
"stdev": 14.232,
"min": 7.903,
"max": 33.579,
"ci95": 35.357
},
"memory.mountain.rss_mb": {
"n": 3,
"mean": 1977.602,
"stdev": 108.627,
"min": 1872.516,
"max": 2089.453,
"ci95": 269.866
},
"tick.mountain.client_ms": {
"n": 3,
"mean": 0.772,
"stdev": 0.332,
"min": 0.463,
"max": 1.123,
"ci95": 0.825
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 2135.313,
"stdev": 54.617,
"min": 2072.344,
"max": 2169.828,
"ci95": 135.686
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 1836.44,
"stdev": 223.694,
"min": 1578.141,
"max": 1965.781,
"ci95": 555.732
},
"memory.footprint_peak_mb": {
"n": 3,
"mean": 3253.456,
"stdev": 68.323,
"min": 3178.409,
"max": 3312.05,
"ci95": 169.737
},
"memory.footprint_steady_mb": {
"n": 3,
"mean": 3144.363,
"stdev": 69.868,
"min": 3069.394,
"max": 3207.66,
"ci95": 173.576
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 1272,
"seconds": 12.002,
"avg_fps": 105.979,
"low_1pct_fps": 21.25,
"low_01pct_fps": 17.394,
"p50_ms": 7.527,
"p95_ms": 22.34,
"p99_ms": 40.461,
"p999_ms": 54.28,
"max_ms": 60.7,
"histogram": [
0,
0,
0,
56,
161,
170,
180,
167,
184,
118,
71,
61,
26,
25,
28,
12,
10,
3,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 1723,
"seconds": 12.007,
"avg_fps": 143.496,
"low_1pct_fps": 24.308,
"low_01pct_fps": 13.602,
"p50_ms": 5.317,
"p95_ms": 16.181,
"p99_ms": 29.771,
"p999_ms": 56.832,
"max_ms": 90.209,
"histogram": [
0,
0,
63,
413,
271,
273,
185,
148,
112,
89,
47,
43,
28,
22,
18,
4,
5,
1,
1,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 346,
"seconds": 12.05,
"avg_fps": 28.713,
"low_1pct_fps": 4.001,
"low_01pct_fps": 3.549,
"p50_ms": 23.684,
"p95_ms": 110.057,
"p99_ms": 225.236,
"p999_ms": 281.776,
"max_ms": 281.776,
"histogram": [
0,
0,
0,
4,
8,
6,
6,
12,
21,
20,
23,
25,
24,
32,
50,
28,
24,
27,
17,
11,
6,
2,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 589,
"seconds": 12.001,
"avg_fps": 49.081,
"low_1pct_fps": 8.19,
"low_01pct_fps": 6.325,
"p50_ms": 15.443,
"p95_ms": 53.004,
"p99_ms": 94.69,
"p999_ms": 158.114,
"max_ms": 158.114,
"histogram": [
0,
0,
0,
2,
7,
20,
30,
46,
53,
48,
56,
62,
61,
66,
63,
18,
22,
20,
10,
4,
1,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 2731,
"seconds": 12.004,
"avg_fps": 227.513,
"low_1pct_fps": 53.473,
"low_01pct_fps": 33.042,
"p50_ms": 3.801,
"p95_ms": 7.955,
"p99_ms": 14.177,
"p999_ms": 24.479,
"max_ms": 38.218,
"histogram": [
0,
0,
526,
1157,
432,
257,
151,
88,
46,
32,
13,
15,
6,
6,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 527,
"seconds": 12.016,
"avg_fps": 43.857,
"low_1pct_fps": 7.338,
"low_01pct_fps": 5.157,
"p50_ms": 16.784,
"p95_ms": 57.353,
"p99_ms": 111.984,
"p999_ms": 193.905,
"max_ms": 193.905,
"histogram": [
0,
0,
0,
1,
2,
9,
16,
29,
40,
61,
50,
54,
52,
62,
57,
35,
28,
11,
13,
5,
2,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 746,
"seconds": 12.001,
"avg_fps": 62.162,
"low_1pct_fps": 14.965,
"low_01pct_fps": 12.372,
"p50_ms": 13.76,
"p95_ms": 35.144,
"p99_ms": 57.63,
"p999_ms": 80.827,
"max_ms": 80.827,
"histogram": [
0,
0,
2,
13,
12,
41,
37,
53,
67,
82,
80,
99,
82,
76,
58,
17,
11,
12,
4,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 3910,
"seconds": 12.002,
"avg_fps": 325.77,
"low_1pct_fps": 79.024,
"low_01pct_fps": 41.466,
"p50_ms": 2.685,
"p95_ms": 5.125,
"p99_ms": 8.342,
"p999_ms": 20.782,
"max_ms": 30.571,
"histogram": [
0,
78,
2526,
892,
203,
95,
49,
27,
13,
11,
7,
3,
2,
3,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 1268,
"seconds": 12.044,
"avg_fps": 105.282,
"low_1pct_fps": 14.736,
"low_01pct_fps": 8.855,
"p50_ms": 6.542,
"p95_ms": 25.245,
"p99_ms": 50.465,
"p999_ms": 79.065,
"max_ms": 146.809,
"histogram": [
0,
0,
4,
171,
190,
169,
167,
133,
105,
93,
53,
43,
35,
41,
26,
13,
11,
10,
3,
1,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 822,
"seconds": 12.011,
"avg_fps": 68.44,
"low_1pct_fps": 14.632,
"low_01pct_fps": 10.101,
"p50_ms": 11.396,
"p95_ms": 35.387,
"p99_ms": 55.24,
"p999_ms": 99.005,
"max_ms": 99.005,
"histogram": [
0,
0,
0,
10,
42,
62,
55,
86,
89,
97,
64,
73,
74,
72,
48,
21,
17,
8,
4,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 4165,
"seconds": 12.006,
"avg_fps": 346.896,
"low_1pct_fps": 75.429,
"low_01pct_fps": 33.579,
"p50_ms": 2.541,
"p95_ms": 4.838,
"p99_ms": 7.998,
"p999_ms": 20.209,
"max_ms": 53.577,
"histogram": [
0,
640,
2307,
855,
178,
93,
34,
22,
15,
3,
3,
8,
2,
2,
2,
0,
0,
1,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 851,
"seconds": 12.0,
"avg_fps": 70.916,
"low_1pct_fps": 11.158,
"low_01pct_fps": 7.903,
"p50_ms": 10.128,
"p95_ms": 36.498,
"p99_ms": 68.434,
"p999_ms": 126.541,
"max_ms": 126.541,
"histogram": [
0,
0,
0,
38,
61,
87,
76,
69,
84,
100,
63,
53,
68,
53,
48,
15,
9,
16,
9,
2,
0,
0,
0,
0
]
}
],
"label": "vanilla-macmini"
}
+771
View File
@@ -0,0 +1,771 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-04T13:08:37Z",
"app": {
"name": "minecraft",
"version": "26.3",
"mods": [
{
"name": "fabric-loader",
"version": "0.19.5"
}
]
},
"hardware": {
"os": "macos",
"arch": "aarch64",
"cpu_threads": 14,
"os_version": "27.0.1",
"cpu": "Apple M4 Pro",
"ram_mb": 65536,
"gpu": {
"name": "Apple M4 Pro",
"vendor": "Apple",
"driver": "4.1 Metal - 91.7",
"backend": "opengl",
"kind": ""
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 32,
"simulation_distance": 12,
"vsync": false,
"offscreen": false,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 18.159,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 8607.88,
"stdev": 949.596,
"min": 8010.323,
"max": 9702.854,
"ci95": 2359.119
},
"startup.world_ms": {
"n": 3,
"mean": 44182.88,
"stdev": 4031.933,
"min": 40172.323,
"max": 48235.854,
"ci95": 10016.685
},
"chunks.village.load_ms": {
"n": 3,
"mean": 17227.171,
"stdev": 5351.234,
"min": 13892.279,
"max": 23399.566,
"ci95": 13294.274
},
"fps.village.avg_fps": {
"n": 3,
"mean": 76.237,
"stdev": 45.661,
"min": 28.922,
"max": 120.041,
"ci95": 113.437
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 17.319,
"stdev": 12.99,
"min": 5.593,
"max": 31.283,
"ci95": 32.272
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 12.642,
"stdev": 9.463,
"min": 4.308,
"max": 22.93,
"ci95": 23.51
},
"memory.village.rss_mb": {
"n": 3,
"mean": 2674.987,
"stdev": 30.71,
"min": 2641.352,
"max": 2701.531,
"ci95": 76.294
},
"tick.village.client_ms": {
"n": 3,
"mean": 1.341,
"stdev": 0.407,
"min": 0.924,
"max": 1.737,
"ci95": 1.012
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 34112.389,
"stdev": 19305.487,
"min": 22633.094,
"max": 56401.09,
"ci95": 47961.36
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 72.848,
"stdev": 51.859,
"min": 18.307,
"max": 121.528,
"ci95": 128.837
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 22.096,
"stdev": 20.299,
"min": 3.973,
"max": 44.03,
"ci95": 50.429
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 16.093,
"stdev": 14.608,
"min": 3.688,
"max": 32.194,
"ci95": 36.291
},
"memory.forest.rss_mb": {
"n": 3,
"mean": 2519.729,
"stdev": 82.097,
"min": 2426.297,
"max": 2580.328,
"ci95": 203.957
},
"tick.forest.client_ms": {
"n": 3,
"mean": 1.115,
"stdev": 0.411,
"min": 0.726,
"max": 1.545,
"ci95": 1.02
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 19066.516,
"stdev": 9372.33,
"min": 13163.781,
"max": 29873.383,
"ci95": 23284.039
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 69.735,
"stdev": 46.656,
"min": 16.524,
"max": 103.635,
"ci95": 115.909
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 17.636,
"stdev": 13.616,
"min": 3.179,
"max": 30.217,
"ci95": 33.827
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 12.115,
"stdev": 10.798,
"min": 2.584,
"max": 23.842,
"ci95": 26.825
},
"memory.ocean.rss_mb": {
"n": 3,
"mean": 2557.143,
"stdev": 84.375,
"min": 2463.781,
"max": 2627.945,
"ci95": 209.616
},
"tick.ocean.client_ms": {
"n": 3,
"mean": 1.056,
"stdev": 0.233,
"min": 0.815,
"max": 1.281,
"ci95": 0.58
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 62214.712,
"stdev": 4884.13,
"min": 58937.045,
"max": 67828.14,
"ci95": 12133.83
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 51.809,
"stdev": 7.118,
"min": 45.235,
"max": 59.369,
"ci95": 17.685
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 6.336,
"stdev": 1.097,
"min": 5.545,
"max": 7.588,
"ci95": 2.725
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 3.458,
"stdev": 0.756,
"min": 2.827,
"max": 4.296,
"ci95": 1.878
},
"memory.mountain.rss_mb": {
"n": 3,
"mean": 2604.227,
"stdev": 15.043,
"min": 2588.234,
"max": 2618.094,
"ci95": 37.371
},
"tick.mountain.client_ms": {
"n": 3,
"mean": 0.957,
"stdev": 0.03,
"min": 0.926,
"max": 0.985,
"ci95": 0.074
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 2731.016,
"stdev": 56.767,
"min": 2688.516,
"max": 2795.484,
"ci95": 141.03
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 2577.339,
"stdev": 77.295,
"min": 2488.203,
"max": 2625.875,
"ci95": 192.028
},
"memory.footprint_peak_mb": {
"n": 3,
"mean": 4612.571,
"stdev": 20.245,
"min": 4595.363,
"max": 4634.879,
"ci95": 50.296
},
"memory.footprint_steady_mb": {
"n": 3,
"mean": 4271.925,
"stdev": 26.308,
"min": 4246.207,
"max": 4298.785,
"ci95": 65.357
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 349,
"seconds": 12.067,
"avg_fps": 28.922,
"low_1pct_fps": 5.593,
"low_01pct_fps": 4.308,
"p50_ms": 24.684,
"p95_ms": 92.764,
"p99_ms": 150.338,
"p999_ms": 232.136,
"max_ms": 232.136,
"histogram": [
0,
0,
0,
0,
0,
0,
2,
3,
13,
17,
26,
31,
40,
46,
50,
32,
25,
20,
30,
10,
4,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 1441,
"seconds": 12.004,
"avg_fps": 120.041,
"low_1pct_fps": 31.283,
"low_01pct_fps": 22.93,
"p50_ms": 7.267,
"p95_ms": 13.622,
"p99_ms": 23.449,
"p999_ms": 42.564,
"max_ms": 44.657,
"histogram": [
0,
0,
0,
0,
0,
64,
543,
397,
218,
100,
54,
24,
20,
7,
7,
5,
2,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 958,
"seconds": 12.013,
"avg_fps": 79.749,
"low_1pct_fps": 15.082,
"low_01pct_fps": 10.689,
"p50_ms": 9.138,
"p95_ms": 28.848,
"p99_ms": 52.247,
"p999_ms": 93.55,
"max_ms": 93.55,
"histogram": [
0,
0,
0,
0,
0,
19,
144,
232,
148,
111,
64,
56,
59,
57,
32,
17,
9,
6,
4,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 220,
"seconds": 12.017,
"avg_fps": 18.307,
"low_1pct_fps": 3.973,
"low_01pct_fps": 3.688,
"p50_ms": 40.635,
"p95_ms": 157.363,
"p99_ms": 235.253,
"p999_ms": 271.153,
"max_ms": 271.153,
"histogram": [
0,
0,
0,
0,
0,
0,
0,
0,
1,
3,
6,
14,
17,
25,
25,
17,
27,
33,
24,
16,
11,
1,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 1459,
"seconds": 12.005,
"avg_fps": 121.528,
"low_1pct_fps": 44.03,
"low_01pct_fps": 32.194,
"p50_ms": 7.381,
"p95_ms": 12.386,
"p99_ms": 18.354,
"p999_ms": 29.428,
"max_ms": 32.695,
"histogram": [
0,
0,
0,
0,
0,
0,
357,
642,
300,
80,
28,
28,
14,
7,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 945,
"seconds": 12.006,
"avg_fps": 78.709,
"low_1pct_fps": 18.284,
"low_01pct_fps": 12.397,
"p50_ms": 9.554,
"p95_ms": 27.657,
"p99_ms": 43.253,
"p999_ms": 80.663,
"max_ms": 80.663,
"histogram": [
0,
0,
0,
0,
0,
0,
18,
304,
191,
119,
67,
59,
53,
70,
34,
15,
10,
3,
2,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 201,
"seconds": 12.164,
"avg_fps": 16.524,
"low_1pct_fps": 3.179,
"low_01pct_fps": 2.584,
"p50_ms": 44.933,
"p95_ms": 155.019,
"p99_ms": 263.919,
"p999_ms": 387.044,
"max_ms": 387.044,
"histogram": [
0,
0,
0,
0,
0,
0,
0,
0,
1,
2,
4,
7,
20,
8,
29,
18,
25,
29,
29,
17,
8,
4,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 1244,
"seconds": 12.004,
"avg_fps": 103.635,
"low_1pct_fps": 30.217,
"low_01pct_fps": 23.842,
"p50_ms": 8.349,
"p95_ms": 17.445,
"p99_ms": 28.276,
"p999_ms": 38.037,
"max_ms": 45.85,
"histogram": [
0,
0,
0,
0,
0,
0,
162,
453,
312,
129,
71,
47,
32,
18,
16,
3,
1,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 1069,
"seconds": 12.005,
"avg_fps": 89.047,
"low_1pct_fps": 19.512,
"low_01pct_fps": 9.92,
"p50_ms": 9.541,
"p95_ms": 20.819,
"p99_ms": 31.409,
"p999_ms": 99.668,
"max_ms": 101.942,
"histogram": [
0,
0,
0,
0,
0,
0,
64,
266,
283,
160,
112,
74,
51,
33,
17,
4,
1,
2,
1,
1,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 551,
"seconds": 12.181,
"avg_fps": 45.235,
"low_1pct_fps": 5.545,
"low_01pct_fps": 3.252,
"p50_ms": 14.921,
"p95_ms": 50.78,
"p99_ms": 105.154,
"p999_ms": 307.476,
"max_ms": 307.476,
"histogram": [
0,
0,
0,
0,
0,
0,
0,
0,
42,
137,
72,
58,
50,
47,
57,
39,
20,
15,
8,
3,
2,
1,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 718,
"seconds": 12.094,
"avg_fps": 59.369,
"low_1pct_fps": 7.588,
"low_01pct_fps": 4.296,
"p50_ms": 12.178,
"p95_ms": 38.273,
"p99_ms": 65.694,
"p999_ms": 232.789,
"max_ms": 232.789,
"histogram": [
0,
0,
0,
0,
0,
0,
0,
0,
140,
202,
115,
72,
48,
47,
43,
19,
19,
6,
4,
0,
3,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 610,
"seconds": 12.003,
"avg_fps": 50.822,
"low_1pct_fps": 5.875,
"low_01pct_fps": 2.827,
"p50_ms": 13.894,
"p95_ms": 44.208,
"p99_ms": 75.023,
"p999_ms": 353.686,
"max_ms": 353.686,
"histogram": [
0,
0,
0,
0,
0,
0,
0,
0,
67,
115,
125,
88,
55,
54,
43,
25,
13,
15,
7,
0,
1,
2,
0,
0
]
}
],
"label": "vanilla-macmini"
}
@@ -68,7 +68,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
true,
))?;
assert!(!renderer.pack_entity_shadows(), "no selected pack");
renderer.set_shader_pack(0, None, &[]);
renderer.set_shader_pack(0, None, &[]).unwrap();
assert!(
!renderer.pack_entity_shadows(),
"selected pack without a compiled post"
@@ -165,6 +165,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
}
let (_, notes) = renderer
.set_shader_pack(dimension, Some(post), &[])
.unwrap()
.unwrap();
if break_pipeline {
assert!(
@@ -190,8 +190,9 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
}
let one = [255u8; 4];
let zero = [0u8; 4];
let pixels = render.pixels.get();
let prepared = PreparedTextures::new(
&render.pixels,
&pixels,
render.sprite_size,
render.texture_layers,
&render.sprite_mips,
@@ -206,7 +207,7 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
let mut renderer = pollster::block_on(Renderer::new(
window,
true,
&render.pixels,
&pixels,
render.sprite_size,
render.sprite_minimum_size,
render.texture_layers,
@@ -224,6 +225,8 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
false,
false,
))?;
drop(pixels);
render.pixels.pack();
let state = &scene.renderer;
let [width, height] = o.size.unwrap_or(state.size);
renderer.resize(width, height);
@@ -79,7 +79,10 @@ fn pack(renderer: &mut Renderer, folder: &Path, valid: bool) {
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &renderer.pack_inputs());
assert_eq!(post.notes.is_empty(), valid, "{:?}", post.notes);
let (_, notes) = renderer.set_shader_pack(0, Some(post), &[]).unwrap();
let (_, notes) = renderer
.set_shader_pack(0, Some(post), &[])
.unwrap()
.unwrap();
assert!(
!valid || notes.iter().all(|n| !n.contains("skipped")),
"{notes:?}"
+99 -2
View File
@@ -84,7 +84,9 @@ pub struct Assets {
pub struct RenderAssets {
pub sprite_size: u32,
pub sprite_minimum_size: u32,
pub pixels: Vec<u8>,
/// The block sprites' RGBA, layer by layer: as built until a renderer has
/// them, then kept compressed ([`SpritePixels`]).
pub pixels: SpritePixels,
pub texture_layers: u32,
pub model_catalog: crate::models::StateModels,
/// Shared parsed resources; item-only validation errors surface at item preparation.
@@ -111,6 +113,76 @@ pub struct RenderAssets {
/// sprites are when it changes (`native_block_sprites_version`).
pub generation: u64,
}
/// The block sprites' pixels for a renderer to upload. Once a renderer has
/// them ([`Self::pack`]) they are kept lz4-compressed: the GPU holds the
/// texture array, and the copy only serves a renderer made again (another
/// window, a lost device, another graphics API), which waits for
/// [`Self::get`] to uncompress them. Shared by the window and the workers'
/// `RenderAssets`, so the compressed form replaces the raw one in place.
pub struct SpritePixels(Arc<PixelStore>);
struct PixelStore {
len: usize,
/// Set by the first `pack`: later calls cost one atomic read.
packing: std::sync::atomic::AtomicBool,
/// As built; dropped once `packed` holds them.
raw: std::sync::Mutex<Option<Arc<Vec<u8>>>>,
packed: std::sync::OnceLock<Vec<u8>>,
}
impl SpritePixels {
pub fn new(pixels: Vec<u8>) -> Self {
Self(Arc::new(PixelStore {
len: pixels.len(),
packing: std::sync::atomic::AtomicBool::new(false),
raw: std::sync::Mutex::new(Some(Arc::new(pixels))),
packed: std::sync::OnceLock::new(),
}))
}
/// Their size in bytes, uncompressed.
pub fn len(&self) -> usize {
self.0.len
}
pub fn is_empty(&self) -> bool {
self.0.len == 0
}
/// The pixels as built, uncompressed again once packed.
pub fn get(&self) -> Arc<Vec<u8>> {
let raw = self.0.raw.lock().unwrap_or_else(|e| e.into_inner()).clone();
raw.unwrap_or_else(|| {
let packed = self.0.packed.get().map(Vec::as_slice).unwrap_or_default();
Arc::new(
lz4_flex::decompress_size_prepended(packed)
.ok()
.filter(|p| p.len() == self.0.len)
.unwrap_or_else(|| vec![0; self.0.len]),
)
})
}
/// Keep them compressed from now on, once a renderer has uploaded them:
/// compressed on a thread of their own, the raw copy dropped after.
pub fn pack(&self) {
use std::sync::atomic::Ordering;
if self.0.len == 0 || self.0.packing.swap(true, Ordering::Relaxed) {
return;
}
let store = self.0.clone();
let job = move || {
let raw = store.raw.lock().unwrap_or_else(|e| e.into_inner()).clone();
if let Some(raw) = raw {
let _ = store.packed.set(lz4_flex::compress_prepend_size(&raw));
// `get` reads the compressed copy from here on.
*store.raw.lock().unwrap_or_else(|e| e.into_inner()) = None;
}
};
let fallback = job.clone();
if std::thread::Builder::new()
.name("Sprite packing".into())
.spawn(job)
.is_err()
{
fallback();
}
}
}
/// The last `RenderAssets::generation` handed out.
static GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
fn invalid(message: &str) -> io::Error {
@@ -333,7 +405,7 @@ impl RenderAssets {
sprite_size: sprites.size,
sprite_minimum_size: sprites.minimum_size,
texture_layers: u32::try_from(sprites.count())?,
pixels: sprites.pixels,
pixels: SpritePixels::new(sprites.pixels),
model_catalog: models.catalog,
model_library,
model_placements: models.placements,
@@ -431,4 +503,29 @@ mod tests {
[ConsumeEffect::ApplyEffects { probability, .. }] if probability == 0.3
));
}
#[test]
fn sprite_pixels_come_back_the_same_once_packed() {
// Two layers of 16x16 RGBA with some structure, as sprites have.
let pixels: Vec<u8> = (0..2 * 16 * 16 * 4).map(|i| (i * 7 % 251) as u8).collect();
let stored = super::SpritePixels::new(pixels.clone());
assert_eq!(*stored.get(), pixels);
stored.pack();
stored.pack();
// The packing thread drops the raw copy once the compressed one is set.
let started = std::time::Instant::now();
while stored.0.raw.lock().unwrap().is_some() {
assert!(started.elapsed().as_secs() < 10, "packing never finished");
std::thread::yield_now();
}
assert!(
stored
.0
.packed
.get()
.is_some_and(|p| p.len() < pixels.len())
);
assert_eq!(*stored.get(), pixels);
assert_eq!(stored.len(), pixels.len());
}
}
+42 -13
View File
@@ -275,19 +275,34 @@ pub fn temp_folder() -> std::io::Result<PathBuf> {
/// `PackSelectionScreen.onFilesDrop`'s `PackDetector`: a zip is a pack; a
/// folder is one when it holds a `pack.mcmeta`, or exactly one folder that
/// does (a folder zipped by the system, which adds a wrapper). Copied into
/// `dir`; false when `path` is no pack.
/// `dir`; false when `path` is no pack, or when part of it could not be copied.
pub fn copy_pack(path: &Path, dir: &Path) -> bool {
fn copy_dir(from: &Path, at: &Path) {
let _ = std::fs::create_dir_all(at);
fn copy_dir(from: &Path, at: &Path) -> bool {
let mut whole = true;
if let Err(e) = std::fs::create_dir_all(at) {
log::warn!("Horizon: {} is not made: {e}", at.display());
whole = false;
}
for entry in std::fs::read_dir(from).into_iter().flatten().flatten() {
let path = entry.path();
let to = at.join(entry.file_name());
if path.is_dir() {
copy_dir(&path, &to);
let copied = if path.is_dir() {
copy_dir(&path, &to)
} else {
let _ = std::fs::copy(&path, &to);
}
match std::fs::copy(&path, &to) {
Ok(_) => true,
Err(e) => {
log::warn!(
"Horizon: {} is not copied into the pack: {e}",
path.display()
);
false
}
}
};
whole &= copied;
}
whole
}
let name = || {
path.file_name()
@@ -307,8 +322,7 @@ pub fn copy_pack(path: &Path, dir: &Path) -> bool {
return false;
}
if path.join("pack.mcmeta").is_file() {
copy_dir(path, &dir.join(name()));
return true;
return copy_dir(path, &dir.join(name()));
}
let own: Vec<PathBuf> = std::fs::read_dir(path)
.into_iter()
@@ -318,10 +332,7 @@ pub fn copy_pack(path: &Path, dir: &Path) -> bool {
.filter(|p| p.is_dir() && p.join("pack.mcmeta").is_file())
.collect();
match own.as_slice() {
[one] => {
copy_dir(one, &dir.join(one.file_name().unwrap_or_default()));
true
}
[one] => copy_dir(one, &dir.join(one.file_name().unwrap_or_default())),
_ => false,
}
}
@@ -914,6 +925,24 @@ mod tests {
assert_eq!(content_id("data/mymod/data_maps/item/fuel.txt"), None);
}
#[test]
fn a_pack_that_cannot_be_copied_is_reported() {
let root = std::env::temp_dir().join(format!("hz-datapack-copy-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let pack = root.join("pack");
std::fs::create_dir_all(pack.join("data")).unwrap();
std::fs::write(pack.join("pack.mcmeta"), "{}").unwrap();
std::fs::write(pack.join("data/functions.mcfunction"), "").unwrap();
// A file where the pack's folder goes: every copy under it fails.
let blocked = root.join("blocked");
std::fs::write(&blocked, "").unwrap();
assert!(!copy_pack(&pack, &blocked));
let dir = root.join("packs");
assert!(copy_pack(&pack, &dir));
assert!(dir.join("pack/pack.mcmeta").is_file());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn enabled_packs_override_worldgen_in_order() {
let dir = std::env::temp_dir().join(format!("hz-datapack-worldgen-{}", std::process::id()));
+264 -35
View File
@@ -90,6 +90,20 @@ fn occlusion_culling_requested(info: &wgpu::AdapterInfo, env: Option<&str>) -> b
env != Some("0") && (env.is_some() || info.device_type != wgpu::DeviceType::Cpu)
}
/// `DrawableGizmoPrimitives.getGroup`: a line whose ARGB alpha is 255 joins the
/// opaque group, drawn with `RenderTypes.lines` (depth written); the rest
/// with `RenderTypes.linesTranslucentNoDepthWrite`.
fn debug_line_opaque(line: &[f32; 8]) -> bool {
line[6].to_bits() >> 24 == 0xFF
}
/// The depth-tested gizmo lines in their upload order: the opaque group first,
/// then the translucent one, each in submission order.
fn debug_lines_opaque_first(lines: &[[f32; 8]]) -> impl Iterator<Item = &[f32; 8]> {
let opaque = lines.iter().filter(|l| debug_line_opaque(l));
opaque.chain(lines.iter().filter(|l| !debug_line_opaque(l)))
}
/// Whether Translucent Batching is offered (`graphics::offer_translucent_batching`): where
/// indirect multi-draws (`multi_draw`) take its batches, but not OpenGL's, which loop over
/// their entries on the CPU (and without them every section is a call of its own anyway).
@@ -510,7 +524,8 @@ impl SectionGpu {
}
}
/// GPU culling's record of it at `key`, its quads within `bounds`
/// (`SectionMesh::bounds`), `FADING` while it fades (`gpu_fading`).
/// (`SectionMesh::bounds`) and in their buckets by facing side (`sides`),
/// `FADING` while it fades (`gpu_fading`).
fn record(&self, key: SectionKey, bounds: Option<[Vec3; 2]>) -> gpu_culling::Record {
let boxed = gpu_culling::rounded_box(bounds);
gpu_culling::Record::new(
@@ -520,6 +535,7 @@ impl SectionGpu {
self.solid,
boxed,
)
.facing(&self.sides)
.fading(self.gpu_fading)
}
/// Whether GPU frames' walk lists it within the render distance
@@ -1094,8 +1110,10 @@ pub struct Renderer {
lines_secondary: wgpu::RenderPipeline,
/// A resource pack's `core/rendertype_lines`, which draws the block outline in their place.
core_lines: Option<core_lines::CoreLines>,
/// Gizmo lines (`RenderTypes.linesTranslucentNoDepthWrite`) and the
/// Gizmo lines: opaque ones (`RenderTypes.lines`, which writes depth),
/// translucent ones (`RenderTypes.linesTranslucentNoDepthWrite`) and the
/// always-on-top ones.
debug_lines_opaque: wgpu::RenderPipeline,
debug_lines: wgpu::RenderPipeline,
debug_lines_on_top: wgpu::RenderPipeline,
debug_line_buffer: wgpu::Buffer,
@@ -1219,6 +1237,10 @@ pub struct Renderer {
/// Whether `lightmap` holds `lightmap_pixels` yet.
lightmap_uploaded: bool,
sprite_minimum_size: u32,
/// Each sprite layer's own side, before the arrays scaled it up (`mipmap::Settings::native_size`).
sprite_native: Vec<u32>,
/// The Mipmap Levels option (`set_mipmap_levels`), which sets a shader pack atlas's padding.
mipmap_option: u32,
indices: wgpu::Buffer,
index_quads: u32,
instances: wgpu::Buffer,
@@ -1338,6 +1360,8 @@ pub struct Renderer {
/// The marker file of an HDR output that has not presented a frame yet
/// (`graphics::HDR_MARKER`).
hdr_marker: Option<std::path::PathBuf>,
/// The HDR Lighting setting, kept for the HDR output made when HDR turns on.
hdr_lighting_setting: bool,
/// The shader pack's post-processing, and the dimension (legacy id) it was loaded for.
pack_post: Option<(i32, Option<pack_post::PackPost>)>,
/// The post passes of one of Horizon's own shader packs; never beside `pack_post`'s.
@@ -1427,6 +1451,10 @@ fn requested_split() -> Option<u32> {
.and_then(|v| v.parse().ok())
.filter(|&n| (1..=SPRITE_SPLIT).contains(&n))
}
fn adapter_requested_split(info: &wgpu::AdapterInfo, requested: Option<u32>) -> Option<u32> {
// Metal can fail to sample large mipmapped array textures despite advertising the layer limit.
requested.or_else(|| (info.backend == wgpu::Backend::Metal).then_some(512))
}
/// The split the CPU mip chains are cut by before the adapter is known.
fn sprite_split() -> u32 {
requested_split().unwrap_or(SPRITE_SPLIT)
@@ -1862,10 +1890,11 @@ fn describe_adapter(adapter: &wgpu::Adapter) {
/// Whether an adapter's texture arrays hold the block textures Horizon puts
/// in one array.
fn check_layers(adapter: &wgpu::Adapter, layers: u32) -> Result<u32> {
let info = adapter.get_info();
Ok(layer_split(
adapter.limits().max_texture_array_layers,
adapter.get_info().backend == wgpu::Backend::Gl,
requested_split(),
info.backend == wgpu::Backend::Gl,
adapter_requested_split(&info, requested_split()),
layers,
)?)
}
@@ -2888,6 +2917,19 @@ impl Renderer {
true,
Some(alpha_blend),
);
// RenderPipelines.LINES: DepthStencilState.DEFAULT, so an opaque hitbox or
// chunk border keeps the water and clouds drawn after it behind it.
let debug_lines_opaque = pipeline(
"Debug lines opaque",
"vs_debug_line",
"fs_debug_line",
&debug_line_buffers,
wgpu::PrimitiveTopology::TriangleStrip,
None,
true,
true,
Some(alpha_blend),
);
let debug_lines = pipeline(
"Debug lines",
"vs_debug_line",
@@ -4450,6 +4492,7 @@ impl Renderer {
lines,
lines_secondary,
core_lines: None,
debug_lines_opaque,
debug_lines,
debug_lines_on_top,
debug_line_buffer,
@@ -4541,6 +4584,8 @@ impl Renderer {
shrink_gate: ShrinkGate::default(),
lightmap_uploaded: false,
sprite_minimum_size,
sprite_native: sprite_mips.iter().map(|s| s.native_size).collect(),
mipmap_option: 4,
indices,
index_quads,
instances,
@@ -4595,6 +4640,7 @@ impl Renderer {
frame_gen: frame_gen::FrameGen::new(),
entity_atlas: atlas_view.clone(),
hdr: None,
hdr_lighting_setting: true,
emission_stats,
hdr_output,
frame_format,
@@ -6065,6 +6111,13 @@ impl Renderer {
None => "HDR: not offered by this display".into(),
}
}
/// The HDR Lighting setting (`Hdr::look`).
pub fn set_hdr_lighting(&mut self, on: bool) {
self.hdr_lighting_setting = on;
if let Some(hdr) = &mut self.hdr {
hdr.look = on;
}
}
/// HDR output (Horizon setting): HDR10 or scRGB at `paper_white` nits, the world's
/// highlights expanded up to `peak` nits. Off where the surface can't present it.
/// Offscreen (`hdr::offscreen_output`) the frame is drawn the same and its HDR screenshot
@@ -6109,6 +6162,9 @@ impl Renderer {
let format = self.frame_format;
self.hdr =
output.map(|output| hdr::Hdr::new(&self.device, &self.queue, format, output));
if let Some(hdr) = &mut self.hdr {
hdr.look = self.hdr_lighting_setting;
}
self.configure_surface();
match output {
Some(output) => log::info!(
@@ -6207,13 +6263,14 @@ impl Renderer {
/// Runs `post` over the world from now on (`None`: no pack), for the dimension `dimension`;
/// `block_ids` gives each block sprite layer's `mc_Entity` (id, fluid) and `at_midBlock.w`
/// (its block's light), three values a layer.
/// Returns the programs that run and the notes of loading them.
/// Returns the programs that run and the notes of loading them, or why the pack can't run:
/// the dimension then runs without a pack.
pub fn set_shader_pack(
&mut self,
dimension: i32,
pack: Option<horizon_shaders::Post>,
block_ids: &[i32],
) -> Option<(Vec<String>, Vec<String>)> {
) -> std::result::Result<Option<(Vec<String>, Vec<String>)>, pack_post::Refused> {
if pack.is_none() {
self.pack_atlas = None;
self.pack_material_atlases = None;
@@ -6231,9 +6288,14 @@ impl Renderer {
};
let buffers = [Some(vertex), Some(instances)];
let flat = self.pack_terrain_flat;
let sprite_rects = self
.pack_atlas
.as_ref()
.map_or_else(Vec::new, |a| a.rects_table());
let post = pack.map(|post| {
let world = pack_post::World {
atlas: self.pack_atlas.as_ref().map(|a| (a.view.clone(), a.size())),
sprite_rects: &sprite_rects,
material_maps: self
.pack_material_atlases
.as_ref()
@@ -6272,6 +6334,15 @@ impl Renderer {
world,
)
});
let post = match post.transpose() {
Ok(post) => post,
Err(e) => {
self.pack_post = Some((dimension, None));
self.pack_atlas = None;
self.pack_material_atlases = None;
return Err(e);
}
};
let report = post.as_ref().map(|p| {
let programs = p.programs().into_iter().map(str::to_owned).collect();
(programs, p.notes.clone())
@@ -6280,7 +6351,7 @@ impl Renderer {
if self.pack_post.as_ref().is_some_and(|p| p.1.is_some()) {
self.native = None;
}
report
Ok(report)
}
/// Drops the running shader pack, of either kind.
pub fn drop_shader_packs(&mut self) {
@@ -6375,6 +6446,13 @@ impl Renderer {
}
fn pack_terrain_inputs(&mut self) -> Option<horizon_shaders::GbuffersInputs> {
// Flat draws whenever the device has them (`render` keeps them on with a pack).
let flat = self.flat.is_some() && self.flat_on;
// Flat draws read each sprite's place from a table: the atlas is stitched as the game's.
// Other draws need the grid.
if !flat && self.pack_atlas.as_ref().is_some_and(|a| a.is_stitched()) {
self.pack_atlas = None;
}
if self.pack_atlas.is_none() {
let mut encoder = self.device.create_command_encoder(&Default::default());
let layers = self
@@ -6382,14 +6460,42 @@ impl Renderer {
.iter()
.map(|t| t.depth_or_array_layers())
.sum();
let mut atlas = pack_atlas::PackAtlas::new(
&self.device,
&mut encoder,
&self.sprite_textures,
self.sprite_split,
layers,
self.sprite_textures[0].width(),
);
let sprite = self.sprite_textures[0].width();
// Vanilla's padding counts the anisotropy bit only with anisotropic filtering.
let anisotropy_bit = if self.texture_filtering == 2 {
u32::from(self.max_anisotropy).trailing_zeros()
} else {
0
};
let stitched = flat
.then(|| {
pack_atlas::PackAtlas::stitched(
&self.device,
&mut encoder,
&self.sprite_textures,
self.sprite_split,
sprite,
&self.sprite_native,
self.mipmap_option,
anisotropy_bit,
)
})
.flatten();
if flat && stitched.is_none() {
log::warn!(
"shader pack block atlas: the sprites don't stitch on this device, a grid instead"
);
}
let mut atlas = stitched.or_else(|| {
pack_atlas::PackAtlas::new(
&self.device,
&mut encoder,
&self.sprite_textures,
self.sprite_split,
layers,
sprite,
)
});
if let Some(atlas) = &mut atlas {
atlas.separate_ao = self.separate_ao;
}
@@ -6410,8 +6516,7 @@ impl Renderer {
self.pack_atlas = atlas;
self.queue.submit([encoder.finish()]);
}
// Flat draws whenever the device has them (`render` keeps them on with a pack).
self.pack_terrain_flat = self.flat.is_some() && self.flat_on;
self.pack_terrain_flat = flat;
Some(
self.pack_atlas
.as_ref()?
@@ -6524,6 +6629,7 @@ impl Renderer {
/// The Mipmap Levels option: block textures sample at most `levels`
/// reduced copies (0: none).
pub fn set_mipmap_levels(&mut self, levels: u32) {
self.mipmap_option = levels;
let levels = crate::sprites::sprite_mipmap_limit(
levels,
self.sprite_textures[0].width(),
@@ -6677,12 +6783,18 @@ impl Renderer {
if let Some(atlases) = &self.pack_material_atlases {
for kind in 0..2 {
let arrays: Vec<_> = arrays.iter().map(|pair| pair[kind].clone()).collect();
atlases[kind].copy(encoder, &arrays, self.animations.changed().iter().copied());
atlases[kind].copy(
&self.device,
encoder,
&arrays,
self.animations.changed().iter().copied(),
);
}
}
}
if let Some(atlas) = &self.pack_atlas {
atlas.copy(
&self.device,
encoder,
&self.sprite_textures,
self.animations.changed().iter().copied(),
@@ -7321,6 +7433,7 @@ impl Renderer {
radius,
frustum: &view,
reach: self.occlusion.reach().filter(|_| culling),
eye,
};
let prepared = crate::spans::time("gpu culling", || {
gpu.prepare(
@@ -7332,7 +7445,7 @@ impl Renderer {
)
});
// A frame whose planes leave the test's integers (the camera millions of blocks
// above or below the level) is the CPU's.
// above or below the level), or whose eye is not a number, is the CPU's.
gpu_frame = prepared == gpu_culling::Prepared::Gpu;
if prepared == gpu_culling::Prepared::OutOfMemory {
log::warn!("Horizon renderer: GPU culling off: its buffers do not fit");
@@ -7854,14 +7967,14 @@ impl Renderer {
self.translucency_cursor += budget;
let translucent_draws = self.draws.len() as u32 - solid_draws;
self.section_draws = self.draws.len();
// The shadow camera's sections after the camera's: quads that can face the light, then
// translucent ones (in their last sorted order; depth decides there).
// The shadow camera's sections after the camera's: quads that can face the light (all of
// them for `every_face`), then translucent ones (in their last sorted order; depth
// decides there).
let shadow_first = self.draws.len() as u32;
let mut shadow_translucent = Vec::new();
self.shadow_runs.clear();
if let Some(view) = &shadow_view {
let base = self.drawn_sections;
let toward = view.light * 1.0e5;
// How far below the nearest section to the light (squared, as bits, as `PageRuns`
// takes distances): each band's draws go by page, a call per page and band.
let height = |v: &resident::Visible| glam::Vec3::from(v.origin).dot(view.light);
@@ -7893,13 +8006,7 @@ impl Renderer {
}
}
let before = self.draws.len();
// Every face of a section within the unculled distance.
let unculled = view.unculled.is_some_and(|d| {
let o = glam::Vec3::from(v.origin) + 8.0;
o.x.abs().max(o.z.abs()) <= d + 8.0
});
let toward = (!unculled).then_some(toward);
facing_runs(&s.sides, toward, v.origin, |first, quads| {
shadow_runs(&s.sides, view, v.origin, |first, quads| {
let start = s.solid_base() + first * 4;
self.draws.push([quads * 6, 1, 0, start, instance]);
self.draw_pages.push((s.page, None));
@@ -7958,6 +8065,11 @@ impl Renderer {
self.section_draw_calls += gpu.draw_calls();
}
let debug_depth = frame.debug_lines.len() as u32;
let debug_opaque = frame
.debug_lines
.iter()
.filter(|l| debug_line_opaque(l))
.count() as u32;
let debug_top = frame.debug_lines_on_top.len() as u32;
let crosshair = frame.debug_crosshair.len() as u32;
if debug_depth + debug_top + crosshair > 0 {
@@ -7971,9 +8083,7 @@ impl Renderer {
mapped_at_creation: false,
});
}
let all: Vec<[f32; 8]> = frame
.debug_lines
.iter()
let all: Vec<[f32; 8]> = debug_lines_opaque_first(frame.debug_lines)
.chain(frame.debug_lines_on_top)
.chain(frame.debug_crosshair)
.copied()
@@ -8071,7 +8181,7 @@ impl Renderer {
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
&& std::env::var_os("HORIZON_HDR_LIGHTING").is_none_or(|v| v != "0");
if let Some(hdr) = &mut self.hdr {
hdr.lighting = hdr_lighting;
hdr.lighting = hdr_lighting && hdr.look;
}
let oit_on = self.improved_transparency
&& !frame.title
@@ -9549,10 +9659,16 @@ impl Renderer {
if outline_edges > 0 && !frame.outline_after_terrain && !pack_draws_outline {
self.draw_outline(&mut pass, outline_edges as u32, frame.outline_high_contrast);
}
if debug_depth > 0 {
// DrawableGizmoPrimitives' opaque group writes depth, the translucent one does not.
if debug_opaque > 0 {
pass.set_pipeline(&self.debug_lines_opaque);
pass.set_vertex_buffer(0, self.debug_line_buffer.slice(..));
pass.draw(0..4, 0..debug_opaque);
}
if debug_depth > debug_opaque {
pass.set_pipeline(&self.debug_lines);
pass.set_vertex_buffer(0, self.debug_line_buffer.slice(..));
pass.draw(0..4, 0..debug_depth);
pass.draw(0..4, debug_opaque..debug_depth);
}
if debug_top > 0 {
pass.set_pipeline(&self.debug_lines_on_top);
@@ -11086,6 +11202,22 @@ fn projection_center(view_proj: Mat4) -> Option<Vec3> {
let b = DVec3::new(rows[0].w, rows[1].w, rows[2].w);
Some((a.inverse() * -b).as_vec3())
}
/// The runs of a section's solid quads the shadow pass draws: every face within the unculled
/// distance, or everywhere for `every_face`; else the quads that can face the light.
fn shadow_runs(
sides: &Sides,
view: &pack_post::ShadowView,
origin: [f32; 3],
draw: impl FnMut(u32, u32),
) {
let unculled = view.unculled.is_some_and(|d| {
let o = Vec3::from(origin) + 8.0;
o.x.abs().max(o.z.abs()) <= d + 8.0
});
let toward = (!unculled && !view.every_face).then_some(view.light * 1.0e5);
facing_runs(sides, toward, origin, draw);
}
/// Calls `draw(first, quads)` for each run of a mesh's solid quads that can
/// face `eye` (`projection_center`), the mesh at the camera-relative
/// `origin`; all of them when there is no eye.
@@ -11126,6 +11258,20 @@ fn frustum(m: Mat4) -> [Vec4; 6] {
#[cfg(test)]
mod tests {
#[test]
fn opaque_gizmo_lines_upload_first_for_the_depth_writing_pipeline() {
let line = |argb: u32| [0.0, 0.0, 0.0, 1.0, 0.0, 0.0, f32::from_bits(argb), 2.5];
// A hitbox's white, the chunk border's outer red columns (alpha 0x7F),
// and the chunk border's yellow.
let lines = [line(0xFFFF_FFFF), line(0x7FFF_0000), line(0xFFFF_FF00)];
assert!(super::debug_line_opaque(&lines[0]));
assert!(!super::debug_line_opaque(&lines[1]));
assert!(!super::debug_line_opaque(&line(0xFE00_0000)));
let order: Vec<u32> = super::debug_lines_opaque_first(&lines)
.map(|l| l[6].to_bits())
.collect();
assert_eq!(order, [0xFFFF_FFFF, 0xFFFF_FF00, 0x7FFF_0000]);
}
use crate::gfx::static_entity_shader;
fn adapter(device_type: wgpu::DeviceType) -> wgpu::AdapterInfo {
@@ -12435,6 +12581,30 @@ mod sprite_upload_tests {
assert!(layer_split(512, false, None, 512 * SPRITE_ARRAYS + 1).is_err());
}
#[test]
fn metal_uses_a_conservative_default_split_but_respects_overrides() {
let mut info = wgpu::AdapterInfo {
name: "test adapter".into(),
vendor: 0,
device: 0,
device_type: wgpu::DeviceType::IntegratedGpu,
device_pci_bus_id: String::new(),
driver: String::new(),
driver_info: String::new(),
backend: wgpu::Backend::Vulkan,
subgroup_min_size: 0,
subgroup_max_size: 0,
transient_saves_memory: None,
limit_bucket: None,
};
info.backend = wgpu::Backend::Metal;
assert_eq!(adapter_requested_split(&info, None), Some(512));
assert_eq!(adapter_requested_split(&info, Some(1024)), Some(1024));
info.backend = wgpu::Backend::Vulkan;
assert_eq!(adapter_requested_split(&info, None), None);
}
#[test]
fn mip_chain_allocates_exactly_its_size() {
for size in [16, 64] {
@@ -12632,3 +12802,62 @@ fn reported(l: horizon_hdr::wayland::Luminances) -> crate::graphics::Reported {
black: hundredths_of_a_nit(l.target_min * 203.0 / l.reference.max(1.0)),
}
}
#[cfg(test)]
mod shadow_face_tests {
use super::*;
/// The faces of one block at `at` (section-relative), each with its own layer:
/// 0 +X, 1 -X, 2 +Y (up), 3 -Y (down), 4 +Z, 5 -Z; counter-clockwise from outside.
fn block(at: [f32; 3]) -> Vec<TerrainVertex> {
let [x, y, z] = at;
let (a, b, c) = (x + 1.0, y + 1.0, z + 1.0);
[
[[a, y, z], [a, b, z], [a, b, c], [a, y, c]],
[[x, y, z], [x, y, c], [x, b, c], [x, b, z]],
[[x, b, z], [x, b, c], [a, b, c], [a, b, z]],
[[x, y, z], [a, y, z], [a, y, c], [x, y, c]],
[[x, y, c], [a, y, c], [a, b, c], [x, b, c]],
[[x, y, z], [x, b, z], [a, b, z], [a, y, z]],
]
.into_iter()
.enumerate()
.flat_map(|(layer, q)| q.map(|p| TerrainVertex::new(p, [0.0; 2], layer as u16, 0, 0)))
.collect()
}
/// The layers (faces) of the quads the shadow pass draws of `solid`.
fn drawn(view: &pack_post::ShadowView, origin: [f32; 3]) -> Vec<u16> {
let mut solid = block([7.0, 7.0, 7.0]);
let sides = crate::mesher::sort_tinted(&mut solid, &mut Vec::new());
let mut layers = Vec::new();
shadow_runs(&sides, view, origin, |first, quads| {
for q in first..first + quads {
layers.push(solid[q as usize * 4].layer);
}
});
layers.sort_unstable();
layers
}
fn view(every_face: bool) -> pack_post::ShadowView {
pack_post::ShadowView {
cull: Mat4::IDENTITY,
light: Vec3::Y,
distance: None,
unculled: None,
every_face,
}
}
/// Iris 1.11.7's shadow map holds faces turned away from the light (#9033): the block's
/// bottom face, under a sun straight above, is drawn too.
#[test]
fn the_shadow_pass_draws_faces_turned_away_from_the_light() {
// A section 32 blocks under the camera.
let origin = [-8.0, -40.0, -8.0];
assert_eq!(drawn(&view(true), origin), [0, 1, 2, 3, 4, 5]);
// Horizon's own packs keep drawing only what can face the light: no bottom face.
assert!(!drawn(&view(false), origin).contains(&3));
}
}
@@ -60,6 +60,7 @@ struct Pipelines {
luma_pyramid: wgpu::ComputePipeline,
scd_histogram: wgpu::ComputePipeline,
scd_divergence: wgpu::ComputePipeline,
scd_finalize: wgpu::ComputePipeline,
search: wgpu::ComputePipeline,
filter: wgpu::ComputePipeline,
scale: wgpu::ComputePipeline,
@@ -84,6 +85,7 @@ pub struct OpticalFlow {
frames: [Frame; 2],
filter: [wgpu::BindGroup; LEVELS],
scd_divergence: wgpu::BindGroup,
scd_finalize: wgpu::BindGroup,
scd_histogram: wgpu::Buffer,
scd_previous_histogram: wgpu::Buffer,
scd_temp: wgpu::Buffer,
@@ -183,6 +185,7 @@ impl OpticalFlow {
luma_pyramid: pipeline("luma_pyramid"),
scd_histogram: pipeline("scd_histogram_pass"),
scd_divergence: pipeline("scd_divergence_pass"),
scd_finalize: pipeline("scd_finalize_pass"),
search: pipeline("search_pass"),
filter: pipeline("filter_flow"),
scale: pipeline("scale_pass"),
@@ -235,6 +238,7 @@ impl OpticalFlow {
HISTOGRAM_WIDTH * 4,
);
let scd_temp = buffer(device, "Scene change temp", 12);
let scd_next_histogram = buffer(device, "Next scene change histogram", HISTOGRAM_WIDTH * 4);
let scd_output = buffer(device, "Scene change detection", 12);
let frame = |current: usize| {
let (cur, prev) = (&luma[current], &luma[1 - current]);
@@ -298,11 +302,22 @@ impl OpticalFlow {
let scd_divergence = bind(
device,
&pipelines.scd_divergence,
&[
(7, whole(&scd_histogram)),
(8, whole(&scd_previous_histogram)),
(9, whole(&scd_temp)),
(11, whole(&scd_next_histogram)),
],
);
let scd_finalize = bind(
device,
&pipelines.scd_finalize,
&[
(7, whole(&scd_histogram)),
(8, whole(&scd_previous_histogram)),
(9, whole(&scd_temp)),
(10, whole(&scd_output)),
(11, whole(&scd_next_histogram)),
],
);
Self {
@@ -312,6 +327,7 @@ impl OpticalFlow {
frames,
filter,
scd_divergence,
scd_finalize,
scd_histogram,
scd_previous_histogram,
scd_temp,
@@ -406,6 +422,7 @@ impl OpticalFlow {
9,
);
run(&p.scd_divergence, &self.scd_divergence, 9, 3, 1);
run(&p.scd_finalize, &self.scd_finalize, 9, 1, 1);
for level in (0..LEVELS).rev() {
let luma = self.display.map(|v| (v >> level).max(1));
run(
@@ -597,6 +614,22 @@ pub(super) mod tests {
.collect()
}
fn scd(device: &wgpu::Device, queue: &wgpu::Queue, flow: &OpticalFlow) -> u32 {
let mut encoder = device.create_command_encoder(&Default::default());
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 12,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_buffer_to_buffer(flow.scene_change(), 0, &buffer, 0, 12);
queue.submit([encoder.finish()]);
buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let bytes = buffer.slice(..).get_mapped_range().unwrap().to_vec();
u32::from_le_bytes(bytes[4..8].try_into().unwrap())
}
/// A frame moved right by 16 and down by 8: the blocks inside report where they were
/// (-16, -8), and nothing reads as a scene change. A much darker frame then does.
#[test]
@@ -630,24 +663,40 @@ pub(super) mod tests {
inside.len(),
&inside[..8]
);
let scd = |device: &wgpu::Device, queue: &wgpu::Queue, flow: &OpticalFlow| {
let mut encoder = device.create_command_encoder(&Default::default());
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 12,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_buffer_to_buffer(flow.scene_change(), 0, &buffer, 0, 12);
queue.submit([encoder.finish()]);
buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let bytes = buffer.slice(..).get_mapped_range().unwrap().to_vec();
u32::from_le_bytes(bytes[4..8].try_into().unwrap())
};
assert_eq!(scd(&device, &queue, &flow) & 0xf, 0);
run(&device, &queue, &mut flow, &other, false);
assert_eq!(scd(&device, &queue, &flow) & 1, 1);
assert!(vectors(&device, &queue, &flow).iter().all(|v| *v == [0, 0]));
}
#[test]
fn repeated_cuts_and_stable_frames_update_the_history() {
let Some((device, queue)) = device() else {
return;
};
let size = [384, 216];
let bright = upload(
&device,
&queue,
size,
&vec![255; (size[0] * size[1] * 4) as usize],
);
let dark_pixels: Vec<u8> = (0..size[0] * size[1])
.flat_map(|_| [0, 0, 0, 255])
.collect();
let dark = upload(&device, &queue, size, &dark_pixels);
let mut flow = OpticalFlow::new(&device, size);
run(&device, &queue, &mut flow, &bright, true);
for _ in 0..6 {
run(&device, &queue, &mut flow, &bright, false);
}
for frame in 0..16 {
let color = if frame % 2 == 0 { &dark } else { &bright };
run(&device, &queue, &mut flow, color, false);
assert_eq!(scd(&device, &queue, &flow) & 1, 1, "cut {frame}");
assert!(vectors(&device, &queue, &flow).iter().all(|v| *v == [0, 0]));
run(&device, &queue, &mut flow, color, false);
assert_eq!(scd(&device, &queue, &flow) & 1, 0, "stable frame {frame}");
}
}
}
@@ -53,10 +53,10 @@ struct Params {
@group(0) @binding(8) var<storage, read_write> scd_previous_histogram: array<f32>;
@group(0) @binding(9) var<storage, read_write> scd_temp: array<atomic<u32>, 3>;
@group(0) @binding(10) var<storage, read_write> scd_output: array<atomic<u32>, 3>;
@group(0) @binding(11) var<storage, read_write> scd_next_histogram: array<f32>;
const SCD_OUTPUT_SCENE_CHANGE_SLOT: u32 = 0u;
const SCD_OUTPUT_HISTORY_BITS_SLOT: u32 = 1u;
const SCD_OUTPUT_COMPLETED_WORKGROUPS_SLOT: u32 = 2u;
fn load_luma(t: texture_2d<f32>, p: vec2<i32>) -> u32 {
let size = vec2<i32>(textureDimensions(t));
@@ -266,7 +266,6 @@ fn scd_divergence_pass(
@builtin(local_invocation_index) local_index: u32,
) {
let factor = 1000000.0;
let where_to_stop = 3u * 9u - 1u;
let histogram_count = 9.0;
let kernel = array(0.0088122291, 0.027143577, 0.065114059, 0.12164907, 0.17699835, 0.20056541);
let li = i32(local_index);
@@ -326,27 +325,32 @@ fn scd_divergence_pass(
let res_float = 1.0 - exp(-(abs(sum.x) + abs(sum.y)));
let res_uint = u32((res_float / histogram_count) * factor);
atomicAdd(&scd_temp[gid.y], res_uint);
let finished = atomicAdd(&scd_output[SCD_OUTPUT_COMPLETED_WORKGROUPS_SLOT], 1u);
if finished == where_to_stop {
let res0 = atomicLoad(&scd_temp[0]);
let res1 = atomicLoad(&scd_temp[1]);
let res2 = atomicLoad(&scd_temp[2]);
let scene_change = f32(min(res0, min(res1, res2))) / factor;
var history = atomicLoad(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT]) << 1u;
if scene_change > 0.45 {
history |= 1u;
}
atomicStore(&scd_output[SCD_OUTPUT_SCENE_CHANGE_SLOT], bitcast<u32>(scene_change));
atomicStore(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT], history);
atomicStore(&scd_output[SCD_OUTPUT_COMPLETED_WORKGROUPS_SLOT], 0u);
atomicStore(&scd_temp[0], 0u);
atomicStore(&scd_temp[1], 0u);
atomicStore(&scd_temp[2], 0u);
}
}
if gid.y == 1u {
scd_previous_histogram[gid.x] = current;
atomicStore(&scd_histogram[gid.x], 0u);
scd_next_histogram[gid.x] = current;
}
}
// All divergence workgroups must finish reading both histograms before either is
// replaced. A separate dispatch also makes every partial sum visible to the reducer.
@compute @workgroup_size(256)
fn scd_finalize_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
scd_previous_histogram[gid.x] = scd_next_histogram[gid.x];
atomicStore(&scd_histogram[gid.x], 0u);
if gid.x == 0u {
let res0 = atomicLoad(&scd_temp[0]);
let res1 = atomicLoad(&scd_temp[1]);
let res2 = atomicLoad(&scd_temp[2]);
let scene_change = f32(min(res0, min(res1, res2))) / 1000000.0;
var history = atomicLoad(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT]) << 1u;
if scene_change > 0.45 {
history |= 1u;
}
atomicStore(&scd_output[SCD_OUTPUT_SCENE_CHANGE_SLOT], bitcast<u32>(scene_change));
atomicStore(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT], history);
atomicStore(&scd_temp[0], 0u);
atomicStore(&scd_temp[1], 0u);
atomicStore(&scd_temp[2], 0u);
}
}
File diff suppressed because it is too large. Load diff
@@ -1,15 +1,18 @@
// GPU culling (gfx/gpu_culling.rs). `apply` writes the records that changed.
// `cull`: one invocation per section id keeps the sections whose column is
// within the render distance, that cave culling's graph reaches (occlusion.rs)
// and whose box is in the frustum, writes each one's origin and counts it in
// its page, per block of 64 ids. `scan`: one workgroup per page turns the
// blocks' counts into where each block's draws start, and the page's count.
// `scatter`: each block writes its sections' draws there, in id order, so a
// page's draws come in the same order every frame (atomics alone would order
// them as the GPU schedules, and where faces of two sections meet at the same
// depth the later draw keeps the pixel). Nothing here computes with floats: the
// origins are the CPU's bits, copied, and the frustum test adds integers the
// CPU computed. `cull` leaves sections fading in or out to the CPU (`FADING`).
// and whose box is in the frustum, writes each one's origin and the runs of its
// solid quads that can face the eye, a draw each, and counts them in its page,
// per block of 64 ids. `scan`: one workgroup per page turns the blocks' counts
// into where each block's draws start, and the page's count. `scatter`: each
// block writes its sections' draws there, in id order and each section's in its
// runs' order, so a page's draws come in the same order every frame (atomics
// alone would order them as the GPU schedules, and where faces of two sections
// meet at the same depth the later draw keeps the pixel). Nothing here computes
// with floats: the origins are the CPU's bits, copied, the frustum test adds
// integers the CPU computed, and facing compares keys that order as the floats
// the CPU compares. `cull` leaves sections fading in or out to the CPU
// (`FADING`).
struct Params {
// Ids to test, records `apply` writes, the arena pages the counts and
@@ -31,12 +34,13 @@ struct Params {
// Each frustum plane's w term, its margin in, then 0, 0.
w: array<vec4<i32>, 2>,
// Cave culling's graph (occlusion.rs `Reach`): from x, from z, radius, lowest
// section y; its height in sections and the level's; REACH and SKY; 0.
// section y; its height in sections and the level's; REACH, SKY and EYE;
// where the eye's keys start in `tables`.
reach: vec4<i32>,
reach_height: i32,
reach_sections: i32,
flags: u32,
pad: u32,
eyes: u32,
}
// gpu_culling.rs `Record`.
@@ -44,9 +48,16 @@ struct Section {
// Section x, y (from the level's bottom), z, and its arena page (-1: a
// free id).
key: vec4<i32>,
// The first vertex of its solid quads, its solid quads, its box
// (gpu_culling.rs `rounded_box`), then its flags (`FADING`).
// The first vertex of its solid quads, its solid quads (where its last
// bucket by facing side ends), its box (gpu_culling.rs `rounded_box`), then
// its flags (`FADING`).
a: vec4<u32>,
// Where its first six buckets end (mesher/sides.rs `BUCKETS`): +X, +Z, the
// other bucket, +Y, -Z, -X.
ends: array<u32, 6>,
// What its buckets facing +X, +Z, +Y, -Z, -X and -Y compare the eye's keys
// with (gpu_culling.rs `Record::facing`).
keys: array<u32, 6>,
}
// gpu_culling.rs `Update`: the record for id `id.x`.
@@ -59,8 +70,9 @@ struct Update {
@group(0) @binding(1) var<storage, read_write> sections: array<Section>;
// gpu_culling.rs `tables`: the origins' f32 bits along x, y and z (n of them);
// per frustum plane its terms for those origins, n each; per plane and axis its
// terms for the 32 box codes; then each page's region of `draws` (its first
// draw and its size).
// terms for the 32 box codes; with an eye (`EYE`), the keys of the eye less
// each origin, n of them; then each page's region of `draws` (its first draw
// and its size).
@group(0) @binding(2) var<storage, read> tables: array<u32>;
@group(0) @binding(3) var<storage, read> updates: array<Update>;
// Sections kept, draws, each page's draws, then per block of ids and page its
@@ -70,7 +82,8 @@ struct Update {
@group(0) @binding(5) var<storage, read_write> draws: array<u32>;
// Per id the instance it is drawn with: origin and UV step, as f32 bits.
@group(0) @binding(6) var<storage, read_write> origins: array<vec4<u32>>;
// Per id its page + 1 when it is drawn this frame, else 0.
// Per id `RUN_WORDS`: its page << PAGE_SHIFT | its draws when it is drawn this
// frame, else 0; then each draw's first quad and quads.
@group(0) @binding(7) var<storage, read_write> runs: array<u32>;
// occlusion.rs `Reach::bits`: whether the search reached each cell of its box,
// a bit each (32 a word, lowest first).
@@ -93,6 +106,13 @@ const REACH: u32 = 1u;
const SKY: u32 = 2u;
// In a section's `a.w`: it fades in or out, and the CPU draws it with its fade.
const FADING: u32 = 1u;
// In `params.flags`: solid quads facing away from the eye are left out, its
// keys in `tables` from `params.eyes`.
const EYE: u32 = 4u;
// Words per id in `runs`, and where its page starts in the first, its draws
// (at most 4: gpu_culling.rs `RUNS`) below.
const RUN_WORDS: u32 = 9u;
const PAGE_SHIFT: u32 = 3u;
// The count of `block`'s draws in `page` (`cull`), then where they start (`scan`).
fn block_count(block: u32, page: u32) -> u32 {
@@ -170,6 +190,34 @@ fn in_frustum(c: vec3<u32>, b: u32) -> bool {
return true;
}
// Where bucket `b` of section `id` ends (mesher/sides.rs `BUCKETS` order): its
// solid quads for the last.
fn bucket_end(id: u32, b: u32) -> u32 {
if b == 6u {
return sections[id].a.y;
}
return sections[id].ends[b];
}
// mesher/sides.rs `Sides::visible_from` for bucket `b` of section `id` (+X, +Z,
// the other bucket, +Y, -Z, -X, -Y), with `eye` the keys of the eye less its
// origin: whether its quads can face the eye. -Y compares the eye's own y, as a
// GPU frame has no water mirror. The keys order as the floats the CPU compares.
fn seen(id: u32, b: u32, eye: vec3<u32>) -> bool {
if b == 2u {
return true;
}
let t = sections[id].keys[b - select(0u, 1u, b > 2u)];
switch b {
case 0u: { return eye.x > t; }
case 1u: { return eye.z > t; }
case 3u: { return eye.y > t; }
case 4u: { return eye.z < t; }
case 5u: { return eye.x < t; }
default: { return eye.y < t; }
}
}
// One invocation per changed record; the CPU lists each id once.
@compute @workgroup_size(64)
fn apply(@builtin(global_invocation_id) gid: vec3<u32>) {
@@ -186,7 +234,8 @@ fn cull(@builtin(global_invocation_id) gid: vec3<u32>) {
if id >= params.ids {
return;
}
runs[id] = 0u;
let at = id * RUN_WORDS;
runs[at] = 0u;
let s = sections[id];
if s.key.w < 0 || s.a.y == 0u || (s.a.w & FADING) != 0u || !near(s.key.x, s.key.z)
|| !reached(s.key.xyz) {
@@ -199,11 +248,52 @@ fn cull(@builtin(global_invocation_id) gid: vec3<u32>) {
if page >= params.pages || any(c >= params.cells.xyz) || !in_frustum(c, s.a.z) {
return;
}
// Kept, whether or not a quad of it can face the eye.
atomicAdd(&counts[0], 1u);
let nx = params.cells.x;
let ny = params.cells.y;
origins[id] = vec4<u32>(tables[c.x], tables[nx + c.y], tables[nx + ny + c.z], STEP_BITS);
runs[id] = page + 1u;
atomicAdd(&counts[block_count(id / BLOCK, page)], 1u);
// gfx.rs `facing_runs`: the runs of its buckets that can face the eye, an
// empty bucket breaking none, a draw each; without an eye, its solid quads.
var n = 0u;
if (params.flags & EYE) == 0u {
runs[at + 1u] = 0u;
runs[at + 2u] = s.a.y;
n = 1u;
} else {
let e = params.eyes;
let eye = vec3<u32>(tables[e + c.x], tables[e + nx + c.y], tables[e + nx + ny + c.z]);
var start = 0u;
var first = 0u;
var open = false;
for (var b = 0u; b < 7u; b++) {
let end = bucket_end(id, b);
if end == start {
continue;
}
let shown = seen(id, b, eye);
if shown && !open {
open = true;
first = start;
} else if !shown && open {
runs[at + 1u + 2u * n] = first;
runs[at + 2u + 2u * n] = start - first;
n += 1u;
open = false;
}
start = end;
}
if open {
runs[at + 1u + 2u * n] = first;
runs[at + 2u + 2u * n] = start - first;
n += 1u;
}
}
if n == 0u {
return;
}
runs[at] = (page << PAGE_SHIFT) | n;
atomicAdd(&counts[block_count(id / BLOCK, page)], n);
}
var<workgroup> partial: array<u32, 256>;
@@ -242,49 +332,54 @@ fn scan(@builtin(workgroup_id) group: vec3<u32>, @builtin(local_invocation_index
start += count;
}
if lane == 255u {
// A region has room for every record of its page; were one short,
// its draws past it go.
// A region has room for every draw of its page's records; were one
// short, its draws past it go.
let total = min(partial[255], region(page).y);
atomicStore(&counts[STATS + page], total);
atomicAdd(&counts[0], partial[255]);
atomicAdd(&counts[1], total);
}
}
var<workgroup> held: array<u32, 64>;
// One workgroup per block of ids: each section's draw goes after those of the
// block's earlier sections in its page.
// One workgroup per block of ids: each section's draws go after those of the
// block's earlier sections in its page, in its runs' order.
@compute @workgroup_size(64)
fn scatter(@builtin(workgroup_id) group: vec3<u32>, @builtin(local_invocation_index) lane: u32) {
let block = group.y * ROW + group.x;
let id = block * BLOCK + lane;
var mine = 0u;
if id < params.ids {
mine = runs[id];
mine = runs[id * RUN_WORDS];
}
held[lane] = mine;
workgroupBarrier();
if mine == 0u {
// Its draws below PAGE_SHIFT, its page above.
let n = mine & 7u;
if n == 0u {
return;
}
let page = mine - 1u;
let page = mine >> PAGE_SHIFT;
var slot = atomicLoad(&counts[block_count(block, page)]);
for (var other = 0u; other < lane; other++) {
if held[other] == mine {
slot += 1u;
// A section not drawn holds 0: no draws.
if (held[other] >> PAGE_SHIFT) == page {
slot += held[other] & 7u;
}
}
let r = region(page);
if slot >= r.y {
return;
let at = id * RUN_WORDS;
let base = sections[id].a.x;
for (var k = 0u; k < n; k++) {
if slot + k >= r.y {
return;
}
// A run of its solid quads, from its first vertex, with its origin.
let d = (r.x + slot + k) * 5u;
draws[d] = runs[at + 2u + 2u * k] * 6u;
draws[d + 1u] = 1u;
draws[d + 2u] = 0u;
draws[d + 3u] = base + runs[at + 1u + 2u * k] * 4u;
draws[d + 4u] = id;
}
// All of its solid quads, from its first vertex, with its origin.
let s = sections[id];
let d = (r.x + slot) * 5u;
draws[d] = s.a.y * 6u;
draws[d + 1u] = 1u;
draws[d + 2u] = 0u;
draws[d + 3u] = s.a.x;
draws[d + 4u] = id;
}
+5 -1
View File
@@ -168,6 +168,9 @@ pub struct Hdr {
/// HDR lighting: off while a shader pack's post or material stage runs, the pack's own, and
/// with `HORIZON_HDR_LIGHTING=0`, to compare.
pub lighting: bool,
/// The HDR Lighting setting: off, nothing in the world is lifted, light sources and the
/// sun included, so the picture is SDR's on the HDR canvas.
pub look: bool,
/// Calibration's test picture while it runs.
pub calibration: Option<Calibration>,
}
@@ -313,6 +316,7 @@ impl Hdr {
ui: 1.0,
masks: MASKS,
lighting: true,
look: true,
calibration: None,
}
}
@@ -553,7 +557,7 @@ impl Hdr {
params[..5].copy_from_slice(&[
paper / 80.0,
(self.peak / paper).max(1.0),
f32::from(u8::from(world)),
f32::from(u8::from(world && self.look)),
f32::from(u8::from(self.debug)),
f32::from(u8::from(pq)),
]);
@@ -59,6 +59,25 @@ fn chosen_cells(
chosen.unwrap_or(d.cells)
}
/// `[shadow]` with the option values `constants`: none while its `option` is off, else the
/// resolution and distance its `resolution_option` and `distance_option` pick.
fn chosen_shadow(
s: &horizon_shader_api::manifest::Shadow,
options: &[horizon_shader_api::manifest::PackOption],
constants: &[(String, f64)],
) -> Option<horizon_shader_api::manifest::Shadow> {
let number = |id: Option<&String>| {
let option = options.iter().find(|o| Some(&o.id) == id)?;
let (_, value) = constants.iter().find(|(k, _)| *k == option.id)?;
option.kind.number(*value)
};
option_on(constants, s.option.as_ref()).then(|| horizon_shader_api::manifest::Shadow {
resolution: number(s.resolution_option.as_ref()).map_or(s.resolution, |r| r as u32),
distance: number(s.distance_option.as_ref()).map_or(s.distance, |d| d as f32),
..s.clone()
})
}
impl NativePack {
/// The pack of the checked stages `report` with the option values `constants`, drawing
/// onto the world's targets as `terrain` describes; the sky reads the terrain's group 0.
@@ -74,8 +93,12 @@ impl NativePack {
) -> Result<(Self, Vec<String>), String> {
let (globals, format) = (terrain.globals, terrain.format);
let textures = super::native_post::pack_textures(device, queue, &report);
let settings = report
// `[shadow]`, unless its option is off, at the size its options pick.
let chosen = report
.shadow
.as_ref()
.and_then(|s| chosen_shadow(s, &report.manifest.options, constants));
let settings = chosen
.as_ref()
.map(|settings| super::native_shadow::fitted(device, settings));
let mut frame = NativeFrame::new(device, settings.as_ref(), textures, lights, terrain.pbr);
@@ -103,8 +126,7 @@ impl NativePack {
report.shadow.is_some(),
)
});
let shadow = report
.shadow
let shadow = chosen
.as_ref()
.map(|s| {
let vertex = report.stages.iter().find(|s| s.kind == StageKind::Vertex);
@@ -240,3 +262,61 @@ impl NativePack {
|| self.post.is_some()
}
}
#[cfg(test)]
mod tests {
use super::chosen_shadow;
use horizon_shader_api::manifest::{OptionKind, PackOption, Shadow};
fn option(id: &str, kind: OptionKind) -> PackOption {
PackOption {
id: id.into(),
name: id.into(),
description: String::new(),
kind,
}
}
#[test]
fn the_shadow_map_follows_its_options() {
let options = [
option("shadows", OptionKind::Bool { default: true }),
option(
"quality",
OptionKind::Choice {
values: vec!["1024".into(), "4096".into()],
default: 0,
},
),
option(
"reach",
OptionKind::Int {
default: 96,
min: 32,
max: 512,
},
),
];
let shadow = Shadow {
option: Some("shadows".into()),
resolution_option: Some("quality".into()),
distance_option: Some("reach".into()),
..Shadow::default()
};
let values = |on: f64, quality: f64, reach: f64| {
[("shadows", on), ("quality", quality), ("reach", reach)].map(|(k, v)| (k.into(), v))
};
let size = |values: &[(String, f64)]| {
chosen_shadow(&shadow, &options, values).map(|s| (s.resolution, s.distance))
};
assert_eq!(size(&values(1.0, 1.0, 200.0)), Some((4096, 200.0)));
assert_eq!(size(&values(1.0, 0.0, 64.0)), Some((1024, 64.0)));
assert_eq!(size(&values(0.0, 1.0, 200.0)), None);
// Without the options' keys: the manifest's own numbers, always drawn.
assert_eq!(
chosen_shadow(&Shadow::default(), &options, &values(0.0, 1.0, 200.0))
.map(|s| (s.resolution, s.distance)),
Some((2048, 128.0))
);
}
}
@@ -366,6 +366,7 @@ impl NativeFrame {
// the sun's azimuth while the sun is low.
distance: None,
unculled: None,
every_face: false,
})
}
@@ -806,12 +806,7 @@ mod tests {
#[test]
fn what_the_camera_stands_on_is_inside_the_shadow_cameras_depth() {
let settings = Settings {
resolution: 2048,
distance: 128.0,
solid_materials: Vec::new(),
};
let shadow = camera(&settings);
let shadow = camera(&Settings::default());
let (view, projection) = super::super::pack_uniforms::shadow_camera(
glam::Vec3::new(0.3, 0.9, 0.0).normalize(),
glam::DVec3::new(-2064.5, 246.0, -4304.5),
@@ -827,11 +822,7 @@ mod tests {
#[test]
fn the_centre_stays_near_the_player_on_both_sides_of_zero() {
let shadow = camera(&Settings {
resolution: 2048,
distance: 128.0,
solid_materials: Vec::new(),
});
let shadow = camera(&Settings::default());
let light = glam::Vec3::Y;
for x in [5.5, -5.5, -2064.5, 2064.5] {
let camera = glam::DVec3::new(x, 64.0, x);
@@ -1,7 +1,14 @@
//! The block atlas of shader packs (spec 5.1, `atlasSize` in 5.2): packs sample one 2D atlas
//! with atlas coordinates, Horizon keeps block sprites in texture arrays. With a pack, every
//! layer is copied into a grid, row by row from the top left, and an animated layer again
//! whenever its frame changes.
//! layer is copied into the atlas, and an animated layer again whenever its frame changes.
//!
//! Flat draws, which read a table per sprite layer, get the game's own layout
//! ([`PackAtlas::stitched`]): every sprite at its own size, placed by vanilla's `Stitcher` with its
//! padding; this gives the `atlasSize` and spans our probes read in Iris 1.11.7. Other draws get
//! a grid of array-sized cells ([`PackAtlas::new`]).
#[path = "pack_atlas/stitch.rs"]
mod stitch;
pub(super) struct PackAtlas {
texture: wgpu::Texture,
@@ -14,6 +21,82 @@ pub(super) struct PackAtlas {
split: u32,
/// OptiFine/Iris separate ambient occlusion: pass vertex alpha to hz_Color.a.
pub separate_ao: bool,
/// The stitched layout, when the atlas has one.
stitched: Option<Stitched>,
}
/// A stitched atlas: where each layer's sprite is, and the pipeline that draws them in.
struct Stitched {
/// Each layer's sprite at the first level: its top left corner and its side.
rects: Vec<[u32; 3]>,
padding: u32,
pipeline: wgpu::RenderPipeline,
/// One per sprite array.
groups: Vec<wgpu::BindGroup>,
}
/// One sprite drawn into one level of a stitched atlas (`BLIT`'s instance).
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct BlitSprite {
/// The sprite's top left corner, side and padding at that level.
rect: [i32; 4],
/// Its layer in its array, the array's level it comes from, the atlas level's size.
source: [u32; 4],
}
/// Draws sprites into a stitched atlas level, padding included: a pixel of the padding repeats
/// the sprite's nearest edge pixel, as vanilla's `animate_sprite` shaders do with a clamped sampler.
const BLIT: &str = r#"
struct Out {
@builtin(position) position: vec4<f32>,
@location(0) @interpolate(flat) rect: vec4<i32>,
@location(1) @interpolate(flat) source: vec2<u32>,
}
@group(0) @binding(0) var sprites: texture_2d_array<f32>;
@vertex
fn vs(@builtin(vertex_index) v: u32, @location(0) rect: vec4<i32>, @location(1) source: vec4<u32>) -> Out {
var corners = array<vec2<f32>, 6>(vec2(0.0, 0.0), vec2(1.0, 0.0), vec2(0.0, 1.0),
vec2(0.0, 1.0), vec2(1.0, 0.0), vec2(1.0, 1.0));
let pixel = vec2<f32>(rect.xy - rect.w) + corners[v] * f32(rect.z + 2 * rect.w);
let size = vec2<f32>(source.zw);
var out: Out;
out.position = vec4(pixel.x / size.x * 2.0 - 1.0, 1.0 - pixel.y / size.y * 2.0, 0.0, 1.0);
out.rect = rect;
out.source = source.xy;
return out;
}
@fragment
fn fs(in: Out) -> @location(0) vec4<f32> {
let texel = clamp(vec2<i32>(floor(in.position.xy)) - in.rect.xy, vec2(0), vec2(in.rect.z - 1));
return textureLoad(sprites, texel, i32(in.source.x), i32(in.source.y));
}
"#;
/// The side a layer of `sprite`-wide arrays has in a stitched atlas: its own, when the arrays
/// hold it scaled up by a power of two (else the array's).
fn atlas_side(native: u32, sprite: u32) -> u32 {
if native == 0
|| native > sprite
|| !sprite.is_multiple_of(native)
|| !(sprite / native).is_power_of_two()
{
sprite
} else {
native
}
}
/// `SpriteLoader.stitch`'s mip level: the option, lowered to fit the smallest sprite.
fn stitch_mip_level(sides: &[u32], option: u32) -> u32 {
let mut lowest = 1u32 << option.min(16);
let mut smallest = u32::MAX;
for &side in sides {
smallest = smallest.min(side);
lowest = lowest.min(side & side.wrapping_neg());
}
let level = smallest.min(lowest).max(1).ilog2();
level.min(option)
}
impl PackAtlas {
@@ -91,11 +174,173 @@ impl PackAtlas {
sprite,
split,
separate_ao: false,
stitched: None,
};
atlas.copy(encoder, arrays, 0..layers);
atlas.copy(device, encoder, arrays, 0..layers);
Some(atlas)
}
/// Every layer of `arrays` at its own size (`native`, its side before the arrays scaled it up
/// to `sprite`), placed as vanilla's `Stitcher` places them with the padding of the Mipmap
/// Levels option and the anisotropy bit; `None` when they don't fit the device.
#[allow(clippy::too_many_arguments)]
pub fn stitched(
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
arrays: &[wgpu::Texture],
split: u32,
sprite: u32,
native: &[u32],
mip_option: u32,
anisotropy_bit: u32,
) -> Option<Self> {
let sides: Vec<u32> = if native.is_empty() {
vec![sprite]
} else {
native.iter().map(|&n| atlas_side(n, sprite)).collect()
};
// A level of the atlas comes from the arrays' level as many halvings further down.
let array_levels = arrays[0].mip_level_count();
let deepest = sides
.iter()
.map(|&s| (sprite / s).ilog2())
.max()
.unwrap_or(0);
let mip =
stitch_mip_level(&sides, mip_option).min(array_levels.saturating_sub(deepest + 1));
let max = device.limits().max_texture_dimension_2d;
let layout = stitch::stitch(&sides, mip, anisotropy_bit, max)?;
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Shader pack block atlas"),
size: wgpu::Extent3d {
width: layout.width.max(1),
height: layout.height.max(1),
depth_or_array_layers: 1,
},
mip_level_count: mip + 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_DST
| wgpu::TextureUsages::COPY_SRC
| wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Shader pack atlas sprites"),
source: wgpu::ShaderSource::Wgsl(BLIT.into()),
});
let group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Shader pack atlas sprites"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2Array,
multisampled: false,
},
count: None,
}],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Shader pack atlas sprites"),
bind_group_layouts: &[Some(&group_layout)],
..Default::default()
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Shader pack atlas sprites"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<BlitSprite>() as u64,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &wgpu::vertex_attr_array![0 => Sint32x4, 1 => Uint32x4],
})],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(wgpu::TextureFormat::Rgba8Unorm.into())],
}),
multiview_mask: None,
cache: None,
});
let groups = arrays
.iter()
.map(|array| {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader pack atlas sprites"),
layout: &group_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&array.create_view(
&wgpu::TextureViewDescriptor {
dimension: Some(wgpu::TextureViewDimension::D2Array),
..Default::default()
},
)),
}],
})
})
.collect();
let rects = layout
.holders
.iter()
.zip(&sides)
.map(|(&[x, y], &side)| [x + layout.padding, y + layout.padding, side])
.collect::<Vec<_>>();
let layers = rects.len() as u32;
let atlas = Self {
texture,
view,
columns: 1,
sprite,
split,
separate_ao: false,
stitched: Some(Stitched {
rects,
padding: layout.padding,
pipeline,
groups,
}),
};
atlas.copy(device, encoder, arrays, 0..layers);
Some(atlas)
}
/// Whether the sprites keep their own sizes ([`PackAtlas::stitched`]).
pub fn is_stitched(&self) -> bool {
self.stitched.is_some()
}
/// The table flat terrain reads (`hz_sprite_rects`): per layer, its sprite's top left corner
/// (x | y << 16) and side, in atlas pixels.
pub fn rects_table(&self) -> Vec<u32> {
if let Some(stitched) = &self.stitched {
return stitched
.rects
.iter()
.flat_map(|&[x, y, side]| [x | (y << 16), side])
.collect();
}
(0..self.columns * self.texture.height() / self.sprite)
.flat_map(|layer| {
let (x, y) = (layer % self.columns, layer / self.columns);
[(x * self.sprite) | ((y * self.sprite) << 16), self.sprite]
})
.collect()
}
/// The atlas's size in pixels (`atlasSize`).
pub fn size(&self) -> [u32; 2] {
[self.texture.width(), self.texture.height()]
@@ -104,10 +349,14 @@ impl PackAtlas {
/// Copies `layers` from the sprite arrays again, every level.
pub fn copy(
&self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
arrays: &[wgpu::Texture],
layers: impl IntoIterator<Item = u32>,
) {
if let Some(stitched) = &self.stitched {
return self.draw_sprites(stitched, device, encoder, layers);
}
for layer in layers {
let Some(array) = arrays.get((layer / self.split) as usize) else {
continue;
@@ -145,6 +394,91 @@ impl PackAtlas {
}
}
}
/// Draws `layers` into every level of a stitched atlas, from the arrays' level where each
/// sprite has its own size.
fn draw_sprites(
&self,
stitched: &Stitched,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
layers: impl IntoIterator<Item = u32>,
) {
let mut layers: Vec<u32> = layers
.into_iter()
.filter(|&l| (l as usize) < stitched.rects.len())
.collect();
if layers.is_empty() {
return;
}
layers.sort_unstable_by_key(|&l| (l / self.split, l));
let levels = self.texture.mip_level_count();
let mut sprites = Vec::with_capacity(layers.len() * levels as usize);
for level in 0..levels {
let size = [
self.texture.width() >> level,
self.texture.height() >> level,
];
for &layer in &layers {
let [x, y, side] = stitched.rects[layer as usize];
sprites.push(BlitSprite {
rect: [x, y, side, stitched.padding].map(|v| (v >> level) as i32),
source: [
layer % self.split,
(self.sprite / side).ilog2() + level,
size[0],
size[1],
],
});
}
}
let buffer = wgpu::util::DeviceExt::create_buffer_init(
device,
&wgpu::util::BufferInitDescriptor {
label: Some("Shader pack atlas sprites"),
contents: bytemuck::cast_slice(&sprites),
usage: wgpu::BufferUsages::VERTEX,
},
);
let per_level = layers.len() as u32;
for level in 0..levels {
let view = self.texture.create_view(&wgpu::TextureViewDescriptor {
base_mip_level: level,
mip_level_count: Some(1),
..Default::default()
});
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Shader pack atlas sprites"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_pipeline(&stitched.pipeline);
pass.set_vertex_buffer(0, buffer.slice(..));
let first = level * per_level;
let mut start = 0;
while start < layers.len() {
let array = layers[start] / self.split;
let end = start
+ layers[start..]
.iter()
.take_while(|&&l| l / self.split == array)
.count();
if let Some(group) = stitched.groups.get(array as usize) {
pass.set_bind_group(0, group, &[]);
pass.draw(0..6, first + start as u32..first + end as u32);
}
start = end;
}
}
}
}
/// A `TerrainVertex` of flat draws' arena page (`hz_vertices`, six words each) into the vertex
@@ -204,7 +538,8 @@ impl PackAtlas {
// vertices (`TerrainVertex`, six words each).
declarations.push_str(
"layout(set = 3, binding = 0) readonly buffer HzBlockIds { int hz_block_ids[]; };\n\
layout(set = 3, binding = 1) readonly buffer HzVertices { uint hz_vertices[]; };\n",
layout(set = 3, binding = 1) readonly buffer HzVertices { uint hz_vertices[]; };\n\
layout(set = 3, binding = 2) readonly buffer HzSpriteRects { uvec2 hz_sprite_rects[]; };\n",
);
"hz_slot_origins[hz_block_slots[uint(gl_VertexIndex) >> 8u]]"
} else {
@@ -212,11 +547,29 @@ impl PackAtlas {
"hz_v_origin"
};
let [width, height] = self.size();
let scale = [
self.sprite as f32 / width as f32,
self.sprite as f32 / height as f32,
];
// A sprite's cell in its own sides, and the share of the atlas a side is: from the table
// in flat draws (`rects_table`), from the grid in others.
let columns = self.columns;
let (cell, side, scale) = if flat {
(
"uvec2 hz_rect = hz_sprite_rects[hz_layer];\n \
hz_cell = vec2(float(hz_rect.x & 65535u), float(hz_rect.x >> 16u)) / float(hz_rect.y);\n "
.to_owned(),
"float(hz_rect.y)",
[1.0 / width as f32, 1.0 / height as f32],
)
} else {
(
format!(
"hz_cell = vec2(float(hz_layer % {columns}u), float(hz_layer / {columns}u));\n "
),
"1.0",
[
self.sprite as f32 / width as f32,
self.sprite as f32 / height as f32,
],
)
};
// UVs past a sprite (models up to 2, distant tiles) cannot repeat in an atlas: clamped.
// The colour's alpha holds HDR's emission level (`mesher::EMISSION_BITS`): packs get 1 unless separate_ao.
let color_a = if self.separate_ao {
@@ -227,8 +580,8 @@ impl PackAtlas {
let body = format!(
" vec4 hz_origin = {origin};\n \
uint hz_layer = hz_v_layer & 32767u;\n \
hz_cell = vec2(float(hz_layer % {columns}u), float(hz_layer / {columns}u));\n \
hz_scale = vec2({:?}, {:?});\n \
{cell}\
hz_scale = vec2({:?}, {:?}) * {side};\n \
hz_Vertex = vec4(hz_v_pos + hz_origin.xyz, 1.0);\n \
hz_uv_scale = hz_origin.w > 0.0 ? uintBitsToFloat(floatBitsToUint(hz_origin.w) & 0xff800000u) : fract(abs(hz_origin.w));\n \
hz_mid = vec2(0.5);\n \
@@ -369,6 +722,128 @@ mod tests {
assert_eq!([red(1, 3), red(3, 2), red(5, 2)], [120, 160, 0]);
}
/// An array of `sprite`-pixel layers (`sprite` the largest of `native`), as the game builds
/// them: each layer scaled up by whole pixels at level 0, its own pixels from the level where
/// it has its own size. Every pixel of a layer's own image differs: (layer, x, y, 255).
pub(super) fn upscaled_array(
device: &wgpu::Device,
queue: &wgpu::Queue,
native: &[u32],
) -> wgpu::Texture {
let sprite = native.iter().copied().max().unwrap();
let levels = sprite.ilog2() + 1;
let mut data = Vec::new();
for (layer, &side) in native.iter().enumerate() {
for level in 0..levels {
let edge = sprite >> level;
// Above its own size a level repeats each pixel; below, it shrinks to the first.
let scale = (edge / side).max(1);
for y in 0..edge {
for x in 0..edge {
let (x, y) = if edge >= side {
(x / scale, y / scale)
} else {
(0, 0)
};
data.extend([layer as u8 * 50, x as u8 * 16, y as u8 * 16, 255]);
}
}
}
}
wgpu::util::DeviceExt::create_texture_with_data(
device,
queue,
&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: sprite,
height: sprite,
depth_or_array_layers: native.len() as u32,
},
mip_level_count: levels,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&data,
)
}
/// Sprites of 2, 4 and 1 px in a 4 px array, no mipmaps (one pixel of padding). Worked through
/// vanilla's `Stitcher` by hand: holders of 6, 4 and 3 px at (0, 0), (8, 0) and (8, 4) in a 16
/// by 8 atlas. Each sprite has its own pixels, and its padding repeats its edge.
#[test]
fn stitched_sprites_keep_their_own_size_and_padding() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let (device, queue) =
pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let array = upscaled_array(&device, &queue, &[2, 4, 1]);
let mut encoder = device.create_command_encoder(&Default::default());
let atlas =
PackAtlas::stitched(&device, &mut encoder, &[array], 3, 4, &[2, 4, 1], 0, 0).unwrap();
assert_eq!(atlas.size(), [16, 8]);
assert_eq!(
atlas.rects_table(),
[9 | 1 << 16, 2, 1 | 1 << 16, 4, 9 | 5 << 16, 1]
);
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256 * 8,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
atlas.texture.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(256),
rows_per_image: None,
},
},
wgpu::Extent3d {
width: 16,
height: 8,
depth_or_array_layers: 1,
},
);
queue.submit([encoder.finish()]);
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let data = read.slice(..).get_mapped_range().unwrap();
let at = |x: usize, y: usize| {
let i = y * 256 + x * 4;
[data[i], data[i + 1], data[i + 2], data[i + 3]]
};
// Layer 0's own 2 px: (layer 0, x, y); its padding repeats the nearest edge pixel.
assert_eq!(
[at(9, 1), at(10, 1), at(10, 2)],
[[0, 0, 0, 255], [0, 16, 0, 255], [0, 16, 16, 255]]
);
assert_eq!(
[at(8, 0), at(11, 3), at(11, 1)],
[[0, 0, 0, 255], [0, 16, 16, 255], [0, 16, 0, 255]]
);
// Layer 1 fills its 4 px, padded; layer 2 is one pixel in the middle of its padding.
assert_eq!(
[at(1, 1), at(4, 4), at(0, 5), at(5, 0)],
[
[50, 0, 0, 255],
[50, 48, 48, 255],
[50, 0, 48, 255],
[50, 48, 0, 255]
]
);
assert_eq!([at(9, 5), at(8, 4), at(10, 6)], [[100, 0, 0, 255]; 3]);
}
#[test]
fn terrain_inputs_respects_separate_ao() {
let instance = crate::gfx::test_gpu::instance();
@@ -398,6 +873,7 @@ mod tests {
sprite: 16,
split: 1,
separate_ao: false,
stitched: None,
};
let inputs_default = atlas.terrain_inputs(false);
assert!(
@@ -123,6 +123,54 @@ fn mid_tex_coord_follows_each_quads_mapped_corners() {
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
/// A stitched atlas (the one `stitched_sprites_keep_their_own_size_and_padding` works out: 16 by
/// 8, sprites of 2, 4 and 1 px at (9, 1), (1, 1) and (9, 5)): flat draws map a quad's UVs onto its
/// sprite's own pixels, so a corner is half the sprite's own side from `mc_midTexCoord`.
#[test]
fn flat_draws_map_uvs_onto_a_stitched_sprite() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let array = super::tests::upscaled_array(&device, &queue, &[2, 4, 1]);
let mut encoder = device.create_command_encoder(&Default::default());
let atlas =
PackAtlas::stitched(&device, &mut encoder, &[array], 3, 4, &[2, 4, 1], 0, 0).unwrap();
queue.submit([encoder.finish()]);
let whole = [[0., 0.], [1., 0.], [1., 1.], [0., 1.]];
let positions = [[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.]];
let vertices: Vec<_> = (0..3)
.flat_map(|layer| {
(0..4).map(move |k| {
TerrainVertex::new(positions[k], whole[k], layer as u16 | 0x8000, u32::MAX, 0)
})
})
.collect();
// Corner and side of each layer's sprite, in atlas pixels.
let sprites = [([9., 1.], 2.), ([1., 1.], 4.), ([9., 5.], 1.)];
let origin = f32::from_bits((1. / UV_STEPS).to_bits());
let pixels = sample(&device, &queue, &atlas, &vertices, origin, [true, false]);
let mut failures = Vec::new();
for (layer, ([x, y], side)) in sprites.into_iter().enumerate() {
let mid = [(x + side / 2.) / 16., (y + side / 2.) / 8.];
for (corner, [u, v]) in whole.iter().enumerate() {
let expected = [mid[0], mid[1], (x + u * side) / 16., (y + v * side) / 8.];
let actual = pixels[4 * layer + corner];
if actual
.iter()
.zip(expected)
.any(|(a, b)| (a - b).abs() > 1e-6)
{
failures.push(format!(
"layer {layer} corner {corner}: {actual:?} != {expected:?}"
));
}
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
fn sample(
device: &wgpu::Device,
queue: &wgpu::Queue,
@@ -183,7 +231,7 @@ fn sample(
}],
vec![storage(0), storage(1)],
vec![],
vec![storage(0), storage(1)],
vec![storage(0), storage(1), storage(2)],
];
let layouts: Vec<_> = entries
.iter()
@@ -268,11 +316,15 @@ fn sample(
bytemuck::cast_slice(vertices),
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
);
let rects = buffer(
bytemuck::cast_slice(&atlas.rects_table()),
wgpu::BufferUsages::STORAGE,
);
let bindings = [
vec![(0, &uniform)],
vec![(0, &slots), (1, &origin)],
vec![],
vec![(0, &ids), (1, &data)],
vec![(0, &ids), (1, &data), (2, &rects)],
];
let groups: Vec<_> = bindings
.iter()
@@ -0,0 +1,178 @@
//! Where a shader pack's block atlas puts each sprite: vanilla's `Stitcher`, so every sprite keeps
//! its own size and the atlas comes out as large as the game's (`atlasSize`). Holders are sorted
//! by height, then width, then (where vanilla compares names) sprite layer.
/// The atlas a set of sprites stitches into.
#[derive(Debug, PartialEq)]
pub(super) struct Stitched {
pub width: u32,
pub height: u32,
/// Each sprite's padded holder: its top left corner. The sprite itself starts `padding` in.
pub holders: Vec<[u32; 2]>,
pub padding: u32,
}
/// `Stitcher.smallestFittingMinTexel`.
fn fit(input: u32, mip_level: u32) -> u32 {
let step = 1 << mip_level;
input.div_ceil(step) * step
}
/// `Mth.smallestEncompassingPowerOfTwo` (0 stays 0).
fn power_of_two(input: u32) -> u32 {
if input == 0 {
0
} else {
input.next_power_of_two()
}
}
/// `Stitcher.Region`.
struct Region {
x: u32,
y: u32,
width: u32,
height: u32,
holder: bool,
sub: Vec<Region>,
}
impl Region {
fn new(x: u32, y: u32, width: u32, height: u32) -> Self {
Self {
x,
y,
width,
height,
holder: false,
sub: Vec::new(),
}
}
/// `Region.add`: the holder's top left corner, when it fits.
fn add(&mut self, [w, h]: [u32; 2]) -> Option<[u32; 2]> {
if self.holder || w > self.width || h > self.height {
return None;
}
if w == self.width && h == self.height {
self.holder = true;
return Some([self.x, self.y]);
}
if self.sub.is_empty() {
let (x, y) = (self.x, self.y);
self.sub.push(Region::new(x, y, w, h));
let (spare_w, spare_h) = (self.width - w, self.height - h);
if spare_h > 0 && spare_w > 0 {
let right = self.height.max(spare_w);
let bottom = self.width.max(spare_h);
if right >= bottom {
self.sub.push(Region::new(x, y + h, w, spare_h));
self.sub.push(Region::new(x + w, y, spare_w, self.height));
} else {
self.sub.push(Region::new(x + w, y, spare_w, h));
self.sub.push(Region::new(x, y + h, self.width, spare_h));
}
} else if spare_w == 0 {
self.sub.push(Region::new(x, y + h, w, spare_h));
} else if spare_h == 0 {
self.sub.push(Region::new(x + w, y, spare_w, h));
}
}
self.sub.iter_mut().find_map(|r| r.add([w, h]))
}
}
/// `Stitcher` over square sprites of `sides` pixels, with vanilla's padding for `mip_level` and
/// `anisotropy_bit`; `None` when they don't fit in `max` by `max`.
pub(super) fn stitch(
sides: &[u32],
mip_level: u32,
anisotropy_bit: u32,
max: u32,
) -> Option<Stitched> {
let padding = (1 << mip_level) << anisotropy_bit.saturating_sub(1).min(4);
let mut order: Vec<usize> = (0..sides.len()).collect();
let holder = |i: usize| fit(sides[i] + padding * 2, mip_level);
order.sort_by_key(|&i| (std::cmp::Reverse(holder(i)), i));
let (mut storage_x, mut storage_y) = (0u32, 0u32);
let mut storage: Vec<Region> = Vec::new();
let mut holders = vec![[0, 0]; sides.len()];
for i in order {
let size = [holder(i); 2];
if let Some(at) = storage.iter_mut().find_map(|r| r.add(size)) {
holders[i] = at;
continue;
}
// `Stitcher.expand`.
let (x_now, y_now) = (power_of_two(storage_x), power_of_two(storage_y));
let (x_new, y_new) = (
power_of_two(storage_x + size[0]),
power_of_two(storage_y + size[1]),
);
let (x_can, y_can) = (x_new <= max, y_new <= max);
if !x_can && !y_can {
return None;
}
let (x_will, y_will) = (x_can && x_now != x_new, y_can && y_now != y_new);
let grow_x = if x_will ^ y_will {
x_will
} else {
x_can && x_now <= y_now
};
let mut slot = if grow_x {
if storage_y == 0 {
storage_y = y_new;
}
let slot = Region::new(storage_x, 0, x_new - storage_x, storage_y);
storage_x = x_new;
slot
} else {
let slot = Region::new(0, storage_y, storage_x, y_new - storage_y);
storage_y = y_new;
slot
};
holders[i] = slot.add(size)?;
storage.push(slot);
}
Some(Stitched {
width: storage_x,
height: storage_y,
holders,
padding,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// Five 16 px sprites with one pixel of padding (no mipmaps), worked through vanilla's
/// `Stitcher` by hand: 18 px holders in a 64 by 64 atlas.
#[test]
fn equal_sprites_follow_vanillas_regions() {
let stitched = stitch(&[16; 5], 0, 0, 1 << 14).unwrap();
assert_eq!(
(stitched.width, stitched.height, stitched.padding),
(64, 64, 1)
);
assert_eq!(
stitched.holders,
[[0, 0], [32, 0], [0, 32], [18, 32], [36, 32]]
);
}
/// The largest sprite goes first; padding and holders round up to the mip step.
#[test]
fn larger_sprites_go_first_and_holders_round_to_the_mip_step() {
// Mip level 2: 4 px padding, holders 24 (16 + 8) and 40 (32 + 8) px.
let stitched = stitch(&[16, 32], 2, 0, 1 << 14).unwrap();
assert_eq!(stitched.padding, 4);
assert_eq!(stitched.holders[1], [0, 0]);
// 40 px grows the atlas to 64 by 64; the 24 px holder fits below the first.
assert_eq!((stitched.width, stitched.height), (64, 64));
assert_eq!(stitched.holders[0], [0, 40]);
// Anisotropy 16x (bit 4) multiplies the padding by 8.
assert_eq!(stitch(&[16], 4, 4, 1 << 14).unwrap().padding, 128);
assert_eq!(stitch(&[16; 2], 0, 0, 16), None);
}
}
@@ -122,6 +122,7 @@ void main() {
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &inputs);
assert!(post.terrain.is_some(), "{:?}", post.notes);
let world = World {
sprite_rects: &[],
atlas: Some((atlases[2].view.clone(), atlases[2].size())),
material_maps: Some([atlases[1].view.clone(), atlases[0].view.clone()]),
lightmap: atlases[2].view.clone(),
@@ -148,7 +149,8 @@ void main() {
post,
wgpu::TextureFormat::Rgba8Unorm,
world,
);
)
.expect("the pack runs");
let mut pass = post
.terrain
.take()
+223 -39
View File
@@ -18,7 +18,7 @@
use std::collections::HashMap;
use glam::{DMat4, DVec3};
use horizon_shaders::{Format, Group, Kind, Post, SHADOW_COLORS, Val};
use horizon_shaders::{Format, Group, Kind, Post, SHADOW_COLORS};
use super::pack_draw::DrawUniforms;
use super::pack_entities::{PROGRAMS, PackEntities};
@@ -29,12 +29,17 @@ use super::pack_uniforms::{self, FrameUniforms, PackInputs};
const BUFFERS: usize = horizon_shaders::BUFFERS;
mod packed;
#[cfg(test)]
mod world_target_tests;
#[cfg(test)]
mod higher_shadowcolor_tests;
#[cfg(test)]
mod shadow_faces_tests;
#[cfg(test)]
mod forced_flip_tests;
@@ -436,6 +441,9 @@ struct Targets {
pub(super) struct World<'a> {
/// The block atlas and its size.
pub atlas: Option<(wgpu::TextureView, [u32; 2])>,
/// Of a stitched block atlas, each sprite layer's place (`PackAtlas::rects_table`); empty
/// for a grid.
pub sprite_rects: &'a [u32],
pub material_maps: Option<[wgpu::TextureView; 2]>,
pub lightmap: wgpu::TextureView,
/// Terrain's geometry, when the pack draws it.
@@ -562,9 +570,9 @@ pub struct PackPost {
down: HashMap<wgpu::TextureFormat, (wgpu::RenderPipeline, wgpu::BindGroupLayout)>,
/// The per-draw uniforms of gbuffers programs.
draws: DrawUniforms,
/// Set 3 of flat terrain (`mc_Entity`'s table) and its bind group for each arena page,
/// with the page buffer it was made for.
block_ids: Option<(wgpu::BindGroupLayout, wgpu::Buffer)>,
/// Set 3 of flat terrain (`mc_Entity`'s table, then the sprites' places) and its bind group
/// for each arena page, with the page buffer it was made for.
block_ids: Option<(wgpu::BindGroupLayout, wgpu::Buffer, wgpu::Buffer)>,
page_groups: HashMap<u32, (wgpu::Buffer, wgpu::BindGroup)>,
gui_size: [u32; 2],
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
@@ -1030,22 +1038,36 @@ pub(super) struct ShadowView {
/// Draw everything, every face, within this many blocks (`shadow.culling=false|reversed`):
/// packs that voxelize the world in their shadow pass need all of it.
pub unculled: Option<f32>,
/// Every face of the drawn sections, not only those that can face the light. Iris 1.11.7's
/// shadow map holds the faces turned away from the light too (#9033): under a hill at low sun
/// a cave's ceiling, which faces away, is often all that shadows the cave.
pub every_face: bool,
}
/// Set 3 of flat terrain, read by the vertex stage: the sprite layers' table (`mc_Entity`'s id
/// and fluid, `at_midBlock.w`; -1, -1, 0 when empty) and, per arena page, the page's vertices
/// (a vertex reads its quad's corners for its normal).
fn block_ids(device: &wgpu::Device, table: &[i32]) -> (wgpu::BindGroupLayout, wgpu::Buffer) {
/// and fluid, `at_midBlock.w`; -1, -1, 0 when empty), per arena page, the page's vertices
/// (a vertex reads its quad's corners for its normal), and each sprite's place in a stitched
/// atlas (`rects`; zeros for a grid, which doesn't read them).
fn block_ids(
device: &wgpu::Device,
table: &[i32],
rects: &[u32],
) -> (wgpu::BindGroupLayout, wgpu::Buffer, wgpu::Buffer) {
let empty = [-1, -1, 0];
let table = if table.is_empty() { &empty[..] } else { table };
let buffer = wgpu::util::DeviceExt::create_buffer_init(
device,
&wgpu::util::BufferInitDescriptor {
label: Some("Shader pack block ids"),
contents: bytemuck::cast_slice(table),
usage: wgpu::BufferUsages::STORAGE,
},
);
let buffer = |label, contents: &[u8]| {
wgpu::util::DeviceExt::create_buffer_init(
device,
&wgpu::util::BufferInitDescriptor {
label: Some(label),
contents,
usage: wgpu::BufferUsages::STORAGE,
},
)
};
let rects = if rects.is_empty() { &[0, 0][..] } else { rects };
let ids = buffer("Shader pack block ids", bytemuck::cast_slice(table));
let rects = buffer("Shader pack sprite rects", bytemuck::cast_slice(rects));
let storage = |binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::VERTEX,
@@ -1058,9 +1080,9 @@ fn block_ids(device: &wgpu::Device, table: &[i32]) -> (wgpu::BindGroupLayout, wg
};
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Shader pack terrain"),
entries: &[storage(0), storage(1)],
entries: &[storage(0), storage(1), storage(2)],
});
(layout, buffer)
(layout, ids, rects)
}
/// The game's geometry a gbuffers program draws: its vertex buffers and, for flat draws
@@ -1094,6 +1116,8 @@ struct Resources<'a> {
storable: u16,
/// The buffers of a three-channel format (`rgb`): they have no alpha to write, and read 1.
rgb: u16,
/// The bits per channel a buffer of a small packed format keeps ([`packed::bits`]).
packed: [Option<[u8; 4]>; BUFFERS],
/// The stage (`texture.<stage>`) of the programs built so far, and the buffers one of them
/// flipped: a `texture.<stage>.colortexN` no longer stands in for buffer N in the stage's
/// later programs, which read what was written, as in Iris. `final` continues
@@ -1178,9 +1202,18 @@ fn bindings(
let released = pack.released.get();
let custom = pack_textures::find(textures, group, &s.ty, &s.name).filter(|&i| {
let t = &textures[i];
t.stage != pack_textures::stage(group)
|| released.0 != t.stage
|| horizon_shaders::buffer_index(&t.name).is_none_or(|b| released.1 & 1 << b == 0)
// A PNG under `texture.<stage>.<name>` whose name is no sampler of the game's
// isn't bound: the sampler reads as if it had no texture. Iris 1.11.7 with a probe
// pack of ours (#9064) bound it over `gaux2` and `noisetex` but not over names of its
// own, where a raw texture was bound; this rule is a fit to those cases.
let unbound = t.png
&& t.stage != horizon_shaders::EVERY_STAGE
&& source(&s.name, false, true, false) == Source::Dummy;
!unbound
&& (t.stage != pack_textures::stage(group)
|| released.0 != t.stage
|| horizon_shaders::buffer_index(&t.name)
.is_none_or(|b| released.1 & 1 << b == 0))
});
let image = pack
.images
@@ -1490,6 +1523,8 @@ fn build_pass(
let final_pass = p.group == Group::Final;
let mut targets = Vec::new();
let mut writes = Vec::new();
// Outputs into a buffer of a small packed format, and the bits it keeps.
let mut packed_outputs = Vec::new();
for &location in &interface.outputs {
let l = location as usize;
if targets.len() <= l {
@@ -1521,6 +1556,11 @@ fn build_pass(
{
t.blend = Some(blend_state(b));
}
if !p.group.shadow()
&& let Some(bits) = pack.packed[buffer as usize]
{
packed_outputs.push((location, bits));
}
targets[l] = state;
writes.push((location, buffer as usize));
}
@@ -1580,7 +1620,9 @@ fn build_pass(
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(m)),
})
};
let (vs, fs) = (module(program.vertex), module(program.fragment));
let mut fragment = program.fragment;
packed::quantise_outputs(&mut fragment, &packed_outputs);
let (vs, fs) = (module(program.vertex), module(fragment));
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(&p.name),
layout: Some(&pipeline_layout),
@@ -1826,8 +1868,16 @@ fn build_compute(
})
}
/// Why a pack can't run, and the notes of loading it until then (why programs were skipped).
#[derive(Debug)]
pub struct Refused {
pub reason: String,
pub notes: Vec<String>,
}
impl PackPost {
/// Builds the pipelines of `post` for a screen of `screen` format.
/// Builds the pipelines of `post` for a screen of `screen` format. An error when the pack
/// can't show anything on the screen: the game turns it off, as a pack that fails to load.
pub fn new(
device: &wgpu::Device,
queue: &wgpu::Queue,
@@ -1835,7 +1885,7 @@ impl PackPost {
mut post: Post,
screen: wgpu::TextureFormat,
world: World<'_>,
) -> Self {
) -> Result<Self, Refused> {
// What the pack's shaders need of its storage buffers, before its parts move out.
let minimums = post.buffer_minimums();
let features = device.features();
@@ -1851,6 +1901,10 @@ impl PackPost {
let rgb = (0..BUFFERS)
.filter(|&i| post.config.buffers[i].format.channels == 3)
.fold(0u16, |m, i| m | 1 << i);
// A small packed format's buffer reads back fewer bits, as in Iris: its clears and
// what its passes write keep only those.
let packed: [Option<[u8; 4]>; BUFFERS] =
std::array::from_fn(|i| packed::bits(post.config.buffers[i].format));
let clears = std::array::from_fn(|i| {
let b = &post.config.buffers[i];
b.clear.then(|| {
@@ -1858,7 +1912,7 @@ impl PackPost {
if rgb & 1 << i != 0 {
c[3] = 1.0;
}
c
packed[i].map_or(c, |bits| packed::quantise(c, bits))
})
});
let mut notes = post.notes;
@@ -1875,7 +1929,7 @@ impl PackPost {
let mut custom = post.custom;
let custom_names: Vec<String> = custom.uniforms().map(|u| u.0.to_owned()).collect();
// A first run finds the names neither the frame nor the pack defines.
custom.eval(0.0, &|n| pack_uniforms::index(n).map(|_| Val::scalar(0.0)));
custom.eval(0.0, &pack_uniforms::zero);
// Its zeros are no start for `smooth`: the first frame's values are, as in Iris.
custom.restart_smoothing();
if !custom.notes.is_empty() {
@@ -1963,6 +2017,7 @@ impl PackPost {
gpu,
storable,
rgb,
packed,
released: std::cell::Cell::new(("", 0)),
stand_ins: &stand_ins,
shadow_linear: if features.contains(wgpu::Features::FLOAT32_FILTERABLE) {
@@ -2058,7 +2113,7 @@ impl PackPost {
.terrain
.as_ref()
.filter(|g| g.table.is_some())
.map(|_| block_ids(device, world.block_ids));
.map(|_| block_ids(device, world.block_ids, world.sprite_rects));
let terrain_geometry = world.terrain.as_ref().map(|g| Geometry {
buffers: g.buffers,
table: g.table,
@@ -2076,13 +2131,24 @@ impl PackPost {
.water
.zip(terrain_geometry.as_ref())
.and_then(|(p, g)| build(p, Some(g)));
// The shadow pass draws terrain faces from both sides, as Iris 1.11.7 does (#9033): a
// cave ceiling faces away from the sun, and is often all that shadows the cave.
let shadow_geometry = terrain_geometry.as_ref().map(|g| Geometry {
buffers: g.buffers,
table: g.table,
depth_write: g.depth_write,
atlas: g.atlas.clone(),
material_maps: g.material_maps,
ids: g.ids,
cull: false,
});
let shadow_terrain = post
.shadow
.zip(terrain_geometry.as_ref())
.zip(shadow_geometry.as_ref())
.and_then(|(p, g)| build(p, Some(g)));
let shadow_water = post
.shadow_water
.zip(terrain_geometry.as_ref())
.zip(shadow_geometry.as_ref())
.and_then(|(p, g)| build(p, Some(g)));
let (hides_sky, hides_celestial) = (post.sky.is_some(), post.sky_textured.is_some());
let sky = (post.sky.is_some() || post.sky_textured.is_some()).then(|| {
@@ -2242,6 +2308,22 @@ impl PackPost {
.filter(|p| !skip.split(',').any(|s| s == p.name))
.filter_map(|p| build(p, None))
.collect();
// The screen gets colortex0 through `final`, or else through Horizon's copy, which reads
// floats: an integer colortex0 (`RGBA32UI`...) with no `final` left to draw can't reach
// it (#9165).
let shows = passes
.iter()
.any(|p| p.group == Group::Final && matches!(p.pipeline, Pipeline::Render(_)));
if !shows && !holds_floats(formats[0]) {
return Err(Refused {
reason: format!(
"colortex0 has an integer format ({:?}) and no `final` program draws it to \
the screen",
formats[0]
),
notes,
});
}
let shadow_comp: Vec<Pass> = post
.shadow_comp
.into_iter()
@@ -2538,7 +2620,7 @@ impl PackPost {
let white = [255u8; 4];
let black = [0u8; 4];
let flat_normal = [128u8, 128, 255, 255];
Self {
Ok(Self {
notes,
setup_pending: true,
passes,
@@ -2701,7 +2783,7 @@ impl PackPost {
u
},
shadow_cache: Default::default(),
}
})
}
/// Writes `pass`'s uniforms for a target of `out` pixels; its bind group for the buffers'
@@ -3723,6 +3805,7 @@ impl PackPost {
horizon_shaders::ShadowCulling::Off => Some(f32::INFINITY),
horizon_shaders::ShadowCulling::Reversed => Some(shadow.voxel_distance),
},
every_face: true,
})
}
@@ -4195,7 +4278,7 @@ impl PackPost {
terrain: &mut TerrainDraw<'_>,
) {
self.resize(device, size);
if let Some((layout, ids)) = &self.block_ids {
if let Some((layout, ids, rects)) = &self.block_ids {
// A page that grew has a new buffer.
self.page_groups
.retain(|p, (b, _)| pages.iter().any(|(q, c)| q == p && c == b));
@@ -4213,6 +4296,10 @@ impl PackPost {
binding: 1,
resource: buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: rects.as_entire_binding(),
},
],
});
(buffer.clone(), group)
@@ -4571,14 +4658,8 @@ impl PackPost {
.as_ref()
.map(|t| &t[main(m, i) ^ usize::from(alt)])
};
// An integer colortex0 has no colour to show: the screen is only cleared.
if !shown && !holds_floats(self.formats[0]) {
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Shader pack colortex0"),
color_attachments: &[attach(screen, overwrite)],
..Default::default()
});
} else if !shown {
// Without `final`, colortex0 holds floats (`new` refuses an integer one).
if !shown {
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader pack colortex0"),
layout: &self.copy.1,
@@ -4726,6 +4807,7 @@ mod tests {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
blur: false,
clamp: false,
png: false,
image: pack_textures::Image::Atlas,
}];
let interface = horizon_shaders::Interface {
@@ -4758,6 +4840,7 @@ mod tests {
gpu: Gpu::of(&adapter, device.features()),
storable: 0,
rgb: 0,
packed: [None; BUFFERS],
released: std::cell::Cell::new(("", 0)),
stand_ins: &stand_ins,
shadow_linear: 0,
@@ -4786,6 +4869,102 @@ mod tests {
assert!(reads(Group::Final) == Source::Color(0));
}
/// Iris 1.11.7 (a probe pack of ours, #9064) didn't bind a PNG under
/// `texture.<stage>.<name>` when `name` was a name of the pack's own, no sampler of the
/// game's; a raw texture there, a PNG over `gaux2` or `noisetex`, and a `customTexture` PNG
/// were bound.
#[test]
fn a_png_under_a_name_of_its_own_is_not_bound() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
let texture = |stage: &str, name: &str, png: bool| pack_textures::PackTexture {
stage: stage.into(),
name: name.into(),
dimension: wgpu::TextureViewDimension::D2,
sample_type: wgpu::TextureSampleType::Float { filterable: true },
blur: !png,
clamp: !png,
png,
image: pack_textures::Image::Atlas,
};
let textures = [
texture("composite", "tp", true),
texture("composite", "tr", false),
texture("composite", "gaux2", true),
texture("composite", "noisetex", true),
texture(horizon_shaders::EVERY_STAGE, "cp", true),
];
let names = ["tp", "tr", "gaux2", "noisetex", "cp"];
let interface = horizon_shaders::Interface {
uniform_size: 48,
uniforms: Vec::new(),
samplers: names
.iter()
.zip(1..)
.map(|(name, k)| horizon_shaders::Sampler {
name: (*name).into(),
ty: "sampler2D".into(),
binding: 2 * k - 1,
})
.collect(),
outputs: vec![0],
draw: false,
images: Vec::new(),
buffers: Vec::new(),
initial_values: vec![0; 48],
};
let buffers = pack_images::Buffers::new(
&device,
Default::default(),
Default::default(),
wgpu::ShaderStages::FRAGMENT,
);
let stand_ins = std::cell::RefCell::new(Vec::new());
let pack = Resources {
textures: &textures,
images: &[],
left_out: &[],
buffers: &buffers,
indices: None,
gpu: Gpu::of(&adapter, device.features()),
storable: 0,
rgb: 0,
released: std::cell::Cell::new(("", 0)),
stand_ins: &stand_ins,
shadow_linear: 0,
packed: [None; BUFFERS],
};
let (_, samplers, ..) = bindings(
device.features(),
&interface,
"final",
Group::Final,
&[0],
None,
&[T::Rgba8Unorm; BUFFERS],
None,
&mut Vec::new(),
&pack,
)
.expect("the pass is kept");
let read: Vec<Source> = samplers.iter().map(|b| b.source).collect();
assert!(
read == [
Source::Dummy,
Source::Atlas,
Source::Atlas,
Source::Atlas,
Source::Atlas
]
);
}
/// Iris 1.11.7 runs a program whose image no `image.*` gives, and its loads read zero
/// (observed with a probe pack). Its pass is kept, the image bound to a stand-in of its
/// declared format, zero again before each pass even after one stored into it.
@@ -4838,6 +5017,7 @@ mod tests {
gpu: Gpu::of(&adapter, device.features()),
storable: 0,
rgb: 0,
packed: [None; BUFFERS],
released: std::cell::Cell::new(("", 0)),
stand_ins: &stand_ins,
shadow_linear: 0,
@@ -4979,6 +5159,7 @@ mod tests {
gpu: Gpu::of(&adapter, device.features()),
storable: 0,
rgb: 0,
packed: [None; BUFFERS],
released: std::cell::Cell::new(("", 0)),
stand_ins: &stand_ins,
shadow_linear: 0,
@@ -5151,6 +5332,9 @@ mod material_tests;
#[cfg(test)]
mod rgb_alpha_tests;
#[cfg(test)]
mod packed_tests;
#[cfg(test)]
mod trinary_tests;
@@ -83,7 +83,8 @@ impl Gpu {
post,
T::Rgba8Unorm,
world,
);
)
.expect("the pack runs");
assert!(
built.passes.iter().any(|p| p.group == Group::Final),
"{:?}",
@@ -334,6 +335,7 @@ fn check(sampler: &str, stage: Stage, scene: Scene, expected: [u8; 4]) {
cull: false,
}),
block_ids: &[],
sprite_rects: &[],
celestial: None,
gui_size: SIZE,
clouds: None,
@@ -707,6 +709,7 @@ fn shadow_depth_filtering(nearest: &str) -> Option<Vec<[u8; 4]>> {
let mut post = gpu.load(
files,
World {
sprite_rects: &[],
atlas: None,
material_maps: None,
lightmap: color.clone(),
@@ -199,8 +199,33 @@ impl Fixture {
}
pub(super) fn build(&self, post: Post) -> PackPost {
let post = self.try_build(post).expect("the pack runs");
assert!(
post.notes.iter().all(|n| n.starts_with("shadow map ")),
"{:?}",
post.notes
);
post
}
/// [`Self::build`] for a pack that may be refused.
pub(super) fn try_build(&self, post: Post) -> Result<PackPost, Refused> {
self.try_build_with_terrain(post, None)
}
/// `build`, with the game's terrain geometry handed to the pack.
pub(super) fn build_with_terrain(&self, post: Post, terrain: Option<Geometry<'_>>) -> PackPost {
self.try_build_with_terrain(post, terrain)
.expect("the pack runs")
}
fn try_build_with_terrain(
&self,
post: Post,
terrain: Option<Geometry<'_>>,
) -> Result<PackPost, Refused> {
let lightmap = self.texture([1, 1], T::Rgba8Unorm);
let post = PackPost::new(
PackPost::new(
&self.device,
&self.queue,
&self.adapter,
@@ -210,24 +235,29 @@ impl Fixture {
atlas: None,
material_maps: None,
lightmap,
terrain: None,
terrain,
block_ids: &[],
sprite_rects: &[],
celestial: None,
gui_size: [16, 16],
clouds: None,
weather: false,
hand: false,
},
);
assert!(
post.notes.iter().all(|n| n.starts_with("shadow map ")),
"{:?}",
post.notes
);
post
)
}
pub(super) fn render(&self, post: &mut PackPost, size: [u32; 2]) -> Vec<[u8; 4]> {
self.render_with_terrain(post, size, &mut |_, _, _, _| {})
}
/// `render`, with `terrain` drawing the game's terrain lists.
pub(super) fn render_with_terrain(
&self,
post: &mut PackPost,
size: [u32; 2],
terrain: &mut TerrainDraw<'_>,
) -> Vec<[u8; 4]> {
let world = self.texture(size, T::Rgba8Unorm);
let depth = self.texture(size, T::Depth32Float);
let screen = self.texture(size, T::Rgba8Unorm);
@@ -252,7 +282,7 @@ impl Fixture {
size,
size,
&[],
&mut |_, _, _, _| {},
terrain,
);
encoder.copy_texture_to_buffer(
screen.texture().as_image_copy(),
@@ -1,16 +1,24 @@
//! A pack may give colortex0 an integer format (OptiFine's `shaders.txt`, "Texture Formats",
//! lists `RGBA32UI`, as #8459 quotes it): the pack still loads, and reads back what it wrote
//! there. #8459's probe; #8459 reports Iris 1.11.7 drawing (0, 200, 0) at every point
//! sampled from it.
//! sampled from it. Without a `final` that draws it, the pack is refused (#9165).
use super::destination_tests::Fixture;
use super::*;
use std::io::{Cursor, Write};
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
/// A `final` that shows the integer colortex0.
const FINAL: &str = "#version 330 compatibility\nuniform usampler2D colortex0;\n\
void main() { gl_FragColor = vec4(texelFetch(colortex0, ivec2(gl_FragCoord.xy), 0)) / 255.0; }\n";
/// A `final` that doesn't compile (it calls a function nobody declares), so it is skipped.
const BROKEN_FINAL: &str =
"#version 330 compatibility\nvoid main() { gl_FragColor = undeclared_function(); }\n";
/// `depth`: the composite reads depthtex0, so the blit copies the world's depth; `last`: the
/// pack has a `final` that shows colortex0 (without, Horizon shows colortex0 itself).
fn pack(depth: bool, last: bool) -> Post {
/// pack's `final`, if it has one.
fn pack(depth: bool, last: Option<&str>) -> Post {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
@@ -34,13 +42,9 @@ fn pack(depth: bool, last: bool) -> Post {
uniform sampler2D depthtex0;\nlayout(location = 0) out uvec4 o;\nvoid main() {{ o = {value}; }}\n"
),
);
if last {
if let Some(source) = last {
add("final.vsh", VERTEX);
add(
"final.fsh",
"#version 330 compatibility\nuniform usampler2D colortex0;\n\
void main() { gl_FragColor = vec4(texelFetch(colortex0, ivec2(gl_FragCoord.xy), 0)) / 255.0; }\n",
);
add("final.fsh", source);
}
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-integer-colortex0",
@@ -49,21 +53,14 @@ fn pack(depth: bool, last: bool) -> Post {
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default());
assert_eq!(
post.programs.len(),
1 + usize::from(last),
"{:?}",
post.notes
);
post
horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default())
}
#[test]
fn an_integer_colortex0_keeps_what_the_pack_wrote() {
let Some(gpu) = Fixture::new() else { return };
for depth in [false, true] {
let mut post = gpu.build(pack(depth, true));
let mut post = gpu.build(pack(depth, Some(FINAL)));
// Twice: the second frame's world blit runs over a colortex0 the first frame wrote.
for frame in 0..2 {
let pixels = gpu.render(&mut post, [8, 8]);
@@ -73,13 +70,20 @@ fn an_integer_colortex0_keeps_what_the_pack_wrote() {
}
}
/// Without a `final`, there are no colours to show from an integer colortex0; what Iris shows
/// then was not measured. The frame must still run.
/// Without a `final` left to draw it (none, or one that is skipped), an integer colortex0 has
/// no colours the screen can show: the pack is refused, so the game turns it off and draws
/// without it instead of a black screen (#9165). The refusal keeps why `final` was skipped.
#[test]
fn an_integer_colortex0_without_final_still_runs() {
fn an_integer_colortex0_without_a_working_final_is_refused() {
let Some(gpu) = Fixture::new() else { return };
let mut post = gpu.build(pack(false, false));
for _ in 0..2 {
gpu.render(&mut post, [8, 8]);
for last in [None, Some(BROKEN_FINAL)] {
let Err(refused) = gpu.try_build(pack(false, last)) else {
panic!("{last:?}: a pack that can't show colortex0 runs");
};
assert_eq!(
refused.notes.iter().any(|n| n.starts_with("final")),
last.is_some(),
"{refused:?}"
);
}
}
@@ -0,0 +1,205 @@
//! The small packed colortex formats `RGB5_A1`, `RGB565` and `R3_G3_B2`, read back as in Iris
//! 1.11.7. wgpu has no such formats, so these buffers are 8-bit (`texture_format`); what a pass
//! writes into one and its clear colour go through [`quantise`] first.
//!
//! Probe pack p8106c of our own wrote 600 values to each format and read them back with
//! `texelFetch` in Iris 1.11.7 (NVIDIA 615.78.08). Our probe's Iris values fit this, with no
//! exception: each channel becomes the byte an `RGBA8` buffer stores for it, keeps that byte's
//! top bits (5, 5, 5 and 1 for `RGB5_A1`; 5, 6 and 5 for `RGB565` and also for `R3_G3_B2`), and
//! reads back those bits repeated to fill 8 (`0.3337` → byte 85 → 10 of 31 → 82 / 255). This is
//! a fit to one driver's values, not a rule Iris states. `RGBA4` and `RGBA2` read back the `RGBA8`
//! bytes in the same probe, as Horizon's 8-bit buffers already do.
use horizon_shaders::{Format, Kind};
use naga::{BinaryOperator, Expression, Literal, MathFunction, Statement, VectorSize};
/// The bits each channel keeps in a buffer of format `f`, for the formats that keep fewer than
/// 8; a three-channel format's alpha keeps 8 (it reads 1 anyway).
pub(super) fn bits(f: Format) -> Option<[u8; 4]> {
match (f.kind, f.channels, f.bits) {
(Kind::Unorm, 4, 5) => Some([5, 5, 5, 1]),
// `RGB565`, and `R3_G3_B2` (3 × 3 bits in `config.rs`), which our probe's Iris values
// put in 5, 6 and 5 bits too.
(Kind::Unorm, 3, 6 | 3) => Some([5, 6, 5, 8]),
_ => None,
}
}
/// The value a buffer keeping `bits` per channel reads back after `v` is written or cleared.
pub(super) fn quantise(v: [f32; 4], bits: [u8; 4]) -> [f32; 4] {
std::array::from_fn(|i| {
let byte = (v[i].clamp(0.0, 1.0) * 255.0 + 0.5).floor() as u32;
let n = u32::from(bits[i]);
let kept = byte >> (8 - n);
// The kept bits, repeated until there are 8.
let mut wide = 0;
let mut have = 0;
while have < 8 {
wide = wide << n | kept;
have += n;
}
(wide >> (have - 8)) as f32 / 255.0
})
}
/// The factors of the shader's [`quantise`] for a channel keeping `n` bits: the kept value is
/// `floor(byte / step)`, and it reads back `kept × step + floor(kept × repeat)`.
fn factors(n: u8) -> (f32, f32) {
let step = f32::from(1u16 << (8 - n));
// `kept × repeat` is the kept bits shifted down to fill what `kept × step` leaves: one bit
// fills the other 7 (× 127); 5 or 6 bits fill 3 or 2 with their top bits.
let repeat = match n {
1 => 127.0,
_ => 2f32.powi(8 - 2 * i32::from(n)),
};
(step, repeat)
}
/// Makes the fragment entry point of `module` write each output location of `outputs` as
/// [`quantise`] gives it for its bits. The translator's entry point ends in one `return` of its
/// outputs; a module without one is left as it is.
pub(super) fn quantise_outputs(module: &mut naga::Module, outputs: &[(u32, [u8; 4])]) {
let Some(ep) =
(module.entry_points.iter_mut()).find(|e| e.stage == naga::ShaderStage::Fragment)
else {
return;
};
let Some(result) = &ep.function.result else {
return;
};
let naga::TypeInner::Struct { members, .. } = &module.types[result.ty].inner else {
return;
};
let vec4 = |ty: naga::Handle<naga::Type>| {
matches!(
module.types[ty].inner,
naga::TypeInner::Vector {
size: VectorSize::Quad,
scalar: naga::Scalar::F32
}
)
};
// (member, bits, the member's type) of each output to change.
let changed: Vec<(usize, [u8; 4], naga::Handle<naga::Type>)> = (members.iter().enumerate())
.filter_map(|(i, m)| {
let Some(naga::Binding::Location { location, .. }) = m.binding else {
return None;
};
let bits = outputs.iter().find(|o| o.0 == location)?.1;
vec4(m.ty).then_some((i, bits, m.ty))
})
.collect();
if changed.is_empty() {
return;
}
let struct_ty = result.ty;
let f = &mut ep.function;
let body = std::mem::take(&mut f.body);
let mut out = naga::Block::with_capacity(body.len() + 1);
for (statement, span) in body.span_into_iter() {
let Statement::Return { value: Some(value) } = statement else {
out.push(statement, span);
continue;
};
let Expression::Compose { components, .. } = &f.expressions[value] else {
out.push(statement, span);
continue;
};
let mut components = components.clone();
let e = &mut f.expressions;
let lit = |e: &mut naga::Arena<Expression>, v: f32| {
e.append(Expression::Literal(Literal::F32(v)), span)
};
// Literals need no emitting: they come before the range that is emitted.
let scalars: Vec<_> = (changed.iter())
.map(|&(_, bits, _)| {
let per = bits.map(factors);
(
per.map(|(step, _)| lit(e, step)),
per.map(|(step, _)| lit(e, 1.0 / step)),
per.map(|(_, repeat)| lit(e, repeat)),
)
})
.collect();
let (zero, one, max, half) = (lit(e, 0.0), lit(e, 1.0), lit(e, 255.0), lit(e, 0.5));
let mut add = |x: Expression| e.append(x, span);
let splat = |add: &mut dyn FnMut(Expression) -> _, value| {
add(Expression::Splat {
size: VectorSize::Quad,
value,
})
};
let (zero, one, max, half) = (
splat(&mut add, zero),
splat(&mut add, one),
splat(&mut add, max),
splat(&mut add, half),
);
let binary = |add: &mut dyn FnMut(Expression) -> _, op, left, right| {
add(Expression::Binary { op, left, right })
};
let math = |add: &mut dyn FnMut(Expression) -> _, fun, arg, arg1, arg2| {
add(Expression::Math {
fun,
arg,
arg1,
arg2,
arg3: None,
})
};
for (&(member, _, ty), (steps, inverse, repeats)) in changed.iter().zip(scalars) {
let mut vector = |components: [_; 4]| {
add(Expression::Compose {
ty,
components: components.to_vec(),
})
};
let (step, inverse, repeat) = (vector(steps), vector(inverse), vector(repeats));
let x = components[member];
let clamped = math(&mut add, MathFunction::Clamp, x, Some(zero), Some(one));
let scaled = binary(&mut add, BinaryOperator::Multiply, clamped, max);
let rounded = binary(&mut add, BinaryOperator::Add, scaled, half);
let byte = math(&mut add, MathFunction::Floor, rounded, None, None);
let shifted = binary(&mut add, BinaryOperator::Multiply, byte, inverse);
let kept = math(&mut add, MathFunction::Floor, shifted, None, None);
let high = binary(&mut add, BinaryOperator::Multiply, kept, step);
let low = binary(&mut add, BinaryOperator::Multiply, kept, repeat);
let low = math(&mut add, MathFunction::Floor, low, None, None);
let wide = binary(&mut add, BinaryOperator::Add, high, low);
components[member] = binary(&mut add, BinaryOperator::Divide, wide, max);
}
let value = add(Expression::Compose {
ty: struct_ty,
components,
});
// Everything from the first splat on is new and not a literal.
let range = naga::Range::new_from_bounds(zero, value);
out.push(Statement::Emit(range), span);
out.push(Statement::Return { value: Some(value) }, span);
}
f.body = out;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clear_colours_keep_the_same_bits() {
// The CPU side, for clear colours. Iris 1.11.7 read p8106d's clear colour back as below in
// RGB5_A1 and RGB565, and p8106c's 0.3337 as 82/255 in the 5-bit channels and 85/255 in
// the 6-bit one.
let to_255 = |v: [f32; 4]| v.map(|c| (c * 255.0 * 1000.0).round() / 1000.0);
let rgb5_a1 = bits(Format::parse("RGB5_A1").unwrap()).unwrap();
let rgb565 = bits(Format::parse("RGB565").unwrap()).unwrap();
let r3_g3_b2 = bits(Format::parse("R3_G3_B2").unwrap()).unwrap();
let clear = [0.3337, 0.1, 0.6, 0.3];
assert_eq!(to_255(quantise(clear, rgb5_a1)), [82.0, 24.0, 156.0, 0.0]);
assert_eq!(to_255(quantise(clear, rgb565))[..3], [82.0, 24.0, 156.0]);
assert_eq!(
to_255(quantise([0.3337; 4], r3_g3_b2))[..3],
[82.0, 85.0, 82.0]
);
assert!(bits(Format::parse("RGBA4").unwrap()).is_none());
}
}
@@ -0,0 +1,95 @@
//! `RGB5_A1`, `RGB565` and `R3_G3_B2` buffers read back as in Iris 1.11.7. Expected values are
//! those probe packs p8106c (written values) and p8106d (clear colours) read back in Iris (the PR
//! has them); the programs here are the probes', cut down. Written values are ones whose byte
//! (× 255) is not near a half: GPUs may round those either way.
use super::destination_tests::Fixture;
use super::*;
use std::io::{Cursor, Write};
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
fn pack() -> Post {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
add("composite.vsh", VERTEX);
add(
"composite.fsh",
"#version 120\n/* RENDERTARGETS: 1,2,3 */\n\
/*\nconst int colortex1Format = RGB5_A1;\nconst int colortex2Format = RGB565;\n\
const int colortex3Format = R3_G3_B2;\nconst int colortex4Format = RGB5_A1;\n\
const int colortex5Format = RGB565;\n*/\n\
const vec4 colortex4ClearColor = vec4(0.3337, 0.1, 0.6, 0.3);\n\
const vec4 colortex5ClearColor = vec4(0.3337, 0.1, 0.6, 0.3);\n\
void main() {\n\
gl_FragData[0] = vec4(175.0, 176.0, 766.5, 256.0) / 511.0;\n\
gl_FragData[1] = vec4(400.0, 175.0, 44.0, 0.0) / 511.0;\n\
gl_FragData[2] = vec4(-255.5, 176.0, 300.0, 0.0) / 511.0;\n}\n",
);
add("final.vsh", VERTEX);
// Pixel column x shows component x % 4 of buffer 1 + x / 4, as the bytes of its bits.
add(
"final.fsh",
"#version 330 compatibility\nuniform sampler2D colortex1;\nuniform sampler2D colortex2;\n\
uniform sampler2D colortex3;\nuniform sampler2D colortex4;\nuniform sampler2D colortex5;\n\
void main() {\n ivec2 p = ivec2(gl_FragCoord.xy);\n vec4 c = vec4(0.0);\n\
switch (p.x / 4) {\n\
case 0: c = texelFetch(colortex1, p, 0); break;\n\
case 1: c = texelFetch(colortex2, p, 0); break;\n\
case 2: c = texelFetch(colortex3, p, 0); break;\n\
case 3: c = texelFetch(colortex4, p, 0); break;\n\
case 4: c = texelFetch(colortex5, p, 0); break;\n\
}\n uint u = floatBitsToUint(c[p.x % 4]);\n\
gl_FragColor = vec4(uvec4(u >> 24u, u >> 16u, u >> 8u, u) & 255u) / 255.0;\n}\n",
);
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-packed",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default());
assert_eq!(post.programs.len(), 2, "{:?}", post.notes);
post
}
#[test]
fn small_packed_buffers_keep_their_bits() {
let Some(gpu) = Fixture::new() else { return };
let mut post = gpu.build(pack());
// Twice: the second frame writes the buffers' other sides.
for frame in 0..2 {
let pixels = gpu.render(&mut post, [20, 1]);
let read: Vec<f32> = pixels
.iter()
.map(|p| f32::from_bits(u32::from_be_bytes(*p)))
.collect();
// Iris 1.11.7, in 255ths: RGB5_A1 written (175, 176, 766.5, 256) / 511; RGB565 written
// (400, 175, 44, 0) / 511; R3_G3_B2 written (-255.5, 176, 300, 0) / 511; RGB5_A1 and
// RGB565 cleared to (0.3337, 0.1, 0.6, 0.3). Three-channel formats read alpha 1.
let expected: [f32; 20] = [
82.0, 90.0, 255.0, 255.0, //
206.0, 85.0, 16.0, 255.0, //
0.0, 89.0, 148.0, 255.0, //
82.0, 24.0, 156.0, 0.0, //
82.0, 24.0, 156.0, 255.0,
];
for (n, (got, want)) in read.iter().zip(expected).enumerate() {
assert!(
(got - want / 255.0).abs() <= 1e-6,
"frame {frame}, buffer {} component {}: {}/255, expected {want}/255; all {:?}",
1 + n / 4,
n % 4,
got * 255.0,
read.iter().map(|v| v * 255.0).collect::<Vec<_>>()
);
}
}
}
@@ -0,0 +1,129 @@
//! The shadow pass draws terrain faces from both sides, as Iris 1.11.7 does (#9033: a probe pack
//! of ours writing `gl_FrontFacing` into `shadowcolor0` found back faces in Iris's shadow map and
//! none in Horizon's). The programs here are original.
use super::destination_tests::Fixture;
use super::*;
use std::io::{Cursor, Write};
use wgpu::util::DeviceExt;
const FINAL_VSH: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
fn pack() -> Post {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
// The terrain's positions are already in clip space.
add(
"shadow.vsh",
"#version 120\nvoid main() { gl_Position=vec4(gl_Vertex.xy,0.5,1.0); }\n",
);
// Red where a front face was drawn, blue where a back face was.
add(
"shadow.fsh",
"#version 120\nvoid main() {\n\
gl_FragData[0]=gl_FrontFacing ? vec4(1.0,0.0,0.0,1.0) : vec4(0.0,0.0,1.0,1.0);\n}\n",
);
add("final.vsh", FINAL_VSH);
add(
"final.fsh",
"#version 120\nconst int shadowMapResolution=16;\nuniform sampler2D shadowcolor0;\n\
varying vec2 uv;\nvoid main() { gl_FragColor=texture2D(shadowcolor0,uv); }\n",
);
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-shadow-faces",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let inputs = horizon_shaders::GameInputs {
terrain: Some(horizon_shaders::GbuffersInputs {
item_id_declared: false,
declarations: "layout(location=0)in vec3 fixture_position;\n".into(),
body: "hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);\
hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);\
hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n"
.into(),
attributes: vec![],
after: String::new(),
alpha_test: None,
}),
..Default::default()
};
horizon_shaders::load_post(&pack, "", &properties, &macros, &inputs)
}
#[test]
fn shadow_pass_draws_terrain_back_faces() {
let Some(gpu) = Fixture::new() else { return };
const ATTRS: [wgpu::VertexAttribute; 1] = wgpu::vertex_attr_array![0=>Float32x3];
let layouts = [Some(wgpu::VertexBufferLayout {
array_stride: 12,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &ATTRS,
})];
// Culled as the game's own terrain is (`Renderer` hands packs `cull: true`).
let mut post = gpu.build_with_terrain(
pack(),
Some(Geometry {
material_maps: false,
buffers: &layouts,
table: None,
depth_write: true,
atlas: None,
ids: None,
cull: true,
}),
);
assert!(post.has_shadow(), "{:?}", post.notes);
// GL clip space: the left half counter-clockwise (a front face), the right half
// clockwise (a back face).
let positions: [[f32; 3]; 12] = [
[-1., -1., 0.],
[0., -1., 0.],
[0., 1., 0.],
[-1., -1., 0.],
[0., 1., 0.],
[-1., 1., 0.],
[0., -1., 0.],
[1., 1., 0.],
[1., -1., 0.],
[0., -1., 0.],
[0., 1., 0.],
[1., 1., 0.],
];
let vertices = gpu
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Original shadow faces geometry"),
contents: bytemuck::cast_slice(&positions),
usage: wgpu::BufferUsages::VERTEX,
});
let size = [8, 8];
let pixels = gpu.render_with_terrain(&mut post, size, &mut |rp, list, _, _| {
if list == TerrainList::ShadowSolid {
rp.set_vertex_buffer(0, vertices.slice(..));
rp.draw(0..12, 0..1);
}
});
let near = |got: [u8; 4], want: [u8; 4]| got.iter().zip(want).all(|(g, w)| g.abs_diff(w) <= 1);
for (i, got) in pixels.iter().enumerate() {
let x = i as u32 % size[0];
let want = if x < size[0] / 2 {
[255, 0, 0, 255]
} else {
[0, 0, 255, 255]
};
assert!(
near(*got, want),
"pixel ({x}, {}): {got:?}, expected {want:?} (red: front face, blue: back face)",
i as u32 / size[0]
);
}
}
@@ -219,13 +219,15 @@ fn pack(
lightmap: lightmap.clone(),
terrain: None,
block_ids: &[],
sprite_rects: &[],
celestial: None,
gui_size: [1, 1],
clouds: None,
weather: false,
hand: false,
},
);
)
.expect("the pack runs");
assert!(post.notes.is_empty(), "{:?}", post.notes);
post
}
@@ -17,6 +17,8 @@ pub(super) struct PackTexture {
pub(super) sample_type: wgpu::TextureSampleType,
pub(super) blur: bool,
pub(super) clamp: bool,
/// Decoded from a PNG, the pack's or the game's (not a raw dump, not a generated texture).
pub(super) png: bool,
pub(super) image: Image,
}
@@ -30,7 +32,7 @@ pub(super) fn upload(
let features = device.features();
let mut out = Vec::new();
for t in textures {
let (image, dimension, sample_type, blur, clamp) = match t.source {
let (image, dimension, sample_type, blur, clamp, png) = match t.source {
TextureSource::Image(texture) => {
let (view, sample_type) = match create(device, queue, &texture, features) {
Ok(created) => created,
@@ -50,6 +52,7 @@ pub(super) fn upload(
sample_type,
texture.blur,
texture.clamp,
!texture.raw,
)
}
TextureSource::Resource(location) => {
@@ -66,7 +69,14 @@ pub(super) fn upload(
continue;
};
let float = wgpu::TextureSampleType::Float { filterable: true };
(image, wgpu::TextureViewDimension::D2, float, true, true)
(
image,
wgpu::TextureViewDimension::D2,
float,
true,
true,
false,
)
}
};
out.push(PackTexture {
@@ -76,6 +86,7 @@ pub(super) fn upload(
sample_type,
blur,
clamp,
png,
image,
});
}
@@ -285,6 +296,7 @@ mod tests {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
blur: false,
clamp: false,
png: false,
image: Image::Lightmap,
}
}
@@ -297,6 +309,7 @@ mod tests {
texels,
blur: false,
clamp: false,
raw: true,
}
}
@@ -718,6 +718,13 @@ fn components(name: &str) -> usize {
}
}
/// What `name` gives custom uniforms before the first frame: zeros, at its size, so their
/// shapes check out as on a frame (#9086).
pub fn zero(name: &str) -> Option<horizon_shaders::Val> {
index(name)?;
Some(horizon_shaders::Val::vector(&[0.0; 16][..components(name)]))
}
/// The position of `name` in [`NAMES`].
pub fn index(name: &str) -> Option<usize> {
NAMES.iter().position(|n| *n == name)
@@ -752,6 +759,22 @@ mod tests {
assert_eq!(out[60..64], [0; 4], "a value past the block is dropped");
}
/// The load-time run of custom uniforms sees each built-in at its size: Iris 1.11.7 reads 2
/// for the first line of our probes K and X (#9086) and 0 for the others.
#[test]
fn custom_uniforms_check_the_shapes_of_built_ins_before_the_first_frame() {
let properties = horizon_shaders::Properties::parse(
"uniform.vec3.a = skyColor * vec3(0.0, 0.0, 0.0) + vec3(2.0, 2.0, 2.0)\n\
uniform.float.b = skyColor\n\
uniform.float.c = gbufferModelView * 0.0 + 2.0\n",
&horizon_shaders::Macros::standard(),
);
let mut custom = horizon_shaders::Custom::parse(&properties);
custom.eval(0.0, &zero);
let broken: Vec<&str> = custom.notes.iter().map(|n| &n[1..2]).collect();
assert_eq!(broken, ["b", "c"], "{:?}", custom.notes);
}
/// Iris's status uniforms as its documentation defines them (shaders.properties, "Status").
#[test]
fn status_uniforms_follow_iris() {
@@ -1006,11 +1029,6 @@ mod initial_history_tests {
#[test]
#[ignore = "requires a host GPU; run with --ignored --nocapture"]
fn core_matrix_inputs_render_provider_values() {
use horizon_shaders::{
GbuffersInputs, Macros, Pack, Target, preprocess, translate_gbuffers,
};
use std::io::Write;
use wgpu::util::DeviceExt;
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
eprintln!("Core matrix GPU: {:?}", adapter.get_info());
@@ -1070,137 +1088,7 @@ mod initial_history_tests {
vec2 p = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);\n\
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);\n}}"
);
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (path, source) in [
("shaders/gbuffers_line.vsh", vertex.as_str()),
(
"shaders/gbuffers_line.fsh",
"#version 330\nin vec3 result; out vec4 color; void main() { color = vec4(result, 1.0); }",
),
] {
zip.start_file(path, zip::write::SimpleFileOptions::default())
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
}
let pack = Pack::from_zip_bytes("matrix-probe.zip", zip.finish().unwrap().into_inner())
.unwrap();
let macros = Macros::standard();
let program = translate_gbuffers(
&preprocess(&pack, "gbuffers_line.vsh", &macros),
&preprocess(&pack, "gbuffers_line.fsh", &macros),
Target::Buffers,
&GbuffersInputs::default(),
)
.unwrap();
let mut bytes = vec![0; program.interface.uniform_size as usize];
for uniform in &program.interface.uniforms {
if let Some(i) = index(&uniform.name) {
values.values[i].write(&uniform.ty, &mut bytes, uniform.offset as usize);
}
}
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: &bytes,
usage: wgpu::BufferUsages::UNIFORM,
});
let module = |module| {
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
})
};
let vs = module(program.vertex);
let fs = module(program.fragment);
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &vs,
entry_point: Some("main"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &fs,
entry_point: Some("main"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba8Unorm,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pipeline.get_bind_group_layout(0),
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: uniform.as_entire_binding(),
}],
});
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_pipeline(&pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
}
encoder.copy_texture_to_buffer(
texture.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(256),
rows_per_image: None,
},
},
texture.size(),
);
queue.submit([encoder.finish()]);
read.slice(..)
.map_async(wgpu::MapMode::Read, |result| result.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let mapped = read.slice(..).get_mapped_range().unwrap();
let mapped = draw_line_program(&device, &queue, &values, &vertex);
// Independent result: translated point (1, .5, -2), perspective (aspect 2,
// vertical FOV 90): clip (.5, .5, z, 2), hence (.25, .25, .5, 1).
for (actual, expected) in mapped[..4].iter().zip(expected) {
@@ -1213,6 +1101,203 @@ mod initial_history_tests {
}
}
/// `normalMatrix` in a core-profile gbuffers program is the view's rotation; in the
/// compatibility profile it stays zero (the issue's Iris 1.11.7 probes, #9136).
#[test]
#[ignore = "requires a host GPU; run with --ignored --nocapture"]
fn core_normal_matrix_renders_the_view_rotation() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
eprintln!("Normal matrix GPU: {:?}", adapter.get_info());
let (device, queue) =
pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let hud = HudBatch::new([800, 400]);
let mut f = frame(&hud);
// Facing west (-X), level: view space looks down -Z with +X to the right (north).
f.yaw = 90.0;
f.pitch = 0.0;
let mut values = FrameUniforms::default();
values.update(&f, &PackInputs::default(), [800, 400]);
for (profile, world, expected) in [
// East (behind the camera) is view +Z.
("330 core", "vec3(1.0, 0.0, 0.0)", [128u8, 128, 255, 255]),
// South (to the left) is view -X; up stays up.
("330 core", "vec3(0.0, 0.0, 1.0)", [0, 128, 128, 255]),
("330 core", "vec3(0.0, 1.0, 0.0)", [128, 255, 128, 255]),
(
"330 compatibility",
"vec3(1.0, 0.0, 0.0)",
[128, 128, 128, 255],
),
] {
let vertex = format!(
"#version {profile}\nuniform mat3 normalMatrix;\nout vec3 result;\n\
void main() {{\n\
result = normalMatrix * {world} * 0.5 + 0.5;\n\
vec2 p = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);\n\
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);\n}}"
);
let pixel = draw_line_program(&device, &queue, &values, &vertex);
eprintln!("{profile} {world}: {pixel:?}");
for (actual, expected) in pixel.iter().zip(expected) {
assert!(
actual.abs_diff(expected) <= 1,
"{profile} {world}: {pixel:?}"
);
}
}
}
/// Draws one fragment through a translated `gbuffers_line` program with `vertex` and the
/// provider's `values`, and reads it back.
fn draw_line_program(
device: &wgpu::Device,
queue: &wgpu::Queue,
values: &FrameUniforms,
vertex: &str,
) -> [u8; 4] {
use horizon_shaders::{
GbuffersInputs, Macros, Pack, Target, preprocess, translate_gbuffers,
};
use std::io::Write;
use wgpu::util::DeviceExt;
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (path, source) in [
("shaders/gbuffers_line.vsh", vertex),
(
"shaders/gbuffers_line.fsh",
"#version 330\nin vec3 result; out vec4 color; void main() { color = vec4(result, 1.0); }",
),
] {
zip.start_file(path, zip::write::SimpleFileOptions::default())
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
}
let pack =
Pack::from_zip_bytes("matrix-probe.zip", zip.finish().unwrap().into_inner()).unwrap();
let macros = Macros::standard();
let program = translate_gbuffers(
&preprocess(&pack, "gbuffers_line.vsh", &macros),
&preprocess(&pack, "gbuffers_line.fsh", &macros),
Target::Buffers,
&GbuffersInputs::default(),
)
.unwrap();
let mut bytes = vec![0; program.interface.uniform_size as usize];
for uniform in &program.interface.uniforms {
if let Some(i) = index(&uniform.name) {
values.values[i].write(&uniform.ty, &mut bytes, uniform.offset as usize);
}
}
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: &bytes,
usage: wgpu::BufferUsages::UNIFORM,
});
let module = |module| {
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
})
};
let vs = module(program.vertex);
let fs = module(program.fragment);
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &vs,
entry_point: Some("main"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &fs,
entry_point: Some("main"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba8Unorm,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pipeline.get_bind_group_layout(0),
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: uniform.as_entire_binding(),
}],
});
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_pipeline(&pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
}
encoder.copy_texture_to_buffer(
texture.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(256),
rows_per_image: None,
},
},
texture.size(),
);
queue.submit([encoder.finish()]);
read.slice(..)
.map_async(wgpu::MapMode::Read, |result| result.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let mapped = read.slice(..).get_mapped_range().unwrap();
let pixel = mapped[..4].try_into().unwrap();
drop(mapped);
read.unmap();
pixel
}
fn int(u: &FrameUniforms, at: usize) -> i32 {
match u.values[at] {
Value::I(v) => v[0],
+8 -1
View File
@@ -676,6 +676,9 @@ pub struct Graphics {
/// HDR brightness of the HUD and menus, in percent of paper white (25..=100); the
/// first-person hand stays at the world's.
pub hdr_ui_brightness: u32,
/// HDR Lighting: Horizon's own HDR look, light sources, the sun and sunlight above paper
/// white. Off, HDR output shows the same picture as SDR, for shader packs or the vanilla look.
pub hdr_lighting: bool,
/// A vanilla key name (`HDR_KEY`): opens HDR's settings from the game. Unbound by default.
pub hdr_key: String,
/// Battery mode: at most 60 frames per second in a world and 30 in menus
@@ -747,6 +750,7 @@ impl Default for Graphics {
hdr_peak: 0,
hdr_black_level: 0,
hdr_ui_brightness: 100,
hdr_lighting: true,
hdr_key: "key.keyboard.unknown".into(),
battery: false,
shadow_cache: false,
@@ -864,6 +868,7 @@ impl Graphics {
}
"frameGeneration" => g.frame_generation = FrameGeneration::parse(value),
"hdr" => g.hdr = flag(g.hdr),
"hdrLighting" => g.hdr_lighting = flag(g.hdr_lighting),
"hdrPaperWhite" => {
g.hdr_paper_white = match number(g.hdr_paper_white) {
0 => 0,
@@ -901,7 +906,7 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
let mut text = format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nparticleCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nparticleCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrLighting:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
@@ -946,6 +951,7 @@ impl Graphics {
self.hdr_peak,
self.hdr_black_level,
self.hdr_ui_brightness,
bool_text(self.hdr_lighting),
self.hdr_key,
bool_text(self.battery),
bool_text(self.shadow_cache),
@@ -1124,6 +1130,7 @@ mod tests {
hdr_peak: 1400,
hdr_black_level: 12,
hdr_ui_brightness: 60,
hdr_lighting: false,
hdr_key: "key.keyboard.h".into(),
battery: true,
shadow_cache: true,
@@ -100,6 +100,7 @@ pub enum H {
HdrPeak,
HdrPaperWhite,
HdrUiBrightness,
HdrLighting,
BatteryMode,
ShadowCache,
GraphicsApi,
@@ -532,6 +533,11 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
None,
"How bright the HUD and menus are, against paper white.",
),
h(
H::HdrLighting,
None,
"Light sources, the sun and sunlight shine past white. Off: just as SDR.",
),
],
),
(
@@ -824,6 +830,7 @@ impl H {
n => labelled("Black Level", format!("{:.2}", n as f32 / 100.0)),
},
H::HdrUiBrightness => labelled_percent("UI Brightness", g.hdr_ui_brightness),
H::HdrLighting => labelled("HDR Lighting", on_off(g.hdr_lighting)),
H::HdrKey => {
Text::translate("Shortcut: %s", &[crate::keybinds::display_name(&g.hdr_key)])
}
@@ -948,6 +955,7 @@ impl H {
g.frame_generation = step(&FrameGeneration::ALL, g.frame_generation, back)
}
H::Hdr => g.hdr = !g.hdr,
H::HdrLighting => g.hdr_lighting = !g.hdr_lighting,
H::BatteryMode => g.battery = !g.battery,
H::ShadowCache => g.shadow_cache = !g.shadow_cache,
H::GraphicsApi => g.api = step(GraphicsApi::choices(), g.api, back),
@@ -998,6 +1006,7 @@ impl Item {
| H::HdrPeak
| H::HdrPaperWhite
| H::HdrUiBrightness
| H::HdrLighting
| H::HdrSmoothGradients,
) if !crate::graphics::hdr_offered() => {
Some("Your display or system doesn't offer HDR output.")
@@ -1013,6 +1022,7 @@ impl Item {
| H::HdrPeak
| H::HdrPaperWhite
| H::HdrUiBrightness
| H::HdrLighting
| H::HdrSmoothGradients,
) if !s.horizon.hdr => Some("Turn HDR on to use it."),
Item::Horizon(H::TranslucentBatching)
@@ -1048,6 +1058,7 @@ impl Item {
Item::Horizon(H::AnimatedTextures) => g.animated_textures,
Item::Horizon(H::DitherCutoutMips) => g.dither_cutout_mips,
Item::Horizon(H::Hdr) => g.hdr,
Item::Horizon(H::HdrLighting) => g.hdr_lighting,
Item::Horizon(H::BatteryMode) => g.battery,
Item::Horizon(H::ShadowCache) => g.shadow_cache,
Item::Horizon(H::TranslucentBatching) => {
@@ -27,6 +27,8 @@ use horizon_world::{
fluid::{FluidKind, FluidState},
registry::BlockStateId,
};
#[cfg(test)]
pub(crate) use sides::sort_tinted;
pub use sides::{Part, Sides, group_parts};
use std::sync::Arc;
@@ -103,6 +103,22 @@ impl Sides {
}
}
/// A sort with these parts' `ends` and `planes`, for tests.
#[cfg(test)]
pub(crate) fn from_parts(
ends: [u32; 14],
grouped: bool,
split: bool,
planes: [f32; 7],
) -> Self {
Self {
ends,
grouped,
split,
planes,
}
}
/// The number of solid quads the buckets hold.
pub fn quads(&self) -> u32 {
self.ends[13]
@@ -128,6 +144,26 @@ impl Sides {
self.runs(seen, part, draw);
}
/// For GPU culling's copy of `visible_from` with every part (`gfx::gpu_culling`),
/// which then only compares: where each bucket of a mesh sorted for one pass ends,
/// in `BUCKETS` order, and what `visible_from` compares the eye with for each. That
/// is the lowest plane less `MARGIN` for a bucket facing a positive direction, the
/// highest plus `MARGIN` for one facing a negative direction, and 0 for the other
/// bucket, which it never compares. `None` for a mesh sorted for two passes
/// (`group_parts`), whose buckets lie in two stretches each.
pub fn thresholds(&self) -> Option<([u32; 7], [f32; 7])> {
if self.grouped {
return None;
}
let t: [f32; 7] = std::array::from_fn(|i| match BUCKETS[i] {
Bucket::Side(_, true) => self.planes[i] - MARGIN,
Bucket::Side(_, false) => self.planes[i] + MARGIN,
Bucket::Other => 0.0,
});
// A bucket's cutout quads end it (`slot`).
Some((std::array::from_fn(|b| self.ends[b * 2 + 1]), t))
}
/// The runs of `part`'s quads facing every way.
pub fn every(&self, part: Part, draw: impl FnMut(u32, u32)) {
self.runs(|_| true, part, draw);
@@ -659,6 +659,30 @@ pub fn around_self(
}
}
/// `LivingEntity.handleEntityEvent(46)`: 128 `ParticleTypes.PORTAL` samples
/// from the entity's old position (`xo`, `yo`, `zo`) to its current one,
/// spread across the bounding box (`getBbWidth() * 2` on x and z, `getBbHeight`
/// on y). The speeds are `(nextFloat() - 0.5) * 0.2`.
pub fn living_teleport_particles(
random: &mut impl RandomSource,
from: [f64; 3],
to: [f64; 3],
width: f64,
height: f64,
mut emit: impl FnMut([f64; 3], [f64; 3]),
) {
for i in 0..128 {
let d = i as f64 / 127.0;
let velocity = [0; 3].map(|_| f64::from((random.next_float() - 0.5) * 0.2));
let position = [
from[0] + d * (to[0] - from[0]) + (random.next_double() - 0.5) * width * 2.0,
from[1] + d * (to[1] - from[1]) + random.next_double() * height,
from[2] + d * (to[2] - from[2]) + (random.next_double() - 0.5) * width * 2.0,
];
emit(position, velocity);
}
}
/// ClientLevel.addDestroyBlockEffect placement and pre-constructor velocities.
pub fn destroy_positions(
pos: [i32; 3],
@@ -5411,6 +5435,47 @@ mod tests {
));
}
/// `LivingEntity.handleEntityEvent(46)`: 128 samples, the first on the old
/// box and the last on the new one, with speeds inside ±0.1.
#[test]
fn living_teleport_particles_span_the_old_and_new_boxes() {
let mut random = LegacyRandom::new(1);
let mut samples = Vec::new();
living_teleport_particles(
&mut random,
[0.0, 1.0, 0.0],
[8.0, 1.0, 0.0],
0.6,
1.8,
|p, v| samples.push((p, v)),
);
assert_eq!(samples.len(), 128);
let first = samples[0].0;
let last = samples[127].0;
assert!(first[0].abs() <= 0.6, "{first:?}");
assert!((1.0..2.8).contains(&first[1]), "{first:?}");
assert!(first[2].abs() <= 0.6, "{first:?}");
assert!((7.4..8.6).contains(&last[0]), "{last:?}");
assert!((1.0..2.8).contains(&last[1]), "{last:?}");
assert!(last[2].abs() <= 0.6, "{last:?}");
// `(nextDouble - 0.5) * width * 2` reaches ±width. A spread of only
// `width` (the factor of 2 dropped) stays inside 0.3 of the line.
let edge = samples
.iter()
.enumerate()
.map(|(i, (p, _))| {
let along = (i as f64 / 127.0) * 8.0;
(p[0] - along).abs()
})
.fold(0.0_f64, f64::max);
assert!(edge > 0.45, "box spread reached only {edge}");
for (_, v) in &samples {
for c in v {
assert!((-0.1..0.1).contains(c), "{v:?}");
}
}
}
#[test]
fn egg_teleport_unpacks_offsets_and_preserves_seeded_draw_order() {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
@@ -101,6 +101,9 @@ pub struct World {
/// Every block position written since the log was last drained, kept
/// only while the world is open to the network (see `lan`).
pub block_log: Option<Vec<(i32, i32, i32)>>,
/// Writes of this tick's block events and block entities, held out of
/// the log while it is sent: they go out with the next tick's.
pub block_log_held: Vec<(i32, i32, i32)>,
/// Every write since the mobs last ticked (position, old and new state),
/// for `ServerLevel.sendBlockUpdated`'s path recomputing; kept only while
/// the mobs of this world tick (`game/creatures.rs`).
@@ -217,6 +220,7 @@ impl World {
day_time: 1000,
weather: Weather::default(),
block_log: None,
block_log_held: Vec::new(),
path_writes: None,
bulk_log: Default::default(),
light_log: None,
@@ -0,0 +1,162 @@
//! An ordinary read of a struct or buffer-block member that an atomic function also uses gets
//! the member's current value (#9081), on a host GPU through the translated program.
use horizon_shaders::{Macros, Pack, preprocess, translate_compute};
/// Runs compute program `source` once (one work group), with `probe_buffer` (set 4, binding 2)
/// starting at `start`, and returns `probe_result.result` (set 4, binding 1).
fn run(device: &wgpu::Device, queue: &wgpu::Queue, source: &str, start: [u32; 2]) -> u32 {
use std::io::Write;
use wgpu::util::DeviceExt;
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
zip.start_file(
"shaders/deferred.csh",
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
let pack =
Pack::from_zip_bytes("atomic-values.zip", zip.finish().unwrap().into_inner()).unwrap();
let program = translate_compute(&preprocess(&pack, "deferred.csh", &Macros::standard()))
.unwrap_or_else(|e| panic!("{e:?}"));
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(program.module)),
});
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: None,
layout: None,
module: &module,
entry_point: Some("main"),
compilation_options: Default::default(),
cache: None,
});
let storage = wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC;
let uniforms = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(program.interface.uniform_size.max(48)),
usage: wgpu::BufferUsages::UNIFORM,
mapped_at_creation: false,
});
let result = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: &[0; 4],
usage: storage,
});
let probe: Vec<u8> = start.iter().flat_map(|v| v.to_le_bytes()).collect();
let probe = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: &probe,
usage: storage,
});
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 4,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let groups: Vec<wgpu::BindGroup> = (0..5)
.map(|set| {
let entries: Vec<wgpu::BindGroupEntry> = match set {
0 => vec![wgpu::BindGroupEntry {
binding: 0,
resource: uniforms.as_entire_binding(),
}],
4 if source.contains("probe_buffer") => vec![
wgpu::BindGroupEntry {
binding: 1,
resource: result.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: probe.as_entire_binding(),
},
],
4 => vec![wgpu::BindGroupEntry {
binding: 1,
resource: result.as_entire_binding(),
}],
_ => Vec::new(),
};
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pipeline.get_bind_group_layout(set),
entries: &entries,
})
})
.collect();
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_compute_pass(&Default::default());
pass.set_pipeline(&pipeline);
for (set, group) in groups.iter().enumerate() {
pass.set_bind_group(set as u32, group, &[]);
}
pass.dispatch_workgroups(1, 1, 1);
}
encoder.copy_buffer_to_buffer(&result, 0, &read, 0, 4);
queue.submit([encoder.finish()]);
read.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let bytes = read.slice(..).get_mapped_range().unwrap();
u32::from_le_bytes(bytes[..4].try_into().unwrap())
}
#[test]
#[ignore = "requires a host GPU; run with --ignored --nocapture"]
fn reads_of_atomic_members_see_the_atomic_updates() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
eprintln!("Atomic member GPU: {}", adapter.get_info().name);
// Pack buffers sit in bind group 4, as in the game.
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: wgpu::Limits {
max_bind_groups: 5,
..Default::default()
},
..Default::default()
}))
.unwrap();
let result = "layout(std430, binding = 1) buffer probe_result { uint result; };\n";
// Eight invocations each add 1 to a zeroed shared member, then read it: 8.
for (host, member) in [
("shared struct { uint other; uint count; } sh;", "sh.count"),
(
"struct Counter { uint count; uint other; };\nshared Counter counters[2];",
"counters[1].count",
),
] {
for (read, expected) in [
("result = M * 10u + 1u;", 81),
("uint r = M; result = r;", 8),
("result = uint(float(M) * 0.5);", 4),
("result = min(M, 5u);", 5),
] {
let source = format!(
"#version 460 core\nlayout(local_size_x = 8) in;\n{host}\n{result}\
void main() {{\n {member} = 0u;\n barrier();\n \
atomicAdd({member}, 1u);\n barrier();\n {}\n}}\n",
read.replace('M', member)
);
assert_eq!(
run(&device, &queue, &source, [0, 0]),
expected,
"{member}: {read}"
);
}
}
// One invocation adds 2 to a buffer member that starts at 5, then reads it: 7.
for (read, expected) in [
("result = pc.count * 10u + 1u;", 71),
("uint r = pc.count; result = r;", 7),
("result = uint(int(pc.count) - 10 + 100);", 97),
] {
let source = format!(
"#version 460 core\nlayout(local_size_x = 1) in;\n\
layout(std430, binding = 2) buffer probe_buffer {{ uint count; uint other; }} pc;\n\
{result}void main() {{\n atomicAdd(pc.count, 2u);\n {read}\n}}\n"
);
assert_eq!(run(&device, &queue, &source, [5, 0]), expected, "{read}");
}
}
@@ -0,0 +1,208 @@
//! Synthetic rendering checks (two output shapes mirror RenderPearl v2.7.2 @86de79c, MIT; see the
//! PR): a fragment output's `component` layout qualifier (GLSL 4.60, 4.4.2) picks the channels of
//! its location that it writes.
use horizon_shaders::{Origin, Preprocessed, Target, translate_composite};
fn source(code: &str) -> Preprocessed {
Preprocessed {
code: code.into(),
files: vec!["components.glsl".into()],
lines: code
.lines()
.enumerate()
.map(|(i, _)| Origin {
file: 0,
line: i as u32 + 1,
})
.collect(),
version: Some((440, None)),
..Default::default()
}
}
/// Draws `fragment` over a 1×1 target of `format` and reads back its 16 bytes.
fn draw(
device: &wgpu::Device,
queue: &wgpu::Queue,
fragment: &str,
format: wgpu::TextureFormat,
) -> [u8; 16] {
let program = translate_composite(
&source("void main() { gl_Position = ftransform(); }"),
&source(fragment),
Target::Buffers,
)
.unwrap_or_else(|e| panic!("{fragment}: {e:?}"));
let module = |module| {
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
})
};
let (vertex, fragment_module) = (module(program.vertex), module(program.fragment));
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &vertex,
entry_point: Some("main"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleStrip,
..Default::default()
},
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &fragment_module,
entry_point: Some("main"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let extent = wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
};
let target = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: extent,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let readback = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let view = target.create_view(&Default::default());
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_pipeline(&pipeline);
// The composite program's uniform block, if it kept one, reads zero.
if program.interface.uniform_size > 0 {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(program.interface.uniform_size).max(16),
usage: wgpu::BufferUsages::UNIFORM,
mapped_at_creation: false,
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pipeline.get_bind_group_layout(0),
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
}],
});
pass.set_bind_group(0, &group, &[]);
}
pass.draw(0..4, 0..1);
}
encoder.copy_texture_to_buffer(
target.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &readback,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(256),
rows_per_image: Some(1),
},
},
extent,
);
queue.submit([encoder.finish()]);
readback
.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let bytes = readback.slice(..).get_mapped_range().unwrap()[..16]
.try_into()
.unwrap();
readback.unmap();
bytes
}
#[test]
#[ignore = "requires a host GPU; run with --ignored --nocapture"]
fn component_qualified_outputs_write_their_own_channels() {
let instance =
wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle().with_env());
let adapter = pollster::block_on(instance.request_adapter(&Default::default()))
.expect("GPU adapter required; missing adapter is not a pass");
eprintln!("Output components GPU: {:?}", adapter.get_info());
assert_ne!(adapter.get_info().device_type, wgpu::DeviceType::Cpu);
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
// The channels no output writes keep the fill of a narrow output: 0, and 1 in alpha.
for (name, fragment, expected) in [
(
"uvec3 at component 1",
"layout(location = 0, component = 1) out uvec3 words;\n\
void main() { words = uvec3(7u, 8u, 9u); }",
[0u32, 7, 8, 9],
),
(
"uint at component 2",
"layout(location = 0, component = 2) out uint word;\n\
void main() { word = 5u; }",
[0, 0, 5, 1],
),
(
"two outputs share location 0",
"layout(location = 0, component = 0) out uint low;\n\
layout(location = 0, component = 1) out uvec3 high;\n\
void main() { low = 3u; high = uvec3(4u, 5u, 6u); }",
[3, 4, 5, 6],
),
(
"the component 1 output declared first",
"layout(location = 0, component = 1) out uvec2 middle;\n\
layout(location = 0) out uint low;\n\
void main() { low = 11u; middle = uvec2(12u, 13u); }",
[11, 12, 13, 1],
),
] {
let bytes = draw(&device, &queue, fragment, wgpu::TextureFormat::Rgba32Uint);
let actual: [u32; 4] = bytemuck::cast(bytes);
eprintln!("{name}: {actual:?}");
assert_eq!(actual, expected, "{name}");
}
let bytes = draw(
&device,
&queue,
"layout(location = 0, component = 0) out float red;\n\
layout(location = 0, component = 1) out vec3 rest;\n\
void main() { red = 0.0; rest = vec3(1.0, 0.0, 1.0); }",
wgpu::TextureFormat::Rgba32Float,
);
let actual: [f32; 4] = bytemuck::cast(bytes);
eprintln!("float outputs share location 0: {actual:?}");
assert_eq!(actual, [0.0, 1.0, 0.0, 1.0]);
}
@@ -0,0 +1,209 @@
//! `textureMatrix` in a gbuffers program (#9080), through the translated program on a host GPU.
//! Expected values: our probe packs K-texmat3 and K-texmat2 in Iris 1.11.7 read the identity in
//! the terrain vertex stage and zero in the terrain fragment stage.
use horizon_shaders::{GbuffersInputs, Macros, Pack, Target, preprocess, translate_gbuffers};
/// Draws one pixel with `vertex` and `fragment` as `gbuffers_terrain`; returns its RGBA8.
fn pixel(device: &wgpu::Device, queue: &wgpu::Queue, vertex: &str, fragment: &str) -> [u8; 4] {
use std::io::Write;
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (path, source) in [
("shaders/gbuffers_terrain.vsh", vertex),
("shaders/gbuffers_terrain.fsh", fragment),
] {
zip.start_file(path, zip::write::SimpleFileOptions::default())
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
}
let pack =
Pack::from_zip_bytes("texture-matrix.zip", zip.finish().unwrap().into_inner()).unwrap();
let macros = Macros::standard();
let program = translate_gbuffers(
&preprocess(&pack, "gbuffers_terrain.vsh", &macros),
&preprocess(&pack, "gbuffers_terrain.fsh", &macros),
Target::Buffers,
&GbuffersInputs::default(),
)
.unwrap_or_else(|e| panic!("{e:?}"));
assert!(!program.interface.draw, "no per-draw block in this probe");
use wgpu::util::DeviceExt;
// The frame's uniforms are all zero: `textureMatrix` must not come from them.
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: &vec![0; program.interface.uniform_size as usize],
usage: wgpu::BufferUsages::UNIFORM,
});
let module = |module| {
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
})
};
let (vs, fs) = (module(program.vertex), module(program.fragment));
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &vs,
entry_point: Some("main"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &fs,
entry_point: Some("main"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba8Unorm,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pipeline.get_bind_group_layout(0),
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: uniform.as_entire_binding(),
}],
});
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_pipeline(&pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
}
encoder.copy_texture_to_buffer(
texture.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(256),
rows_per_image: None,
},
},
texture.size(),
);
queue.submit([encoder.finish()]);
read.slice(..)
.map_async(wgpu::MapMode::Read, |result| result.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let mapped = read.slice(..).get_mapped_range().unwrap();
[mapped[0], mapped[1], mapped[2], mapped[3]]
}
/// A full-screen triangle; `extra` declares and sets more.
fn vertex(extra: &str, set: &str) -> String {
format!(
"#version 460 core\n{extra}\nvoid main() {{\n\
vec2 p = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);\n\
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);\n{set}\n}}\n"
)
}
/// Four values `v` as v / 4 + 0.5 (K-texmat3's encoding): 0 is 128, 1 is 191.
const ENCODE: &str = "color = vec4(e) / 4.0 + 0.5;";
fn assert_close(actual: [u8; 4], expected: [u8; 4], what: &str) {
assert!(
actual.iter().zip(expected).all(|(a, e)| a.abs_diff(e) <= 1),
"{what}: {actual:?}, expected {expected:?}"
);
}
#[test]
#[ignore = "requires a host GPU; run with --ignored --nocapture"]
fn texture_matrix_is_the_identity_in_the_vertex_stage_and_zero_in_the_fragment_stage() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
eprintln!("Texture matrix GPU: {:?}", adapter.get_info().name);
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let fragment_in = "#version 460 core\nflat in vec4 e;\nlayout(location = 0) out vec4 color;\n\
void main() { ";
// The vertex stage hands elements [0][0], [1][1], [3][0], [3][3] on (K-texmat3).
let elements = pixel(
&device,
&queue,
&vertex(
"uniform mat4 textureMatrix;\nflat out vec4 e;",
"e = vec4(textureMatrix[0][0], textureMatrix[1][1], textureMatrix[3][0], textureMatrix[3][3]);",
),
&format!("{fragment_in}{ENCODE} }}\n"),
);
assert_close(elements, [191, 191, 128, 191], "vertex-stage elements");
// A UV through it is the UV (K-texmat's `mat2(m) * uv + m[3].xy`).
let uv = pixel(
&device,
&queue,
&vertex(
"uniform mat4 textureMatrix;\nflat out vec4 e;",
"vec2 uv = vec2(0.25, 0.75);\n\
e = vec4(mat2(textureMatrix) * uv + textureMatrix[3].xy, (textureMatrix * vec4(uv, 0.0, 1.0)).xy);",
),
"#version 460 core\nflat in vec4 e;\nlayout(location = 0) out vec4 color;\n\
void main() { color = e; }\n",
);
assert_close(uv, [64, 191, 64, 191], "transformed UV");
// The fragment stage's own declaration reads zero, with or without the vertex stage's (K-texmat2).
for vertex_declares in [false, true] {
let v = if vertex_declares {
vertex(
"uniform mat4 textureMatrix;\nflat out vec4 e;",
"e = textureMatrix[0];",
)
} else {
vertex("flat out vec4 e;", "e = vec4(0.0);")
};
let f = "#version 460 core\nuniform mat4 textureMatrix;\nflat in vec4 e;\n\
layout(location = 0) out vec4 color;\nvoid main() {\n\
vec4 m = vec4(textureMatrix[0][0], textureMatrix[1][1], textureMatrix[3][0], textureMatrix[3][3]);\n\
color = m / 4.0 + 0.5 + e * 0.0;\n}\n";
assert_close(
pixel(&device, &queue, &v, f),
[128, 128, 128, 128],
&format!("fragment stage (vertex declares: {vertex_declares})"),
);
}
}
@@ -111,6 +111,8 @@
"Peak Brightness": "Peak Brightness",
"Black Level": "Black Level",
"UI Brightness": "UI Brightness",
"HDR Lighting": "HDR Lighting",
"Light sources, the sun and sunlight shine past white. Off: just as SDR.": "Light sources, the sun and sunlight shine past white. Off: just as SDR.",
"Smooth Gradients": "Smooth Gradients",
"16-bit frame: dark skies and shadows don't band. A bit more VRAM. Next start.": "16-bit frame: dark skies and shadows don't band. A bit more VRAM. Next start.",
"Battery Mode": "Battery Mode",
@@ -0,0 +1,22 @@
//! Chest inventory loading without unpacking loot, for the vanilla differential harness.
use horizon_client::save;
use std::{fs, io::Write};
pub fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = super::args().collect();
let input = fs::read_to_string(&args[1])?;
let mut out = fs::File::create(&args[2])?;
for line in input.lines() {
let tag = horizon_world::nbt::parse_snbt(line)?.0;
let (_, chest) = save::decode_block_entity(&tag).ok_or("not a chest")?;
let mut result = String::new();
for (i, stack) in chest.slots().iter().enumerate() {
if let Some(stack) = stack {
use std::fmt::Write;
write!(result, "{i}:{}:{};", stack.item, stack.count)?;
}
}
writeln!(out, "{}", if result.is_empty() { "empty" } else { &result })?;
}
Ok(())
}
@@ -26,6 +26,7 @@ mod breaking_fixture;
mod camera_probe;
mod cauldron_fixture;
mod chat_screen_fixture;
mod chest_load_fixture;
mod chest_pair_fixture;
mod cloud_fixture;
mod command_parse_fixture;
@@ -128,6 +129,7 @@ probes! {
cauldron_fixture,
chat_screen_fixture,
chest_pair_fixture,
chest_load_fixture,
cloud_fixture,
command_parse_fixture,
container_screen_probe,
@@ -12,7 +12,7 @@ use std::{
fn digest(render: &RenderAssets) -> u64 {
let mut h = DefaultHasher::new();
render.pixels.hash(&mut h);
render.pixels.get().hash(&mut h);
(
render.sprite_size,
render.sprite_minimum_size,
+19 -12
View File
@@ -583,6 +583,24 @@ pub enum EntityHit {
/// flies on and falls to the ground.
Dodged,
}
/// `ProjectileDeflection.REVERSE` with power `0.2`: each axis of the movement
/// is multiplied by `-0.2 * 0.5` (`-0.1`), and the yaw turns `170 + nextFloat * 20`
/// degrees. No hit sound. One left almost still (`lengthSqr < 1e-7`) drops its
/// pickup when that is ALLOWED, then is discarded.
pub fn deflect_reverse(velocity: &mut Vec3, yaw: &mut f32, st: &mut ArrowState) {
let power = 0.2;
let rotation = 170.0 + st.random.next_float() * 20.0;
let v = *velocity;
let k = -power * 0.5;
*velocity = Vec3::new(v.x * k, v.y * k, v.z * k);
*yaw += rotation;
if velocity.length_squared() < 1.0e-7 {
if st.pickup == Pickup::Allowed {
st.events.push(ArrowEvent::Drop);
}
st.removed = true;
}
}
/// The rest of the tick of an arrow that hit an entity: whether to keep it.
pub fn finish_entity_hit(p: &mut Projectile, scene: &ArrowScene, outcome: EntityHit) -> bool {
let Some(mut st) = p.arrow.take() else {
@@ -981,18 +999,7 @@ impl Ticker<'_, '_, '_, '_> {
/// a half turn give or take ten degrees at a tenth of the speed; one
/// left almost still drops its pickup (ALLOWED) and is discarded.
fn turn_back(&mut self) {
let power = 0.2;
let rotation = 170.0 + self.st.random.next_float() * 20.0;
let v = self.p.velocity;
let k = -power * 0.5;
self.p.velocity = Vec3::new(v.x * k, v.y * k, v.z * k);
self.p.yaw += rotation;
if self.p.velocity.length_squared() < 1.0e-7 {
if self.st.pickup == Pickup::Allowed {
self.st.events.push(ArrowEvent::Drop);
}
self.st.removed = true;
}
deflect_reverse(&mut self.p.velocity, &mut self.p.yaw, self.st);
}
/// `AbstractArrow.onHitBlock`: back off the face by 0.05 along each axis
/// it moved on, stop, and stick.
+195
View File
@@ -92,8 +92,112 @@ fn slime_at(world: &World, facts: &BlockFacts, x: i32, y: i32, z: i32) -> bool {
mod fluids;
pub use fluids::update_entity_fluids;
#[cfg(test)]
mod drop_motion_tests {
use super::drop_motion;
use horizon_core::random::{LegacyRandom, RandomSource};
/// Vanilla's own `Mth` and `LegacyRandomSource(7)` running
/// `createItemStackToDrop`'s arithmetic: three throws along the view,
/// then two scattered drops, bit for bit, then the random's next int.
#[test]
fn drops_match_vanillas_motion_bit_for_bit() {
let mut r = LegacyRandom::new(7);
let bits = |v: [f64; 3]| v.map(|c| c.to_bits() as i64);
let thrown = [
(
0.0,
0.0,
[
-4658604453255681268,
4595435376155295744,
4598847571894135436,
],
),
(
37.5,
-21.25,
[
-4627867408251048793,
4597245257657090048,
4597448471913462163,
],
),
(
-123.4,
60.0,
[
4593476083220988603,
-4626282285467959296,
-4631965182103987462,
],
),
];
for (yaw, pitch, want) in thrown {
assert_eq!(
bits(drop_motion(&mut r, yaw, pitch, false)),
want,
"{yaw} {pitch}"
);
}
let scattered = [
[
-4634512480041697280,
4596373779801702400,
-4661085178024689664,
],
[
4600899508711194624,
4596373779801702400,
-4629159495333838848,
],
];
for want in scattered {
assert_eq!(bits(drop_motion(&mut r, 0.0, 0.0, true)), want);
}
assert_eq!(r.next_int(), 357425922);
}
}
/// `LivingEntity.createItemStackToDrop`'s motion for a stack thrown by an
/// entity facing `yaw` and `pitch` (degrees), from that entity's random:
/// `randomly` scatters it in any direction (a death's drops), else it goes
/// along the view (the drop key).
pub fn drop_motion(
r: &mut impl horizon_core::random::RandomSource,
yaw: f32,
pitch: f32,
randomly: bool,
) -> [f64; 3] {
use horizon_core::{
geometry::trig::{DEG_TO_RAD, cos, sin},
jmath,
};
let tau = std::f32::consts::TAU;
if randomly {
let pow = r.next_float() * 0.5;
let dir = f64::from(r.next_float() * tau);
return [
f64::from(-sin(dir) * pow),
f64::from(0.2f32),
f64::from(cos(dir) * pow),
];
}
let (x, y) = (f64::from(pitch * DEG_TO_RAD), f64::from(yaw * DEG_TO_RAD));
let (sin_x, cos_x, sin_y, cos_y) = (sin(x), cos(x), sin(y), cos(y));
let dir = f64::from(r.next_float() * tau);
let pow = f64::from(0.02 * r.next_float());
[
f64::from(-sin_y * cos_x * 0.3) + jmath::cos(dir) * pow,
f64::from(-sin_x * 0.3 + 0.1 + (r.next_float() - r.next_float()) * 0.1),
f64::from(cos_y * cos_x * 0.3) + jmath::sin(dir) * pow,
]
}
#[derive(Clone, Debug)]
pub struct ItemEntity {
/// `Entity.yRot` and `xRot` (`/tp`, `/data get ... Rotation`).
pub rotation: [f32; 2],
/// When it joined its list ([`crate::join_seq`]); set by the list, not by the spawn site.
pub join: crate::join_seq::JoinSeq,
pub health: i32,
@@ -275,6 +379,7 @@ impl ItemEntity {
fluids: &crate::motion::FluidResources,
entity_boxes: &[Aabb],
border: Option<&BorderGeometry>,
blocks: Option<&horizon_world::movement_properties::BoundMovementProperties>,
) -> bool {
let (water, lava) = self.update_fluids(world, fluids);
self.water_entry(water > 0.0, self.ticks == 0, &mut |_| {});
@@ -286,6 +391,7 @@ impl ItemEntity {
(water, lava),
entity_boxes,
border,
blocks,
);
crate::motion::bubble_column_push(
world,
@@ -453,6 +559,7 @@ impl ItemEntity {
(water_height, lava_height): (f64, f64),
entity_boxes: &[Aabb],
border: Option<&BorderGeometry>,
blocks: Option<&horizon_world::movement_properties::BoundMovementProperties>,
) -> ItemStep {
self.ticks += 1;
self.previous = self.position;
@@ -550,6 +657,15 @@ impl ItemEntity {
slime,
);
}
// Entity.move's last step: soul sand and honey slow the item's
// horizontal speed. A move through blocks (noPhysics) returns
// before it. `blocks` is None only for the isolated unit tests.
if !no_physics && let Some(blocks) = blocks {
let factor =
crate::motion::block_speed_factor_at(world, blocks, self.position, None);
self.velocity =
horizon_world::entity_move::scale_by_block_speed(self.velocity, factor);
}
// Entity.move -> applyEffectsFromBlocks: honey then bubble columns
// (before air drag), matching ItemEntity.tick.
self.apply_honey_slide(world, facts);
@@ -951,6 +1067,8 @@ pub const ORB_QUAD_SCALE: f32 = 0.3;
/// ExperienceOrb: gravity 0.03, homing towards the player within 8 blocks.
#[derive(Clone, Debug)]
pub struct ExperienceOrb {
/// `Entity.yRot` and `xRot` (`/tp`, `/data get ... Rotation`).
pub rotation: [f32; 2],
/// When it joined its list ([`crate::join_seq`]); set by the list, not by the spawn site.
pub join: crate::join_seq::JoinSeq,
/// The entity id servers track it by.
@@ -1210,6 +1328,7 @@ impl ExperienceOrb {
);
}
}
/// `Entity.moveTowardsClosestSpace` from the middle of an entity's box
/// (`bounds`, its feet at `position`): the velocity pushed towards the
/// nearest side of its block (north, south, west, east or up) without a full
@@ -1464,6 +1583,8 @@ impl ItemFrame {
/// An armor stand (`ArmorStand`).
#[derive(Clone, Debug)]
pub struct ArmorStand {
/// `Entity.xRot`: kept for `/tp` and `Rotation`; the kind turns only in yaw.
pub pitch: f32,
/// When it joined its list ([`crate::join_seq`]); set by the list, not by the spawn site.
pub join: crate::join_seq::JoinSeq,
pub fire_ticks: i32,
@@ -1663,6 +1784,7 @@ impl ArmorStand {
}
pub fn new(id: u32, position: Vec3, yaw: f32) -> Self {
Self {
pitch: 0.0,
join: Default::default(),
fire_ticks: 0,
health: 20.0,
@@ -2037,6 +2159,7 @@ mod fire_environment_tests {
)
.unwrap();
let make = |id: &str| ItemEntity {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
id: 1,
@@ -2171,6 +2294,7 @@ mod destroyed_bundle_environment_tests {
ItemStack::new("minecraft:diamond", 1),
];
let mut bundle = ItemEntity {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
id: 1,
@@ -2269,6 +2393,7 @@ mod merge_tests {
fn item(id: &str, count: u32, x: f64, age: i32) -> ItemEntity {
let at = Vec3::new(x, 64.0, 0.5);
ItemEntity {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
id: 0,
@@ -2453,6 +2578,64 @@ mod merge_tests {
mod world_border_tests {
use super::*;
/// `Entity.move`'s last step, `getBlockSpeedFactor`: a dropped item sliding
/// over soul sand or honey keeps 40% of the horizontal speed it keeps over
/// stone, in the same tick. Skips quietly without the vanilla data (CI has none).
#[test]
fn a_dropped_item_on_soul_sand_or_honey_is_slowed_by_the_move() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
let fluids = crate::motion::FluidResources::new(&assets, &facts).unwrap();
let collision = crate::collisions::load(&root, assets.registry).unwrap();
let motion = crate::motion::MotionResources::load(&root, &assets).unwrap();
let mut after = Vec::new();
// The tops of the blocks: stone 1, soul sand 7/8, honey 15/16.
for (block, top) in [
("minecraft:stone", 4.0),
("minecraft:soul_sand", 3.875),
("minecraft:honey_block", 3.9375),
] {
let mut world = World::test_scene(&assets, &render, 3).unwrap();
let id = assets.registry.default_state(block).unwrap();
for x in 18..=23 {
for z in 19..=21 {
world.set(x, 3, z, id).unwrap();
}
}
let mut item = super::honey_slide_tests::falling_item(19.5, top, 20.5, 0.2, 0.0, 0.0);
item.on_ground = true;
item.tick(
&world,
assets.registry,
&facts,
&collision,
false,
&fluids,
&[],
None,
Some(&motion.blocks),
);
after.push(item.velocity.x);
}
let (stone, soul, honey) = (after[0], after[1], after[2]);
assert!(stone > 0.0);
assert!(
(soul - stone * f64::from(0.4_f32)).abs() < 1.0e-9,
"soul sand {soul} vs stone {stone}"
);
assert!(
(honey - stone * f64::from(0.4_f32)).abs() < 1.0e-9,
"honey {honey} vs stone {stone}"
);
}
/// `Entity.collectColliders` (#7080): the world border's wall stops an
/// item and an experience orb as it stops the player. A border of size
/// 10 around (20, 20) has its east wall at x = 25; each body starts at
@@ -2484,8 +2667,10 @@ mod world_border_tests {
&fluids,
&[],
border,
None,
);
let mut orb = ExperienceOrb {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
id: 2,
@@ -2569,6 +2754,7 @@ mod honey_slide_tests {
&fluids,
&[],
None,
None,
);
ys.push(item.velocity.y);
}
@@ -2634,6 +2820,7 @@ mod honey_slide_tests {
&fluids,
&[],
None,
None,
);
}
assert!(
@@ -2694,6 +2881,7 @@ mod honey_slide_tests {
pub(super) fn falling_item(x: f64, y: f64, z: f64, vx: f64, vy: f64, vz: f64) -> ItemEntity {
let at = Vec3::new(x, y, z);
ItemEntity {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
id: 1,
@@ -2844,6 +3032,7 @@ mod slime_bounce_tests {
fn grounded_item() -> ItemEntity {
let at = Vec3::new(0.5, 2.0, 0.5);
ItemEntity {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
id: 1,
@@ -2935,6 +3124,7 @@ mod slime_bounce_tests {
&fluids,
&[],
None,
None,
);
assert_eq!(falling.position.y, 4.0);
assert!(falling.velocity.y > 0.0);
@@ -2954,6 +3144,7 @@ mod slime_bounce_tests {
&fluids,
&[],
None,
None,
);
assert!(!falling.needs_sync);
}
@@ -3047,6 +3238,7 @@ mod entity_collision_tests {
&fluids,
boxes,
None,
None,
);
}
item.position.y
@@ -3096,6 +3288,7 @@ mod entity_collision_tests {
&fluids,
&[],
None,
None,
);
}
item.position.x
@@ -3551,6 +3744,7 @@ mod item_destroyed_tests {
.map(|(id, count)| ItemStack::new(id, *count))
.collect();
ItemEntity {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
id: 1,
@@ -3644,6 +3838,7 @@ mod render_light_tests {
fn orb(y: f64) -> ExperienceOrb {
ExperienceOrb {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
id: 1,
@@ -242,6 +242,7 @@ mod tests {
}
let at = Vec3::new(20.5, 4.0, 20.5);
let item = ItemEntity {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
id: 1,
@@ -278,6 +279,7 @@ mod tests {
false,
&fluids,
&[],
None,
None
));
assert_eq!(still.position.x, at.x);
@@ -292,6 +294,7 @@ mod tests {
false,
&fluids,
&[],
None,
None
));
// First push is damped by buoyancy before motion, second follows drag.
@@ -307,6 +310,7 @@ mod tests {
&fluids,
&[],
None,
None,
);
}
assert!(carried.position.x > at.x + 0.5);
@@ -342,6 +346,7 @@ mod tests {
}
let at = Vec3::new(20.5, 4.0, 20.5);
let orb = ExperienceOrb {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
id: 1,
+57 -10
View File
@@ -2139,7 +2139,7 @@ pub fn item_entity_to_nbt(e: &ItemEntity, uuid: [i32; 4], raw: Option<&Tag>) ->
uuid,
position: e.position,
motion: e.velocity,
rotation: [0.0, 0.0],
rotation: e.rotation,
on_ground: e.on_ground,
fall_distance: 0.0,
};
@@ -2193,6 +2193,7 @@ pub fn tnt_to_nbt(
fuse: i32,
power: f32,
block_state: Tag,
rotation: [f32; 2],
uuid: [i32; 4],
raw: Option<&Tag>,
) -> Tag {
@@ -2201,7 +2202,7 @@ pub fn tnt_to_nbt(
uuid,
position,
motion,
rotation: [0.0, 0.0],
rotation,
on_ground,
fall_distance: 0.0,
}
@@ -2245,6 +2246,7 @@ pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemE
let stack = item_from_nbt(tag.get("Item")?)?;
let position = position(tag)?;
Some(ItemEntity {
rotation: rotation(tag),
join: Default::default(),
no_gravity: flag(tag, "NoGravity"),
health: number(tag.get("Health")).unwrap_or(5) as i32,
@@ -2324,7 +2326,7 @@ pub fn orb_to_nbt(o: &ExperienceOrb, uuid: [i32; 4]) -> Tag {
uuid,
position: o.position,
motion: o.velocity,
rotation: [0.0, 0.0],
rotation: o.rotation,
on_ground: o.on_ground,
fall_distance: 0.0,
};
@@ -2350,6 +2352,7 @@ pub fn orbs_from_nbt(tag: &Tag) -> Vec<ExperienceOrb> {
};
let count = number(tag.get("Count")).unwrap_or(1).max(1) as u32;
let orb = ExperienceOrb {
rotation: rotation(tag),
join: Default::default(),
no_gravity: flag(tag, "NoGravity"),
id: 0,
@@ -2803,6 +2806,7 @@ pub fn firework_rocket_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<R
/// What `FireworkRocketEntity.addAdditionalSaveData` writes, over `Projectile`'s
/// Owner and LeftOwner.
pub struct RocketSave<'a> {
pub rotation: [f32; 2],
pub position: Vec3,
pub motion: Vec3,
pub life: i32,
@@ -2818,7 +2822,7 @@ pub fn firework_rocket_to_nbt(r: &RocketSave, uuid: [i32; 4], raw: Option<&Tag>)
uuid,
position: r.position,
motion: r.motion,
rotation: [0.0, 0.0],
rotation: r.rotation,
on_ground: false,
fall_distance: 0.0,
}
@@ -2944,6 +2948,7 @@ pub fn falling_block_to_nbt(
drop_item: bool,
damage: crate::falling_block::Damage,
fall_distance: f64,
rotation: [f32; 2],
uuid: [i32; 4],
) -> Tag {
let base = Base {
@@ -2951,7 +2956,7 @@ pub fn falling_block_to_nbt(
uuid,
position,
motion,
rotation: [0.0, 0.0],
rotation,
on_ground: false,
fall_distance,
};
@@ -3159,7 +3164,7 @@ pub fn armor_stand_to_nbt(a: &ArmorStand, uuid: [i32; 4], raw: Option<&Tag>) ->
uuid,
position: a.position,
motion: a.velocity,
rotation: [a.yaw, 0.0],
rotation: [a.yaw, a.pitch],
on_ground: a.on_ground,
fall_distance: 0.0,
};
@@ -3302,7 +3307,7 @@ pub fn boat_to_nbt(
id: &str,
position: Vec3,
motion: Vec3,
yaw: f32,
rotation: [f32; 2],
uuid: [i32; 4],
raw: Option<&Tag>,
items: Option<&[Option<crate::items::ItemStack>]>,
@@ -3312,7 +3317,7 @@ pub fn boat_to_nbt(
uuid,
position,
motion,
rotation: [yaw, 0.0],
rotation,
on_ground: false,
fall_distance: 0.0,
}
@@ -3327,7 +3332,7 @@ pub fn vehicle_to_nbt(
id: &str,
position: Vec3,
motion: Vec3,
yaw: f32,
rotation: [f32; 2],
uuid: [i32; 4],
raw: Option<&Tag>,
) -> Tag {
@@ -3336,7 +3341,7 @@ pub fn vehicle_to_nbt(
uuid,
position,
motion,
rotation: [yaw, 0.0],
rotation,
on_ground: false,
fall_distance: 0.0,
}
@@ -3565,6 +3570,7 @@ mod tnt_power_tests {
20,
10.0,
string("minecraft:tnt"),
[0.0, 0.0],
[1, 2, 3, 4],
None,
);
@@ -3576,6 +3582,7 @@ mod tnt_power_tests {
20,
4.0,
string("minecraft:tnt"),
[0.0, 0.0],
[1, 2, 3, 4],
None,
);
@@ -3911,6 +3918,7 @@ mod tests {
let item = ItemStack::new("minecraft:firework_rocket", 1);
let tag = firework_rocket_to_nbt(
&RocketSave {
rotation: [0.0, 0.0],
position: Vec3::new(1.5, 70.0, -3.25),
motion: Vec3::new(0.0, 0.5, 0.0),
life: 7,
@@ -4065,6 +4073,7 @@ mod tests {
false,
damage,
19.75,
[0.0, 0.0],
[1, 2, 3, 4],
);
let (loaded_state, pos, motion, age, distance) = falling_block_from_nbt(&tag).unwrap();
@@ -4110,6 +4119,7 @@ mod tests {
&fluids,
&[],
None,
None,
)
};
let fresh = Tag::compound([
@@ -4178,6 +4188,7 @@ mod tests {
#[test]
fn item_entities_round_trip() {
let e = ItemEntity {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
health: 2,
@@ -4282,11 +4293,46 @@ mod tests {
assert_eq!(text(tag.get("CustomName")).as_deref(), Some("x"));
assert_eq!(number(tag.get("Health")), Some(5));
}
#[test]
fn saved_rotation_is_the_entitys_own() {
// `/tp <entity> x y z 90 10` sets the entity's yaw and pitch; the save
// (`/data get ... Rotation`) writes them, not [0, 0].
let tnt = tnt_to_nbt(
Vec3::ZERO,
Vec3::ZERO,
false,
20,
4.0,
string("minecraft:tnt"),
[90.0, 10.0],
[1; 4],
None,
);
assert_eq!(rotation(&tnt), [90.0, 10.0]);
let mut stand = ArmorStand::new(1, Vec3::ZERO, 0.0);
stand.yaw = 90.0;
stand.pitch = 10.0;
assert_eq!(
rotation(&armor_stand_to_nbt(&stand, [1; 4], None)),
[90.0, 10.0]
);
let cart = vehicle_to_nbt(
"minecraft:minecart",
Vec3::ZERO,
Vec3::ZERO,
[90.0, 10.0],
[1; 4],
None,
);
assert_eq!(rotation(&cart), [90.0, 10.0]);
}
#[test]
fn entities_without_fire_save_zero_and_a_mob_saves_its_speed_instance() {
// Entity.remainingFireTicks starts at 0; LivingEntity.removeFrost makes
// the movement_speed instance AttributeMap.pack then saves.
let orb = ExperienceOrb {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
id: 0,
@@ -4349,6 +4395,7 @@ mod tests {
#[test]
fn orbs_keep_their_count() {
let o = ExperienceOrb {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
id: 0,
+69 -23
View File
@@ -67,6 +67,7 @@ mod block_entity_anims;
mod block_entity_ticks;
mod block_items;
mod blockctl;
mod boarding;
mod boat_authority_fixture;
mod boat_leads;
mod boat_passengers;
@@ -343,6 +344,7 @@ mod tutorial;
mod tweaks;
mod ui_control;
mod vault;
mod vehicle_teleport;
mod villagers;
mod wandering_trader;
mod win;
@@ -1840,6 +1842,8 @@ impl LevelAccess for GameLevel<'_> {
/// FallingBlockEntity carrying a block state.
#[derive(Clone, Debug)]
struct FallingBlock {
/// `Entity.yRot` and `xRot` (`/tp`, `/data get ... Rotation`).
pub rotation: [f32; 2],
/// When it joined its list (`horizon_client::join_seq`); set by the list.
pub(super) join: horizon_client::join_seq::JoinSeq,
/// Its entity id: from the entity counter on the server, the server's on
@@ -2866,6 +2870,13 @@ struct Game<'a> {
cart_models: minecarts::CartModels,
/// The boat the player rides.
riding: Option<u32>,
/// `Entity.boardingCooldown` of the acting player, and its vehicle at the
/// last tick (`game/boarding.rs`).
boarding_cooldown: i32,
riding_at_tick: Option<u32>,
/// A rotation-only write (`/rotate`, `Entity.forceSetRotation`) to the player of this
/// turn: `leave_guest` sends it as `ClientboundPlayerRotationPacket`, not a teleport.
rotation_forced: bool,
/// The mount the last `position_rider` put the player on, to tell the tick
/// a seat is taken (`Entity.moveTo`, no camera sweep) from the ones that
/// follow it (`positionRider`, interpolated as usual).
@@ -3960,9 +3971,7 @@ impl Game<'_> {
stacks.retain(|stack| stack.count > 0 && &*stack.item != "minecraft:air");
for stack in stacks {
let r = &mut self.world.random;
let strength = f64::from(r.next_float()) * 0.5;
let angle = f64::from(r.next_float()) * std::f64::consts::TAU;
let v = [-angle.sin() * strength, 0.2, angle.cos() * strength];
let v = horizon_client::entities::drop_motion(r, self.yaw, self.pitch, true);
self.spawn_item(stack, at, v, 40);
}
}
@@ -3998,6 +4007,7 @@ impl Game<'_> {
// ItemEntity.bobOffs comes from the entity's own random, not the level's.
let bob = self.particles.random().next_float() * std::f32::consts::PI * 2.0;
self.dropped.push(ItemEntity {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
health: 5,
@@ -4146,19 +4156,11 @@ impl Game<'_> {
/// drop key) names the player as the thrower; `drop(stack, false)`, for
/// what no longer fits in the inventory, does not.
fn throw_from_eyes(&mut self, thrown: ItemStack, eye: [f64; 3], thrown_from_hand: bool) {
let (yaw, pitch) = (
f64::from(self.yaw).to_radians(),
f64::from(self.pitch).to_radians(),
);
let r = &mut self.world.random;
let spread = f64::from(r.next_float()) * std::f64::consts::TAU;
let strength = 0.02 * f64::from(r.next_float());
let v = [
-yaw.sin() * pitch.cos() * 0.3 + spread.cos() * strength,
-pitch.sin() * 0.3 + 0.1 + f64::from(r.next_float() - r.next_float()) * 0.1,
yaw.cos() * pitch.cos() * 0.3 + spread.sin() * strength,
];
self.spawn_item(thrown, [eye[0], eye[1] - 0.3, eye[2]], v, 40);
let v = horizon_client::entities::drop_motion(r, self.yaw, self.pitch, false);
// `getEyeY() - 0.3F`.
let y = eye[1] - f64::from(0.3f32);
self.spawn_item(thrown, [eye[0], y, eye[2]], v, 40);
// A native mod can keep the item out of the level (native_entity_join_level).
if thrown_from_hand {
let uuid = self.acting_player_uuid();
@@ -4286,6 +4288,7 @@ impl Game<'_> {
no_physics,
&nearby,
Some(self.border.geometry()),
Some(&self.res.motion().blocks),
&mut |splash| entered.push(splash),
);
// Entity.doWaterSplashEffect: the server plays the sound (broadcast
@@ -4892,7 +4895,10 @@ impl Game<'_> {
}
/// ServerLevel.tick block phases: scheduled ticks, random ticks in the
/// chunks the tickets simulate, block events and moving pistons.
fn tick_blocks(&mut self) -> Result<()> {
/// The length the block log had after the changed blocks went out
/// (`ServerChunkCache.tick`'s broadcast), before the block events and the
/// block entities ran.
fn tick_blocks(&mut self) -> Result<Option<usize>> {
// ServerChunkCache.tick: the tickets that time out.
self.tickets.tick_portal();
self.sync_tick_chunks();
@@ -5127,6 +5133,9 @@ impl Game<'_> {
});
}
});
});
let sent = self.lan_broadcast_changes();
self.with_level(|b, level, ticks| {
b.run_block_events(level, ticks);
b.tick_block_entities(level, ticks);
});
@@ -5136,7 +5145,7 @@ impl Game<'_> {
self.push_piston_entities(pushes);
self.process_level_events()?;
self.sync_moving_pistons();
Ok(())
Ok(sent)
}
/// Copies the moving-piston block entities into the world, where the
/// collision queries and the renderer read them.
@@ -5533,6 +5542,7 @@ impl Game<'_> {
);
self.next_entity += 1;
self.falling.push(FallingBlock {
rotation: [0.0, 0.0],
join: Default::default(),
id: self.next_entity,
state,
@@ -7708,9 +7718,10 @@ impl Game<'_> {
// here too, before falling-block schedules run (#4510).
horizon_client::spans::time("tick_local_hand", || self.tick_local_hand())?;
horizon_client::spans::time("postprocess_chunks", || self.postprocess_chunks())?;
let mut sent = None;
if running {
horizon_client::spans::time("tick_lan_packets", || self.tick_lan_packets());
horizon_client::spans::time("tick_blocks", || self.tick_blocks())?;
sent = horizon_client::spans::time("tick_blocks", || self.tick_blocks())?;
// What the blocks spawned (a dispensed minecart, a lit TNT, a
// placed boat) joins before the entities tick.
self.native_joins_flush(false, 0);
@@ -7787,7 +7798,20 @@ impl Game<'_> {
// The rest of the tick's spawns join, and what left is told of.
self.native_joins_flush(false, 0);
self.native_leaves_flush();
// ServerLevel.tick broadcasts the changed blocks after the scheduled and
// random ticks (`tick_blocks`), before the block events, the entities
// and the block entities: what those change goes out with the next
// tick's. A piston's moved block reaches the clients once their own
// copy of it finished moving, so a slow tick rate shows it slide
// (#8430).
if let (Some(sent), Some(log)) = (sent, self.world.block_log.as_mut()) {
self.world.block_log_held = log.split_off(sent.min(log.len()));
}
horizon_client::spans::time("tick_lan", || self.tick_lan(false));
let held = std::mem::take(&mut self.world.block_log_held);
if let Some(log) = self.world.block_log.as_mut() {
log.extend(held);
}
// Everything that has joined has had its spawn packet.
self.ordered.announced = horizon_client::join_seq::JoinSeq::upcoming();
self.tick_friends();
@@ -13725,6 +13749,7 @@ impl Game<'_> {
let p = Vec3::new(at[0], at[1], at[2]);
self.next_entity += 1;
self.orbs.push(ExperienceOrb {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
id: self.next_entity,
@@ -14776,6 +14801,8 @@ impl Game<'_> {
let Some(at) = self.sleeping_position(head) else {
return;
};
// LivingEntity.startSleeping: stopRiding once the bed is valid.
self.stop_riding();
self.stop_mining();
self.left = false;
self.right = false;
@@ -15622,6 +15649,7 @@ impl Game<'_> {
// compressed (a no-op once it is).
if self.renderer.is_some() {
self.gui.atlas.pack_pixels();
self.pack.render.pixels.pack();
}
// Before any text of this frame: a full glyph region starts over.
self.gui.atlas.font.begin_frame();
@@ -16917,6 +16945,18 @@ impl Game<'_> {
self.all_sections_changed();
}
self.prepare_lod();
// The columns the single-player server changed in bulk without sending them, which far terrain still drew
// as this window last saw them: summarized again (taken every tick, far terrain on or off, so none waits).
if self.remote.is_some() {
let changed = horizon_client::lan::handoff::take_changed();
if let Some(lod) = &mut self.lod {
for chunk in changed {
if !self.world.chunks.contains_key(&(chunk.x, chunk.z)) {
lod.observe(chunk, self.res.facts());
}
}
}
}
if self.lod.is_some() {
for &((x, _, z), _, _) in &self.world.light_edits {
self.lod_observe.insert((x >> 4, z >> 4));
@@ -19234,6 +19274,9 @@ impl<'a> Game<'a> {
trim_slots: Vec::new(),
cart_models: prepared.cart_models,
riding: None,
boarding_cooldown: 0,
riding_at_tick: None,
rotation_forced: false,
posed_rider: None,
pending_vehicle: None,
ender_items: vec![None; 27],
@@ -20674,7 +20717,7 @@ fn load_world_rest(
fn prepare_renderer_textures(pack: &PackResources) -> horizon_client::gfx::PreparedTextures {
let assets = &pack.render;
horizon_client::gfx::PreparedTextures::new(
&assets.pixels,
&assets.pixels.get(),
assets.sprite_size,
assets.texture_layers,
&assets.sprite_mips,
@@ -20720,7 +20763,7 @@ fn build_renderer(
let mut renderer = pollster::block_on(Renderer::new(
window,
offscreen,
&assets.pixels,
&assets.pixels.get(),
assets.sprite_size,
assets.sprite_minimum_size,
assets.texture_layers,
@@ -21704,11 +21747,13 @@ pub fn run_with() -> Result<()> {
// horizon-ctl drives: those start in a source checkout and wrote the list there.
if !options.offscreen && !synchronous_startup(&options) {
horizon_client::heartbeat::start();
if let Err(e) =
horizon_client::multiplayer::add_official_server(&horizon_client::logging::player_dir())
{
let player_dir = horizon_client::logging::player_dir();
if let Err(e) = horizon_client::multiplayer::add_official_server(&player_dir) {
log::warn!("could not add the official server to servers.dat: {e}");
}
if let Err(e) = horizon_client::multiplayer::add_partner_servers(&player_dir) {
log::warn!("could not add the partner servers to servers.dat: {e}");
}
}
mods::register_components(&vanilla_root)?;
mods::register_standalone_models();
@@ -22413,6 +22458,7 @@ mod tests {
fn a_falling_block_keeps_its_model_offset() {
let at = Vec3::new(3.5, 70.25, -1.5);
let falling = FallingBlock {
rotation: [0.0, 0.0],
join: Default::default(),
id: 0,
state: BlockStateId(7),
+5
View File
@@ -348,6 +348,11 @@ impl Game<'_> {
{
v["sign"] = json!({ "front": sign.front.lines, "back": sign.back.lines });
}
// A block a piston moves, as this client animates it: its own
// PistonMovingBlockEntity, which a block event's moveBlocks made.
if let Some((_, piston)) = self.world.moving_pistons.iter().find(|(p, _)| *p == pos) {
v["moving"] = json!({ "progress": piston.progress, "extending": piston.extending });
}
v
}
pub(super) fn stack_value(&self, s: &ItemStack) -> Value {
+5 -1
View File
@@ -4,6 +4,7 @@
use super::Game;
use horizon_client::{
containers::BlockEntity,
first_person::Hand,
items::ItemStack,
mobs::{Species, allay},
};
@@ -291,7 +292,8 @@ impl Game<'_> {
self.consume_held();
return true;
}
if holds && item.is_none() {
// Allay.mobInteract takes the item back only through an empty main hand.
if holds && item.is_none() && self.hand == Hand::Main {
let m = &mut self.mobs[index];
// The hand's stack keeps the components (a potion) the items came with.
let held = m
@@ -305,6 +307,8 @@ impl Game<'_> {
m.extra.owner = None;
self.audio
.play("minecraft:entity.allay.item_taken", Some(at), 2.0, 1.0);
// Allay.mobInteract ends with swing(MAIN_HAND, ...).
self.mobs[index].swing();
if let Some(stack) = carried {
self.spawn_item(stack, at, [0.0, 0.2, 0.0], 40);
}
@@ -0,0 +1,63 @@
//! The player's `Entity.boardingCooldown`: 60 ticks after leaving a vehicle in
//! which a non-forced `startRiding` (`Entity.canRide`) is refused.
//! Mobs keep theirs in `extra.boarding_cooldown`; a server's guests keep theirs
//! in `Guest::boarding_cooldown`, swapped in for their turn like `riding`.
use super::{Game, boat_passengers::BOARDING_COOLDOWN};
/// `Entity.baseTick`: the cooldown counts down while positive.
pub(super) fn tick_down(cooldown: i32) -> i32 {
(cooldown - 1).max(0)
}
/// `Entity.removePassenger`: the passenger that left sets its cooldown to 60.
/// `was` and `now` are its vehicle before and after.
pub(super) fn after_leaving(cooldown: i32, was: Option<u32>, now: Option<u32>) -> i32 {
if was.is_some() && now.is_none() {
BOARDING_COOLDOWN
} else {
cooldown
}
}
impl Game<'_> {
/// `Entity.canRide` for the player acting now (the host's, or the guest
/// whose turn it is): the cooldown has elapsed, and the player did not
/// leave a vehicle since the tick began (the tick that starts the
/// cooldown has not run yet).
pub(super) fn may_board(&self) -> bool {
self.boarding_cooldown <= 0 && !(self.riding_at_tick.is_some() && self.riding.is_none())
}
/// The host's own cooldown, once per tick.
pub(super) fn tick_boarding(&mut self) {
self.boarding_cooldown = tick_down(self.boarding_cooldown);
self.boarding_cooldown =
after_leaving(self.boarding_cooldown, self.riding_at_tick, self.riding);
self.riding_at_tick = self.riding;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn leaving_sets_sixty_and_boarding_waits_it_out() {
let mut cd = after_leaving(0, Some(7), None);
assert_eq!(cd, 60);
for _ in 0..59 {
cd = tick_down(cd);
assert!(cd > 0, "still refused");
}
cd = tick_down(cd);
assert_eq!(cd, 0, "allowed on the sixtieth tick");
}
#[test]
fn staying_or_boarding_does_not_set_it() {
assert_eq!(after_leaving(0, Some(7), Some(7)), 0);
assert_eq!(after_leaving(0, None, Some(7)), 0);
assert_eq!(after_leaving(0, None, None), 0);
}
#[test]
fn never_negative() {
assert_eq!(tick_down(0), 0);
}
}
+4
View File
@@ -35,6 +35,8 @@ pub(super) enum Status {
}
#[derive(Clone, Debug)]
pub(super) struct Boat {
/// `Entity.xRot`: kept for `/tp` and `Rotation`; the kind turns only in yaw.
pub pitch: f32,
/// When it joined its list (`horizon_client::join_seq`); set by the list.
pub(super) join: horizon_client::join_seq::JoinSeq,
pub id: u32,
@@ -157,6 +159,7 @@ impl Boat {
pub fn new(id: u32, wood: Arc<str>, position: Vec3, yaw: f32) -> Self {
Self {
pitch: 0.0,
join: Default::default(),
id,
wood,
@@ -1375,6 +1378,7 @@ impl Game<'_> {
if sneaking
|| self.boats[i].out_of_control >= 60.0
|| self.riding.is_some()
|| !self.may_board()
|| self.ridden_by_guest(self.boats[i].id)
|| !self.boat_has_room(i)
{
+33 -6
View File
@@ -1511,6 +1511,7 @@ impl Game<'_> {
let _ = self.in_target_level(t, |g| g.teleport_to_in_level(t, pos, rotation));
}
fn teleport_to_in_level(&mut self, t: Target, pos: [f64; 3], rotation: Option<[f32; 2]>) {
use super::entity::Entity;
let from = self.target_position(t);
let v = Vec3::new(pos[0], pos[1], pos[2]);
// PositionMoveRotation.calculateAbsolute clamps pitch before setXRot.
@@ -1590,26 +1591,35 @@ impl Game<'_> {
if let Some(i) = self.dropped.iter_mut().find(|i| i.id == id) {
i.position = v;
i.previous = v;
if let Some(r) = rotation {
Entity::set_rotation(i, r);
}
}
}
Target::Orb(id) => {
if let Some(o) = self.orbs.iter_mut().find(|o| o.id == id) {
o.position = v;
o.previous = v;
if let Some(r) = rotation {
Entity::set_rotation(o, r);
}
}
}
Target::Projectile(id) => {
if let Some(a) = self.creatures.projectiles.iter_mut().find(|a| a.id == id) {
a.position = v;
a.previous = v;
if let Some(r) = rotation {
Entity::set_rotation(a, r);
}
}
}
Target::Stand(id) => {
if let Some(a) = self.armor_stands.iter_mut().find(|a| a.id == id) {
a.position = v;
a.previous = v;
if let Some([yaw, _]) = rotation.filter(|r| r[0].is_finite()) {
a.yaw = yaw;
if let Some(r) = rotation {
Entity::set_rotation(a, r);
}
}
}
@@ -1617,8 +1627,8 @@ impl Game<'_> {
if let Some(b) = self.boats.iter_mut().find(|b| b.id == id) {
b.position = v;
b.previous = v;
if let Some([yaw, _]) = rotation.filter(|r| r[0].is_finite()) {
b.yaw = yaw;
if let Some(r) = rotation {
Entity::set_rotation(b, r);
}
}
}
@@ -1626,8 +1636,8 @@ impl Game<'_> {
if let Some(c) = self.carts.iter_mut().find(|c| c.id == id) {
c.position = v;
c.previous = v;
if let Some([yaw, _]) = rotation.filter(|r| r[0].is_finite()) {
c.yaw = yaw;
if let Some(r) = rotation {
Entity::set_rotation(c, r);
}
}
}
@@ -1667,24 +1677,36 @@ impl Game<'_> {
if let Some(t) = self.tnts.iter_mut().find(|t| t.id == id) {
t.position = v;
t.previous = v;
if let Some(r) = rotation {
Entity::set_rotation(t, r);
}
}
}
Target::Falling(id) => {
if let Some(f) = self.falling.iter_mut().find(|f| f.id == id) {
f.position = v;
f.previous = v;
if let Some(r) = rotation {
Entity::set_rotation(f, r);
}
}
}
Target::Rocket(id) => {
if let Some(r) = self.rockets.iter_mut().find(|r| r.id == id) {
r.position = v;
r.previous = v;
if let Some(rot) = rotation {
Entity::set_rotation(r, rot);
}
}
}
Target::Trident(id) => {
if let Some(t) = self.tridents.iter_mut().find(|t| t.id == id) {
t.position = v;
t.previous = v;
if let Some(r) = rotation {
Entity::set_rotation(t, r);
}
}
}
Target::Spawner(id) => {
@@ -1700,6 +1722,10 @@ impl Game<'_> {
// Wall-mounted entities do not move.
Target::Frame(_) | Target::Painting(_) => {}
}
// Entity.teleportPassengers: the riders go with their vehicle at once.
if let (Some(from), Some(to)) = (from, self.target_position(t)) {
self.teleport_passengers(t, from, to);
}
if self.options.dedicated.is_some()
&& let (Some(entity), Some(from), Some(to)) =
(self.network_entity_id(t), from, self.target_position(t))
@@ -2337,6 +2363,7 @@ impl Game<'_> {
let at = self.unstuck_orb(pos);
self.next_entity += 1;
self.orbs.push(horizon_client::entities::ExperienceOrb {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
id: self.next_entity,
@@ -137,7 +137,7 @@ impl Game<'_> {
&b.entity_type(),
b.position,
b.velocity,
b.yaw,
[b.yaw, b.pitch],
uuid,
self.persist.raw.get(&id),
b.chest.then_some(&b.items[..]),
@@ -206,6 +206,7 @@ impl Game<'_> {
t.fuse,
t.power,
t.block_state_nbt(self.pack.assets.registry),
t.rotation,
uuid,
self.persist.raw.get(&id),
)
@@ -224,6 +225,7 @@ impl Game<'_> {
f.drop_item,
f.damage,
f.fall_distance,
f.rotation,
uuid,
);
crate::game::persist::with_raw(tag, raw)
@@ -424,7 +424,8 @@ impl Game<'_> {
look_at_target = Some((anchored_eyes, to, faced_entity));
look_at(from, to)
};
self.teleport_to(t, pos, Some(rot));
// RotateCommand: Entity.forceSetRotation, not a teleport.
self.set_rotation(t, rot);
if let Some((eyes, to, entity)) = look_at_target {
self.send_player_look_at(t, eyes, to, entity);
}
@@ -867,8 +868,24 @@ impl Game<'_> {
let Some(Arg::Resource(attribute)) = ctx.get("attribute").cloned() else {
return Ok(0);
};
// An unbounded selector reaches every loaded level, so the target may
// be in another one than the command's (EntitySelector.findEntities,
// AttributeCommand acting on the entity itself).
self.in_target_level(target, |g| {
g.attribute_in_level(id, ctx, target, &attribute)
})
.unwrap_or(Ok(0))
}
/// `/attribute` on a target, with the target's own level in place.
fn attribute_in_level(
&mut self,
id: &str,
ctx: &Context,
target: Target,
attribute: &str,
) -> R<i32> {
let name = self.target_name(target);
let description = Self::attribute_description(&attribute);
let description = Self::attribute_description(attribute);
let short = attribute.trim_start_matches("minecraft:").to_owned();
let no_attribute = |name: &Text, d: &Text| {
text_error(
@@ -893,7 +910,7 @@ impl Game<'_> {
let Some(player) = self.player.as_mut() else {
return Ok(0);
};
let Some(key) = player.attributes.key(&attribute) else {
let Some(key) = player.attributes.key(attribute) else {
return Err(no_attribute(&name, &description));
};
let unavailable = |_| no_attribute(&name, &description);
@@ -981,7 +998,7 @@ impl Game<'_> {
.cloned()
.unwrap_or_else(|| "add_value".into());
player
.set_modifier(&attribute, &modifier_id, value_arg, &op, true)
.set_modifier(attribute, &modifier_id, value_arg, &op, true)
.map_err(|_| no_attribute(&name, &description))?;
self.feedback(Text::translate(
"commands.attribute.modifier.add.success",
@@ -1005,7 +1022,7 @@ impl Game<'_> {
));
}
let _ =
player.set_modifier(&attribute, &modifier_id, 0.0, "add_value", false);
player.set_modifier(attribute, &modifier_id, 0.0, "add_value", false);
self.feedback(Text::translate(
"commands.attribute.modifier.remove.success",
&[Text::literal(modifier_id), description, name],
@@ -1063,7 +1080,7 @@ impl Game<'_> {
self.generic_attribute(
target,
id,
&attribute,
attribute,
(&name, &description),
(value_arg, scale, &modifier_id, &op),
)
+3 -1
View File
@@ -190,7 +190,9 @@ impl Game<'_> {
} else {
"minecraft:block.composter.fill"
};
self.audio.play(sound, Some(center), 1.0, 1.0);
// `handleFill` is `playLocalSound`: the client that receives event 1500
// plays it. Capturing it here would also send a sound packet.
self.audio.play_unshared(sound, Some(center), 1.0, 1.0);
// getShape(..).max(Y, 0.5, 0.5): the compost surface (SHAPES[8] = SHAPES[7]).
let level = self.composter_level([pos.x, pos.y, pos.z]).unwrap_or(0);
let height = f64::from((1 + level.min(7) * 2).clamp(2, 16)) / 16.0 + 0.03125;
+67 -18
View File
@@ -184,7 +184,13 @@ impl Game<'_> {
}
// The position packet moves the old position along (absolute xyz).
self.previous_position = at;
self.portal_particles();
// `broadcastEntityEvent(46)` after the teleport. A server-thread window
// is a remote client, so the packet is what draws the particles there.
if let Some(id) = self.lan_acting_entity {
self.lan_entity_event(id as i32, 46, [at.x, at.y, at.z]);
} else {
self.portal_particles();
}
true
}
fn state_in_tag(&self, tag: &str, state: horizon_world::registry::BlockStateId) -> bool {
@@ -195,27 +201,70 @@ impl Game<'_> {
.get(tag)
.is_some_and(|s| s.contains(&**name))
}
/// `LivingEntity.handleEntityEvent(46)`: 128 portal particles from the old
/// to the new position, from the client player's random (the particle
/// engine's here).
/// `LivingEntity.handleEntityEvent(46)` for this client: 128 portal
/// particles from the old position to the new one. The local player's
/// random is the particle engine's.
fn portal_particles(&mut self) {
let Some(p) = self.player.as_ref() else {
return;
let (from, to, width, height) = {
let Some(p) = self.player.as_ref() else {
return;
};
(
[
self.previous_position.x,
self.previous_position.y,
self.previous_position.z,
],
[p.position.x, p.position.y, p.position.z],
f64::from(p.dimensions.width),
f64::from(p.dimensions.height),
)
};
let (from, to) = (self.previous_position, p.position);
let (width, height) = (
f64::from(p.dimensions.width),
f64::from(p.dimensions.height),
self.emit_living_teleport(from, to, width, height);
}
/// `LivingEntity.handleEntityEvent(46)` for the entity the packet names:
/// this player, or a mob the client is drawing.
pub(super) fn entity_teleport_particles(&mut self, id: i32) {
if self.remote.as_ref().is_some_and(|r| r.entity_id == id) {
self.portal_particles();
return;
}
let spawned = {
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) else {
return;
};
let from = [m.previous.x, m.previous.y, m.previous.z];
let to = [m.position.x, m.position.y, m.position.z];
let (width, height) = (f64::from(m.width()), f64::from(m.height()));
let mut spawned = Vec::with_capacity(128);
horizon_client::particles::living_teleport_particles(
&mut m.random,
from,
to,
width,
height,
|p, v| spawned.push((p, v)),
);
spawned
};
let options = ParticleOptions::simple(ParticleType::Portal);
for (p, v) in spawned {
self.particles.add(&options, p, v);
}
}
fn emit_living_teleport(&mut self, from: [f64; 3], to: [f64; 3], width: f64, height: f64) {
let mut spawned = Vec::with_capacity(128);
horizon_client::particles::living_teleport_particles(
self.particles.random(),
from,
to,
width,
height,
|p, v| spawned.push((p, v)),
);
let options = ParticleOptions::simple(ParticleType::Portal);
for i in 0..128 {
let d = f64::from(i) / 127.0;
let r = self.particles.random();
let v = [0; 3].map(|_| f64::from((r.next_float() - 0.5) * 0.2));
let x = from.x + d * (to.x - from.x) + (r.next_double() - 0.5) * width * 2.0;
let y = from.y + d * (to.y - from.y) + r.next_double() * height;
let z = from.z + d * (to.z - from.z) + (r.next_double() - 0.5) * width * 2.0;
self.particles.add(&options, [x, y, z], v);
for (p, v) in spawned {
self.particles.add(&options, p, v);
}
}
/// `LivingEntity.stopRiding`.
+195 -55
View File
@@ -34,6 +34,18 @@ use horizon_core::{
};
use std::sync::Arc;
/// `AbstractArrow.onHitEntity` when `hurtOrSimulate` returned false.
/// An enderman that teleported (`projectileReceivesSideEffectsOnHit` is
/// false) leaves the arrow in flight. Every other refusal, including
/// `Shulker.hurtServer` while `isClosed()`, is
/// `deflect(ProjectileDeflection.REVERSE, entity, owner, false, 0.2)`.
fn arrow_miss(dodged: bool) -> EntityHit {
if dodged {
EntityHit::Dodged
} else {
EntityHit::Refused
}
}
/// The world impact sound after successful arrow damage, including self-shots.
/// `AbstractArrow.onHitEntity`'s damage: the crit bonus comes from the
/// arrow's own random.
@@ -1020,7 +1032,7 @@ impl Game<'_> {
.iter()
.map(super::boat_leads::boat_state)
.collect();
let base = self.mob_world_base(&[]);
let mut base = self.mob_world_base(&[]);
let path_changes = horizon_client::mobs::collision_changes(&base, &path_writes);
// Each mob keeps its snapshot slot all tick; the first slot of an
// id answers, as a scan would.
@@ -1032,16 +1044,33 @@ impl Game<'_> {
villager_slots.push(i as u32);
}
}
let mut cached_difficulty: Option<((i32, i32), spawning::LocalDifficulty)> = None;
for i in 0..mobs.len() {
let mut w = MobWorld {
others: &snapshot,
others_grid: Some(&grid),
others_index: Some(&index),
others_villagers: Some(&villager_slots),
..base.clone()
};
let m = &mut mobs[i];
w.controlling_player_movement = horizon_client::mobs::equine::controlled(m)
let (bx, bz) = (m.position.x.floor() as i32, m.position.z.floor() as i32);
let seen_guest = if guests.is_empty() {
None
} else {
let bound = bindings.get(&m.id).copied();
let conn = super::lan_mobs::player_for(m, base.player.as_ref(), &guests, bound);
match conn {
Some(conn) => {
bindings.insert(m.id, conn);
}
None => {
bindings.remove(&m.id);
}
}
conn
};
if !base.world.loaded(bx, bz) {
keep.push(!m.hostile() || m.persistent);
continue;
}
let nearest_player = nearest
.nearest(m.position.x, m.position.y, m.position.z)
.map(|p| p.position);
let controlling_player_movement = horizon_client::mobs::equine::controlled(m)
.then(|| {
base.player
.as_ref()
@@ -1055,40 +1084,29 @@ impl Game<'_> {
})
})
.flatten();
// getCurrentDifficultyAt(blockPosition()): the mob's own chunk,
// not the local player's (a server thread has no player).
w.difficulty = self.local_difficulty([m.position.x, m.position.y, m.position.z]);
w.nearest_player = nearest
.nearest(m.position.x, m.position.y, m.position.z)
.map(|p| p.position);
let seen_guest = if guests.is_empty() {
None
} else {
let bound = bindings.get(&m.id).copied();
let conn = super::lan_mobs::player_for(m, w.player.as_ref(), &guests, bound);
match conn {
Some(conn) => {
bindings.insert(m.id, conn);
w.player = guests.iter().find(|g| g.0 == conn).map(|g| g.1.clone());
}
None => {
bindings.remove(&m.id);
}
}
conn
};
if !w
.world
.loaded(m.position.x.floor() as i32, m.position.z.floor() as i32)
{
keep.push(!m.hostile() || m.persistent);
continue;
}
// ServerLevel.tick: every loaded entity checks despawning, but
// only those in entity-ticking chunks (within the simulation
// distance) tick.
if !ticking(m.position.x.floor() as i32, m.position.z.floor() as i32) {
if !ticking(bx, bz) {
let active_player = match seen_guest {
Some(conn) => guests.iter().find(|g| g.0 == conn).map(|g| g.1.clone()),
None => base.player.take(),
};
let mut w = MobWorld {
others: &snapshot,
others_grid: Some(&grid),
others_index: Some(&index),
others_villagers: Some(&villager_slots),
nearest_player,
player: active_player,
teams: std::sync::Arc::clone(&base.teams),
..base
};
w.controlling_player_movement = controlling_player_movement;
let kept = horizon_client::mobs::ai::despawn_check(m, &w);
if seen_guest.is_none() {
base.player = w.player;
}
if !kept {
died.remove(&m.id);
snapshot[i].alive = false;
@@ -1101,10 +1119,41 @@ impl Game<'_> {
keep.push(true);
continue;
}
// getCurrentDifficultyAt(blockPosition()): the mob's own chunk,
// not the local player's (a server thread has no player).
let chunk_key = (bx >> 4, bz >> 4);
let difficulty = match cached_difficulty {
Some((key, difficulty)) if key == chunk_key => difficulty,
_ => {
let difficulty =
self.local_difficulty([m.position.x, m.position.y, m.position.z]);
cached_difficulty = Some((chunk_key, difficulty));
difficulty
}
};
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
horizon_client::mobs::blocks_changed(m, &path_changes);
let from = events.len();
let active_player = match seen_guest {
Some(conn) => guests.iter().find(|g| g.0 == conn).map(|g| g.1.clone()),
None => base.player.take(),
};
let mut w = MobWorld {
others: &snapshot,
others_grid: Some(&grid),
others_index: Some(&index),
others_villagers: Some(&villager_slots),
difficulty,
nearest_player,
player: active_player,
teams: std::sync::Arc::clone(&base.teams),
..base
};
w.controlling_player_movement = controlling_player_movement;
let kept = m.tick(&w, &mut scratch, &mut events);
if seen_guest.is_none() {
base.player = w.player;
}
// A killed mob's body is removed: its effects that act on
// death run (`LivingEntity.remove` with KILLED).
if !kept && !m.alive() && !m.removed && !m.extra.effects.is_empty() {
@@ -1188,6 +1237,7 @@ impl Game<'_> {
self.lan_entity_event(id as i32, 60, at);
self.death_poof(at, width, height);
}
self.tick_boarding();
self.sync_riders();
self.tick_skeleton_traps();
self.tick_apparition();
@@ -2062,7 +2112,7 @@ impl Game<'_> {
MobEvent::DamagePlayer { amount, from, kind } => {
self.mob_hits_player(amount, from, kind);
}
MobEvent::ElderCurse { at } => self.elder_curse(at),
MobEvent::ElderCurse { at, silent } => self.elder_curse(at, silent),
MobEvent::SetVelocity { target, velocity } => {
let v = Vec3::new(velocity[0], velocity[1], velocity[2]);
match target {
@@ -2925,8 +2975,12 @@ impl Game<'_> {
}
MobEvent::DigParticles { at } => self.warden_dig_particles(at),
MobEvent::BlockBreakEffect { pos, block } => {
// Turtle.aiStep broadcasts LevelEvent 2001, not the
// particles themselves: the event carries the particles and
// the sand's break sound, and reaches the clients the same
// way a broken block's does.
let state = self.world.block(block[0], block[1], block[2]);
self.destroy_particles(pos, state);
self.level_event_at(2001, pos, state.0 as i32);
}
MobEvent::Darkness { at, radius } => self.apply_darkness(at, radius),
MobEvent::CreakingHurt {
@@ -3471,15 +3525,16 @@ impl Game<'_> {
/// `MobEffectUtil.addEffectToPlayersAround` for an elder guardian:
/// 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]) {
fn elder_curse(&mut self, at: [f64; 3], silent: bool) {
let mut cursed = Vec::new();
self.for_each_player(|g| {
if g.elder_curse_player(at) {
if g.elder_curse_player(at, silent) {
cursed.push(g.lan_acting);
}
});
// A guest's client shows the apparition and plays the curse itself
// (ClientboundGameEventPacket.GUARDIAN_ELDER_EFFECT).
// (ClientboundGameEventPacket.GUARDIAN_ELDER_EFFECT), the sound only
// for 1: `isSilent() ? 0.0F : 1.0F`.
if let Some(host) = self.lan.as_ref() {
for conn in cursed.into_iter().flatten() {
host.hub.send_to(
@@ -3487,7 +3542,7 @@ impl Game<'_> {
&horizon_protocol::packets::play::Clientbound::GameEvent(
horizon_protocol::packets::play::GameEvent {
event: horizon_client::lan::hub::game_event::GUARDIAN_ELDER_EFFECT,
param: 1.0,
param: crate::mobs::guardian::elder_curse_param(silent),
},
),
);
@@ -3495,7 +3550,7 @@ impl Game<'_> {
}
}
/// The curse on the player in place; whether it took.
fn elder_curse_player(&mut self, at: [f64; 3]) -> bool {
fn elder_curse_player(&mut self, at: [f64; 3], silent: bool) -> bool {
if self.creative || self.spectator() {
return false;
}
@@ -3515,8 +3570,12 @@ impl Game<'_> {
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);
// ClientPacketListener.handleGameEvent: the apparition always, the
// curse sound unless the elder is silent.
if crate::mobs::guardian::elder_curse_param(silent) == 1.0 {
self.audio
.play("minecraft:entity.elder_guardian.curse", Some(at), 1.0, 1.0);
}
self.creatures.elder_apparition = super::mob_blocks::APPARITION_TICKS;
}
true
@@ -3843,6 +3902,10 @@ impl Game<'_> {
}
fn tick_projectiles(&mut self) -> Result<()> {
if self.creatures.projectiles.is_empty() {
self.flush_lan_explosions();
return self.flush_wind_bursts();
}
let mut projectiles = std::mem::take(&mut self.creatures.projectiles);
let mut hits = Vec::new();
let mut deflections: Vec<[f64; 3]> = Vec::new();
@@ -3865,7 +3928,7 @@ impl Game<'_> {
.collect();
// Drawn only when a breeze could deflect a projectile: an empty
// level must not move the level random every tick.
let jitter = if breezes.is_empty() || projectiles.is_empty() {
let jitter = if breezes.is_empty() {
0.0
} else {
self.world.random.next_float()
@@ -3875,8 +3938,14 @@ impl Game<'_> {
let scene = self.arrow_scene(&rain);
// Mobs' projectiles also hit the guests (game/lan_mobs.rs).
let guests = self.lan_projectile_targets();
let mut targets_guests = targets.clone();
targets_guests.extend(guests);
let has_guests = !guests.is_empty();
let targets_guests = if has_guests {
let mut targets_guests = targets.clone();
targets_guests.extend(guests);
targets_guests
} else {
Vec::new()
};
let border = Some(self.border.bounds());
let pw = ProjWorld {
w: &w,
@@ -3888,6 +3957,8 @@ impl Game<'_> {
targets: targets_guests,
border,
};
// Every guest, not only the ones a projectile can hit: a dead or
// spectating guest's pearl or arrow still resolves to them.
let guest_entities = self.lan_guest_entities();
// ThrownEnderpearl.tick: a dead player's pearl is discarded in
// place of its tick while ender_pearls_vanish_on_death is on.
@@ -3935,7 +4006,8 @@ impl Game<'_> {
if shooter.is_some() {
p.owner = shooter;
}
let (keep, hit) = match (p.arrow.is_some(), mob_shot || pvp, winds.is_empty()) {
let use_guests = (mob_shot || pvp) && has_guests;
let (keep, hit) = match (p.arrow.is_some(), use_guests, winds.is_empty()) {
(true, false, true) => horizon_client::arrows::tick(p, &scene, &pw.targets),
(true, true, true) => {
horizon_client::arrows::tick(p, &scene, &pw_guests.targets)
@@ -4718,6 +4790,13 @@ impl Game<'_> {
let mut arrow = p.clone();
let amount = arrow_damage(&mut arrow, &mut self.world.random) + p.bonus.damage;
let player_fire = self.player.as_ref().map(|pl| pl.fire_ticks);
// AbstractArrow.onHitEntity saves the fire, ignites, then puts
// it back when hurtOrSimulate fails and the target still takes
// a projectile's side effects.
let mob_fire = match who {
Who::Mob(id) => self.mobs.iter().find(|m| m.id == id).map(|m| m.fire_ticks),
Who::Player => None,
};
let mut dodged = false;
// AbstractArrow.onHitEntity: a burning arrow sets its target alight.
if p.arrow.as_ref().is_some_and(|a| a.on_fire()) {
@@ -4872,10 +4951,22 @@ impl Game<'_> {
}
// EnderMan.hurtServer teleported away: the arrow flies on.
(false, Who::Mob(_)) if dodged => {
self.finish_arrow_hit(arrow, EntityHit::Dodged);
self.finish_arrow_hit(arrow, arrow_miss(true));
}
// hurtOrSimulate returned false and
// projectileReceivesSideEffectsOnHit(false) is the default
// (Shulker.hurtServer on a closed shell, invulnerability).
// The fire the arrow just set goes back, then
// deflect(REVERSE, entity, owner, false, 0.2). The hit
// sound plays only when the hurt lands.
(false, Who::Mob(id)) => {
if let (Some(m), Some(ticks)) =
(self.mobs.iter_mut().find(|m| m.id == id), mob_fire)
{
m.fire_ticks = ticks;
}
self.finish_arrow_hit(arrow, arrow_miss(false));
}
// Mobs' other refusals don't follow hurtServer yet: the arrow is gone.
(false, Who::Mob(_)) => {}
}
}
// AbstractArrow.onHitBlock plays its sound itself (ArrowEvent).
@@ -5042,6 +5133,8 @@ impl Game<'_> {
let from = pl.position;
self.spawn_mob("minecraft:endermite", [from.x, from.y, from.z], false);
}
// ServerPlayer.teleport: removeVehicle before the placement.
self.stop_riding();
if let Some(pl) = self.player.as_mut() {
pl.position = to;
pl.velocity = Vec3::ZERO;
@@ -7782,6 +7875,53 @@ fn breeding_credit(
from_parent.or(from_partner)
}
#[cfg(test)]
mod shulker_arrow_tests {
use super::*;
use horizon_client::arrows::{ArrowEvent, deflect_reverse};
use horizon_core::random::LegacyRandom;
/// `AbstractArrow.onHitEntity` after `hurtOrSimulate` fails and the target
/// still takes a projectile's side effects: `deflect(REVERSE, …, 0.2)`,
/// and no hit sound. A closed shulker's refusal is that miss
/// (`shulker::tests`). An enderman's teleport is `Dodged`, not this.
#[test]
fn a_refused_arrow_reverses_without_a_hit_sound() {
assert_eq!(arrow_miss(false), EntityHit::Refused);
assert_eq!(arrow_miss(true), EntityHit::Dodged);
let mut arrow = Projectile::new(
ProjectileKind::Arrow {
base_damage: 2.0,
critical: false,
effect: None,
},
Vec3::new(0.0, 1.0, 0.0),
Some(Who::Player),
)
.with_arrow_random(LegacyRandom::new(7));
arrow.velocity = Vec3::new(3.0, -1.0, 2.0);
arrow.yaw = 15.0;
let mut oracle = LegacyRandom::new(7);
let expect_yaw = 15.0 + 170.0 + oracle.next_float() * 20.0;
let mut state = arrow.arrow.take().expect("an arrow");
deflect_reverse(&mut arrow.velocity, &mut arrow.yaw, &mut state);
// power 0.2, and REVERSE multiplies by -power * 0.5.
assert!((arrow.velocity.x - -0.3).abs() < 1e-6);
assert!((arrow.velocity.y - 0.1).abs() < 1e-6);
assert!((arrow.velocity.z - -0.2).abs() < 1e-6);
assert!((arrow.yaw - expect_yaw).abs() < 1e-4);
assert!(!state.removed, "the reversed arrow stays in the world");
assert!(
state
.events
.iter()
.all(|e| !matches!(e, ArrowEvent::Sound { .. })),
"the deflect branch plays no hit sound"
);
}
}
#[cfg(test)]
mod breeding_tests {
use super::*;
+1 -1
View File
@@ -338,7 +338,7 @@ impl Game<'_> {
}
// `Entity.startRiding`: a passenger first gets off what it rides
// (`stopRiding`), so a seated player moves to this cushion.
if !self.native_may_board(id) {
if !self.may_board() || !self.native_may_board(id) {
return true;
}
self.stop_riding();
+2 -2
View File
@@ -3203,7 +3203,7 @@ impl Dragon {
"minecraft:ender_dragon",
self.position,
self.velocity,
self.yaw,
[self.yaw, 0.0],
uuid,
raw,
);
@@ -3240,7 +3240,7 @@ impl Crystal {
"minecraft:end_crystal",
self.position,
Vec3::ZERO,
0.0,
[0.0, 0.0],
uuid,
raw,
);
+151
View File
@@ -62,6 +62,12 @@ pub(super) trait Entity: EntityRules {
/// `Entity.getDeltaMovement`.
fn velocity(&self) -> Vec3;
fn velocity_mut(&mut self) -> &mut Vec3;
/// `Entity.getYRot` and `getXRot`: yaw, pitch.
#[allow(dead_code)] // read by the entity-interface slices that follow
fn rotation(&self) -> [f32; 2];
/// The rotation part of `Entity.teleportSetPosition`: `setYRot`, `setXRot`
/// (clamped to -90..90), then `setOldRot`.
fn set_rotation(&mut self, rotation: [f32; 2]);
fn bounds(&self) -> Aabb;
/// What a selector finds this entity as (`@e`), or `None` for one it cannot
/// name yet. Required, so a new kind states which.
@@ -306,6 +312,7 @@ pub(super) fn tick_item(
no_physics: bool,
entity_boxes: &[Aabb],
border: Option<&horizon_core::geometry::border::BorderGeometry>,
blocks: Option<&horizon_world::movement_properties::BoundMovementProperties>,
splash: &mut impl FnMut(horizon_client::entities::ItemSplash),
) -> bool {
let (water, _lava) = item.push_by_fluid(world, fluids);
@@ -319,6 +326,7 @@ pub(super) fn tick_item(
(water, _lava),
entity_boxes,
border,
blocks,
);
item.push_by_bubble_column(world, registry, facts, collision);
let alive = item.tick_settle(world, facts, no_physics, step);
@@ -688,6 +696,20 @@ impl Game<'_> {
}
impl Entity for Mob {
fn rotation(&self) -> [f32; 2] {
[self.yaw, self.head_pitch]
}
fn set_rotation(&mut self, [yaw, pitch]: [f32; 2]) {
let previous = self.rotation();
let yaw = if yaw.is_finite() { yaw } else { previous[0] };
let pitch = if pitch.is_nan() { previous[1] } else { pitch };
self.yaw = yaw;
self.body_yaw = yaw;
self.head_yaw = yaw;
self.head_pitch = pitch.clamp(-90.0, 90.0);
self.previous_yaw = self.yaw;
self.previous_head_pitch = self.head_pitch;
}
fn native_type(&self) -> Cow<'static, str> {
super::native_hooks::mob_kind(self).into()
}
@@ -786,6 +808,15 @@ impl Entity for Mob {
}
impl Entity for ItemEntity {
fn rotation(&self) -> [f32; 2] {
self.rotation
}
fn set_rotation(&mut self, [yaw, pitch]: [f32; 2]) {
let previous = self.rotation();
let yaw = if yaw.is_finite() { yaw } else { previous[0] };
let pitch = if pitch.is_nan() { previous[1] } else { pitch };
self.rotation = [yaw, pitch.clamp(-90.0, 90.0)];
}
fn fluid_eye_y(&self) -> f64 {
ItemEntity::fluid_eye_y(self)
}
@@ -847,6 +878,15 @@ impl Entity for ItemEntity {
}
impl Entity for ExperienceOrb {
fn rotation(&self) -> [f32; 2] {
self.rotation
}
fn set_rotation(&mut self, [yaw, pitch]: [f32; 2]) {
let previous = self.rotation();
let yaw = if yaw.is_finite() { yaw } else { previous[0] };
let pitch = if pitch.is_nan() { previous[1] } else { pitch };
self.rotation = [yaw, pitch.clamp(-90.0, 90.0)];
}
fn ticks_after_projectiles(&self) -> bool {
true
}
@@ -905,6 +945,16 @@ impl Entity for ExperienceOrb {
}
impl Entity for Boat {
fn rotation(&self) -> [f32; 2] {
[self.yaw, self.pitch]
}
fn set_rotation(&mut self, [yaw, pitch]: [f32; 2]) {
let previous = self.rotation();
let yaw = if yaw.is_finite() { yaw } else { previous[0] };
let pitch = if pitch.is_nan() { previous[1] } else { pitch };
self.yaw = yaw;
self.pitch = pitch.clamp(-90.0, 90.0);
}
fn native_type(&self) -> Cow<'static, str> {
self.entity_type().into()
}
@@ -966,6 +1016,16 @@ impl Entity for Boat {
}
impl Entity for Minecart {
fn rotation(&self) -> [f32; 2] {
[self.yaw, self.pitch]
}
fn set_rotation(&mut self, [yaw, pitch]: [f32; 2]) {
let previous = self.rotation();
let yaw = if yaw.is_finite() { yaw } else { previous[0] };
let pitch = if pitch.is_nan() { previous[1] } else { pitch };
self.yaw = yaw;
self.pitch = pitch.clamp(-90.0, 90.0);
}
fn native_type(&self) -> Cow<'static, str> {
self.kind.id().into()
}
@@ -1035,6 +1095,17 @@ fn armor_stand_size(stand: &ArmorStand) -> (f64, f64) {
}
impl Entity for ArmorStand {
fn rotation(&self) -> [f32; 2] {
[self.yaw, self.pitch]
}
fn set_rotation(&mut self, [yaw, pitch]: [f32; 2]) {
let previous = self.rotation();
let yaw = if yaw.is_finite() { yaw } else { previous[0] };
let pitch = if pitch.is_nan() { previous[1] } else { pitch };
self.yaw = yaw;
self.previous_yaw = yaw;
self.pitch = pitch.clamp(-90.0, 90.0);
}
fn fluid_eye_y(&self) -> f64 {
ArmorStand::fluid_eye_y(self)
}
@@ -1126,6 +1197,15 @@ impl Entity for ArmorStand {
}
impl Entity for PrimedTnt {
fn rotation(&self) -> [f32; 2] {
self.rotation
}
fn set_rotation(&mut self, [yaw, pitch]: [f32; 2]) {
let previous = self.rotation();
let yaw = if yaw.is_finite() { yaw } else { previous[0] };
let pitch = if pitch.is_nan() { previous[1] } else { pitch };
self.rotation = [yaw, pitch.clamp(-90.0, 90.0)];
}
fn ticks_after_projectiles(&self) -> bool {
true
}
@@ -1186,6 +1266,15 @@ impl Entity for PrimedTnt {
}
impl Entity for FallingBlock {
fn rotation(&self) -> [f32; 2] {
self.rotation
}
fn set_rotation(&mut self, [yaw, pitch]: [f32; 2]) {
let previous = self.rotation();
let yaw = if yaw.is_finite() { yaw } else { previous[0] };
let pitch = if pitch.is_nan() { previous[1] } else { pitch };
self.rotation = [yaw, pitch.clamp(-90.0, 90.0)];
}
fn ticks_after_projectiles(&self) -> bool {
true
}
@@ -1222,6 +1311,18 @@ impl Entity for FallingBlock {
}
impl Entity for Projectile {
fn rotation(&self) -> [f32; 2] {
[self.yaw, self.pitch]
}
fn set_rotation(&mut self, [yaw, pitch]: [f32; 2]) {
let previous = self.rotation();
let yaw = if yaw.is_finite() { yaw } else { previous[0] };
let pitch = if pitch.is_nan() { previous[1] } else { pitch };
self.yaw = yaw;
self.pitch = pitch.clamp(-90.0, 90.0);
self.previous_yaw = self.yaw;
self.previous_pitch = self.pitch;
}
fn native_type(&self) -> Cow<'static, str> {
self.kind.id().into()
}
@@ -1310,6 +1411,15 @@ impl Entity for Projectile {
}
impl Entity for Rocket {
fn rotation(&self) -> [f32; 2] {
self.rotation
}
fn set_rotation(&mut self, [yaw, pitch]: [f32; 2]) {
let previous = self.rotation();
let yaw = if yaw.is_finite() { yaw } else { previous[0] };
let pitch = if pitch.is_nan() { previous[1] } else { pitch };
self.rotation = [yaw, pitch.clamp(-90.0, 90.0)];
}
fn native_type(&self) -> Cow<'static, str> {
"minecraft:firework_rocket".into()
}
@@ -1358,6 +1468,16 @@ impl Entity for Rocket {
}
impl Entity for ThrownTrident {
fn rotation(&self) -> [f32; 2] {
[self.yaw, self.pitch]
}
fn set_rotation(&mut self, [yaw, pitch]: [f32; 2]) {
let previous = self.rotation();
let yaw = if yaw.is_finite() { yaw } else { previous[0] };
let pitch = if pitch.is_nan() { previous[1] } else { pitch };
self.yaw = yaw;
self.pitch = pitch.clamp(-90.0, 90.0);
}
fn native_type(&self) -> Cow<'static, str> {
"minecraft:trident".into()
}
@@ -1668,3 +1788,34 @@ mod join_order_tests {
);
}
}
#[cfg(test)]
mod set_rotation_tests {
use super::Entity as _;
use horizon_client::entities::ExperienceOrb;
/// `Entity.teleportSetPosition`: yaw as given, pitch clamped to -90..90, on
/// every kind (the ones with no field of their own used to drop it).
#[test]
fn set_rotation_keeps_yaw_and_clamps_pitch_on_kinds_that_had_none() {
let mut orb = ExperienceOrb {
join: Default::default(),
no_gravity: false,
id: 0,
position: horizon_core::geometry::Vec3::ZERO,
previous: horizon_core::geometry::Vec3::ZERO,
velocity: horizon_core::geometry::Vec3::ZERO,
value: 1,
age: 0,
on_ground: false,
count: 1,
ticks: 0,
health: 5,
rotation: [0.0, 0.0],
};
orb.set_rotation([90.0, 10.0]);
assert_eq!(orb.rotation(), [90.0, 10.0]);
orb.set_rotation([0.0, 200.0]);
assert_eq!(orb.rotation(), [0.0, 90.0]);
}
}
+13 -4
View File
@@ -29,6 +29,8 @@ enum RocketHit {
}
pub(super) struct Rocket {
/// `Entity.yRot` and `xRot` (`/tp`, `/data get ... Rotation`).
pub rotation: [f32; 2],
/// When it joined its list (`horizon_client::join_seq`); set by the list.
pub(super) join: horizon_client::join_seq::JoinSeq,
/// Its entity id: from the entity counter on the server, the server's on
@@ -64,6 +66,7 @@ impl Rocket {
let item = ItemStack::new("minecraft:firework_rocket", 1);
let (_, explosions) = fireworks_of(&item);
Self {
rotation: [0.0, 0.0],
join: Default::default(),
id,
position,
@@ -90,6 +93,7 @@ impl Rocket {
) -> horizon_world::nbt::Tag {
horizon_client::entity_nbt::firework_rocket_to_nbt(
&horizon_client::entity_nbt::RocketSave {
rotation: self.rotation,
position: self.position,
motion: self.velocity,
life: self.life,
@@ -114,6 +118,7 @@ impl Rocket {
.unwrap_or_else(|| ItemStack::new("minecraft:firework_rocket", 1));
let (_, explosions) = fireworks_of(&item);
Self {
rotation: [0.0, 0.0],
join: Default::default(),
id: 0,
position: r.position,
@@ -231,6 +236,7 @@ impl Game<'_> {
// Entity.<init>: the id comes from the one counter at construction.
let id = self.take_entity_id();
Rocket {
rotation: [0.0, 0.0],
join: Default::default(),
id,
position: at,
@@ -308,16 +314,19 @@ impl Game<'_> {
/// hand with the player's motion.
pub(super) fn follow_own_player(&mut self, r: &mut Rocket) {
let hand = self.rocket_hand_angle();
let look = view_vector(self.yaw, self.pitch);
// getLookAngle reads Mth's float sine table, as the server does; the
// f64 sin/cos of super::view_vector drift the boost off the server's
// path and trip the movement check while gliding under a rocket.
let look = horizon_world::view_direction::view_vector(self.pitch, self.yaw);
let Some(p) = self.player.as_mut() else {
return;
};
if p.fall_flying {
let m = p.velocity;
p.velocity = Vec3::new(
m.x + look[0] * 0.1 + (look[0] * 1.5 - m.x) * 0.5,
m.y + look[1] * 0.1 + (look[1] * 1.5 - m.y) * 0.5,
m.z + look[2] * 0.1 + (look[2] * 1.5 - m.z) * 0.5,
m.x + look.x * 0.1 + (look.x * 1.5 - m.x) * 0.5,
m.y + look.y * 0.1 + (look.y * 1.5 - m.y) * 0.5,
m.z + look.z * 0.1 + (look.z * 1.5 - m.z) * 0.5,
);
}
r.position = p.position.add_vector(Vec3::new(hand[0], hand[1], hand[2]));
+63 -1
View File
@@ -126,6 +126,24 @@ fn pull_item(item: &mut horizon_client::entities::ItemEntity, delta: Vec3) {
// cannot request the immediate motion update for tracking clients.
item.needs_sync = true;
}
/// `FishingHook.pullEntity`: add a tenth of the vector from the hook to its owner.
fn pull_hooked(velocity: Vec3, hook: Vec3, owner: Vec3) -> Vec3 {
Vec3::new(
velocity.x + (owner.x - hook.x) * 0.1,
velocity.y + (owner.y - hook.y) * 0.1,
velocity.z + (owner.z - hook.z) * 0.1,
)
}
/// Minecarts a hook can catch. `AbstractMinecart.isPickable` is true while the
/// cart exists, and `Projectile.canHitEntity` skips the vehicle the owner rides.
fn minecart_hook_targets<'a>(
carts: impl Iterator<Item = &'a super::minecarts::Minecart> + 'a,
own_vehicle: Option<u32>,
) -> impl Iterator<Item = (u32, Aabb)> + 'a {
carts
.filter(move |c| Some(c.id) != own_vehicle)
.map(|c| (c.id, c.bounds()))
}
impl Hook {
pub(super) fn bounds(&self) -> Aabb {
bounds(self.position)
@@ -272,7 +290,7 @@ impl Game<'_> {
owner.y - hook.position.y,
owner.z - hook.position.z,
);
let pulled = |v: Vec3| Vec3::new(v.x + d.x * 0.1, v.y + d.y * 0.1, v.z + d.z * 0.1);
let pulled = |v: Vec3| pull_hooked(v, hook.position, owner);
let mut hooked_item = false;
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.velocity = pulled(m.velocity);
@@ -285,6 +303,9 @@ impl Game<'_> {
a.velocity = pulled(a.velocity);
} else if let Some(b) = self.boats.iter_mut().find(|b| b.id == id) {
b.velocity = pulled(b.velocity);
} else if let Some(c) = self.carts.iter_mut().find(|c| c.id == id) {
// `AbstractMinecart` is pickable, so `pullEntity` moves it too.
c.velocity = pulled(c.velocity);
} else if self.creatures.hook_targets.iter().any(|t| t.id == id) {
// A player: the hub moves them when the tick ends.
self.creatures
@@ -710,6 +731,7 @@ impl Game<'_> {
.filter(|b| Some(b.id) != own_vehicle)
.map(|b| (b.id, b.bounds())),
)
.chain(minecart_hook_targets(self.carts.iter(), own_vehicle))
.chain(self.dropped.iter().map(|i| (i.id, i.bounds())))
.chain(
self.tnts
@@ -772,6 +794,12 @@ impl Game<'_> {
.find(|b| b.id == id)
.map(|b| (b.position, super::boats::HEIGHT))
})
.or_else(|| {
self.carts
.iter()
.find(|c| c.id == id)
.map(|c| (c.position, super::minecarts::HEIGHT))
})
.or_else(|| {
self.dropped
.iter()
@@ -1360,6 +1388,12 @@ impl Game<'_> {
.iter()
.find(|m| m.id == id && m.alive())
.map(|m| (m.position, f64::from(m.height())))
.or_else(|| {
self.carts
.iter()
.find(|c| c.id == id)
.map(|c| (c.position, super::minecarts::HEIGHT))
})
.or_else(|| {
self.dropped
.iter()
@@ -1514,6 +1548,7 @@ mod tests {
#[test]
fn reeling_an_item_requests_an_immediate_motion_update() {
let mut item = ItemEntity {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
health: 5,
@@ -1594,4 +1629,31 @@ mod tests {
Some(1)
);
}
/// A minecart is pickable (`AbstractMinecart.isPickable`), so the hook
/// meets it. The cart the owner rides is the same vehicle and is skipped.
#[test]
fn a_fishing_hook_meets_a_minecart_but_not_the_one_it_rides() {
use super::super::minecarts::{CartKind, Minecart};
let cart = Minecart::new(7, CartKind::Rideable, Vec3::new(0.6, 0.0, 0.0));
let ridden = Minecart::new(3, CartKind::Chest, Vec3::new(0.2, 0.0, 0.0));
let boxes = super::minecart_hook_targets([&cart, &ridden].into_iter(), Some(3));
assert_eq!(
first_entity_hit(Vec3::ZERO, Vec3::new(1.0, 0.0, 0.0), 0.0, boxes),
Some(7)
);
}
/// `FishingHook.pullEntity` scales the hook-to-owner vector by 0.1.
#[test]
fn reeling_pulls_a_hooked_minecart_a_tenth_of_the_way() {
// Still cart at the origin, owner 10 east and 2 up: +1 x and +0.2 y,
// on top of the cart's own 0.5.
let pulled = super::pull_hooked(
Vec3::new(0.5, 0.0, 0.0),
Vec3::ZERO,
Vec3::new(10.0, 2.0, 0.0),
);
assert_eq!(pulled, Vec3::new(1.5, 0.2, 0.0));
}
}
+1 -1
View File
@@ -90,7 +90,7 @@ impl Game<'_> {
if harnessed && !sneaking {
// doPlayerRide.
let id = self.mobs[index].id;
if !self.native_may_board(id) {
if !self.may_board() || !self.native_may_board(id) {
return true;
}
self.break_leash(id, true, false);
@@ -69,6 +69,7 @@ mod tests {
fn item_at(x: f64, y: f64, z: f64) -> ItemEntity {
let p = Vec3::new(x, y, z);
ItemEntity {
rotation: [0.0, 0.0],
join: Default::default(),
health: 5,
fire_ticks: 0,
+57 -27
View File
@@ -2222,6 +2222,17 @@ impl Game<'_> {
/// before the entities': what joined the level up to here (a falling
/// block, a dispensed arrow, the last tick's late joins) is paired and
/// first updated in this tick's pass (`Entities::synced`).
/// `ServerChunkCache.tick`'s broadcast, where ServerLevel.tick runs it:
/// after the scheduled and random ticks, before the block events, so the
/// players get the blocks as they are then. The block log's length after
/// it: what the tick writes from there on goes out with the next tick's.
pub(super) fn lan_broadcast_changes(&mut self) -> Option<usize> {
if let Some(host) = self.lan.as_mut() {
host.hub.broadcast_changes(&mut self.world);
}
self.world.block_log.as_ref().map(Vec::len)
}
pub(super) fn lan_mark_synced(&mut self) {
if let Some(next) = self.lan_ids_so_far()
&& let Some(host) = self.lan.as_mut()
@@ -2364,10 +2375,11 @@ impl Game<'_> {
self.clock.frozen(),
self.clock.step_state(),
);
// `runBlockEvents`' broadcast precedes the tick's block changes
// (`ChunkHolder.broadcastChanges` in `tick_level` below): the
// guest re-runs a piston's `triggerEvent` while its level still
// has the blocks as they were before the move.
// `runBlockEvents`' broadcast precedes what the events change (held
// for the next tick, `tick_world`), and follows the blocks changed
// before them (`lan_broadcast_changes`): the guest re-runs a
// piston's `triggerEvent` on the blocks as they were before the
// move, a base the last tick restored included.
for (pos, b0, b1, state) in std::mem::take(&mut self.lan_block_events) {
host.hub.block_event(pos, b0, b1, state);
}
@@ -2644,30 +2656,17 @@ impl Game<'_> {
if own && self.lan_break_pos == Some([pos.x, pos.y, pos.z]) {
own_break = false;
}
// The acting guest does not predict the egg's teleport
// particles, bone meal's growth, anvil result sounds, or a
// finished brushing or an eye inserted in a portal frame (these calls name no
// excluded player), nor the digger's own crack and hit
// sound (2019, 2020).
let except = if matches!(
// The acting guest does not predict these, and the vanilla
// call names no excluded player (`levelEvent(id, pos, data)`
// passes null): the composter fill (1500), the egg's
// teleport, bone meal's growth, anvil result sounds, a
// finished brushing or an eye inserted in a portal frame,
// and the digger's own crack and hit sound (2019, 2020).
let except = level_event_except(
*id,
2015 | 1501
| 1503
| 1505
| 1029
| 1030
| 1045
| 1052
| 2019
| 2020
| 3000
| 3008
) || (*id == 2001 && self.lan_break_pos.is_some() && !own)
{
None
} else {
self.lan_acting
};
self.lan_acting,
*id == 2001 && self.lan_break_pos.is_some() && !own,
);
self.lan_events
.push((*id, [pos.x, pos.y, pos.z], *data, except));
}
@@ -3094,6 +3093,26 @@ fn event_kind(event: &HostEvent) -> EventKind {
}
}
/// Who `ServerLevel.levelEvent` leaves out of the broadcast.
///
/// `LevelAccessor.levelEvent(int, BlockPos, int)` passes a null source, so the
/// acting player is included. `ComposterBlock.useItemOn` (and the hopper input
/// container) fill that way: event 1500, which the client turns into
/// `handleFill` (the fill particles and `playLocalSound`). A call that names
/// the player still excludes them. `collateral_break` is a later 2001 from
/// `destroyBlock` with no player, after the breaker's own `playerWillDestroy`.
fn level_event_except(id: i32, acting: Option<u32>, collateral_break: bool) -> Option<u32> {
if matches!(
id,
2015 | 1500 | 1501 | 1503 | 1505 | 1029 | 1030 | 1045 | 1052 | 2019 | 2020 | 3000 | 3008
) || collateral_break
{
None
} else {
acting
}
}
/// Notes how long the event that started at `started` took (`spans::note`), with what it
/// was about when [`Game::event_detail`] said.
fn note_event(host: &LanHost, started: Option<(Instant, EventKind, Option<UseDetail>)>) {
@@ -3221,6 +3240,17 @@ mod tests {
assert_eq!(living_flags(Some((OFFHAND_SLOT, 12))), 3);
}
/// `ComposterBlock.useItemOn` calls `levelEvent(1500, pos, data)`, the
/// null-source overload, so the player who filled it still receives
/// `handleFill`. `Block.playerWillDestroy`'s 2001 names that player.
#[test]
fn the_composter_fill_reaches_the_player_who_filled_it() {
let actor = Some(4);
assert_eq!(super::level_event_except(1500, actor, false), None);
assert_eq!(super::level_event_except(2001, actor, false), actor);
assert_eq!(super::level_event_except(2001, actor, true), None);
}
/// The names a mod matches on for a broken item's slot (a contract).
#[test]
fn a_broken_items_slot_is_named_for_mods() {
+41 -24
View File
@@ -191,6 +191,7 @@ pub(super) struct GuestSwap {
left: bool,
right: bool,
riding: Option<u32>,
host_boarding: (i32, Option<u32>),
host_station: Option<super::stations::Station>,
sleeping: Option<[i32; 3]>,
sleep_timer: u32,
@@ -522,6 +523,11 @@ impl Game<'_> {
self.right = play.using.is_some() || self.using.is_some();
// The guest's vehicle (Entity.vehicle) for its turn.
let riding = std::mem::replace(&mut self.riding, g.vehicle);
// The guest's boarding cooldown for its turn; `guest_server_tick`
// counts it down once per tick (Entity.baseTick), not per turn.
let host_boarding = (self.boarding_cooldown, self.riding_at_tick);
self.boarding_cooldown = g.boarding_cooldown;
self.riding_at_tick = g.vehicle;
// `Entity.isPassenger()` for that turn, from the same vehicle: the
// guest's body keeps no swim (`Entity.updateSwimming` needs it false).
// Taken from `g.vehicle` each turn, so boarding and getting out land.
@@ -635,6 +641,7 @@ impl Game<'_> {
left,
right,
riding,
host_boarding,
host_station,
sleeping,
sleep_timer,
@@ -734,6 +741,7 @@ impl Game<'_> {
left,
right,
riding,
host_boarding,
host_station,
sleeping,
sleep_timer,
@@ -912,6 +920,11 @@ impl Game<'_> {
self.guest_attachments.entry(g.uuid).or_default(),
);
let left_vehicle = g.vehicle.filter(|_| guest_vehicle.is_none());
// Entity.removePassenger: 60 ticks before it may board again.
let guest_boarding =
super::boarding::after_leaving(self.boarding_cooldown, g.vehicle, guest_vehicle);
(self.boarding_cooldown, self.riding_at_tick) = host_boarding;
g.boarding_cooldown = guest_boarding;
if guest_vehicle.is_none() {
g.vehicle_good = None;
}
@@ -967,9 +980,19 @@ impl Game<'_> {
std::mem::swap(&mut self.armor, &mut g.armor);
std::mem::swap(&mut self.offhand, &mut g.offhand);
std::mem::swap(&mut self.carried, &mut g.carried);
// A teleport with a rotation (commands) turns the guest.
let turned = [self.yaw, self.pitch] != [g.yaw, g.pitch];
// A teleport with a rotation (commands) turns the guest. `/rotate`
// (ServerPlayer.forceSetRotation) sends the rotation alone, rider or not.
let forced = std::mem::take(&mut self.rotation_forced);
let turned = !forced && [self.yaw, self.pitch] != [g.yaw, g.pitch];
[g.yaw, g.pitch] = [self.yaw, self.pitch];
if forced {
g.send(&C::PlayerRotation(play::PlayerRotation {
yaw: g.yaw,
relative_yaw: false,
pitch: g.pitch,
relative_pitch: false,
}));
}
self.yaw = yaw;
self.pitch = pitch;
self.eye = eye.1;
@@ -997,14 +1020,8 @@ impl Game<'_> {
let teleported = std::mem::take(&mut m.teleported);
let moved: f64 = (0..3).map(|k| (at[k] - g.position[k]).powi(2)).sum();
g.motion = Some(Box::new(m));
// Entity.teleportTo takes a rider out of its vehicle.
let left_vehicle = match g.vehicle.filter(|_| teleported) {
Some(v) => {
(g.vehicle, g.vehicle_good) = (None, None);
Some(v)
}
None => left_vehicle,
};
// The pearl and the bed took the rider out of its vehicle at their
// source (`stop_riding`), so `left_vehicle` already says so.
// ServerPlayer.dismountTo: the client hears it is out of the
// vehicle before the teleport beside it. A rider sits where its
// vehicle puts it (lan_vehicles.rs), with no teleport.
@@ -2110,10 +2127,13 @@ impl Game<'_> {
g.forced_hit = Some(hit);
g.guest_use = Some(GuestUse::OnBlock(hand));
let swings = g.swings_started;
let consumed = matches!(g.use_item(), Ok(Some(_)));
// InteractionResult.consumesAction: a FAIL (`use_failed`,
// as BlockItem.place's failed placement) does not count,
// even though the click ended on it.
let consumed = matches!(g.use_item(), Ok(Some(_))) && !g.use_failed;
// handleUseItemOn: a consuming useItemOn triggers
// ANY_BLOCK_USE with the hand's stack as it now is.
if consumed && !g.use_failed {
if consumed {
let stack = g
.with_hand(hand, |g| g.held().cloned())
.unwrap_or_else(|| ItemStack::new("minecraft:air", 0));
@@ -2854,19 +2874,12 @@ impl Game<'_> {
let g = &host.hub.guests[gi];
let eye = [
g.position[0],
g.position[1] + g.eye_height() - 0.3,
g.position[1] + g.eye_height() - f64::from(0.3f32),
g.position[2],
];
let (yaw, pitch) = (
f64::from(g.yaw).to_radians(),
f64::from(g.pitch).to_radians(),
);
for stack in stacks {
let v = [
-yaw.sin() * pitch.cos() * 0.3,
-pitch.sin() * 0.3 + 0.1,
yaw.cos() * pitch.cos() * 0.3,
];
let r = &mut self.world.random;
let v = horizon_client::entities::drop_motion(r, g.yaw, g.pitch, false);
self.spawn_item(stack, eye, v, 40);
}
}
@@ -3878,8 +3891,8 @@ impl Game<'_> {
.death_reward(self.ui.rules.bool("keep_inventory") || g.game_mode == SPECTATOR);
let (name, uuid) = (g.name.clone(), g.uuid);
// ServerPlayer.die: dropAllDeathLoot unless a spectator, from the eyes less 0.3.
let drops =
(g.game_mode != SPECTATOR).then(|| [at[0], at[1] + g.eye_height() - 0.3, at[2]]);
let drops = (g.game_mode != SPECTATOR)
.then(|| [at[0], at[1] + g.eye_height() - f64::from(0.3f32), at[2]]);
let cause = host.play.get_mut(&conn).and_then(|p| p.death_cause.take());
self.native_player_died(uuid, cause.as_deref());
// ServerPlayer.die: the neutral mobs near a guest in this level
@@ -4321,6 +4334,10 @@ impl Game<'_> {
/// regeneration and the food tick (ServerPlayer.doTick). Their hurt
/// sounds are heard at the guest.
fn guest_server_tick(&mut self, host: &mut LanHost, conn: u32) {
// Entity.baseTick: the boarding cooldown counts down once a tick.
if let Some(g) = host.hub.guest_mut(conn) {
g.boarding_cooldown = super::boarding::tick_down(g.boarding_cooldown);
}
self.guest_replay(host, conn);
self.guest_vehicle_tick(host, conn);
// The last move's fall stands in for the server's delta movement.
@@ -234,6 +234,8 @@ impl Game<'_> {
if dead || (boat.is_none() && !cart && !mount && !cushion) {
let g = &mut host.hub.guests[gi];
(g.vehicle, g.vehicle_good) = (None, None);
// Entity.removePassenger: the rider waits 60 ticks to board again.
g.boarding_cooldown = super::boat_passengers::BOARDING_COOLDOWN;
return;
}
let mut ejected = false;
+1 -1
View File
@@ -133,7 +133,7 @@ impl Game<'_> {
}
// doPlayerRide; mounting drops a lead.
let id = self.mobs[index].id;
if !self.native_may_board(id) {
if !self.may_board() || !self.native_may_board(id) {
return true;
}
self.break_leash(id, true, false);
@@ -1518,6 +1518,7 @@ mod item_column_tests {
fn item(id: u32, x: f64, y: f64, z: f64) -> ItemEntity {
let at = Vec3::new(x, y, z);
ItemEntity {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
health: 5,
+7 -2
View File
@@ -2119,7 +2119,12 @@ impl Game<'_> {
let id = self.carts[i].id;
// isVehicle: a mob already sits in it.
let has_mob = self.mobs.iter().any(|m| m.extra.mount == Some(id));
if sneaking || self.riding.is_some() || self.ridden_by_guest(id) || has_mob {
if sneaking
|| self.riding.is_some()
|| !self.may_board()
|| self.ridden_by_guest(id)
|| has_mob
{
return false;
}
if !self.native_may_board(id) {
@@ -2703,7 +2708,7 @@ impl Minecart {
self.kind.id(),
self.position,
self.velocity,
self.yaw,
[self.yaw, self.pitch],
uuid,
raw,
);
+89 -26
View File
@@ -54,6 +54,50 @@ impl MountState {
self.jump_scale = 0.0;
}
}
/// `AbstractHorse.isFood` and `handleEating`: what a mount eats, and what that
/// mouthful does — `(heal, age up seconds, temper, falls in love)`. The temper
/// is the `u8` the mob stores it in.
///
/// `AbstractHorse.isFood` is the `#minecraft:horse_food` tag, `ZombieHorse`
/// overrides it with `#minecraft:zombie_horse_food` (a red mushroom and
/// nothing else), and the horse food tag carries no red mushrooms; a skeleton
/// horse inherits the horse food tag but only a tamed one gets that far
/// (`SkeletonHorse.mobInteract` passes for an untamed one).
fn eating(species: Species, item: &str) -> Option<(f32, i32, u8, bool)> {
let camel = species == Species::Camel;
// CamelHusk.isFood: rabbit's feet (and it never falls in love).
if species == Species::CamelHusk && item != "minecraft:rabbit_foot" {
return None;
}
if species != Species::CamelHusk && camel != (item == "minecraft:cactus") {
return None;
}
let food = match species {
Species::ZombieHorse => item == "minecraft:red_mushroom",
Species::Horse | Species::Donkey | Species::Mule | Species::SkeletonHorse => {
item != "minecraft:red_mushroom"
}
// Camels and llamas answer their own tags above or in the table.
_ => true,
};
if !food {
return None;
}
Some(match item {
"minecraft:rabbit_foot" if species == Species::CamelHusk => (2.0, 10, 0, false),
// Camel.handleEating.
"minecraft:cactus" => (2.0, 10, 0, true),
"minecraft:wheat" => (2.0, 20, 3, false),
"minecraft:sugar" => (1.0, 30, 3, false),
"minecraft:hay_block" => (20.0, 180, 0, false),
"minecraft:apple" => (3.0, 60, 3, false),
"minecraft:red_mushroom" => (3.0, 0, 3, false),
"minecraft:carrot" => (3.0, 60, 3, false),
"minecraft:golden_carrot" => (4.0, 60, 5, true),
"minecraft:golden_apple" | "minecraft:enchanted_golden_apple" => (10.0, 240, 10, true),
_ => return None,
})
}
/// `EntityTypeTags.DISMOUNTS_UNDERWATER` (`Entity.dismountsUnderwater`): the mounts that throw their
/// riders off when the rider's eyes are in water.
pub(super) fn dismounts_underwater(species: Species) -> bool {
@@ -145,7 +189,7 @@ impl Game<'_> {
return false;
}
let id = m.id;
if !self.native_may_board(id) {
if !self.may_board() || !self.native_may_board(id) {
return true;
}
self.riding = Some(id);
@@ -306,7 +350,7 @@ impl Game<'_> {
}
// doPlayerRide
let id = self.mobs[index].id;
if !self.native_may_board(id) {
if !self.may_board() || !self.native_may_board(id) {
return true;
}
let m = &mut self.mobs[index];
@@ -321,33 +365,10 @@ impl Game<'_> {
}
/// `AbstractHorse.handleEating`.
fn feed_mount(&mut self, index: usize, item: &str) -> bool {
let species = self.mobs[index].species();
let camel = species == Species::Camel;
// CamelHusk.isFood: rabbit's feet (and it never falls in love).
if species == Species::CamelHusk && item != "minecraft:rabbit_foot" {
let Some((heal, age_up, temper, love)) = eating(self.mobs[index].species(), item) else {
return false;
}
if species != Species::CamelHusk && camel != (item == "minecraft:cactus") {
return false;
}
let (heal, age_up, temper, love) = match item {
"minecraft:rabbit_foot" if species == Species::CamelHusk => (2.0, 10, 0, false),
// Camel.handleEating.
"minecraft:cactus" => (2.0, 10, 0, true),
"minecraft:wheat" => (2.0, 20, 3, false),
"minecraft:sugar" => (1.0, 30, 3, false),
"minecraft:hay_block" => (20.0, 180, 0, false),
"minecraft:apple" => (3.0, 60, 3, false),
"minecraft:red_mushroom" => (3.0, 0, 3, false),
"minecraft:carrot" => (3.0, 60, 3, false),
"minecraft:golden_carrot" => (4.0, 60, 5, true),
"minecraft:golden_apple" | "minecraft:enchanted_golden_apple" => (10.0, 240, 10, true),
_ => return false,
};
let m = &mut self.mobs[index];
if matches!(m.species(), Species::SkeletonHorse | Species::ZombieHorse) {
return false;
}
let mut used = false;
let mut loved = false;
if love && m.extra.tame && m.extra.age == 0 && m.extra.in_love <= 0 {
@@ -381,6 +402,8 @@ impl Game<'_> {
Species::Donkey => "minecraft:entity.donkey.eat",
Species::Mule => "minecraft:entity.mule.eat",
Species::Horse if m.is_baby() => "minecraft:entity.baby_horse.eat",
// ZombieHorse.getEatingSound.
Species::ZombieHorse => "minecraft:entity.zombie_horse.eat",
_ => "minecraft:entity.horse.eat",
};
let pitch = (self.world.random.next_float() - self.world.random.next_float()) * 0.2 + 1.0;
@@ -1022,6 +1045,46 @@ mod tests {
use super::*;
use std::collections::HashMap;
/// `ZombieHorse.isFood` is `#minecraft:zombie_horse_food`, which holds only
/// the red mushroom, and `AbstractHorse.isFood` is `#minecraft:horse_food`,
/// which holds none (#8593). A skeleton horse inherits the horse food tag.
#[test]
fn only_a_zombie_horse_eats_a_red_mushroom() {
assert_eq!(
eating(Species::ZombieHorse, "minecraft:red_mushroom"),
Some((3.0, 0, 3, false)),
"a zombie horse takes the red mushroom"
);
for item in ["minecraft:wheat", "minecraft:apple", "minecraft:hay_block"] {
assert_eq!(eating(Species::ZombieHorse, item), None, "{item}");
}
for species in [
Species::Horse,
Species::Donkey,
Species::Mule,
Species::SkeletonHorse,
] {
assert_eq!(
eating(species, "minecraft:red_mushroom"),
None,
"{species:?}: horse food has no red mushrooms"
);
assert_eq!(
eating(species, "minecraft:wheat"),
Some((2.0, 20, 3, false)),
"{species:?}: wheat is horse food"
);
}
assert_eq!(
eating(Species::Camel, "minecraft:cactus"),
Some((2.0, 10, 0, true))
);
assert_eq!(
eating(Species::CamelHusk, "minecraft:rabbit_foot"),
Some((2.0, 10, 0, false))
);
}
#[test]
fn the_mounts_that_throw_a_swimmer_off_are_the_dismounts_underwater_tag() {
// data/minecraft/tags/entity_type/dismounts_underwater.json of 26.3.
@@ -93,6 +93,7 @@ impl Game<'_> {
let at = Vec3::new(pos[0], pos[1], pos[2]);
self.next_entity += 1;
self.falling.push(FallingBlock {
rotation: [0.0, 0.0],
join: Default::default(),
id: self.next_entity,
state,
+16 -2
View File
@@ -974,8 +974,8 @@ use horizon_client::mod_api::{
};
use horizon_client::mod_api::{AttachmentResult, Attachments, Holder, Suggestion};
pub(crate) use horizon_client::mod_api::{
NativeBlockRim, NativeColoredLight, NativeLootDrops, NativeMinimap, NativeMod,
NativePanelEvent, NativeScreen, NativeScreenView, NativeStack, NativeZoom,
ClientboundPlayPacket, NativeBlockRim, NativeColoredLight, NativeLootDrops, NativeMinimap,
NativeMod, NativePanelEvent, NativeScreen, NativeScreenView, NativeStack, NativeZoom,
};
use horizon_client::{
atlas_sources::qualified,
@@ -6820,6 +6820,20 @@ impl Game<'_> {
pub(super) fn native_net_from_server(&mut self, channel: &str, data: &[u8]) {
mod_api::dispatch_net_from_server(self, mod_api::mods(), channel, data);
}
/// A play packet from the server reached the window's game, decoded,
/// just before the game handles it (`Connection.genericsFtw` handing it
/// to `ClientPacketListener`): every packet of the play phase, custom
/// payloads included, in the order the server sent them. Packets the
/// network thread handles itself are not heard: keep-alives, bundle
/// delimiters, resource pack pushes and pops, cookies, server links,
/// transfers, disconnects, the configuration switch, `minecraft:brand`
/// and far terrain tiles; neither is the configuration phase.
/// Read-only: the game handles the packet as it would without the mod.
/// Client-side, on any server, single player's own included.
/// API @API:client_packet@.
pub(super) fn native_client_packet(&mut self, packet: &ClientboundPlayPacket) {
mod_api::dispatch_client_packet(self, mod_api::mods(), packet);
}
/// `dimension`'s level opened on the server (NeoForge's `LevelEvent.Load`,
/// posted in `MinecraftServer.createLevels`): before any of its chunks
/// (`native_chunk_load`), so a mod sets up what it keeps for the level
@@ -5,12 +5,14 @@
//! reaches `native_net_from_client` once the hub is back, and what a mod sends
//! a player leaves at the tick's end, from whichever hook sent it.
use super::{Game, lan::LanHost};
use horizon_client::mod_api::ServerboundPlayPacket;
use horizon_protocol::{
mod_net::{self, Message},
packets::{
configuration::CustomPayload,
play::{Clientbound as C, Serverbound as S},
},
quiet,
};
use std::collections::HashMap;
@@ -187,4 +189,97 @@ impl Game<'_> {
self.send_chat(message);
Ok(())
}
/// Sends `packet` to the server as it is, to any server, a vanilla one
/// included: a plugin channel's payload (`ServerboundCustomPayloadPacket`)
/// or any other play packet. Nothing counts it, and the game doesn't apply
/// it on its side: a packet that needs a sequence number, a signature or
/// the client's own state is the mod's to get right. An error when the
/// player isn't in a server's play state, and for a custom payload past
/// `ServerboundCustomPayloadPacket.MAX_PAYLOAD_SIZE`, which a server
/// disconnects for. API @API:send_packet@.
pub(super) fn native_send_packet(
&mut self,
packet: ServerboundPlayPacket,
) -> Result<(), String> {
let Some(remote) = self.remote.as_ref() else {
return Err("not connected to a server".into());
};
// A server that reconfigures the player has them out of the play state.
if !remote.joined || remote.reconfiguring.is_some() {
return Err("the player has not joined yet".into());
}
if let S::CustomPayload(payload) = &packet
&& payload.data.len() > quiet::MAX_PAYLOAD
{
return Err(format!("the payload is past {} bytes", quiet::MAX_PAYLOAD));
}
self.send(packet);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::super::{
Audio, Game, Options, Progress, Settings, execution_role::ExecutionRole, load_resources,
};
use super::{CustomPayload, S, quiet};
use horizon_protocol::{connection::Pipe, packets::configuration::Ping};
/// A mod's packet goes out only from a joined player, and a custom
/// payload only within vanilla's size limit.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn mod_packets_need_a_joined_player_and_a_payload_within_the_limit() {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES")
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
);
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!("no menu handoff"),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = ExecutionRole::Client;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
let payload = |len| {
S::CustomPayload(CustomPayload {
channel: "voicechat:request_secret".into(),
data: vec![0; len],
})
};
assert!(game.native_send_packet(payload(4)).is_err());
let (client, _server) = Pipe::pair();
game.connect_local(client, "PacketTest").unwrap();
assert!(game.native_send_packet(payload(4)).is_err());
game.remote.as_mut().unwrap().joined = true;
assert!(game.native_send_packet(payload(4)).is_ok());
assert!(game.native_send_packet(payload(quiet::MAX_PAYLOAD)).is_ok());
assert!(
game.native_send_packet(payload(quiet::MAX_PAYLOAD + 1))
.is_err()
);
assert!(game.native_send_packet(S::Pong(Ping { id: 7 })).is_ok());
}
}
+1 -1
View File
@@ -128,7 +128,7 @@ impl Game<'_> {
if tame && !food {
// doPlayerRide.
let id = self.mobs[index].id;
if !self.native_may_board(id) {
if !self.may_board() || !self.native_may_board(id) {
return true;
}
self.break_leash(id, true, false);
@@ -963,6 +963,7 @@ impl Game<'_> {
t.fuse,
t.power,
t.block_state_nbt(self.pack.assets.registry),
t.rotation,
uuid,
self.persist_raw(id),
);
@@ -1036,7 +1037,7 @@ impl Game<'_> {
&b.entity_type(),
b.position,
b.velocity,
b.yaw,
[b.yaw, b.pitch],
uuid,
self.persist_raw(id),
b.chest.then_some(&b.items[..]),
@@ -1269,6 +1270,7 @@ impl Game<'_> {
f.drop_item,
f.damage,
f.fall_distance,
f.rotation,
uuid,
);
let mut tag = super::persist::with_raw(modelled, f.saved.as_ref().map(|s| &s.1));
@@ -500,6 +500,7 @@ impl Game<'_> {
r.set_frame_generation(
s.horizon.frame_generation != horizon_client::graphics::FrameGeneration::Off,
);
r.set_hdr_lighting(s.horizon.hdr_lighting);
r.set_hdr(
s.horizon.hdr,
s.horizon.paper_white_nits() as f32,
+5 -2
View File
@@ -138,7 +138,9 @@ impl Game<'_> {
else {
return;
};
for m in &mut self.mobs {
let mut mobs = std::mem::take(&mut self.mobs);
let w = self.mob_world_base(&[]);
for m in &mut mobs {
if m.species() == Species::Panda
&& !m.is_baby()
&& m.on_ground
@@ -148,12 +150,13 @@ impl Game<'_> {
{
// LivingEntity.jumpFromGround.
m.needs_sync = true;
let power = f64::from(m.jump_power());
let power = f64::from(m.jump_power(&w));
if power > 1.0e-5 {
m.velocity.y = power.max(m.velocity.y);
}
}
}
self.mobs = mobs;
}
/// What a window draws of its pandas: the crumbs of a bite
/// (`addEatingParticles`) and the sneeze's particle.
+6 -2
View File
@@ -563,6 +563,7 @@ impl Game<'_> {
self.remember_uuid(self.next_entity, uuid, &tag);
let uuid = self.uuid_for(self.next_entity);
self.falling.push(FallingBlock {
rotation: [0.0, 0.0],
join: Default::default(),
id: self.next_entity,
state,
@@ -634,6 +635,7 @@ impl Game<'_> {
_ => 80,
};
self.tnts.push(super::tnt::PrimedTnt {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: entity_nbt::flag(&tag, "NoGravity"),
id,
@@ -979,7 +981,7 @@ impl Game<'_> {
&b.entity_type(),
b.position,
b.velocity,
b.yaw,
[b.yaw, b.pitch],
uuid,
self.persist.raw.get(&root),
b.chest.then_some(&b.items[..]),
@@ -1685,6 +1687,7 @@ impl Game<'_> {
f.drop_item,
f.damage,
f.fall_distance,
f.rotation,
uuid,
);
let mut tag = with_raw(tag, raw.as_ref());
@@ -1734,6 +1737,7 @@ impl Game<'_> {
t.fuse,
t.power,
t.block_state_nbt(self.pack.assets.registry),
t.rotation,
uuid,
self.persist.raw.get(&id),
);
@@ -1755,7 +1759,7 @@ impl Game<'_> {
&b.entity_type(),
b.position,
b.velocity,
b.yaw,
[b.yaw, b.pitch],
uuid,
self.persist.raw.get(&id),
b.chest.then_some(&b.items[..]),
@@ -150,6 +150,7 @@ mod tests {
fn orb_at(x: f64, y: f64, z: f64) -> Taken {
let at = horizon_core::geometry::Vec3::new(x, y, z);
Taken::Orb(Box::new(ExperienceOrb {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
id: 0,
+4 -2
View File
@@ -157,8 +157,10 @@ fn excused(g: &Guest, world: &World, entities: &[EntityState], guests: &[Guest])
};
let unsent = world
.block_log
.as_ref()
.is_some_and(|log| log.iter().any(|&(x, y, z)| near(at, [x, y, z], slack)));
.iter()
.flatten()
.chain(&world.block_log_held)
.any(|&(x, y, z)| near(at, [x, y, z], slack));
let pistons = world
.moving_pistons
.iter()
+53 -12
View File
@@ -640,7 +640,7 @@ impl Game<'_> {
fn apply_raid_action(&mut self, id: i32, action: Action) {
match action {
Action::Spawn { pos, wave, spawns } => self.spawn_wave(id, pos, wave, &spawns),
Action::Horn { pos, .. } => self.raid_horn(id, pos),
Action::Horn { pos, seed } => self.raid_horn(id, pos, seed),
Action::Remove { id: mob } => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) {
let s = raider::state_mut(m);
@@ -666,20 +666,61 @@ impl Game<'_> {
}),
}
}
/// `Raid.playSound`: the horn, 13 blocks away from the player toward the
/// wave, for players within 64 blocks or in the raid.
fn raid_horn(&mut self, id: i32, pos: [i32; 3]) {
let Some(p) = self.level_players().first().map(|p| p.position) else {
return;
};
/// `Raid.playSound`: the horn, 13 blocks from each player toward the wave,
/// for everyone within 64 blocks or in the raid. One seed for every packet
/// (`SoundSource.NEUTRAL`, volume 64, pitch 1).
fn raid_horn(&mut self, id: i32, pos: [i32; 3], seed: i64) {
let in_raid = self.raiding.raids.get(id).is_some_and(|r| r.bar.player);
let (rx, rz) = (f64::from(pos[0]) + 0.5, f64::from(pos[2]) + 0.5);
let distance = ((rx - p.x).powi(2) + (rz - p.z).powi(2)).sqrt();
if distance <= 64.0 || in_raid {
let k = 13.0 / distance;
let at = [p.x + k * (rx - p.x), p.y, p.z + k * (rz - p.z)];
// The raid tracks one participant (`bar.player`): the first player in
// the level. Everyone else is heard by distance alone.
let local_first = self.player.is_some();
if let Some(p) = self.player.as_ref()
&& let Some(at) = raid::horn_at(
[p.position.x, p.position.y, p.position.z],
rx,
rz,
in_raid && local_first,
)
{
// This window only. A positioned `play` would be broadcast to
// every guest on top of the packet each of them is sent.
let capture = self.audio.capture.take();
let neutral = horizon_client::settings::SOUND_SOURCES
.iter()
.position(|s| *s == "neutral")
.unwrap_or(0);
self.audio
.play("minecraft:event.raid.horn", Some(at), 64.0, 1.0);
.play_on(neutral, "minecraft:event.raid.horn", Some(at), 64.0, 1.0);
self.audio.capture = capture;
}
let Some(host) = self.lan.as_ref() else {
return;
};
let dim = host.hub.config.dimension.clone();
let spotted: Vec<(u32, [f64; 3], bool)> = host
.hub
.guests
.iter()
.map(|g| (g.conn, g.position, g.placed && g.dimension == dim))
.collect();
let neutral = horizon_client::settings::SOUND_SOURCES
.iter()
.position(|s| *s == "neutral")
.unwrap_or(0) as i32;
let mut first_guest = !local_first;
for (conn, position, here) in spotted {
if !here {
continue;
}
let participating = std::mem::replace(&mut first_guest, false) && in_raid;
let Some(at) = raid::horn_at(position, rx, rz, participating) else {
continue;
};
let packet =
host.hub
.sound_packet("minecraft:event.raid.horn", at, 64.0, 1.0, neutral, seed);
host.hub.send_to(conn, &packet);
}
}
/// `Raid.spawnGroup`: a wave of raiders at `pos`, the leader wearing the
+41 -3
View File
@@ -942,7 +942,6 @@ impl Game<'_> {
self.dropped.clear();
self.particles.clear();
self.pickups.clear();
self.orbs.clear();
self.falling.clear();
self.mining = None;
self.container = None;
@@ -1147,6 +1146,13 @@ impl Game<'_> {
// Kept mod block entity geometry belongs to the world leaving.
self.kept_block_entities.get_mut().clear();
self.clear_narrator();
// updateLevelInEngines(null): every sound stops and the music manager
// lets go of its track. Leaving a server comes straight here, not
// through finish_close_world, so the biome's ambient loop would
// otherwise play on over the title screen until the next world.
self.ambience.clear(&mut self.audio);
self.music.stop_playing(None, &mut self.audio);
self.audio.stop_all();
// A world is leaving either way: its icon attempt goes with it.
self.world_icon = None;
// Minecraft.disconnect: the client level goes.
@@ -1815,6 +1821,7 @@ impl Game<'_> {
if let Received::Event(Event::Packet(packet)) = &item
&& let C::ForgetLevelChunk(p) = &**packet
{
self.native_client_packet(packet);
forgotten.insert((p.pos[0], p.pos[1]));
continue;
}
@@ -1844,6 +1851,7 @@ impl Game<'_> {
continue;
}
};
self.native_client_packet(&packet);
if let C::LevelChunkWithLight(p) = *packet {
self.remote_chunk(&p, Some((&format, column)));
}
@@ -2563,6 +2571,7 @@ impl Game<'_> {
// Its value (ExperienceOrb.DATA_VALUE) follows in SetEntityData.
self.orbs.retain(|o| o.id != id as u32);
self.orbs.push(ExperienceOrb {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
id: id as u32,
@@ -2650,6 +2659,26 @@ impl Game<'_> {
return;
}
if let Some(m) = mob {
// Shulker.createInterpolationHandler is NO_OP, so a move packet
// is setPos at once. The 6-tick slide starts from the block
// the shulker occupied before this packet.
if m.species() == horizon_client::mobs::Species::Shulker {
r.stepped.remove(&id);
if path.is_some() {
horizon_client::mobs::shulker::client_set_pos(
m,
position[0],
position[1],
position[2],
);
}
if let Some((yaw, pitch)) = rotation {
m.yaw += wrap_degrees(yaw - m.yaw);
m.head_pitch = (pitch % 360.0).clamp(-90.0, 90.0);
}
m.on_ground = on_ground;
return;
}
// A living entity: LivingEntity.createInterpolationHandler's
// SteppedInterpolationHandler, over its type's update interval.
let entity = PosRot {
@@ -3066,6 +3095,7 @@ impl Game<'_> {
}
fn handle_packet(&mut self, packet: C) -> Result<()> {
self.native_client_packet(&packet);
match packet {
// ClientCommonPacketListenerImpl.handlePing runs on the game
// thread, after all preceding packets (especially teleports).
@@ -4134,6 +4164,11 @@ impl Game<'_> {
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
horizon_client::mobs::sync::entity_event(m, p.event as u8);
}
// LivingEntity.handleEntityEvent 46: portal particles from the
// old position to the new one (chorus fruit, an enderman).
if p.event == 46 {
self.entity_teleport_particles(p.id);
}
// LivingEntity.handleEntityEvent 60: the poof as the dead body goes.
if p.event == 60
&& let Some(m) = self.mob_index.get(&self.mobs, p.id as u32)
@@ -5542,6 +5577,7 @@ impl Game<'_> {
let at = Vec3::new(p[0], p[1], p[2]);
let bob_offset = self.world.random.next_float() * std::f32::consts::PI * 2.0;
self.dropped.push(ItemEntity {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
health: 5,
@@ -6754,8 +6790,9 @@ impl Game<'_> {
if m.species() == horizon_client::mobs::Species::Bat {
horizon_client::mobs::bat::setup_animation_states(m);
}
// The animation states a server computes in its own `ai_step`.
horizon_client::mobs::sync::client_tick(m, game_time);
// The animation states a server computes in its own `ai_step`,
// and the particles vanilla draws on the client.
horizon_client::mobs::sync::client_tick(m, game_time, &mut mob_fx);
match m.species() {
// WitherBoss.aiStep: the side heads turn toward their targets.
horizon_client::mobs::Species::Wither => {
@@ -6864,6 +6901,7 @@ impl Game<'_> {
// The server decides what an item rests on; this copy only moves between updates.
&[],
border,
Some(&self.res.motion().blocks),
&mut |splash| entered.push(splash),
);
}
@@ -782,21 +782,21 @@ mod tests {
return;
};
apply(&mut m, &[(20, s::BOOLEAN, Value::Bool(true))]);
client_tick(&mut m, 0);
client_tick(&mut m, 0, &mut Vec::new());
assert!((m.anim.v[tame::INTEREST] - 0.4).abs() < 1e-6);
assert_eq!(m.anim.vo[tame::INTEREST], 0.0);
client_tick(&mut m, 0);
client_tick(&mut m, 0, &mut Vec::new());
assert!((m.anim.v[tame::INTEREST] - 0.64).abs() < 1e-6);
assert!((m.anim.lerp(tame::INTEREST, 0.5) - 0.52).abs() < 1e-6);
for _ in 0..30 {
client_tick(&mut m, 0);
client_tick(&mut m, 0, &mut Vec::new());
}
assert!(m.anim.v[tame::INTEREST] > 0.999);
apply(&mut m, &[(20, s::BOOLEAN, Value::Bool(false))]);
client_tick(&mut m, 0);
client_tick(&mut m, 0, &mut Vec::new());
assert!(m.anim.v[tame::INTEREST] < 0.61);
for _ in 0..30 {
client_tick(&mut m, 0);
client_tick(&mut m, 0, &mut Vec::new());
}
assert!(m.anim.v[tame::INTEREST] < 0.001);
}
+295 -32
View File
@@ -6,7 +6,7 @@
use super::Game;
use horizon_client::{
first_person::Hand,
mobs::{Species, equine, llama},
mobs::{Mob, Species, armadillo, equine, llama, nautilus, render::DYE_NAMES},
};
impl Game<'_> {
@@ -30,13 +30,24 @@ impl Game<'_> {
{
return true;
}
// Armadillo.mobInteract: a rolled-up armadillo fails before
// AgeableMob/Animal.mobInteract, so food and age lock are not taken.
// A brush still reaches brushOffScute below.
if m.species() == Species::Armadillo && armadillo::client_rejects_food(m, item) {
return false;
}
// AgeableMob.mobInteract: a golden dandelion on a baby that can be age
// locked is SUCCESS on a client too (the server sets the lock).
if self.age_lock_takes(m, item) {
return true;
}
// Animal.mobInteract: its food is CONSUME on a client.
if m.kind.flags.animal && m.kind.food.iter().any(|f| f.as_str() == item) {
// Animal.mobInteract: its food is CONSUME on a client. A nautilus
// overrides mobInteract, and an untamed one only takes its taming
// items, so it answers in its own arm below.
if m.kind.flags.animal
&& !matches!(m.species(), Species::Nautilus | Species::ZombieNautilus)
&& m.kind.food.iter().any(|f| f.as_str() == item)
{
return true;
}
let sneaking = self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft);
@@ -57,7 +68,7 @@ impl Game<'_> {
// TamableAnimal (wolf, cat, parrot) on a client: CONSUME once tame.
Species::Wolf | Species::Cat | Species::Parrot => m.extra.tame,
// Allay.mobInteract: gives an item or takes it back.
Species::Allay => !item.is_empty() || m.extra.main_hand.is_some(),
Species::Allay => allay_takes(hand, item, m.extra.main_hand.is_some(), m.extra.flag),
Species::Cow | Species::Goat => item == "minecraft:bucket" && !baby,
Species::Mooshroom => {
!baby
@@ -67,9 +78,27 @@ impl Game<'_> {
) || m.extra.variant == 1
&& horizon_client::flowers::stew_effect(item).is_some())
}
Species::Sheep => item == "minecraft:shears" || item.ends_with("_dye"),
Species::Sheep => sheep_takes(item, m.extra.sheared, m.extra.color),
// SnowGolem.mobInteract: shears take the pumpkin off.
Species::SnowGolem => item == "minecraft:shears" && !m.extra.sheared,
// AbstractNautilus.mobInteract: an untamed adult only answers its
// taming items, a tame one is fed, mounted or opens its screen.
Species::Nautilus | Species::ZombieNautilus => {
let taming: Vec<String> = self
.res
.items()
.item_tags
.get("minecraft:nautilus_taming_items")
.map(|t| t.iter().cloned().collect())
.unwrap_or_default();
nautilus_takes(nautilus::is_food(m, item, &taming), m.extra.tame, baby)
}
// IronGolem.mobInteract.
Species::IronGolem => iron_golem_takes(item, m.health, m.attrs.max_health),
// CopperGolem.mobInteract.
Species::CopperGolem => {
copper_golem_takes(item, m.extra.main_hand.is_some(), self.golem_shearable(i))
}
// Bucketable.bucketMobPickup.
Species::Cod
| Species::Salmon
@@ -81,7 +110,9 @@ impl Game<'_> {
item == "minecraft:water_bucket"
|| !m.extra.age_locked && m.kind.food.iter().any(|f| f.as_str() == item)
}
Species::Armadillo => item == "minecraft:brush",
// Armadillo.brushOffScute fails on a baby, so its brush falls
// through to FAIL (scared) or Mob.mobInteract's PASS.
Species::Armadillo => item == "minecraft:brush" && !baby,
// ZombieVillager.mobInteract: CONSUME for a golden apple.
Species::ZombieVillager => item == "minecraft:golden_apple",
// Creeper.mobInteract: CREEPER_IGNITERS.
@@ -106,7 +137,7 @@ impl Game<'_> {
/// villager particle. The server draws it on its own level only, so no
/// packet brings it.
pub(super) fn remote_feed_particles(&mut self, i: usize, hand: Hand) {
let Some(m) = self.mobs.get(i).filter(|m| m.alive() && m.is_baby()) else {
let Some(m) = self.mobs.get(i).filter(|m| m.alive()) else {
return;
};
let stack = match hand {
@@ -114,32 +145,264 @@ impl Game<'_> {
Hand::Off => self.offhand.as_ref(),
};
let item = stack.map_or("", |s| &*s.item);
let ages = match m.species() {
// Llama.handleEating: wheat 10, hay 90.
s if llama::is_llama(s) => matches!(item, "minecraft:wheat" | "minecraft:hay_block"),
// Camel.handleEating: its food ages it by 10.
Species::Camel => m.kind.food.iter().any(|f| f.as_str() == item),
// AbstractHorse.handleEating: every food but the red mushroom.
Species::Horse | Species::Donkey | Species::Mule => matches!(
item,
"minecraft:wheat"
| "minecraft:sugar"
| "minecraft:hay_block"
| "minecraft:apple"
| "minecraft:carrot"
| "minecraft:golden_carrot"
| "minecraft:golden_apple"
| "minecraft:enchanted_golden_apple"
),
_ => false,
};
if ages {
let id = m.id;
self.particles_around_mob(
id,
"happy_villager",
horizon_client::particles::Burst::HORSE_EATING,
if !ages_on_food(m, item) {
return;
}
let id = m.id;
self.particles_around_mob(
id,
"happy_villager",
horizon_client::particles::Burst::HORSE_EATING,
);
}
}
/// `Sheep.mobInteract` on a client answers CONSUME for shears whether or not the
/// sheep is sheared (only the server checks `readyForShearing`).
/// `DyeItem.interactLivingEntity` takes a living, unsheared sheep of another
/// colour, and passes otherwise.
fn sheep_takes(item: &str, sheared: bool, color: u8) -> bool {
let dye = item
.strip_prefix("minecraft:")
.and_then(|name| name.strip_suffix("_dye"))
.and_then(|name| DYE_NAMES.iter().position(|&d| d == name));
item == "minecraft:shears" || !sheared && dye.is_some_and(|c| c != usize::from(color))
}
/// `AbstractNautilus.mobInteract` on a client: its food is answered while the
/// nautilus takes it (taming items for an untamed adult, its food once tamed),
/// and a tame adult answers every click because that is the mount.
fn nautilus_takes(food: bool, tame: bool, baby: bool) -> bool {
food || (tame && !baby)
}
/// `IronGolem.mobInteract`: an iron ingot heals 25 and only answers SUCCESS when
/// that changed the health, so a full golem leaves the click to the item.
fn iron_golem_takes(item: &str, health: f32, max_health: f32) -> bool {
item == "minecraft:iron_ingot" && health < max_health
}
/// `CopperGolem.mobInteract` on a client: an empty hand makes the golem throw
/// what it holds, and shears answer only when `readyForShearing`. Honeycomb and
/// axes return `PASS` here because the waxing and scraping run on the server.
fn copper_golem_takes(item: &str, holding: bool, shearable: bool) -> bool {
(item.is_empty() && holding) || (item == "minecraft:shears" && shearable)
}
/// `Allay.mobInteract` on the client's copy: a dancing allay with a
/// `#minecraft:duplicates_allays` item duplicates, an empty-handed one takes
/// what the used hand offers, and one that holds something gives it back only
/// to a **main hand** that is empty (vanilla compares the hand and returns
/// `PASS` otherwise, which lets the item be used).
fn allay_takes(hand: Hand, item: &str, holding: bool, dancing: bool) -> bool {
if dancing && item == "minecraft:amethyst_shard" {
return true;
}
if !holding {
return !item.is_empty();
}
hand == Hand::Main && item.is_empty()
}
/// `AbstractHorse.handleEating` (and the llama and camel variants): this
/// mouthful is the one that ages the baby up, which is the same moment the
/// level draws one happy villager particle.
///
/// `handleEating` gates both on `isBaby() && !isAgeLocked()` — a locked baby
/// does not grow, and the particle goes with the growth.
pub(super) fn ages_on_food(m: &Mob, item: &str) -> bool {
if !m.is_baby() || m.extra.age_locked || item.is_empty() {
return false;
}
match m.species() {
// Llama.handleEating: wheat 10, hay 90.
s if llama::is_llama(s) => matches!(item, "minecraft:wheat" | "minecraft:hay_block"),
// Camel.handleEating: its food ages it by 10.
Species::Camel => m.kind.food.iter().any(|f| f.as_str() == item),
// AbstractHorse.handleEating: every food but the red mushroom.
Species::Horse | Species::Donkey | Species::Mule => matches!(
item,
"minecraft:wheat"
| "minecraft:sugar"
| "minecraft:hay_block"
| "minecraft:apple"
| "minecraft:carrot"
| "minecraft:golden_carrot"
| "minecraft:golden_apple"
| "minecraft:enchanted_golden_apple"
),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
fn mob(kind: &str) -> Option<Mob> {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("entity-types.json").exists() {
return None;
}
let data = horizon_client::mobs::MobTypes::load(&root).ok()?;
let kind = Arc::new(data.types.get(kind)?.clone());
Some(Mob::new(
1,
kind,
horizon_core::geometry::Vec3::new(0.0, 4.0, 0.0),
0.0,
))
}
/// `Sheep.mobInteract` and `DyeItem.interactLivingEntity` on a client (#9121).
#[test]
fn a_sheep_takes_shears_always_and_dye_only_to_change_its_colour() {
for sheared in [false, true] {
assert!(
sheep_takes("minecraft:shears", sheared, 0),
"shears, sheared {sheared}"
);
}
assert!(sheep_takes("minecraft:red_dye", false, 0));
assert!(!sheep_takes("minecraft:white_dye", false, 0), "same colour");
assert!(sheep_takes("minecraft:white_dye", false, 14));
assert!(!sheep_takes("minecraft:red_dye", false, 14), "same colour");
assert!(!sheep_takes("minecraft:red_dye", true, 0), "sheared");
assert!(!sheep_takes("minecraft:stick", false, 0));
}
/// `AbstractNautilus.mobInteract` (#8595): an untamed adult takes its taming
/// items only, and a tame adult is mounted by any click.
#[test]
fn a_nautilus_answers_its_food_and_a_tame_one_is_ridden() {
assert!(
!nautilus_takes(false, false, false),
"untamed, taming item missing"
);
assert!(nautilus_takes(true, false, false), "a taming item");
assert!(
nautilus_takes(false, true, false),
"a tame adult is mounted"
);
assert!(!nautilus_takes(false, true, true), "a baby is not mounted");
// `AbstractNautilus.isFood`: taming items while untamed, its food after.
let Some(mut m) = mob("minecraft:nautilus") else {
return;
};
let taming = vec!["minecraft:pufferfish".to_string()];
m.extra.age = 0;
m.extra.tame = false;
assert!(nautilus::is_food(&m, "minecraft:pufferfish", &taming));
assert!(
!nautilus::is_food(&m, "minecraft:cod", &taming),
"cod tames nothing"
);
m.extra.tame = true;
assert!(
nautilus::is_food(&m, "minecraft:cod", &taming),
"a tame one eats cod"
);
}
/// `IronGolem.mobInteract` and `CopperGolem.mobInteract` (#8630): the click
/// has to end on the golem, or the client goes on to use the item and turns
/// to the off hand.
#[test]
fn the_golems_answer_the_click() {
assert!(iron_golem_takes("minecraft:iron_ingot", 50.0, 100.0));
assert!(
!iron_golem_takes("minecraft:iron_ingot", 100.0, 100.0),
"full"
);
assert!(
!iron_golem_takes("minecraft:stick", 50.0, 100.0),
"not ingot"
);
assert!(copper_golem_takes("", true, false), "takes its item back");
assert!(!copper_golem_takes("", false, false), "nothing to take");
assert!(
copper_golem_takes("minecraft:shears", false, true),
"shears"
);
assert!(
!copper_golem_takes("minecraft:shears", false, false),
"no flower"
);
assert!(
!copper_golem_takes("minecraft:honeycomb", false, true),
"waxing is server-only"
);
assert!(
!copper_golem_takes("minecraft:iron_axe", false, true),
"scraping too"
);
}
/// `Allay.mobInteract` (#8629): a holding allay gives its item back only to
/// an empty **main** hand, so an item in hand is still used and an empty
/// off hand does not take the item.
#[test]
fn a_holding_allay_only_answers_an_empty_main_hand() {
assert!(allay_takes(Hand::Main, "", true, false), "main hand, empty");
assert!(!allay_takes(Hand::Off, "", true, false), "off hand, empty");
assert!(
!allay_takes(Hand::Main, "minecraft:bread", true, false),
"item in hand"
);
assert!(!allay_takes(Hand::Off, "minecraft:bread", true, false));
assert!(
allay_takes(Hand::Main, "minecraft:stick", false, false),
"give it one"
);
assert!(
allay_takes(Hand::Off, "minecraft:stick", false, false),
"either hand gives"
);
assert!(
!allay_takes(Hand::Main, "", false, false),
"empty hand gives nothing"
);
assert!(
allay_takes(Hand::Main, "minecraft:amethyst_shard", true, true),
"a dancing allay duplicates"
);
}
/// `AbstractHorse.handleEating` draws the particle only while the baby can
/// still grow: `isBaby() && !isAgeLocked()` (#8590).
#[test]
fn a_locked_baby_draws_no_happy_villager() {
let Some(mut horse) = mob("minecraft:horse") else {
return;
};
horse.extra.age = -24000;
assert!(ages_on_food(&horse, "minecraft:wheat"));
horse.extra.age_locked = true;
assert!(!ages_on_food(&horse, "minecraft:wheat"), "locked");
horse.extra.age_locked = false;
horse.extra.age = 0;
assert!(!ages_on_food(&horse, "minecraft:wheat"), "adult");
horse.extra.age = -24000;
assert!(!ages_on_food(&horse, "minecraft:red_mushroom"), "no food");
if let Some(mut llama) = mob("minecraft:llama") {
llama.extra.age = -24000;
assert!(ages_on_food(&llama, "minecraft:hay_block"));
llama.extra.age_locked = true;
assert!(!ages_on_food(&llama, "minecraft:hay_block"), "locked llama");
}
if let Some(mut camel) = mob("minecraft:camel")
&& camel.kind.food.iter().any(|f| f == "minecraft:cactus")
{
camel.extra.age = -24000;
assert!(ages_on_food(&camel, "minecraft:cactus"));
camel.extra.age_locked = true;
assert!(!ages_on_food(&camel, "minecraft:cactus"), "locked camel");
}
}
}
@@ -443,6 +443,11 @@ impl Game<'_> {
}
/// Every object a server sent (a new connection, level or dimension).
/// Experience orbs too: `ClientPacketListener.handleRespawn` replaces the
/// `ClientLevel` on a dimension change, and `Hub::change_level` does not
/// remove the old level's entities one by one. An orb kept here follows
/// the player (`ExperienceOrb.tick` on the client) and is never
/// `playerTouch`ed.
pub(super) fn clear_remote_objects(&mut self) {
self.text_displays.clear();
self.interactions.clear();
@@ -454,6 +459,7 @@ impl Game<'_> {
self.cushions.clear();
self.item_frames.clear();
self.paintings.clear();
self.orbs.clear();
}
pub(super) fn remove_remote_object(&mut self, id: u32) {
@@ -344,6 +344,7 @@ impl Game<'_> {
match kind {
"minecraft:evoker_fangs" => self.insert_fangs(id, position, yaw, 0, 0),
"minecraft:tnt" => self.tnts.push(super::tnt::PrimedTnt {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
id,
@@ -359,6 +360,7 @@ impl Game<'_> {
}),
// FallingBlockEntity.recreateFromPacket: the block state is the data.
"minecraft:falling_block" => self.falling.push(FallingBlock {
rotation: [0.0, 0.0],
join: Default::default(),
id,
state: BlockStateId(data.max(0) as u32),
@@ -663,17 +665,18 @@ impl Game<'_> {
// fuse down (the swell and flash follow it) between the server's
// updates (every 10 ticks), and a client discards it when the fuse
// runs out; only the server explodes it.
let (world, collision, assets, facts, fluids, border) = (
let (world, collision, assets, facts, fluids, border, blocks) = (
&self.world,
self.collision,
&self.pack.assets,
self.res.facts(),
&self.res.motion().fluids,
Some(self.border.geometry()),
Some(&self.res.motion().blocks),
);
self.tnts.retain_mut(|t| {
// A fluid lookup that fails leaves it where its move put it.
let _ = t.tick(world, collision, assets, facts, fluids, border);
let _ = t.tick(world, collision, assets, facts, fluids, border, blocks);
t.fuse > 0
});
// PrimedTnt.tick: while the fuse is still lit, a client adds one SMOKE
+21 -10
View File
@@ -293,9 +293,15 @@ impl Game<'_> {
table.len() / 3,
started_ids.elapsed().as_millis()
);
if let Some((programs, notes)) =
renderer.set_shader_pack(dimension, Some(post), &table)
{
let report = renderer
.set_shader_pack(dimension, Some(post), &table)
.map_err(|refused| {
for note in refused.notes {
log::warn!("shader pack {name}: {note}");
}
refused.reason
})?;
if let Some((programs, notes)) = report {
for note in notes {
log::warn!("shader pack {name}: {note}");
}
@@ -310,12 +316,15 @@ impl Game<'_> {
Err(e) => {
self.pack_ids = None;
log::warn!("shader pack {name}: {e}");
renderer.set_shader_pack(dimension, None, &[]);
// Without a pack this can't fail.
let _ = renderer.set_shader_pack(dimension, None, &[]);
}
}
Ok(())
});
if let Err(panic) = loaded {
self.shader_pack_failed(name, dimension, &panic);
// A pack that can't run is turned off like one that panics.
if let Err(error) = loaded.flatten() {
self.shader_pack_failed(name, dimension, &error);
}
}
let lerp = |(old, new): (f32, f32)| old + (new - old) * partial;
@@ -445,14 +454,16 @@ impl Game<'_> {
}
}
/// Turns off the pack `name` after loading it panicked: nothing runs in its place, it is no
/// longer the picked pack (so the next start does not load it again), and the chat says so.
/// Turns off the pack `name` after loading it panicked or failed: nothing runs in its place,
/// it is no longer the picked pack (so the next start does not load it again), and the chat
/// says so.
fn shader_pack_failed(&mut self, name: &str, dimension: i32, error: &str) {
log::error!("shader pack {name}: turned off: {error}");
self.pack_ids = None;
if let Some(renderer) = &mut self.renderer {
// Marks this dimension as loaded, without a pack, so it is not tried again.
renderer.set_shader_pack(dimension, None, &[]);
// Marks this dimension as loaded, without a pack, so it is not tried again (without
// a pack this can't fail).
let _ = renderer.set_shader_pack(dimension, None, &[]);
}
if self.settings.horizon.shader_pack == name {
self.settings.horizon.shader_pack.clear();
+100 -10
View File
@@ -15,6 +15,8 @@ use horizon_world::level::flags as level_flags;
#[derive(Clone, Debug)]
pub(super) struct PrimedTnt {
/// `Entity.yRot` and `xRot` (`/tp`, `/data get ... Rotation`).
pub rotation: [f32; 2],
/// When it joined its list (`horizon_client::join_seq`); set by the list.
pub(super) join: horizon_client::join_seq::JoinSeq,
/// The entity id a server tracks it by (0 until it is tracked).
@@ -66,6 +68,7 @@ impl PrimedTnt {
facts: &horizon_client::blocks::BlockFacts,
fluids: &horizon_client::motion::FluidResources,
border: Option<&horizon_core::geometry::border::BorderGeometry>,
blocks: Option<&horizon_world::movement_properties::BoundMovementProperties>,
) -> Result<Vec<BlockPos>> {
self.previous = self.position;
self.velocity.y -= self.get_gravity();
@@ -109,6 +112,13 @@ impl PrimedTnt {
&*facts.get(below).name == "minecraft:slime_block",
);
}
// Entity.move's last step: soul sand and honey slow the TNT's
// horizontal speed. `blocks` is None only for the isolated unit tests.
if let Some(blocks) = blocks {
let factor =
horizon_client::motion::block_speed_factor_at(world, blocks, self.position, None);
self.velocity = horizon_world::entity_move::scale_by_block_speed(self.velocity, factor);
}
// Entity.applyEffectsFromBlocks: a bubble column lifts or drags it, and the
// columns it is at the top of send their surface particles.
let mut tops = Vec::new();
@@ -225,6 +235,7 @@ impl Game<'_> {
// An id from the entity counter at once, so a command can name it.
self.next_entity += 1;
self.tnts.push(PrimedTnt {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
id: self.next_entity,
@@ -254,6 +265,7 @@ impl Game<'_> {
self.res.facts(),
&self.res.motion().fluids,
Some(self.border.geometry()),
Some(&self.res.motion().blocks),
)?;
let snow = (t.previous, t.position, t.bounds(), t.velocity);
self.entity_powder_snow_particles(snow.0, snow.1, snow.2, snow.3, false);
@@ -537,6 +549,7 @@ mod tests {
]))
.unwrap();
let mut tnt = PrimedTnt {
rotation: [0.0, 0.0],
join: Default::default(),
id: 1,
position: Vec3::ZERO,
@@ -564,6 +577,7 @@ mod tests {
tnt.fuse,
tnt.power,
tnt.block_state_nbt(&registry),
[0.0, 0.0],
[221, 0, 0, 1],
Some(&raw),
);
@@ -597,6 +611,7 @@ mod tests {
use horizon_core::geometry::Direction;
use horizon_world::piston_push::bounce_velocity;
let mut tnt = PrimedTnt {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
id: 0,
@@ -639,6 +654,7 @@ mod tests {
for bordered in [false, true] {
let at = Vec3::new(24.0, 4.0, 20.5);
let mut tnt = PrimedTnt {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
id: 0,
@@ -653,7 +669,7 @@ mod tests {
block_state: None,
};
let border = bordered.then_some(&border);
tnt.tick(&world, &collision, &assets, &facts, &fluids, border)
tnt.tick(&world, &collision, &assets, &facts, &fluids, border, None)
.unwrap();
let east = tnt.bounds().max_position().x;
if bordered {
@@ -667,6 +683,77 @@ mod tests {
}
}
/// `Entity.move`'s last step, `getBlockSpeedFactor`: primed TNT sliding
/// over soul sand or honey keeps 40% of the horizontal speed it keeps over
/// stone. Skips quietly without the vanilla data (CI has none).
#[test]
fn primed_tnt_on_soul_sand_or_honey_is_slowed_by_the_move() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = horizon_client::assets::Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let facts = horizon_client::blocks::BlockFacts::load(&root, assets.registry).unwrap();
let fluids = horizon_client::motion::FluidResources::new(&assets, &facts).unwrap();
let collision = horizon_client::collisions::load(&root, assets.registry).unwrap();
let motion = horizon_client::motion::MotionResources::load(&root, &assets).unwrap();
let mut after = Vec::new();
// The tops of the blocks: stone 1, soul sand 7/8, honey 15/16.
for (block, top) in [
("minecraft:stone", 4.0),
("minecraft:soul_sand", 3.875),
("minecraft:honey_block", 3.9375),
] {
let mut world = horizon_client::world::World::test_scene(&assets, &render, 3).unwrap();
let id = assets.registry.default_state(block).unwrap();
for x in 18..=23 {
for z in 19..=21 {
world.set(x, 3, z, id).unwrap();
}
}
let at = Vec3::new(19.5, top, 20.5);
let mut tnt = PrimedTnt {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
id: 0,
position: at,
previous: at,
velocity: Vec3::new(0.2, 0.0, 0.0),
fuse: 80,
on_ground: true,
owner: None,
guest: None,
power: 4.0,
block_state: None,
};
tnt.tick(
&world,
&collision,
&assets,
&facts,
&fluids,
None,
Some(&motion.blocks),
)
.unwrap();
after.push(tnt.velocity.x);
}
let (stone, soul, honey) = (after[0], after[1], after[2]);
assert!(stone > 0.0);
assert!(
(soul - stone * f64::from(0.4_f32)).abs() < 1.0e-9,
"soul sand {soul} vs stone {stone}"
);
assert!(
(honey - stone * f64::from(0.4_f32)).abs() < 1.0e-9,
"honey {honey} vs stone {stone}"
);
}
#[test]
fn primed_tnt_follows_water_currents_after_moving() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
@@ -695,6 +782,7 @@ mod tests {
world.set(21, 4, 20, flowing).unwrap();
let at = Vec3::new(20.5, 4.0, 20.5);
let mut tnt = PrimedTnt {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
id: 0,
@@ -708,7 +796,7 @@ mod tests {
power: 4.0,
block_state: None,
};
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
tnt.tick(&world, &collision, &assets, &facts, &fluids, None, None)
.unwrap();
assert_eq!(
tnt.position.x, at.x,
@@ -720,14 +808,14 @@ mod tests {
tnt.velocity
);
assert_eq!(tnt.fuse, 79);
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
tnt.tick(&world, &collision, &assets, &facts, &fluids, None, None)
.unwrap();
assert!((tnt.position.x - at.x - 0.014).abs() < 1e-12);
assert!(tnt.on_ground);
tnt.position = at;
tnt.velocity = Vec3::ZERO;
tnt.fuse = 1;
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
tnt.tick(&world, &collision, &assets, &facts, &fluids, None, None)
.unwrap();
assert_eq!(tnt.fuse, 0);
assert_eq!(
@@ -738,7 +826,7 @@ mod tests {
tnt.position = at;
tnt.velocity = Vec3::ZERO;
tnt.fuse = 80;
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
tnt.tick(&world, &collision, &assets, &facts, &fluids, None, None)
.unwrap();
assert_eq!(
tnt.velocity.x, 0.0,
@@ -754,7 +842,7 @@ mod tests {
.unwrap();
tnt.on_ground = false;
tnt.velocity = Vec3::ZERO;
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
tnt.tick(&world, &collision, &assets, &facts, &fluids, None, None)
.unwrap();
assert!(
tnt.position.y < at.y && tnt.velocity.y < 0.0,
@@ -799,6 +887,7 @@ mod tests {
// (Entity.handleOnInsideBubbleColumn / handleOnAboveBubbleColumn).
let at = Vec3::new(20.5, 6.0, 20.5);
let mut tnt = PrimedTnt {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
id: 0,
@@ -813,7 +902,7 @@ mod tests {
block_state: None,
};
for _ in 0..10 {
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
tnt.tick(&world, &collision, &assets, &facts, &fluids, None, None)
.unwrap();
}
assert!(
@@ -834,7 +923,7 @@ mod tests {
tnt.velocity = Vec3::ZERO;
tnt.on_ground = false;
for _ in 0..4 {
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
tnt.tick(&world, &collision, &assets, &facts, &fluids, None, None)
.unwrap();
}
assert!(
@@ -879,6 +968,7 @@ mod tests {
world.set(20, 2, 20, soul_sand).unwrap();
let at = Vec3::new(20.5, 6.0, 20.5);
let primed = || PrimedTnt {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
id: 0,
@@ -896,7 +986,7 @@ mod tests {
let mut tnt = primed();
for _ in 0..30 {
tops.extend(
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
tnt.tick(&world, &collision, &assets, &facts, &fluids, None, None)
.unwrap(),
);
}
@@ -910,7 +1000,7 @@ mod tests {
let mut tnt = primed();
for _ in 0..10 {
assert!(
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
tnt.tick(&world, &collision, &assets, &facts, &fluids, None, None)
.unwrap()
.is_empty()
);
+147 -96
View File
@@ -10,7 +10,7 @@ use horizon_client::{
enchant::EffectContext,
items::ItemStack,
mesher::Vertex,
mobs::{Damage, DamageKind, Who, anim::Poses},
mobs::{Damage, DamageKind, Species, Who, anim::Poses},
projectiles::ProjectileWorld,
};
use horizon_core::{
@@ -45,8 +45,10 @@ pub(super) struct ThrownTrident {
pub(super) fire_ticks: i32,
/// Water membership from the preceding `baseTick` (not updated while stuck).
was_touching_water: bool,
/// `Entity.random`, used for the extinguishing sound.
/// `Entity.random`, used for the extinguishing sound and a breeze's turn.
random: horizon_core::random::LegacyRandom,
/// `Projectile.lastDeflectedBy`: the breeze that already turned this trident.
deflected_by: Option<u32>,
/// `Entity.tickCount`: `ProjectileUtil.computeMargin` grows with it.
age: u32,
/// The entity id a server sent as its owner (the one Loyalty returns it to).
@@ -133,6 +135,7 @@ impl ThrownTrident {
age: 0,
owner: 0,
guest: None,
deflected_by: None,
}
}
/// `ThrownTrident.tick`'s Loyalty return: pulled up and towards the
@@ -203,6 +206,15 @@ impl ThrownTrident {
}
}
const HALF: f64 = 0.25;
/// `Breeze.PROJECTILE_DEFLECTION`: `ProjectileDeflection.REVERSE` at power
/// `(1, 1, 1)`. Each axis is multiplied by `-0.5`, and the yaw turns by
/// `170 + nextFloat * 20`.
fn breeze_deflect(velocity: Vec3, yaw: f32, jitter: f32) -> (Vec3, f32) {
(
Vec3::new(velocity.x * -0.5, velocity.y * -0.5, velocity.z * -0.5),
yaw + 170.0 + jitter * 20.0,
)
}
/// The entry distance of a segment into a box, if it crosses it.
fn segment_box(from: Vec3, d: Vec3, b: Aabb) -> Option<f64> {
let (lo, hi) = (b.min_position(), b.max_position());
@@ -465,6 +477,7 @@ impl Game<'_> {
age: 0,
owner: 0,
guest: self.lan_acting,
deflected_by: None,
});
self.player_projectile_shot();
self.audio
@@ -686,110 +699,136 @@ impl Game<'_> {
// No damage, sound or deflection: it flies on.
} else if let Some((id, d)) = hit_mob {
// The trident stops where it met the box.
t.position = Vec3::new(
let at = Vec3::new(
t.position.x + t.velocity.x * d,
t.position.y + t.velocity.y * d,
t.position.z + t.velocity.z * d,
);
// ThrownTrident.getDamage: `EnchantmentHelper.modifyDamage` on 8
// (impaling hits `#sensitive_to_impaling`, the aquatic tag).
let victim = self
t.position = at;
// `Breeze.deflection`: a trident is not a wind charge, so
// `PROJECTILE_DEFLECTION` turns it around. `onHit` does not
// run, so the breeze takes no damage and Channeling never fires.
let breeze = self
.mobs
.iter()
.find(|m| m.id == id)
.map(|m| m.kind.id.clone())
.or_else(|| {
self.boats
.iter()
.find(|b| b.id == id)
.map(|b| b.entity_type())
})
.or_else(|| {
self.carts
.iter()
.find(|c| c.id == id)
.map(|c| c.kind.id().to_owned())
});
let ctx = EffectContext {
this: victim.as_deref(),
attacker: Some("minecraft:player"),
direct_attacker: Some("minecraft:trident"),
is_direct: true,
damage_type: Some("minecraft:trident"),
items: Some(world_items!(self)),
..Default::default()
};
let damage = world_stations!(self).enchantments.modify_value(
Some(&t.stack),
"minecraft:damage",
&ctx,
&mut self.world.random,
8.0,
);
t.dealt_damage = true;
let mut hurt_at = None;
let aim = self.attacker_aim(Some(Who::Player));
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
let from = Some([t.position.x, t.position.y, t.position.z]);
let hurt = m.damage_projectile(
damage,
Damage {
kind: DamageKind::Arrow,
attacker: Some(Who::Player),
from,
},
aim,
[t.velocity.x, t.velocity.z],
);
if hurt {
hurt_at = Some([m.position.x, m.position.y, m.position.z]);
.find(|m| m.id == id && m.alive() && m.species() == Species::Breeze);
if let Some(breeze) = breeze {
if t.deflected_by != Some(id) {
let sound_at =
[breeze.position.x, breeze.position.y, breeze.position.z];
self.audio.play(
"minecraft:entity.breeze.deflect",
Some(sound_at),
1.0,
1.0,
);
let jitter = t.random.next_float();
let (velocity, yaw) = breeze_deflect(t.velocity, t.yaw, jitter);
t.velocity = velocity;
t.yaw = yaw;
t.deflected_by = Some(id);
}
}
// ThrownTrident.onHitEntity on a boat or minecart:
// VehicleEntity.hurtServer takes the damage.
let creative = t.guest.is_none() && self.creative;
if let Some(i) = self.boats.iter().position(|b| b.id == id) {
self.hurt_boat(i, damage, creative);
} else if let Some(i) = self.carts.iter().position(|c| c.id == id) {
self.hurt_cart(i, damage, creative, false);
}
// `minecraft:post_attack` of Channeling, on the victim.
if let Some(at) = hurt_at
&& t.stack.enchantment_level("minecraft:channeling") > 0
{
self.channel_lightning(at, t.guest);
}
if hurt_at.is_some()
&& let Some(m) = self.mobs.iter().find(|m| m.id == id)
{
// LivingEntity.hurtServer: PLAYER_HURT_ENTITY.
let victim = self.mob_entity(m);
let direct = horizon_client::advancements::EntityView {
kind: "minecraft:trident".into(),
position: [t.position.x, t.position.y, t.position.z],
} else {
// ThrownTrident.getDamage: `EnchantmentHelper.modifyDamage` on 8
// (impaling hits `#sensitive_to_impaling`, the aquatic tag).
let victim = self
.mobs
.iter()
.find(|m| m.id == id)
.map(|m| m.kind.id.clone())
.or_else(|| {
self.boats
.iter()
.find(|b| b.id == id)
.map(|b| b.entity_type())
})
.or_else(|| {
self.carts
.iter()
.find(|c| c.id == id)
.map(|c| c.kind.id().to_owned())
});
let ctx = EffectContext {
this: victim.as_deref(),
attacker: Some("minecraft:player"),
direct_attacker: Some("minecraft:trident"),
is_direct: true,
damage_type: Some("minecraft:trident"),
items: Some(world_items!(self)),
..Default::default()
};
self.thrower_hit(t.guest, &victim, direct, damage);
let damage = world_stations!(self).enchantments.modify_value(
Some(&t.stack),
"minecraft:damage",
&ctx,
&mut self.world.random,
8.0,
);
t.dealt_damage = true;
let mut hurt_at = None;
let aim = self.attacker_aim(Some(Who::Player));
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
let from = Some([t.position.x, t.position.y, t.position.z]);
let hurt = m.damage_projectile(
damage,
Damage {
kind: DamageKind::Arrow,
attacker: Some(Who::Player),
from,
},
aim,
[t.velocity.x, t.velocity.z],
);
if hurt {
hurt_at = Some([m.position.x, m.position.y, m.position.z]);
}
}
// ThrownTrident.onHitEntity on a boat or minecart:
// VehicleEntity.hurtServer takes the damage.
let creative = t.guest.is_none() && self.creative;
if let Some(i) = self.boats.iter().position(|b| b.id == id) {
self.hurt_boat(i, damage, creative);
} else if let Some(i) = self.carts.iter().position(|c| c.id == id) {
self.hurt_cart(i, damage, creative, false);
}
// `minecraft:post_attack` of Channeling, on the victim.
if let Some(at) = hurt_at
&& t.stack.enchantment_level("minecraft:channeling") > 0
{
self.channel_lightning(at, t.guest);
}
if hurt_at.is_some()
&& let Some(m) = self.mobs.iter().find(|m| m.id == id)
{
// LivingEntity.hurtServer: PLAYER_HURT_ENTITY.
let victim = self.mob_entity(m);
let direct = horizon_client::advancements::EntityView {
kind: "minecraft:trident".into(),
position: [t.position.x, t.position.y, t.position.z],
..Default::default()
};
self.thrower_hit(t.guest, &victim, direct, damage);
}
// Projectile.onHit: the landing is heard, at the hit.
self.projectile_event(
"minecraft:projectile_land",
Some(Who::Player),
[t.position.x, t.position.y, t.position.z],
);
self.audio.play(
"minecraft:item.trident.hit",
Some([t.position.x, t.position.y, t.position.z]),
1.0,
1.0,
);
// ProjectileDeflection.REVERSE (half, reversed) scaled by
// (0.02, 0.2, 0.02).
t.velocity = Vec3::new(
t.velocity.x * (-0.02 * 0.5),
t.velocity.y * (-0.2 * 0.5),
t.velocity.z * (-0.02 * 0.5),
);
}
// Projectile.onHit: the landing is heard, at the hit.
self.projectile_event(
"minecraft:projectile_land",
Some(Who::Player),
[t.position.x, t.position.y, t.position.z],
);
self.audio.play(
"minecraft:item.trident.hit",
Some([t.position.x, t.position.y, t.position.z]),
1.0,
1.0,
);
// ProjectileDeflection.REVERSE (half, reversed) scaled by
// (0.02, 0.2, 0.02).
t.velocity = Vec3::new(
t.velocity.x * (-0.02 * 0.5),
t.velocity.y * (-0.2 * 0.5),
t.velocity.z * (-0.02 * 0.5),
);
} else {
// AbstractArrow.tick: a zero-width ray clip of the movement
// (`ClipContext.Block.COLLIDER`, no fluids), then
@@ -1028,6 +1067,18 @@ horizon_client::impl_joined!(ThrownTrident);
#[cfg(test)]
mod tests {
use super::breeze_deflect;
use horizon_core::geometry::Vec3;
/// `ProjectileDeflection.REVERSE` at power 1: half speed, the other way,
/// and a turn of 170 degrees plus a fifth of the random draw.
#[test]
fn a_breeze_turns_a_trident_around_at_half_speed() {
let (velocity, yaw) = breeze_deflect(Vec3::new(4.0, -2.0, 1.0), 10.0, 0.25);
assert_eq!(velocity, Vec3::new(-2.0, 1.0, -0.5));
assert_eq!(yaw, 185.0);
}
/// A stack can reach its final durability point after use began. The release
/// guard is independent of admission, and short charges still do nothing.
#[test]
@@ -0,0 +1,203 @@
//! The writers `/tp` and `/rotate` share with a vehicle's riders: a vehicle
//! that is teleported takes its passengers along in the same operation
//! (`Entity.teleportSameDimension` -> `teleportPassengers` with
//! `calculatePassengerTransition`), and `/rotate` turns an entity without
//! teleporting it (`RotateCommand` -> `Entity.forceSetRotation`).
use super::Game;
use super::commands::Target;
use horizon_core::geometry::Vec3;
use horizon_protocol::packets::play::{self, Clientbound as C};
/// `Entity.calculatePassengerTransition` with no relative axes: the passenger
/// keeps its offset from the vehicle.
pub(super) fn passenger_destination(old_vehicle: Vec3, new_vehicle: Vec3, passenger: Vec3) -> Vec3 {
Vec3::new(
new_vehicle.x + (passenger.x - old_vehicle.x),
new_vehicle.y + (passenger.y - old_vehicle.y),
new_vehicle.z + (passenger.z - old_vehicle.z),
)
}
/// `PositionMoveRotation.calculateAbsolute` then `Entity.setXRot`: the pitch
/// is clamped to -90..90 first (so 360 and +infinity are +90), and NaN is
/// discarded (`None`).
pub(super) fn absolute_pitch(pitch: f32) -> Option<f32> {
let clamped = pitch.clamp(-90.0, 90.0);
(!clamped.is_nan()).then(|| (clamped % 360.0).clamp(-90.0, 90.0))
}
/// The entity id a vehicle's riders point at, for the targets that carry some.
fn vehicle_id(t: Target) -> Option<u32> {
match t {
Target::Boat(id) | Target::Cart(id) | Target::Mob(id) => Some(id),
_ => None,
}
}
impl Game<'_> {
/// `Entity.teleportPassengers`: after `t` moved from `from` to `to`, its
/// riders move by the same offset and stay seated, recursively. The
/// player in place and the hub's guests (a guest also gets its own
/// position packet, as `ServerPlayer.teleport` sends one for a passenger).
pub(super) fn teleport_passengers(&mut self, t: Target, from: [f64; 3], to: [f64; 3]) {
let Some(id) = vehicle_id(t) else {
return;
};
let (old, new) = (
Vec3::new(from[0], from[1], from[2]),
Vec3::new(to[0], to[1], to[2]),
);
self.move_riders(id, old, new);
}
fn move_riders(&mut self, vehicle: u32, old: Vec3, new: Vec3) {
let riders: Vec<u32> = self
.mobs
.iter()
.filter(|m| m.extra.mount == Some(vehicle))
.map(|m| m.id)
.collect();
for id in riders {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.position = passenger_destination(old, new, m.position);
m.previous = m.position;
}
self.move_riders(id, old, new);
}
if self.riding == Some(vehicle)
&& let Some(p) = self.player.as_mut()
{
p.position = passenger_destination(old, new, p.position);
self.previous_position = p.position;
}
let [yaw, pitch] = self.vehicle_rotation(vehicle);
if let Some(host) = self.lan.as_mut() {
for i in 0..host.hub.guests.len() {
if host.hub.guests[i].vehicle != Some(vehicle) {
continue;
}
// Entity.sendTeleportTransitionToRidingPlayers: the rider's
// client moves the vehicle it drives itself, so it hears the
// vehicle's teleport (else its next MoveVehicle undoes it).
host.hub.guests[i].send(&C::TeleportEntity(play::TeleportEntity {
id: vehicle as i32,
position: [new.x, new.y, new.z],
delta: [0.0; 3],
yaw,
pitch,
relatives: 0,
on_ground: false,
}));
let g = host.hub.guests[i].position;
let at = passenger_destination(old, new, Vec3::new(g[0], g[1], g[2]));
host.hub.teleport_keeping_motion(i, [at.x, at.y, at.z]);
}
}
}
/// A vehicle's yaw and pitch, for the riders' teleport packet.
fn vehicle_rotation(&self, id: u32) -> [f32; 2] {
if let Some(b) = self.boats.iter().find(|b| b.id == id) {
[b.yaw, 0.0]
} else if let Some(c) = self.carts.iter().find(|c| c.id == id) {
[c.yaw, c.pitch]
} else if let Some(m) = self.mobs.iter().find(|m| m.id == id) {
[m.yaw, m.head_pitch]
} else {
[0.0, 0.0]
}
}
/// `Entity.forceSetRotation`: yaw, head yaw, pitch and their old values
/// only. Position, motion, the vehicle and the riders stay.
pub(super) fn set_rotation(&mut self, t: Target, rotation: [f32; 2]) {
let _ = self.in_target_level(t, |g| g.set_rotation_in_level(t, rotation));
}
fn set_rotation_in_level(&mut self, t: Target, [yaw, pitch]: [f32; 2]) {
let pitch = absolute_pitch(pitch);
match t {
Target::Guest(_) => {
self.for_player(t, |g| {
g.set_rotation_in_level(Target::Player, [yaw, pitch.unwrap_or(f32::NAN)])
});
}
Target::Player => {
if yaw.is_finite() {
self.yaw = yaw;
}
if let Some(pitch) = pitch {
self.pitch = pitch;
}
// A server's player hears the turn alone when its turn ends.
self.rotation_forced = self.lan_acting.is_some();
}
Target::Mob(id) => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
if yaw.is_finite() {
m.yaw = yaw;
m.body_yaw = yaw;
m.head_yaw = yaw;
}
if let Some(pitch) = pitch {
m.head_pitch = pitch;
}
m.previous_yaw = m.yaw;
m.previous_head_pitch = m.head_pitch;
}
}
Target::Other(id) => {
if let Some(o) = self.others.iter_mut().find(|o| o.id == id) {
if yaw.is_finite() {
o.yaw = yaw;
}
if let Some(pitch) = pitch {
o.pitch = pitch;
}
}
}
Target::Stand(id) if yaw.is_finite() => {
if let Some(a) = self.armor_stands.iter_mut().find(|a| a.id == id) {
a.yaw = yaw;
}
}
Target::Boat(id) if yaw.is_finite() => {
if let Some(b) = self.boats.iter_mut().find(|b| b.id == id) {
b.yaw = yaw;
}
}
Target::Cart(id) if yaw.is_finite() => {
if let Some(c) = self.carts.iter_mut().find(|c| c.id == id) {
c.yaw = yaw;
}
}
Target::Fangs(id) if yaw.is_finite() => {
if let Some(f) = self.fangs.list.iter_mut().find(|f| f.id == id) {
f.yaw = yaw;
}
}
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn passenger_keeps_its_offset() {
let at = passenger_destination(
Vec3::new(0.0, 64.0, 0.0),
Vec3::new(100.0, 70.0, -20.0),
Vec3::new(0.0, 64.5, 0.25),
);
assert_eq!((at.x, at.y, at.z), (100.0, 70.5, -19.75));
}
#[test]
fn pitch_clamps_before_the_setter() {
assert_eq!(absolute_pitch(360.0), Some(90.0));
assert_eq!(absolute_pitch(-360.0), Some(-90.0));
assert_eq!(absolute_pitch(f32::INFINITY), Some(90.0));
assert_eq!(absolute_pitch(f32::NEG_INFINITY), Some(-90.0));
assert_eq!(absolute_pitch(45.0), Some(45.0));
assert_eq!(absolute_pitch(f32::NAN), None);
}
}
Loaded 100 of 298 files, more files were not shown because too many files have changed in this diff. Show more