Merge remote-tracking branch 'origin/main' into refactor/entity-trait-pistons

This commit is contained in:
HrafnaValkyrja committed 2026-11-01 19:17:34 -05:00
commit 10fcd81920
132 files changed
+12360 -812

No files matched your search

@@ -0,0 +1,758 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-01T22:55:52Z",
"app": {
"name": "horizon-client",
"version": "0.1.0",
"commit": "1bd24e4042"
},
"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": 16,
"simulation_distance": 12,
"vsync": false,
"offscreen": true,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 113.978,
"window": [
1920,
1080
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 1524.333,
"stdev": 364.142,
"min": 1112.127,
"max": 1802.295,
"ci95": 904.651
},
"startup.world_ms": {
"n": 3,
"mean": 14001.774,
"stdev": 8119.623,
"min": 7466.904,
"max": 23091.569,
"ci95": 20171.889
},
"chunks.village.load_ms": {
"n": 3,
"mean": 8159.696,
"stdev": 3897.327,
"min": 5313.528,
"max": 12601.663,
"ci95": 9682.279
},
"chunks.village.meshed_count": {
"n": 3,
"mean": 25431.333,
"stdev": 1389.728,
"min": 24149.0,
"max": 26908.0,
"ci95": 3452.555
},
"fps.village.avg_fps": {
"n": 3,
"mean": 568.391,
"stdev": 11.948,
"min": 558.517,
"max": 581.673,
"ci95": 29.684
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 270.644,
"stdev": 9.955,
"min": 259.156,
"max": 276.728,
"ci95": 24.732
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 209.951,
"stdev": 30.24,
"min": 191.819,
"max": 244.86,
"ci95": 75.126
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 8879.707,
"stdev": 1758.259,
"min": 7205.686,
"max": 10711.562,
"ci95": 4368.11
},
"chunks.forest.meshed_count": {
"n": 3,
"mean": 29366.667,
"stdev": 273.35,
"min": 29053.0,
"max": 29554.0,
"ci95": 679.094
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 333.026,
"stdev": 1.469,
"min": 331.478,
"max": 334.4,
"ci95": 3.649
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 193.014,
"stdev": 11.504,
"min": 185.57,
"max": 206.264,
"ci95": 28.58
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 137.067,
"stdev": 28.701,
"min": 108.337,
"max": 165.738,
"ci95": 71.302
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 6399.435,
"stdev": 2268.592,
"min": 4346.111,
"max": 8834.786,
"ci95": 5635.949
},
"chunks.ocean.meshed_count": {
"n": 3,
"mean": 25436.0,
"stdev": 813.154,
"min": 24718.0,
"max": 26319.0,
"ci95": 2020.149
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 411.986,
"stdev": 1.42,
"min": 410.981,
"max": 413.61,
"ci95": 3.527
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 209.307,
"stdev": 10.427,
"min": 197.391,
"max": 216.759,
"ci95": 25.905
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 126.88,
"stdev": 17.642,
"min": 106.534,
"max": 137.93,
"ci95": 43.829
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 14672.976,
"stdev": 9832.9,
"min": 8230.723,
"max": 25990.951,
"ci95": 24428.25
},
"chunks.mountain.meshed_count": {
"n": 3,
"mean": 27715.0,
"stdev": 1706.033,
"min": 26572.0,
"max": 29676.0,
"ci95": 4238.362
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 357.149,
"stdev": 2.391,
"min": 355.009,
"max": 359.73,
"ci95": 5.941
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 199.658,
"stdev": 12.172,
"min": 187.504,
"max": 211.847,
"ci95": 30.238
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 154.465,
"stdev": 13.484,
"min": 144.923,
"max": 169.891,
"ci95": 33.498
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 1892.0,
"stdev": 49.025,
"min": 1835.406,
"max": 1921.438,
"ci95": 121.794
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 1616.755,
"stdev": 47.256,
"min": 1564.734,
"max": 1657.031,
"ci95": 117.4
},
"memory.footprint_peak_mb": {
"n": 3,
"mean": 3083.815,
"stdev": 25.002,
"min": 3062.456,
"max": 3111.315,
"ci95": 62.113
},
"memory.footprint_steady_mb": {
"n": 3,
"mean": 2495.271,
"stdev": 36.569,
"min": 2453.284,
"max": 2520.151,
"ci95": 90.85
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 6981,
"seconds": 12.002,
"avg_fps": 581.673,
"low_1pct_fps": 276.049,
"low_01pct_fps": 191.819,
"p50_ms": 1.655,
"p95_ms": 2.267,
"p99_ms": 3.277,
"p999_ms": 4.174,
"max_ms": 7.418,
"cpu_p50_ms": 0.955,
"gpu_p50_ms": 1.646,
"histogram": [
0,
6445,
353,
176,
3,
3,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 6703,
"seconds": 12.001,
"avg_fps": 558.517,
"low_1pct_fps": 276.728,
"low_01pct_fps": 244.86,
"p50_ms": 1.725,
"p95_ms": 2.341,
"p99_ms": 3.396,
"p999_ms": 3.939,
"max_ms": 4.185,
"cpu_p50_ms": 0.767,
"gpu_p50_ms": 1.708,
"histogram": [
17,
6041,
442,
202,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 6780,
"seconds": 12.0,
"avg_fps": 564.982,
"low_1pct_fps": 259.156,
"low_01pct_fps": 193.174,
"p50_ms": 1.707,
"p95_ms": 2.311,
"p99_ms": 3.407,
"p999_ms": 4.984,
"max_ms": 5.322,
"cpu_p50_ms": 0.923,
"gpu_p50_ms": 1.698,
"histogram": [
5,
6118,
480,
163,
8,
6,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 3978,
"seconds": 12.001,
"avg_fps": 331.478,
"low_1pct_fps": 187.208,
"low_01pct_fps": 137.127,
"p50_ms": 2.93,
"p95_ms": 3.969,
"p99_ms": 4.719,
"p999_ms": 6.451,
"max_ms": 8.835,
"cpu_p50_ms": 0.857,
"gpu_p50_ms": 2.92,
"histogram": [
0,
29,
2279,
1541,
109,
14,
4,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 3999,
"seconds": 12.002,
"avg_fps": 333.199,
"low_1pct_fps": 185.57,
"low_01pct_fps": 108.337,
"p50_ms": 2.921,
"p95_ms": 3.862,
"p99_ms": 4.679,
"p999_ms": 6.817,
"max_ms": 11.683,
"cpu_p50_ms": 0.828,
"gpu_p50_ms": 2.903,
"histogram": [
3,
16,
2356,
1511,
97,
12,
1,
0,
2,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 4013,
"seconds": 12.001,
"avg_fps": 334.4,
"low_1pct_fps": 206.264,
"low_01pct_fps": 165.738,
"p50_ms": 2.915,
"p95_ms": 3.809,
"p99_ms": 4.525,
"p999_ms": 5.078,
"max_ms": 8.228,
"cpu_p50_ms": 0.861,
"gpu_p50_ms": 2.899,
"histogram": [
1,
18,
2357,
1553,
77,
5,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 4932,
"seconds": 12.001,
"avg_fps": 410.981,
"low_1pct_fps": 216.759,
"low_01pct_fps": 137.93,
"p50_ms": 2.353,
"p95_ms": 3.181,
"p99_ms": 4.01,
"p999_ms": 5.767,
"max_ms": 9.713,
"cpu_p50_ms": 0.888,
"gpu_p50_ms": 2.343,
"histogram": [
0,
163,
4474,
264,
23,
4,
1,
2,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 4937,
"seconds": 12.001,
"avg_fps": 411.366,
"low_1pct_fps": 213.772,
"low_01pct_fps": 136.177,
"p50_ms": 2.349,
"p95_ms": 3.258,
"p99_ms": 4.015,
"p999_ms": 5.985,
"max_ms": 8.539,
"cpu_p50_ms": 0.871,
"gpu_p50_ms": 2.339,
"histogram": [
0,
202,
4406,
295,
25,
5,
1,
2,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 4963,
"seconds": 11.999,
"avg_fps": 413.61,
"low_1pct_fps": 197.391,
"low_01pct_fps": 106.534,
"p50_ms": 2.335,
"p95_ms": 3.214,
"p99_ms": 4.111,
"p999_ms": 7.65,
"max_ms": 11.399,
"cpu_p50_ms": 0.878,
"gpu_p50_ms": 2.323,
"histogram": [
2,
228,
4417,
274,
28,
7,
2,
2,
1,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 4317,
"seconds": 12.001,
"avg_fps": 359.73,
"low_1pct_fps": 211.847,
"low_01pct_fps": 169.891,
"p50_ms": 2.7,
"p95_ms": 3.753,
"p99_ms": 4.366,
"p999_ms": 5.041,
"max_ms": 8.726,
"cpu_p50_ms": 1.113,
"gpu_p50_ms": 2.687,
"histogram": [
0,
68,
3445,
718,
80,
5,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 4281,
"seconds": 12.001,
"avg_fps": 356.707,
"low_1pct_fps": 199.624,
"low_01pct_fps": 144.923,
"p50_ms": 2.712,
"p95_ms": 3.806,
"p99_ms": 4.535,
"p999_ms": 5.453,
"max_ms": 8.354,
"cpu_p50_ms": 1.111,
"gpu_p50_ms": 2.694,
"histogram": [
0,
64,
3353,
717,
138,
5,
2,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 4261,
"seconds": 12.003,
"avg_fps": 355.009,
"low_1pct_fps": 187.504,
"low_01pct_fps": 148.582,
"p50_ms": 2.727,
"p95_ms": 4.083,
"p99_ms": 4.744,
"p999_ms": 6.091,
"max_ms": 7.442,
"cpu_p50_ms": 1.126,
"gpu_p50_ms": 2.711,
"histogram": [
0,
155,
3198,
719,
165,
17,
6,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
}
],
"label": "horizon-m4pro-1080p"
}
@@ -0,0 +1,758 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-01T23:17:23Z",
"app": {
"name": "horizon-client",
"version": "0.1.0",
"commit": "1bd24e4042"
},
"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": 28.236,
"window": [
1920,
1080
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 2190.186,
"stdev": 1780.667,
"min": 906.481,
"max": 4223.028,
"ci95": 4423.779
},
"startup.world_ms": {
"n": 3,
"mean": 14254.027,
"stdev": 4604.903,
"min": 10130.899,
"max": 19223.339,
"ci95": 11440.138
},
"chunks.village.load_ms": {
"n": 3,
"mean": 6509.117,
"stdev": 1616.283,
"min": 5457.214,
"max": 8370.17,
"ci95": 4015.394
},
"chunks.village.meshed_count": {
"n": 3,
"mean": 48522.333,
"stdev": 1713.943,
"min": 46732.0,
"max": 50148.0,
"ci95": 4258.014
},
"fps.village.avg_fps": {
"n": 3,
"mean": 326.473,
"stdev": 6.811,
"min": 319.588,
"max": 333.208,
"ci95": 16.921
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 152.497,
"stdev": 34.142,
"min": 116.575,
"max": 184.525,
"ci95": 84.82
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 122.567,
"stdev": 33.914,
"min": 87.235,
"max": 154.858,
"ci95": 84.254
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 10363.339,
"stdev": 3122.178,
"min": 6760.012,
"max": 12265.121,
"ci95": 7756.547
},
"chunks.forest.meshed_count": {
"n": 3,
"mean": 82591.667,
"stdev": 1489.69,
"min": 81053.0,
"max": 84027.0,
"ci95": 3700.895
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 165.414,
"stdev": 4.368,
"min": 162.757,
"max": 170.455,
"ci95": 10.851
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 103.992,
"stdev": 20.41,
"min": 83.527,
"max": 124.346,
"ci95": 50.705
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 94.785,
"stdev": 24.344,
"min": 69.796,
"max": 118.428,
"ci95": 60.478
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 10450.642,
"stdev": 2599.832,
"min": 7510.454,
"max": 12445.71,
"ci95": 6458.863
},
"chunks.ocean.meshed_count": {
"n": 3,
"mean": 76405.667,
"stdev": 649.936,
"min": 75681.0,
"max": 76937.0,
"ci95": 1614.662
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 210.179,
"stdev": 5.279,
"min": 205.559,
"max": 215.932,
"ci95": 13.114
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 128.777,
"stdev": 30.982,
"min": 93.017,
"max": 147.571,
"ci95": 76.97
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 115.916,
"stdev": 39.398,
"min": 70.443,
"max": 139.838,
"ci95": 97.879
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 17168.619,
"stdev": 3613.606,
"min": 13749.902,
"max": 20949.796,
"ci95": 8977.42
},
"chunks.mountain.meshed_count": {
"n": 3,
"mean": 91622.667,
"stdev": 482.189,
"min": 91089.0,
"max": 92027.0,
"ci95": 1197.921
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 190.088,
"stdev": 2.175,
"min": 187.78,
"max": 192.099,
"ci95": 5.403
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 108.92,
"stdev": 14.244,
"min": 92.641,
"max": 119.094,
"ci95": 35.388
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 91.132,
"stdev": 12.82,
"min": 76.343,
"max": 99.09,
"ci95": 31.849
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 2536.536,
"stdev": 40.31,
"min": 2506.281,
"max": 2582.297,
"ci95": 100.144
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 2195.922,
"stdev": 37.757,
"min": 2167.078,
"max": 2238.656,
"ci95": 93.8
},
"memory.footprint_peak_mb": {
"n": 3,
"mean": 5559.837,
"stdev": 47.18,
"min": 5505.457,
"max": 5589.863,
"ci95": 117.211
},
"memory.footprint_steady_mb": {
"n": 3,
"mean": 4373.034,
"stdev": 82.743,
"min": 4311.909,
"max": 4467.191,
"ci95": 205.561
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 3920,
"seconds": 12.002,
"avg_fps": 326.623,
"low_1pct_fps": 116.575,
"low_01pct_fps": 87.235,
"p50_ms": 2.938,
"p95_ms": 4.356,
"p99_ms": 7.397,
"p999_ms": 9.497,
"max_ms": 13.274,
"cpu_p50_ms": 1.611,
"gpu_p50_ms": 2.871,
"histogram": [
0,
115,
2297,
1271,
149,
33,
10,
24,
18,
1,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 3835,
"seconds": 12.0,
"avg_fps": 319.588,
"low_1pct_fps": 156.39,
"low_01pct_fps": 125.608,
"p50_ms": 3.04,
"p95_ms": 4.322,
"p99_ms": 5.265,
"p999_ms": 7.732,
"max_ms": 8.183,
"cpu_p50_ms": 1.733,
"gpu_p50_ms": 2.936,
"histogram": [
0,
1,
1610,
2006,
142,
51,
15,
10,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 3999,
"seconds": 12.002,
"avg_fps": 333.208,
"low_1pct_fps": 184.525,
"low_01pct_fps": 154.858,
"p50_ms": 2.9,
"p95_ms": 4.01,
"p99_ms": 4.955,
"p999_ms": 6.193,
"max_ms": 6.805,
"cpu_p50_ms": 1.51,
"gpu_p50_ms": 2.788,
"histogram": [
0,
8,
2499,
1328,
132,
25,
7,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 1957,
"seconds": 12.004,
"avg_fps": 163.03,
"low_1pct_fps": 83.527,
"low_01pct_fps": 69.796,
"p50_ms": 6.011,
"p95_ms": 8.032,
"p99_ms": 10.903,
"p999_ms": 14.322,
"max_ms": 14.333,
"cpu_p50_ms": 1.555,
"gpu_p50_ms": 5.857,
"histogram": [
0,
5,
26,
16,
36,
883,
739,
194,
29,
22,
5,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 1954,
"seconds": 12.006,
"avg_fps": 162.757,
"low_1pct_fps": 104.102,
"low_01pct_fps": 96.13,
"p50_ms": 5.98,
"p95_ms": 7.665,
"p99_ms": 9.049,
"p999_ms": 10.323,
"max_ms": 10.482,
"cpu_p50_ms": 1.584,
"gpu_p50_ms": 5.715,
"histogram": [
0,
0,
0,
1,
9,
1028,
737,
130,
46,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 2046,
"seconds": 12.003,
"avg_fps": 170.455,
"low_1pct_fps": 124.346,
"low_01pct_fps": 118.428,
"p50_ms": 5.719,
"p95_ms": 7.342,
"p99_ms": 7.815,
"p999_ms": 8.38,
"max_ms": 8.506,
"cpu_p50_ms": 1.384,
"gpu_p50_ms": 5.572,
"histogram": [
0,
0,
0,
0,
26,
1452,
396,
169,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 2467,
"seconds": 12.001,
"avg_fps": 205.559,
"low_1pct_fps": 93.017,
"low_01pct_fps": 70.443,
"p50_ms": 4.708,
"p95_ms": 6.6,
"p99_ms": 9.432,
"p999_ms": 13.566,
"max_ms": 15.228,
"cpu_p50_ms": 1.664,
"gpu_p50_ms": 4.483,
"histogram": [
0,
18,
55,
87,
1811,
290,
143,
27,
20,
13,
2,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 2509,
"seconds": 12.002,
"avg_fps": 209.045,
"low_1pct_fps": 145.742,
"low_01pct_fps": 139.838,
"p50_ms": 4.642,
"p95_ms": 6.15,
"p99_ms": 6.72,
"p999_ms": 7.081,
"max_ms": 7.254,
"cpu_p50_ms": 1.74,
"gpu_p50_ms": 4.376,
"histogram": [
0,
0,
0,
78,
2008,
277,
142,
4,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 2591,
"seconds": 11.999,
"avg_fps": 215.932,
"low_1pct_fps": 147.571,
"low_01pct_fps": 137.466,
"p50_ms": 4.466,
"p95_ms": 5.947,
"p99_ms": 6.512,
"p999_ms": 7.262,
"max_ms": 7.283,
"cpu_p50_ms": 1.422,
"gpu_p50_ms": 4.286,
"histogram": [
0,
2,
0,
111,
2114,
248,
111,
5,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 2285,
"seconds": 12.002,
"avg_fps": 190.386,
"low_1pct_fps": 92.641,
"low_01pct_fps": 76.343,
"p50_ms": 5.038,
"p95_ms": 7.005,
"p99_ms": 9.383,
"p999_ms": 11.731,
"max_ms": 15.266,
"cpu_p50_ms": 2.239,
"gpu_p50_ms": 4.915,
"histogram": [
0,
0,
54,
45,
916,
985,
168,
78,
22,
15,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 2306,
"seconds": 12.004,
"avg_fps": 192.099,
"low_1pct_fps": 119.094,
"low_01pct_fps": 99.09,
"p50_ms": 5.017,
"p95_ms": 6.58,
"p99_ms": 7.586,
"p999_ms": 9.195,
"max_ms": 11.428,
"cpu_p50_ms": 2.261,
"gpu_p50_ms": 4.823,
"histogram": [
0,
0,
3,
12,
1070,
1003,
165,
43,
9,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 2254,
"seconds": 12.003,
"avg_fps": 187.78,
"low_1pct_fps": 115.026,
"low_01pct_fps": 97.962,
"p50_ms": 5.175,
"p95_ms": 6.791,
"p99_ms": 7.916,
"p999_ms": 9.284,
"max_ms": 11.986,
"cpu_p50_ms": 2.158,
"gpu_p50_ms": 4.942,
"histogram": [
0,
0,
0,
17,
774,
1199,
182,
69,
12,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
}
],
"label": "horizon-m4pro-1080p"
}
@@ -0,0 +1,758 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-01T22:55:52Z",
"app": {
"name": "horizon-client",
"version": "0.1.0",
"commit": "1bd24e4042"
},
"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": 8,
"simulation_distance": 12,
"vsync": false,
"offscreen": true,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 73.292,
"window": [
1920,
1080
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 7204.239,
"stdev": 1586.64,
"min": 5409.683,
"max": 8421.051,
"ci95": 3941.749
},
"startup.world_ms": {
"n": 3,
"mean": 11407.51,
"stdev": 2085.692,
"min": 9027.004,
"max": 12913.993,
"ci95": 5181.563
},
"chunks.village.load_ms": {
"n": 3,
"mean": 5116.146,
"stdev": 744.611,
"min": 4548.096,
"max": 5959.13,
"ci95": 1849.865
},
"chunks.village.meshed_count": {
"n": 3,
"mean": 7220.0,
"stdev": 137.292,
"min": 7092.0,
"max": 7365.0,
"ci95": 341.079
},
"fps.village.avg_fps": {
"n": 3,
"mean": 540.213,
"stdev": 182.263,
"min": 432.911,
"max": 750.659,
"ci95": 452.803
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 195.561,
"stdev": 75.888,
"min": 141.859,
"max": 282.38,
"ci95": 188.533
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 134.559,
"stdev": 74.125,
"min": 87.663,
"max": 220.016,
"ci95": 184.153
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 7185.67,
"stdev": 4093.192,
"min": 4323.79,
"max": 11874.133,
"ci95": 10168.874
},
"chunks.forest.meshed_count": {
"n": 3,
"mean": 8302.667,
"stdev": 538.894,
"min": 7853.0,
"max": 8900.0,
"ci95": 1338.794
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 364.068,
"stdev": 8.947,
"min": 355.173,
"max": 373.067,
"ci95": 22.229
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 145.064,
"stdev": 21.824,
"min": 121.995,
"max": 165.382,
"ci95": 54.218
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 97.299,
"stdev": 15.364,
"min": 81.075,
"max": 111.627,
"ci95": 38.17
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 4944.497,
"stdev": 1598.757,
"min": 3816.976,
"max": 6774.175,
"ci95": 3971.854
},
"chunks.ocean.meshed_count": {
"n": 3,
"mean": 7041.0,
"stdev": 276.949,
"min": 6821.0,
"max": 7352.0,
"ci95": 688.036
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 410.281,
"stdev": 35.314,
"min": 370.881,
"max": 439.08,
"ci95": 87.731
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 161.057,
"stdev": 15.769,
"min": 144.003,
"max": 175.109,
"ci95": 39.175
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 111.852,
"stdev": 16.415,
"min": 93.045,
"max": 123.299,
"ci95": 40.78
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 8844.497,
"stdev": 4291.678,
"min": 5269.587,
"max": 13604.075,
"ci95": 10661.98
},
"chunks.mountain.meshed_count": {
"n": 3,
"mean": 7381.0,
"stdev": 88.606,
"min": 7290.0,
"max": 7467.0,
"ci95": 220.127
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 466.294,
"stdev": 2.738,
"min": 464.707,
"max": 469.455,
"ci95": 6.801
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 164.71,
"stdev": 21.73,
"min": 141.437,
"max": 184.468,
"ci95": 53.984
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 90.643,
"stdev": 14.864,
"min": 76.851,
"max": 106.387,
"ci95": 36.928
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 1536.969,
"stdev": 19.205,
"min": 1516.188,
"max": 1554.063,
"ci95": 47.711
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 1379.422,
"stdev": 9.712,
"min": 1371.438,
"max": 1390.234,
"ci95": 24.129
},
"memory.footprint_peak_mb": {
"n": 3,
"mean": 2093.701,
"stdev": 13.714,
"min": 2078.862,
"max": 2105.909,
"ci95": 34.07
},
"memory.footprint_steady_mb": {
"n": 3,
"mean": 1883.495,
"stdev": 10.346,
"min": 1871.987,
"max": 1892.026,
"ci95": 25.702
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 9008,
"seconds": 12.0,
"avg_fps": 750.659,
"low_1pct_fps": 282.38,
"low_01pct_fps": 220.016,
"p50_ms": 1.047,
"p95_ms": 2.169,
"p99_ms": 3.163,
"p999_ms": 4.077,
"max_ms": 5.462,
"cpu_p50_ms": 0.447,
"gpu_p50_ms": 1.021,
"histogram": [
3979,
3877,
1033,
112,
6,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 5245,
"seconds": 12.0,
"avg_fps": 437.07,
"low_1pct_fps": 141.859,
"low_01pct_fps": 87.663,
"p50_ms": 1.822,
"p95_ms": 4.165,
"p99_ms": 5.399,
"p999_ms": 10.052,
"max_ms": 12.712,
"cpu_p50_ms": 0.652,
"gpu_p50_ms": 1.943,
"histogram": [
831,
1968,
606,
1582,
154,
82,
3,
3,
10,
4,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 5195,
"seconds": 12.0,
"avg_fps": 432.911,
"low_1pct_fps": 162.445,
"low_01pct_fps": 95.997,
"p50_ms": 1.806,
"p95_ms": 4.202,
"p99_ms": 5.259,
"p999_ms": 7.591,
"max_ms": 17.328,
"cpu_p50_ms": 0.636,
"gpu_p50_ms": 1.876,
"histogram": [
297,
2502,
650,
1469,
192,
73,
6,
1,
3,
1,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 4262,
"seconds": 12.0,
"avg_fps": 355.173,
"low_1pct_fps": 165.382,
"low_01pct_fps": 111.627,
"p50_ms": 2.917,
"p95_ms": 3.854,
"p99_ms": 5.022,
"p999_ms": 7.808,
"max_ms": 9.769,
"cpu_p50_ms": 0.59,
"gpu_p50_ms": 2.91,
"histogram": [
98,
416,
2506,
1080,
118,
29,
10,
1,
4,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 4477,
"seconds": 12.001,
"avg_fps": 373.067,
"low_1pct_fps": 121.995,
"low_01pct_fps": 81.075,
"p50_ms": 2.243,
"p95_ms": 4.687,
"p99_ms": 5.835,
"p999_ms": 11.831,
"max_ms": 13.367,
"cpu_p50_ms": 0.541,
"gpu_p50_ms": 2.346,
"histogram": [
183,
1975,
392,
887,
903,
99,
12,
6,
11,
6,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 4368,
"seconds": 12.001,
"avg_fps": 363.964,
"low_1pct_fps": 147.816,
"low_01pct_fps": 99.196,
"p50_ms": 2.264,
"p95_ms": 4.763,
"p99_ms": 5.86,
"p999_ms": 7.641,
"max_ms": 11.427,
"cpu_p50_ms": 0.562,
"gpu_p50_ms": 2.203,
"histogram": [
73,
1978,
472,
695,
1004,
104,
35,
3,
1,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 4451,
"seconds": 12.001,
"avg_fps": 370.881,
"low_1pct_fps": 175.109,
"low_01pct_fps": 119.212,
"p50_ms": 2.858,
"p95_ms": 4.279,
"p99_ms": 5.045,
"p999_ms": 6.057,
"max_ms": 10.866,
"cpu_p50_ms": 0.609,
"gpu_p50_ms": 2.922,
"histogram": [
193,
1245,
964,
1732,
270,
42,
2,
0,
1,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 5269,
"seconds": 12.0,
"avg_fps": 439.08,
"low_1pct_fps": 144.003,
"low_01pct_fps": 93.045,
"p50_ms": 1.767,
"p95_ms": 4.188,
"p99_ms": 5.191,
"p999_ms": 10.375,
"max_ms": 11.271,
"cpu_p50_ms": 0.545,
"gpu_p50_ms": 1.815,
"histogram": [
1239,
1550,
644,
1555,
201,
59,
1,
4,
10,
6,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 5051,
"seconds": 12.001,
"avg_fps": 420.881,
"low_1pct_fps": 164.058,
"low_01pct_fps": 123.299,
"p50_ms": 1.872,
"p95_ms": 4.455,
"p99_ms": 5.38,
"p999_ms": 6.923,
"max_ms": 11.189,
"cpu_p50_ms": 0.548,
"gpu_p50_ms": 1.874,
"histogram": [
519,
2114,
673,
1086,
577,
65,
13,
3,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 5635,
"seconds": 12.003,
"avg_fps": 469.455,
"low_1pct_fps": 184.468,
"low_01pct_fps": 106.387,
"p50_ms": 1.626,
"p95_ms": 3.874,
"p99_ms": 4.614,
"p999_ms": 5.913,
"max_ms": 18.185,
"cpu_p50_ms": 0.564,
"gpu_p50_ms": 1.604,
"histogram": [
1491,
1599,
704,
1688,
128,
20,
1,
2,
0,
1,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 5578,
"seconds": 12.003,
"avg_fps": 464.72,
"low_1pct_fps": 141.437,
"low_01pct_fps": 76.851,
"p50_ms": 1.587,
"p95_ms": 4.127,
"p99_ms": 5.085,
"p999_ms": 10.138,
"max_ms": 21.644,
"cpu_p50_ms": 0.588,
"gpu_p50_ms": 1.625,
"histogram": [
1321,
1798,
770,
1453,
173,
41,
2,
5,
7,
6,
1,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 5576,
"seconds": 11.999,
"avg_fps": 464.707,
"low_1pct_fps": 168.226,
"low_01pct_fps": 88.692,
"p50_ms": 1.627,
"p95_ms": 4.154,
"p99_ms": 4.908,
"p999_ms": 9.555,
"max_ms": 14.784,
"cpu_p50_ms": 0.595,
"gpu_p50_ms": 1.64,
"histogram": [
1054,
2049,
837,
1377,
217,
32,
3,
0,
2,
4,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
}
],
"label": "horizon-m4pro-1080p"
}
@@ -0,0 +1,68 @@
# Horizon main vs vanilla 26.3 on a loaded Mac mini M4 Pro (2026-11-01)
`tools/perf_bench.py` reports for Horizon main 1bd24e4042, vanilla 26.3 and vanilla with performance mods, render distances
8, 16 and 32, three games each, every game from a fresh world. **These are not player numbers:** the Mac mini was running other
work the whole time (1-minute load average 15-114 on 14 cores), and every game rendered without presenting. They are kept because
they're the first Mac reports and show how each game behaves under heavy CPU contention. The reports are the
`*-m4pro-1080p-rd*.json` files next to this page.
## Setup
- **Machine:** Mac mini, Apple M4 Pro (10 performance + 4 efficiency cores, 20-core GPU), 64 GB, macOS 27.0.1. Screen
1920x1080; the session was locked, so every game ran `offscreen` (Horizon renders into a texture; vanilla draws into a
window nobody sees).
- **Horizon:** main 1bd24e4042, `cargo build --release -p horizon-client`, Metal.
- **Vanilla:** the 26.3 client jar with Fabric Loader and the driver mod, Java 25 (Temurin), `-Xmx2G` with the launcher's G1
flags, OpenGL 4.1 (Apple's Metal-backed driver).
- **Vanilla + mods:** the same with Sodium mc26.3-0.9.2, Lithium mc26.3-0.26.2, FerriteCore 9.0.0, ImmediatelyFast 1.17.1 and
Fabric API (`tools/benchmark-deps/mods.lock.json`).
- **Order:** the games took turns inside each repeat (vanilla, mods, Horizon; then mods, Horizon, vanilla; then Horizon, vanilla,
mods), one game at a time.
## Why these numbers aren't a fair comparison
- **Load.** Load before each game: RD 8 46-82, RD 16 43-114, RD 32 15-59 (the later RD 32 games ran at 15-30). Horizon raises its
render and server threads to the user-interactive QoS class, and the JVM's threads stay at the default, so under this
contention macOS runs Horizon's frame first. Vanilla's 1% lows of 2-15 FPS show that, more than how fast either game is.
- **Resolution.** Vanilla's 1920x1080 window was shrunk to **1920x930** by the 1080p screen (menu and title bars); Horizon,
offscreen, rendered 1920x1080. Vanilla drew 14% fewer pixels, which favours it in GPU-bound scenes. Because the recorded
windows differ, `perf_bench.py --table` refuses to compare these reports. The tables below were made with the window check
skipped. A rerun at a size that fits the screen (1600x900) would avoid it.
## Results
Mean of 3 games ± 95% confidence interval. FPS over the 12-second flight (average / 1% low / 0.1% low):
| RD | scene | Horizon main | Vanilla 26.3 | Vanilla + mods |
| --- | --- | --- | --- | --- |
| 8 | forest | 364 ±22 / 145 ±54 / 97 ±38 | 66 ±22 / 8 ±10 / 6 ±8 | 47 ±42 / 4 ±2 / 3 ±1 |
| 8 | mountain | 466 ±7 / 165 ±54 / 91 ±37 | 46 ±37 / 5 ±7 / 4 ±6 | 67 ±35 / 8 ±5 / 4 ±4 |
| 8 | ocean | 410 ±88 / 161 ±39 / 112 ±41 | 50 ±44 / 6 ±13 / 5 ±11 | 35 ±16 / 5 ±4 / 4 ±4 |
| 8 | village | 540 ±453 / 196 ±189 / 135 ±184 | 42 ±16 / 4 ±3 / 3 ±0 | 33 ±46 / 4 ±6 / 3 ±4 |
| 16 | forest | 333 ±4 / 193 ±29 / 137 ±71 | 23 ±9 / 2 ±1 / 2 ±1 | 62 ±94 / 10 ±21 / 7 ±14 |
| 16 | mountain | 357 ±6 / 200 ±30 / 154 ±33 | 44 ±12 / 7 ±2 / 4 ±1 | 36 ±44 / 4 ±5 / 3 ±5 |
| 16 | ocean | 412 ±4 / 209 ±26 / 127 ±44 | 51 ±16 / 6 ±8 / 4 ±7 | 75 ±159 / 13 ±33 / 7 ±16 |
| 16 | village | 568 ±30 / 271 ±25 / 210 ±75 | 46 ±110 / 9 ±27 / 7 ±17 | 46 ±45 / 7 ±9 / 5 ±4 |
| 32 | forest | 165 ±11 / 104 ±51 / 95 ±60 | 68 ±122 / 18 ±49 / 14 ±42 | 114 ±132 / 29 ±44 / 21 ±34 |
| 32 | mountain | 190 ±5 / 109 ±35 / 91 ±32 | 29 ±19 / 3 ±2 / 2 ±3 | 57 ±49 / 7 ±2 / 3 ±3 |
| 32 | ocean | 210 ±13 / 129 ±77 / 116 ±98 | 44 ±80 / 5 ±4 / 2 ±1 | 119 ±176 / 32 ±66 / 17 ±37 |
| 32 | village | 326 ±17 / 152 ±85 / 123 ±84 | 81 ±99 / 15 ±20 / 11 ±11 | 92 ±28 / 13 ±6 / 9 ±3 |
Start to title / in-world, mean scene load, RSS steady / peak, physical footprint steady / peak (macOS `footprint`, which on Apple
Silicon includes the game's GPU allocations), load average before the first game:
| RD | Horizon main | Vanilla 26.3 | Vanilla + mods |
| --- | --- | --- | --- |
| 8 | 7.2 / 11.4, 6.5, 1379 / 1537, 1883 / 2094, 73 | 49.9 / 89.1, 18.2, 1811 / 1877, 2635 / 2709, 66 | 56.3 / 97.7, 17.9, 1927 / 2019, 2396 / 2441, 74 |
| 16 | 1.5 / 14.0, 9.5, 1617 / 1892, 2495 / 3084, 114 | 19.4 / 78.9, 33.3, 2431 / 2555, 3450 / 3713, 76 | 23.8 / 76.9, 27.1, 2355 / 2566, 3212 / 3431, 70 |
| 32 | 2.2 / 14.3, 11.1, 2196 / 2537, 4373 / 5560, 28 | 20.6 / 124.2, 44.2, 2838 / 2958, 4265 / 4618, 59 | 13.5 / 63.2, 29.9, 3705 / 4078, 4647 / 5250, 37 |
## What it shows
- Horizon is GPU-bound on the M4 Pro in every scene at every distance: its median GPU time per frame is 1.6-2.5 ms at RD 8 and
2.9-5.7 ms at RD 32, its render thread's CPU time 0.6-2.2 ms.
- In the least-loaded RD 32 games (load 15-22), vanilla + mods matched Horizon's average FPS over the forest (162 vs 163-170) and
the ocean (187 vs 209-216); Horizon's 1% lows were 2.4 times the modded client's there, and 15-17 times over the village and the
mountain.
- Horizon's resident memory is 1.2-1.7 times smaller than both clients'. Its physical footprint, GPU memory included, is level
with vanilla's at RD 32 (4373 vs 4265 MB steady) and above it at peak (5560 vs 4618 MB).
@@ -0,0 +1,707 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-01T22:55:52Z",
"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": 16,
"simulation_distance": 12,
"vsync": false,
"offscreen": true,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 75.854,
"window": [
1920,
930
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 19419.646,
"stdev": 10653.755,
"min": 8240.178,
"max": 29455.403,
"ci95": 26467.531
},
"startup.world_ms": {
"n": 3,
"mean": 78944.646,
"stdev": 36737.011,
"min": 51495.357,
"max": 120678.403,
"ci95": 91267.16
},
"chunks.village.load_ms": {
"n": 3,
"mean": 21947.683,
"stdev": 10432.774,
"min": 10427.451,
"max": 30758.368,
"ci95": 25918.54
},
"fps.village.avg_fps": {
"n": 3,
"mean": 45.524,
"stdev": 44.352,
"min": 15.749,
"max": 96.497,
"ci95": 110.185
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 9.141,
"stdev": 10.812,
"min": 1.86,
"max": 21.565,
"ci95": 26.862
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 6.727,
"stdev": 6.902,
"min": 1.785,
"max": 14.612,
"ci95": 17.146
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 39062.839,
"stdev": 21806.234,
"min": 15037.908,
"max": 57603.235,
"ci95": 54174.061
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 23.347,
"stdev": 3.693,
"min": 20.154,
"max": 27.392,
"ci95": 9.175
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 2.419,
"stdev": 0.269,
"min": 2.196,
"max": 2.718,
"ci95": 0.669
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 1.972,
"stdev": 0.438,
"min": 1.551,
"max": 2.425,
"ci95": 1.088
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 22097.713,
"stdev": 4463.742,
"min": 18053.319,
"max": 26887.066,
"ci95": 11089.445
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 51.279,
"stdev": 6.309,
"min": 44.786,
"max": 57.386,
"ci95": 15.673
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 6.15,
"stdev": 3.128,
"min": 3.444,
"max": 9.574,
"ci95": 7.77
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 4.161,
"stdev": 2.739,
"min": 2.487,
"max": 7.321,
"ci95": 6.803
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 50257.39,
"stdev": 34798.619,
"min": 25919.598,
"max": 90115.595,
"ci95": 86451.54
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 44.346,
"stdev": 4.882,
"min": 39.246,
"max": 48.975,
"ci95": 12.128
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 6.637,
"stdev": 0.69,
"min": 5.888,
"max": 7.246,
"ci95": 1.714
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 4.444,
"stdev": 0.6,
"min": 3.937,
"max": 5.107,
"ci95": 1.491
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 2554.552,
"stdev": 218.163,
"min": 2328.859,
"max": 2764.313,
"ci95": 541.991
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 2430.854,
"stdev": 230.654,
"min": 2186.328,
"max": 2644.531,
"ci95": 573.023
},
"memory.footprint_peak_mb": {
"n": 3,
"mean": 3713.311,
"stdev": 191.61,
"min": 3512.331,
"max": 3893.925,
"ci95": 476.025
},
"memory.footprint_steady_mb": {
"n": 3,
"mean": 3450.061,
"stdev": 76.233,
"min": 3370.253,
"max": 3522.128,
"ci95": 189.388
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 189,
"seconds": 12.001,
"avg_fps": 15.749,
"low_1pct_fps": 1.86,
"low_01pct_fps": 1.785,
"p50_ms": 22.219,
"p95_ms": 254.854,
"p99_ms": 515.134,
"p999_ms": 560.079,
"max_ms": 560.079,
"histogram": [
0,
0,
0,
1,
2,
2,
7,
9,
10,
16,
12,
11,
14,
16,
22,
9,
6,
5,
11,
10,
15,
9,
2,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 292,
"seconds": 12.004,
"avg_fps": 24.325,
"low_1pct_fps": 3.998,
"low_01pct_fps": 3.783,
"p50_ms": 15.477,
"p95_ms": 165.164,
"p99_ms": 242.898,
"p999_ms": 264.363,
"max_ms": 264.363,
"histogram": [
0,
0,
0,
0,
4,
26,
20,
32,
20,
20,
17,
12,
12,
10,
17,
14,
20,
14,
16,
16,
21,
1,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 1158,
"seconds": 12.0,
"avg_fps": 96.497,
"low_1pct_fps": 21.565,
"low_01pct_fps": 14.612,
"p50_ms": 7.567,
"p95_ms": 22.849,
"p99_ms": 34.806,
"p999_ms": 62.86,
"max_ms": 74.011,
"histogram": [
0,
0,
0,
0,
1,
38,
452,
158,
102,
94,
125,
68,
34,
42,
30,
7,
4,
2,
1,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 271,
"seconds": 12.047,
"avg_fps": 22.496,
"low_1pct_fps": 2.196,
"low_01pct_fps": 1.551,
"p50_ms": 18.263,
"p95_ms": 189.284,
"p99_ms": 317.823,
"p999_ms": 644.675,
"max_ms": 644.675,
"histogram": [
0,
0,
0,
0,
0,
3,
22,
23,
13,
22,
22,
19,
24,
22,
14,
17,
12,
13,
9,
16,
15,
4,
1,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 242,
"seconds": 12.007,
"avg_fps": 20.154,
"low_1pct_fps": 2.343,
"low_01pct_fps": 1.939,
"p50_ms": 19.762,
"p95_ms": 157.949,
"p99_ms": 376.292,
"p999_ms": 515.678,
"max_ms": 515.678,
"histogram": [
0,
0,
0,
0,
0,
13,
17,
16,
26,
19,
11,
9,
10,
11,
9,
16,
17,
12,
21,
19,
9,
6,
1,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 331,
"seconds": 12.084,
"avg_fps": 27.392,
"low_1pct_fps": 2.718,
"low_01pct_fps": 2.425,
"p50_ms": 13.0,
"p95_ms": 162.797,
"p99_ms": 325.827,
"p999_ms": 412.349,
"max_ms": 412.349,
"histogram": [
0,
0,
0,
0,
3,
5,
26,
34,
42,
41,
24,
27,
15,
21,
22,
8,
10,
11,
10,
14,
10,
8,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 620,
"seconds": 12.001,
"avg_fps": 51.664,
"low_1pct_fps": 3.444,
"low_01pct_fps": 2.487,
"p50_ms": 10.358,
"p95_ms": 54.808,
"p99_ms": 181.112,
"p999_ms": 402.152,
"max_ms": 402.152,
"histogram": [
0,
0,
0,
0,
0,
30,
79,
95,
86,
90,
60,
54,
28,
28,
19,
9,
8,
8,
6,
8,
7,
5,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 689,
"seconds": 12.006,
"avg_fps": 57.386,
"low_1pct_fps": 9.574,
"low_01pct_fps": 7.321,
"p50_ms": 12.76,
"p95_ms": 43.737,
"p99_ms": 69.357,
"p999_ms": 136.592,
"max_ms": 136.592,
"histogram": [
0,
0,
0,
0,
0,
23,
46,
80,
80,
92,
68,
74,
54,
49,
51,
24,
22,
16,
6,
4,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 538,
"seconds": 12.013,
"avg_fps": 44.786,
"low_1pct_fps": 5.431,
"low_01pct_fps": 2.674,
"p50_ms": 14.011,
"p95_ms": 71.23,
"p99_ms": 141.423,
"p999_ms": 373.913,
"max_ms": 373.913,
"histogram": [
0,
0,
0,
0,
0,
1,
8,
38,
84,
83,
53,
62,
52,
54,
29,
12,
15,
13,
22,
10,
1,
1,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 588,
"seconds": 12.006,
"avg_fps": 48.975,
"low_1pct_fps": 6.777,
"low_01pct_fps": 3.937,
"p50_ms": 12.678,
"p95_ms": 64.221,
"p99_ms": 108.513,
"p999_ms": 254.008,
"max_ms": 254.008,
"histogram": [
0,
0,
0,
0,
4,
34,
21,
41,
108,
62,
55,
55,
40,
45,
41,
21,
16,
17,
18,
9,
0,
1,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 471,
"seconds": 12.001,
"avg_fps": 39.246,
"low_1pct_fps": 5.888,
"low_01pct_fps": 4.289,
"p50_ms": 17.65,
"p95_ms": 73.314,
"p99_ms": 135.3,
"p999_ms": 233.136,
"max_ms": 233.136,
"histogram": [
0,
0,
0,
0,
3,
12,
22,
29,
36,
40,
43,
38,
41,
46,
62,
29,
20,
19,
22,
5,
4,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 538,
"seconds": 12.004,
"avg_fps": 44.818,
"low_1pct_fps": 7.246,
"low_01pct_fps": 5.107,
"p50_ms": 15.959,
"p95_ms": 63.485,
"p99_ms": 109.639,
"p999_ms": 195.815,
"max_ms": 195.815,
"histogram": [
0,
0,
0,
0,
0,
4,
6,
21,
75,
56,
54,
78,
54,
69,
36,
23,
22,
18,
14,
7,
1,
0,
0,
0
]
}
],
"label": "vanilla-m4pro-1080p"
}
@@ -0,0 +1,707 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-01T23:17:23Z",
"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": true,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 58.672,
"window": [
1920,
930
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 20579.57,
"stdev": 13060.035,
"min": 10660.541,
"max": 35376.471,
"ci95": 32445.545
},
"startup.world_ms": {
"n": 3,
"mean": 124180.237,
"stdev": 102723.197,
"min": 51510.541,
"max": 241702.471,
"ci95": 255199.163
},
"chunks.village.load_ms": {
"n": 3,
"mean": 23389.087,
"stdev": 5416.249,
"min": 18538.934,
"max": 29233.632,
"ci95": 13455.795
},
"fps.village.avg_fps": {
"n": 3,
"mean": 81.421,
"stdev": 39.944,
"min": 35.32,
"max": 105.706,
"ci95": 99.234
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 15.288,
"stdev": 8.185,
"min": 5.907,
"max": 20.97,
"ci95": 20.334
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 10.544,
"stdev": 4.403,
"min": 5.804,
"max": 14.505,
"ci95": 10.938
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 48382.741,
"stdev": 32005.955,
"min": 26161.003,
"max": 85067.56,
"ci95": 79513.616
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 67.82,
"stdev": 49.16,
"min": 12.722,
"max": 107.193,
"ci95": 122.129
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 17.769,
"stdev": 19.765,
"min": 2.609,
"max": 40.124,
"ci95": 49.103
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 13.876,
"stdev": 16.851,
"min": 2.308,
"max": 33.21,
"ci95": 41.864
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 37630.797,
"stdev": 33094.177,
"min": 14901.304,
"max": 75599.155,
"ci95": 82217.128
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 44.125,
"stdev": 32.286,
"min": 13.532,
"max": 77.872,
"ci95": 80.209
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 4.619,
"stdev": 1.694,
"min": 3.247,
"max": 6.512,
"ci95": 4.208
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 2.354,
"stdev": 0.44,
"min": 1.967,
"max": 2.833,
"ci95": 1.094
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 67242.203,
"stdev": 17886.115,
"min": 56354.348,
"max": 87884.948,
"ci95": 44435.159
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 28.745,
"stdev": 7.77,
"min": 20.237,
"max": 35.466,
"ci95": 19.304
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 3.013,
"stdev": 0.849,
"min": 2.273,
"max": 3.94,
"ci95": 2.109
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 2.377,
"stdev": 1.031,
"min": 1.246,
"max": 3.263,
"ci95": 2.561
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 2958.083,
"stdev": 22.425,
"min": 2937.734,
"max": 2982.125,
"ci95": 55.71
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 2837.568,
"stdev": 165.159,
"min": 2648.188,
"max": 2951.719,
"ci95": 410.31
},
"memory.footprint_peak_mb": {
"n": 3,
"mean": 4618.337,
"stdev": 95.349,
"min": 4543.347,
"max": 4725.644,
"ci95": 236.878
},
"memory.footprint_steady_mb": {
"n": 3,
"mean": 4264.652,
"stdev": 87.422,
"min": 4173.589,
"max": 4347.91,
"ci95": 217.185
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 424,
"seconds": 12.004,
"avg_fps": 35.32,
"low_1pct_fps": 5.907,
"low_01pct_fps": 5.804,
"p50_ms": 17.195,
"p95_ms": 95.694,
"p99_ms": 164.6,
"p999_ms": 172.292,
"max_ms": 172.292,
"histogram": [
0,
0,
0,
0,
0,
0,
5,
19,
39,
45,
44,
52,
42,
41,
38,
21,
20,
21,
19,
12,
6,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 1239,
"seconds": 12.001,
"avg_fps": 103.238,
"low_1pct_fps": 20.97,
"low_01pct_fps": 14.505,
"p50_ms": 8.073,
"p95_ms": 18.618,
"p99_ms": 34.704,
"p999_ms": 53.789,
"max_ms": 84.091,
"histogram": [
0,
0,
0,
0,
0,
2,
275,
412,
260,
117,
60,
35,
25,
23,
14,
7,
5,
3,
1,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 1271,
"seconds": 12.024,
"avg_fps": 105.706,
"low_1pct_fps": 18.988,
"low_01pct_fps": 11.324,
"p50_ms": 7.988,
"p95_ms": 16.852,
"p99_ms": 35.156,
"p999_ms": 65.46,
"max_ms": 111.155,
"histogram": [
0,
0,
0,
0,
0,
3,
220,
500,
318,
101,
43,
21,
15,
19,
17,
4,
4,
5,
0,
1,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 154,
"seconds": 12.105,
"avg_fps": 12.722,
"low_1pct_fps": 2.609,
"low_01pct_fps": 2.308,
"p50_ms": 54.156,
"p95_ms": 216.738,
"p99_ms": 333.238,
"p999_ms": 433.205,
"max_ms": 433.205,
"histogram": [
0,
0,
0,
0,
0,
0,
0,
0,
1,
4,
5,
7,
9,
13,
25,
5,
7,
12,
17,
26,
18,
5,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 1287,
"seconds": 12.006,
"avg_fps": 107.193,
"low_1pct_fps": 40.124,
"low_01pct_fps": 33.21,
"p50_ms": 8.355,
"p95_ms": 14.243,
"p99_ms": 22.791,
"p999_ms": 29.023,
"max_ms": 31.2,
"histogram": [
0,
0,
0,
0,
0,
0,
0,
627,
416,
130,
43,
33,
16,
18,
4,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 1003,
"seconds": 12.005,
"avg_fps": 83.546,
"low_1pct_fps": 10.575,
"low_01pct_fps": 6.111,
"p50_ms": 9.556,
"p95_ms": 22.218,
"p99_ms": 53.64,
"p999_ms": 143.879,
"max_ms": 183.38,
"histogram": [
0,
0,
0,
0,
0,
0,
0,
57,
515,
239,
64,
45,
21,
30,
12,
4,
4,
5,
3,
3,
1,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 163,
"seconds": 12.045,
"avg_fps": 13.532,
"low_1pct_fps": 3.247,
"low_01pct_fps": 2.833,
"p50_ms": 52.758,
"p95_ms": 208.496,
"p99_ms": 263.074,
"p999_ms": 352.963,
"max_ms": 352.963,
"histogram": [
0,
0,
0,
0,
0,
0,
0,
0,
1,
2,
4,
5,
5,
14,
23,
10,
12,
17,
30,
18,
20,
2,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 951,
"seconds": 12.212,
"avg_fps": 77.872,
"low_1pct_fps": 6.512,
"low_01pct_fps": 1.967,
"p50_ms": 8.895,
"p95_ms": 31.377,
"p99_ms": 72.053,
"p999_ms": 508.284,
"max_ms": 508.284,
"histogram": [
0,
0,
0,
0,
0,
0,
0,
359,
306,
104,
47,
29,
31,
16,
18,
10,
9,
10,
6,
2,
3,
0,
1,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 495,
"seconds": 12.082,
"avg_fps": 40.97,
"low_1pct_fps": 4.097,
"low_01pct_fps": 2.261,
"p50_ms": 11.995,
"p95_ms": 80.625,
"p99_ms": 146.758,
"p999_ms": 442.316,
"max_ms": 442.316,
"histogram": [
0,
0,
0,
0,
0,
0,
3,
80,
109,
56,
31,
37,
24,
30,
28,
17,
17,
27,
24,
8,
2,
2,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 243,
"seconds": 12.008,
"avg_fps": 20.237,
"low_1pct_fps": 2.827,
"low_01pct_fps": 2.623,
"p50_ms": 21.487,
"p95_ms": 177.814,
"p99_ms": 315.716,
"p999_ms": 381.289,
"max_ms": 381.289,
"histogram": [
0,
0,
0,
0,
0,
0,
0,
0,
5,
20,
26,
24,
32,
25,
21,
8,
19,
13,
18,
15,
10,
7,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 431,
"seconds": 12.153,
"avg_fps": 35.466,
"low_1pct_fps": 2.273,
"low_01pct_fps": 1.246,
"p50_ms": 12.232,
"p95_ms": 106.955,
"p99_ms": 249.209,
"p999_ms": 802.438,
"max_ms": 802.438,
"histogram": [
0,
0,
0,
0,
0,
0,
0,
0,
66,
137,
62,
41,
21,
23,
20,
3,
14,
11,
11,
10,
8,
2,
2,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 367,
"seconds": 12.02,
"avg_fps": 30.533,
"low_1pct_fps": 3.94,
"low_01pct_fps": 3.263,
"p50_ms": 21.354,
"p95_ms": 104.182,
"p99_ms": 218.448,
"p999_ms": 306.483,
"max_ms": 306.483,
"histogram": [
0,
0,
0,
0,
0,
0,
0,
0,
4,
52,
38,
32,
44,
53,
47,
20,
21,
19,
16,
15,
4,
2,
0,
0
]
}
],
"label": "vanilla-m4pro-1080p"
}
@@ -0,0 +1,707 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-01T22:55:52Z",
"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": 8,
"simulation_distance": 12,
"vsync": false,
"offscreen": true,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 66.477,
"window": [
1920,
930
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 49895.61,
"stdev": 37830.08,
"min": 27410.441,
"max": 93571.631,
"ci95": 93982.713
},
"startup.world_ms": {
"n": 3,
"mean": 89080.61,
"stdev": 49418.222,
"min": 59243.441,
"max": 146123.631,
"ci95": 122771.577
},
"chunks.village.load_ms": {
"n": 3,
"mean": 29280.957,
"stdev": 15620.91,
"min": 18681.538,
"max": 47219.974,
"ci95": 38807.625
},
"fps.village.avg_fps": {
"n": 3,
"mean": 42.245,
"stdev": 6.241,
"min": 36.416,
"max": 48.83,
"ci95": 15.506
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 4.137,
"stdev": 1.2,
"min": 3.03,
"max": 5.412,
"ci95": 2.981
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 2.891,
"stdev": 0.196,
"min": 2.777,
"max": 3.118,
"ci95": 0.488
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 16212.425,
"stdev": 1584.155,
"min": 14800.702,
"max": 17925.672,
"ci95": 3935.576
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 66.392,
"stdev": 8.735,
"min": 57.65,
"max": 75.119,
"ci95": 21.699
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 8.327,
"stdev": 3.862,
"min": 6.012,
"max": 12.786,
"ci95": 9.595
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 6.154,
"stdev": 3.399,
"min": 3.873,
"max": 10.06,
"ci95": 8.444
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 9054.345,
"stdev": 1795.805,
"min": 7429.882,
"max": 10982.71,
"ci95": 4461.386
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 49.891,
"stdev": 17.901,
"min": 29.637,
"max": 63.591,
"ci95": 44.472
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 6.144,
"stdev": 5.262,
"min": 2.948,
"max": 12.218,
"ci95": 13.074
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 5.254,
"stdev": 4.377,
"min": 2.603,
"max": 10.306,
"ci95": 10.874
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 18171.415,
"stdev": 5234.921,
"min": 12170.39,
"max": 21800.551,
"ci95": 13005.314
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 45.9,
"stdev": 14.757,
"min": 29.055,
"max": 56.549,
"ci95": 36.661
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 4.83,
"stdev": 2.815,
"min": 2.104,
"max": 7.727,
"ci95": 6.994
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 3.986,
"stdev": 2.385,
"min": 1.597,
"max": 6.366,
"ci95": 5.924
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 1877.276,
"stdev": 149.19,
"min": 1724.094,
"max": 2022.125,
"ci95": 370.639
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 1810.693,
"stdev": 157.376,
"min": 1671.984,
"max": 1981.719,
"ci95": 390.975
},
"memory.footprint_peak_mb": {
"n": 3,
"mean": 2708.649,
"stdev": 200.476,
"min": 2538.472,
"max": 2929.644,
"ci95": 498.051
},
"memory.footprint_steady_mb": {
"n": 3,
"mean": 2635.498,
"stdev": 241.221,
"min": 2402.909,
"max": 2884.511,
"ci95": 599.274
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 586,
"seconds": 12.001,
"avg_fps": 48.83,
"low_1pct_fps": 3.03,
"low_01pct_fps": 2.779,
"p50_ms": 6.298,
"p95_ms": 102.59,
"p99_ms": 300.171,
"p999_ms": 359.883,
"max_ms": 359.883,
"histogram": [
0,
0,
30,
105,
67,
75,
58,
68,
43,
24,
14,
15,
8,
4,
8,
4,
7,
8,
18,
11,
11,
8,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 498,
"seconds": 12.003,
"avg_fps": 41.489,
"low_1pct_fps": 5.412,
"low_01pct_fps": 3.118,
"p50_ms": 14.331,
"p95_ms": 81.666,
"p99_ms": 133.706,
"p999_ms": 320.73,
"max_ms": 320.73,
"histogram": [
0,
0,
0,
5,
15,
23,
21,
37,
43,
41,
55,
45,
50,
45,
30,
15,
18,
18,
21,
13,
2,
1,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 437,
"seconds": 12.0,
"avg_fps": 36.416,
"low_1pct_fps": 3.97,
"low_01pct_fps": 2.777,
"p50_ms": 12.279,
"p95_ms": 133.385,
"p99_ms": 203.287,
"p999_ms": 360.113,
"max_ms": 360.113,
"histogram": [
0,
0,
0,
9,
28,
21,
19,
41,
52,
41,
31,
33,
27,
25,
27,
13,
13,
17,
12,
14,
12,
2,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 797,
"seconds": 12.002,
"avg_fps": 66.407,
"low_1pct_fps": 6.184,
"low_01pct_fps": 4.528,
"p50_ms": 7.208,
"p95_ms": 64.408,
"p99_ms": 126.005,
"p999_ms": 220.846,
"max_ms": 220.846,
"histogram": [
0,
0,
2,
22,
78,
137,
123,
136,
77,
58,
31,
14,
13,
14,
13,
10,
11,
21,
20,
13,
4,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 692,
"seconds": 12.003,
"avg_fps": 57.65,
"low_1pct_fps": 6.012,
"low_01pct_fps": 3.873,
"p50_ms": 10.783,
"p95_ms": 48.101,
"p99_ms": 121.963,
"p999_ms": 258.167,
"max_ms": 258.167,
"histogram": [
0,
0,
0,
13,
52,
52,
41,
62,
83,
88,
50,
46,
40,
50,
43,
20,
23,
9,
11,
6,
2,
1,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 902,
"seconds": 12.008,
"avg_fps": 75.119,
"low_1pct_fps": 12.786,
"low_01pct_fps": 10.06,
"p50_ms": 9.096,
"p95_ms": 38.051,
"p99_ms": 67.18,
"p999_ms": 99.405,
"max_ms": 99.405,
"histogram": [
0,
0,
0,
26,
115,
72,
65,
100,
118,
90,
66,
51,
54,
50,
39,
16,
17,
13,
10,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 687,
"seconds": 12.171,
"avg_fps": 56.446,
"low_1pct_fps": 3.267,
"low_01pct_fps": 2.853,
"p50_ms": 4.938,
"p95_ms": 103.639,
"p99_ms": 257.27,
"p999_ms": 350.504,
"max_ms": 350.504,
"histogram": [
0,
0,
28,
199,
125,
99,
51,
35,
32,
18,
5,
7,
11,
10,
11,
6,
2,
4,
7,
14,
15,
8,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 764,
"seconds": 12.014,
"avg_fps": 63.591,
"low_1pct_fps": 12.218,
"low_01pct_fps": 10.306,
"p50_ms": 11.549,
"p95_ms": 45.214,
"p99_ms": 67.493,
"p999_ms": 97.034,
"max_ms": 97.034,
"histogram": [
0,
0,
0,
9,
45,
41,
59,
58,
87,
101,
71,
85,
42,
56,
49,
15,
16,
22,
8,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 357,
"seconds": 12.046,
"avg_fps": 29.637,
"low_1pct_fps": 2.948,
"low_01pct_fps": 2.603,
"p50_ms": 10.947,
"p95_ms": 173.526,
"p99_ms": 311.134,
"p999_ms": 384.135,
"max_ms": 384.135,
"histogram": [
0,
0,
0,
11,
47,
31,
12,
34,
32,
23,
15,
26,
21,
14,
15,
7,
6,
16,
18,
6,
15,
8,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 355,
"seconds": 12.218,
"avg_fps": 29.055,
"low_1pct_fps": 2.104,
"low_01pct_fps": 1.597,
"p50_ms": 7.766,
"p95_ms": 145.251,
"p99_ms": 368.51,
"p999_ms": 626.205,
"max_ms": 626.205,
"histogram": [
0,
0,
41,
53,
25,
28,
15,
22,
7,
16,
10,
15,
17,
12,
15,
11,
11,
8,
14,
19,
7,
7,
2,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 680,
"seconds": 12.025,
"avg_fps": 56.549,
"low_1pct_fps": 7.727,
"low_01pct_fps": 6.366,
"p50_ms": 11.633,
"p95_ms": 52.508,
"p99_ms": 111.848,
"p999_ms": 157.09,
"max_ms": 157.09,
"histogram": [
0,
0,
1,
32,
38,
43,
49,
71,
55,
55,
54,
57,
53,
41,
54,
21,
19,
17,
12,
7,
1,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 632,
"seconds": 12.132,
"avg_fps": 52.095,
"low_1pct_fps": 4.66,
"low_01pct_fps": 3.995,
"p50_ms": 8.601,
"p95_ms": 78.611,
"p99_ms": 172.405,
"p999_ms": 250.342,
"max_ms": 250.342,
"histogram": [
0,
0,
9,
75,
86,
53,
32,
57,
40,
41,
30,
38,
25,
24,
39,
16,
23,
7,
12,
15,
9,
1,
0,
0
]
}
],
"label": "vanilla-m4pro-1080p"
}
@@ -0,0 +1,723 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-01T22:55:52Z",
"app": {
"name": "minecraft",
"version": "26.3",
"mods": [
{
"name": "fabric-loader",
"version": "0.19.5"
},
{
"name": "sodium",
"version": "mc26.3-0.9.2-fabric"
},
{
"name": "lithium",
"version": "mc26.3-0.26.2-fabric"
},
{
"name": "ferrite-core",
"version": "9.0.0-fabric"
},
{
"name": "immediatelyfast",
"version": "1.17.1+26.3-fabric"
}
]
},
"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": 16,
"simulation_distance": 12,
"vsync": false,
"offscreen": true,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 69.878,
"window": [
1920,
930
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 23843.039,
"stdev": 7524.513,
"min": 16643.766,
"max": 31655.294,
"ci95": 18693.436
},
"startup.world_ms": {
"n": 3,
"mean": 76908.706,
"stdev": 36719.804,
"min": 45051.766,
"max": 117069.294,
"ci95": 91224.412
},
"chunks.village.load_ms": {
"n": 3,
"mean": 19608.485,
"stdev": 9449.035,
"min": 13510.149,
"max": 30492.958,
"ci95": 23474.6
},
"fps.village.avg_fps": {
"n": 3,
"mean": 46.159,
"stdev": 17.954,
"min": 31.413,
"max": 66.152,
"ci95": 44.604
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 6.544,
"stdev": 3.452,
"min": 3.225,
"max": 10.116,
"ci95": 8.577
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 4.689,
"stdev": 1.808,
"min": 2.647,
"max": 6.085,
"ci95": 4.491
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 23344.685,
"stdev": 5493.145,
"min": 17038.709,
"max": 27089.821,
"ci95": 13646.83
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 61.892,
"stdev": 37.939,
"min": 22.354,
"max": 98.0,
"ci95": 94.254
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 9.736,
"stdev": 8.597,
"min": 2.296,
"max": 19.148,
"ci95": 21.359
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 6.651,
"stdev": 5.574,
"min": 2.202,
"max": 12.903,
"ci95": 13.847
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 27267.052,
"stdev": 21587.34,
"min": 10640.097,
"max": 51663.797,
"ci95": 53630.253
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 75.493,
"stdev": 63.885,
"min": 25.975,
"max": 147.604,
"ci95": 158.711
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 13.396,
"stdev": 13.366,
"min": 4.634,
"max": 28.78,
"ci95": 33.205
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 7.356,
"stdev": 6.609,
"min": 3.26,
"max": 14.98,
"ci95": 16.418
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 38284.867,
"stdev": 4237.4,
"min": 33922.931,
"max": 42385.65,
"ci95": 10527.134
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 35.918,
"stdev": 17.742,
"min": 18.135,
"max": 53.619,
"ci95": 44.077
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 3.999,
"stdev": 2.132,
"min": 2.453,
"max": 6.431,
"ci95": 5.297
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 3.017,
"stdev": 1.831,
"min": 1.775,
"max": 5.12,
"ci95": 4.549
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 2566.339,
"stdev": 218.228,
"min": 2339.094,
"max": 2774.266,
"ci95": 542.153
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 2354.852,
"stdev": 240.105,
"min": 2082.984,
"max": 2537.867,
"ci95": 596.503
},
"memory.footprint_peak_mb": {
"n": 3,
"mean": 3430.598,
"stdev": 77.063,
"min": 3345.395,
"max": 3495.426,
"ci95": 191.451
},
"memory.footprint_steady_mb": {
"n": 3,
"mean": 3211.931,
"stdev": 99.984,
"min": 3124.098,
"max": 3320.738,
"ci95": 248.394
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 377,
"seconds": 12.002,
"avg_fps": 31.413,
"low_1pct_fps": 3.225,
"low_01pct_fps": 2.647,
"p50_ms": 15.145,
"p95_ms": 128.716,
"p99_ms": 276.685,
"p999_ms": 377.749,
"max_ms": 377.749,
"histogram": [
0,
0,
0,
0,
17,
26,
21,
30,
23,
43,
16,
25,
28,
28,
35,
16,
14,
12,
15,
13,
11,
4,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 794,
"seconds": 12.003,
"avg_fps": 66.152,
"low_1pct_fps": 10.116,
"low_01pct_fps": 6.085,
"p50_ms": 11.137,
"p95_ms": 41.078,
"p99_ms": 73.531,
"p999_ms": 164.347,
"max_ms": 164.347,
"histogram": [
0,
0,
0,
20,
57,
65,
52,
70,
82,
88,
80,
67,
53,
48,
52,
18,
20,
10,
10,
1,
1,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 491,
"seconds": 12.001,
"avg_fps": 40.912,
"low_1pct_fps": 6.291,
"low_01pct_fps": 5.336,
"p50_ms": 15.962,
"p95_ms": 71.604,
"p99_ms": 137.056,
"p999_ms": 187.401,
"max_ms": 187.401,
"histogram": [
0,
0,
0,
0,
1,
5,
10,
32,
54,
60,
45,
49,
44,
49,
50,
22,
21,
20,
18,
8,
3,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 784,
"seconds": 12.002,
"avg_fps": 65.322,
"low_1pct_fps": 7.763,
"low_01pct_fps": 4.848,
"p50_ms": 10.024,
"p95_ms": 43.46,
"p99_ms": 94.19,
"p999_ms": 206.286,
"max_ms": 206.286,
"histogram": [
0,
0,
0,
17,
40,
69,
80,
95,
88,
82,
53,
58,
39,
49,
52,
21,
15,
14,
7,
3,
2,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 1176,
"seconds": 12.0,
"avg_fps": 98.0,
"low_1pct_fps": 19.148,
"low_01pct_fps": 12.903,
"p50_ms": 6.214,
"p95_ms": 30.125,
"p99_ms": 41.199,
"p999_ms": 73.675,
"max_ms": 81.328,
"histogram": [
0,
0,
3,
179,
205,
175,
115,
111,
79,
39,
21,
25,
34,
79,
77,
20,
10,
2,
2,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 271,
"seconds": 12.123,
"avg_fps": 22.354,
"low_1pct_fps": 2.296,
"low_01pct_fps": 2.202,
"p50_ms": 14.989,
"p95_ms": 214.282,
"p99_ms": 404.5,
"p999_ms": 454.134,
"max_ms": 454.134,
"histogram": [
0,
0,
0,
1,
5,
18,
19,
19,
28,
28,
13,
19,
20,
12,
21,
14,
5,
8,
4,
9,
18,
10,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 1772,
"seconds": 12.005,
"avg_fps": 147.604,
"low_1pct_fps": 28.78,
"low_01pct_fps": 14.98,
"p50_ms": 5.328,
"p95_ms": 15.261,
"p99_ms": 25.961,
"p999_ms": 58.586,
"max_ms": 74.925,
"histogram": [
0,
0,
34,
421,
331,
290,
175,
163,
122,
83,
44,
33,
26,
28,
16,
2,
2,
1,
1,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 635,
"seconds": 12.004,
"avg_fps": 52.9,
"low_1pct_fps": 6.774,
"low_01pct_fps": 3.828,
"p50_ms": 9.578,
"p95_ms": 61.195,
"p99_ms": 112.118,
"p999_ms": 261.228,
"max_ms": 261.228,
"histogram": [
0,
0,
0,
38,
49,
64,
63,
63,
51,
60,
27,
29,
27,
17,
37,
28,
30,
27,
15,
8,
1,
1,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 312,
"seconds": 12.011,
"avg_fps": 25.975,
"low_1pct_fps": 4.634,
"low_01pct_fps": 3.26,
"p50_ms": 24.629,
"p95_ms": 115.493,
"p99_ms": 169.827,
"p999_ms": 306.737,
"max_ms": 306.737,
"histogram": [
0,
0,
0,
0,
0,
0,
2,
5,
10,
10,
24,
39,
33,
38,
47,
16,
16,
21,
23,
22,
5,
1,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 218,
"seconds": 12.021,
"avg_fps": 18.135,
"low_1pct_fps": 2.453,
"low_01pct_fps": 2.156,
"p50_ms": 17.447,
"p95_ms": 257.129,
"p99_ms": 377.63,
"p999_ms": 463.802,
"max_ms": 463.802,
"histogram": [
0,
0,
7,
13,
15,
18,
11,
9,
7,
16,
8,
3,
11,
10,
14,
7,
5,
16,
8,
15,
12,
13,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 653,
"seconds": 12.179,
"avg_fps": 53.619,
"low_1pct_fps": 6.431,
"low_01pct_fps": 5.12,
"p50_ms": 8.427,
"p95_ms": 58.968,
"p99_ms": 141.423,
"p999_ms": 195.302,
"max_ms": 195.302,
"histogram": [
0,
0,
0,
38,
66,
76,
79,
64,
45,
38,
20,
29,
22,
24,
46,
25,
36,
17,
14,
10,
4,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 432,
"seconds": 12.0,
"avg_fps": 35.999,
"low_1pct_fps": 3.112,
"low_01pct_fps": 1.775,
"p50_ms": 16.724,
"p95_ms": 83.13,
"p99_ms": 220.496,
"p999_ms": 563.493,
"max_ms": 563.493,
"histogram": [
0,
0,
0,
7,
14,
17,
23,
35,
35,
26,
24,
33,
34,
43,
43,
23,
27,
20,
15,
5,
4,
3,
1,
0
]
}
],
"label": "vanilla-mods-m4pro-1080p"
}
@@ -0,0 +1,723 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-01T23:17:23Z",
"app": {
"name": "minecraft",
"version": "26.3",
"mods": [
{
"name": "fabric-loader",
"version": "0.19.5"
},
{
"name": "sodium",
"version": "mc26.3-0.9.2-fabric"
},
{
"name": "lithium",
"version": "mc26.3-0.26.2-fabric"
},
{
"name": "ferrite-core",
"version": "9.0.0-fabric"
},
{
"name": "immediatelyfast",
"version": "1.17.1+26.3-fabric"
}
]
},
"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": true,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 37.185,
"window": [
1920,
930
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 13521.661,
"stdev": 573.908,
"min": 13085.942,
"max": 14171.935,
"ci95": 1425.783
},
"startup.world_ms": {
"n": 3,
"mean": 63229.994,
"stdev": 6185.388,
"min": 56237.942,
"max": 67987.935,
"ci95": 15366.595
},
"chunks.village.load_ms": {
"n": 3,
"mean": 14807.746,
"stdev": 2020.358,
"min": 13414.414,
"max": 17124.849,
"ci95": 5019.253
},
"fps.village.avg_fps": {
"n": 3,
"mean": 91.753,
"stdev": 11.452,
"min": 78.555,
"max": 99.067,
"ci95": 28.451
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 13.105,
"stdev": 2.514,
"min": 10.354,
"max": 15.282,
"ci95": 6.244
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 9.203,
"stdev": 1.382,
"min": 7.689,
"max": 10.396,
"ci95": 3.432
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 24180.167,
"stdev": 777.711,
"min": 23290.973,
"max": 24733.555,
"ci95": 1932.098
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 113.871,
"stdev": 53.305,
"min": 56.542,
"max": 161.938,
"ci95": 132.428
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 28.748,
"stdev": 17.633,
"min": 12.823,
"max": 47.698,
"ci95": 43.807
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 20.551,
"stdev": 13.547,
"min": 8.937,
"max": 35.433,
"ci95": 33.655
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 19314.547,
"stdev": 5853.258,
"min": 15068.272,
"max": 25991.528,
"ci95": 14541.473
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 119.116,
"stdev": 71.027,
"min": 45.498,
"max": 187.231,
"ci95": 176.454
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 31.721,
"stdev": 26.624,
"min": 8.585,
"max": 60.822,
"ci95": 66.144
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 16.8,
"stdev": 14.937,
"min": 5.876,
"max": 33.821,
"ci95": 37.109
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 61290.415,
"stdev": 15063.343,
"min": 49453.256,
"max": 78246.003,
"ci95": 37422.439
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 56.905,
"stdev": 19.674,
"min": 37.807,
"max": 77.109,
"ci95": 48.878
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 7.384,
"stdev": 0.62,
"min": 6.67,
"max": 7.784,
"ci95": 1.539
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 3.48,
"stdev": 1.306,
"min": 2.526,
"max": 4.968,
"ci95": 3.244
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 4078.01,
"stdev": 79.593,
"min": 4020.391,
"max": 4168.828,
"ci95": 197.735
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 3705.104,
"stdev": 149.415,
"min": 3541.375,
"max": 3834.078,
"ci95": 371.196
},
"memory.footprint_peak_mb": {
"n": 3,
"mean": 5250.386,
"stdev": 54.229,
"min": 5200.662,
"max": 5308.208,
"ci95": 134.723
},
"memory.footprint_steady_mb": {
"n": 3,
"mean": 4647.172,
"stdev": 29.179,
"min": 4613.552,
"max": 4665.911,
"ci95": 72.492
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 1172,
"seconds": 12.004,
"avg_fps": 97.637,
"low_1pct_fps": 15.282,
"low_01pct_fps": 10.396,
"p50_ms": 7.394,
"p95_ms": 26.126,
"p99_ms": 50.505,
"p999_ms": 79.614,
"max_ms": 112.764,
"histogram": [
0,
0,
0,
24,
111,
198,
193,
170,
129,
109,
59,
42,
43,
23,
38,
10,
10,
7,
5,
1,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 1189,
"seconds": 12.002,
"avg_fps": 99.067,
"low_1pct_fps": 13.678,
"low_01pct_fps": 9.523,
"p50_ms": 7.061,
"p95_ms": 28.029,
"p99_ms": 58.166,
"p999_ms": 103.326,
"max_ms": 106.682,
"histogram": [
0,
0,
0,
59,
132,
223,
172,
171,
107,
90,
59,
39,
36,
28,
35,
14,
9,
9,
4,
2,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 943,
"seconds": 12.004,
"avg_fps": 78.555,
"low_1pct_fps": 10.354,
"low_01pct_fps": 7.689,
"p50_ms": 8.817,
"p95_ms": 31.62,
"p99_ms": 70.58,
"p999_ms": 130.049,
"max_ms": 130.049,
"histogram": [
0,
0,
0,
8,
34,
97,
118,
168,
126,
106,
69,
58,
49,
36,
32,
8,
9,
13,
8,
4,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 679,
"seconds": 12.009,
"avg_fps": 56.542,
"low_1pct_fps": 12.823,
"low_01pct_fps": 8.937,
"p50_ms": 14.344,
"p95_ms": 42.09,
"p99_ms": 65.756,
"p999_ms": 111.897,
"max_ms": 111.897,
"histogram": [
0,
0,
0,
0,
2,
33,
50,
62,
68,
60,
48,
67,
84,
76,
64,
27,
18,
14,
5,
1,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 1478,
"seconds": 12.003,
"avg_fps": 123.133,
"low_1pct_fps": 25.723,
"low_01pct_fps": 17.282,
"p50_ms": 6.388,
"p95_ms": 17.972,
"p99_ms": 29.029,
"p999_ms": 57.659,
"max_ms": 58.069,
"histogram": [
0,
0,
0,
3,
336,
298,
222,
186,
144,
77,
70,
57,
39,
23,
13,
5,
3,
2,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 1944,
"seconds": 12.005,
"avg_fps": 161.938,
"low_1pct_fps": 47.698,
"low_01pct_fps": 35.433,
"p50_ms": 5.168,
"p95_ms": 12.12,
"p99_ms": 17.337,
"p999_ms": 26.48,
"max_ms": 29.965,
"histogram": [
0,
0,
0,
18,
793,
527,
243,
125,
91,
47,
41,
34,
15,
7,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 548,
"seconds": 12.044,
"avg_fps": 45.498,
"low_1pct_fps": 8.585,
"low_01pct_fps": 5.876,
"p50_ms": 15.416,
"p95_ms": 62.523,
"p99_ms": 98.757,
"p999_ms": 170.17,
"max_ms": 170.17,
"histogram": [
0,
0,
0,
0,
2,
15,
26,
52,
53,
58,
37,
56,
39,
58,
60,
27,
24,
18,
21,
1,
1,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 1496,
"seconds": 12.005,
"avg_fps": 124.619,
"low_1pct_fps": 25.756,
"low_01pct_fps": 10.702,
"p50_ms": 6.649,
"p95_ms": 15.751,
"p99_ms": 26.208,
"p999_ms": 76.546,
"max_ms": 110.33,
"histogram": [
0,
0,
0,
15,
250,
299,
266,
216,
163,
115,
68,
40,
32,
13,
14,
3,
0,
0,
1,
1,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 2247,
"seconds": 12.001,
"avg_fps": 187.231,
"low_1pct_fps": 60.822,
"low_01pct_fps": 33.821,
"p50_ms": 4.73,
"p95_ms": 8.497,
"p99_ms": 12.315,
"p999_ms": 21.724,
"max_ms": 42.26,
"histogram": [
0,
0,
0,
347,
987,
453,
211,
122,
67,
32,
15,
6,
4,
2,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 670,
"seconds": 12.007,
"avg_fps": 55.799,
"low_1pct_fps": 7.784,
"low_01pct_fps": 2.526,
"p50_ms": 12.887,
"p95_ms": 45.129,
"p99_ms": 76.315,
"p999_ms": 395.821,
"max_ms": 395.821,
"histogram": [
0,
0,
0,
0,
0,
16,
54,
79,
75,
81,
68,
60,
53,
70,
54,
17,
16,
9,
17,
0,
0,
1,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 455,
"seconds": 12.035,
"avg_fps": 37.807,
"low_1pct_fps": 6.67,
"low_01pct_fps": 4.968,
"p50_ms": 15.986,
"p95_ms": 82.012,
"p99_ms": 117.987,
"p999_ms": 201.279,
"max_ms": 201.279,
"histogram": [
0,
0,
0,
0,
0,
3,
27,
37,
47,
49,
37,
39,
31,
39,
34,
20,
24,
30,
26,
10,
2,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 933,
"seconds": 12.1,
"avg_fps": 77.109,
"low_1pct_fps": 7.697,
"low_01pct_fps": 2.946,
"p50_ms": 8.593,
"p95_ms": 34.696,
"p99_ms": 79.266,
"p999_ms": 339.486,
"max_ms": 339.486,
"histogram": [
0,
0,
0,
0,
0,
45,
176,
209,
196,
95,
45,
43,
26,
19,
30,
9,
9,
14,
13,
2,
1,
1,
0,
0
]
}
],
"label": "vanilla-mods-m4pro-1080p"
}
@@ -0,0 +1,723 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-01T22:55:52Z",
"app": {
"name": "minecraft",
"version": "26.3",
"mods": [
{
"name": "fabric-loader",
"version": "0.19.5"
},
{
"name": "sodium",
"version": "mc26.3-0.9.2-fabric"
},
{
"name": "lithium",
"version": "mc26.3-0.26.2-fabric"
},
{
"name": "ferrite-core",
"version": "9.0.0-fabric"
},
{
"name": "immediatelyfast",
"version": "1.17.1+26.3-fabric"
}
]
},
"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": 8,
"simulation_distance": 12,
"vsync": false,
"offscreen": true,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 74.321,
"window": [
1920,
930
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 56311.461,
"stdev": 26982.658,
"min": 36031.835,
"max": 86935.815,
"ci95": 67034.048
},
"startup.world_ms": {
"n": 3,
"mean": 97731.794,
"stdev": 21691.925,
"min": 72951.835,
"max": 113284.815,
"ci95": 53890.078
},
"chunks.village.load_ms": {
"n": 3,
"mean": 13954.335,
"stdev": 10146.301,
"min": 7679.006,
"max": 25660.115,
"ci95": 25206.843
},
"fps.village.avg_fps": {
"n": 3,
"mean": 33.239,
"stdev": 18.526,
"min": 12.03,
"max": 46.264,
"ci95": 46.026
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 3.772,
"stdev": 2.399,
"min": 1.734,
"max": 6.416,
"ci95": 5.96
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 2.866,
"stdev": 1.762,
"min": 1.433,
"max": 4.834,
"ci95": 4.379
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 19803.961,
"stdev": 14910.114,
"min": 8220.752,
"max": 36626.588,
"ci95": 37041.766
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 47.303,
"stdev": 17.044,
"min": 32.698,
"max": 66.03,
"ci95": 42.343
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 4.365,
"stdev": 0.709,
"min": 3.658,
"max": 5.076,
"ci95": 1.761
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 3.128,
"stdev": 0.564,
"min": 2.651,
"max": 3.751,
"ci95": 1.402
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 18639.188,
"stdev": 8912.103,
"min": 11960.198,
"max": 28758.72,
"ci95": 22140.678
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 34.908,
"stdev": 6.372,
"min": 29.271,
"max": 41.822,
"ci95": 15.831
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 4.614,
"stdev": 1.558,
"min": 2.986,
"max": 6.09,
"ci95": 3.869
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 4.06,
"stdev": 1.662,
"min": 2.224,
"max": 5.463,
"ci95": 4.13
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 19191.558,
"stdev": 2902.338,
"min": 15845.729,
"max": 21030.7,
"ci95": 7210.388
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 67.004,
"stdev": 13.923,
"min": 51.98,
"max": 79.471,
"ci95": 34.589
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 7.862,
"stdev": 1.987,
"min": 6.339,
"max": 10.109,
"ci95": 4.936
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 3.993,
"stdev": 1.679,
"min": 2.06,
"max": 5.088,
"ci95": 4.172
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 2019.089,
"stdev": 53.398,
"min": 1960.188,
"max": 2064.328,
"ci95": 132.657
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 1927.195,
"stdev": 108.077,
"min": 1813.609,
"max": 2028.758,
"ci95": 268.5
},
"memory.footprint_peak_mb": {
"n": 3,
"mean": 2440.727,
"stdev": 72.508,
"min": 2375.394,
"max": 2518.738,
"ci95": 180.134
},
"memory.footprint_steady_mb": {
"n": 3,
"mean": 2395.53,
"stdev": 71.597,
"min": 2318.504,
"max": 2460.05,
"ci95": 177.872
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 146,
"seconds": 12.136,
"avg_fps": 12.03,
"low_1pct_fps": 1.734,
"low_01pct_fps": 1.433,
"p50_ms": 28.465,
"p95_ms": 339.625,
"p99_ms": 455.663,
"p999_ms": 697.859,
"max_ms": 697.859,
"histogram": [
0,
0,
1,
4,
9,
9,
5,
5,
8,
6,
5,
5,
1,
10,
9,
6,
11,
10,
5,
8,
15,
13,
1,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 556,
"seconds": 12.018,
"avg_fps": 46.264,
"low_1pct_fps": 6.416,
"low_01pct_fps": 4.834,
"p50_ms": 13.72,
"p95_ms": 71.774,
"p99_ms": 120.078,
"p999_ms": 206.87,
"max_ms": 206.87,
"histogram": [
0,
0,
0,
6,
5,
16,
22,
57,
55,
64,
62,
62,
50,
38,
29,
19,
23,
16,
21,
9,
2,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 498,
"seconds": 12.022,
"avg_fps": 41.423,
"low_1pct_fps": 3.166,
"low_01pct_fps": 2.331,
"p50_ms": 7.7,
"p95_ms": 114.025,
"p99_ms": 246.232,
"p999_ms": 428.98,
"max_ms": 428.98,
"histogram": [
0,
0,
15,
63,
55,
58,
33,
38,
31,
22,
23,
17,
24,
24,
20,
11,
9,
10,
14,
15,
12,
4,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 521,
"seconds": 12.065,
"avg_fps": 43.182,
"low_1pct_fps": 4.362,
"low_01pct_fps": 3.751,
"p50_ms": 8.121,
"p95_ms": 110.621,
"p99_ms": 191.925,
"p999_ms": 266.575,
"max_ms": 266.575,
"histogram": [
0,
0,
1,
63,
27,
49,
61,
67,
39,
45,
27,
17,
13,
17,
7,
11,
13,
11,
18,
22,
11,
2,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 793,
"seconds": 12.01,
"avg_fps": 66.03,
"low_1pct_fps": 5.076,
"low_01pct_fps": 2.651,
"p50_ms": 8.218,
"p95_ms": 44.767,
"p99_ms": 143.139,
"p999_ms": 377.207,
"max_ms": 377.207,
"histogram": [
0,
0,
0,
60,
106,
55,
58,
127,
97,
64,
41,
28,
32,
31,
39,
12,
7,
8,
14,
9,
4,
1,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 395,
"seconds": 12.08,
"avg_fps": 32.698,
"low_1pct_fps": 3.658,
"low_01pct_fps": 2.983,
"p50_ms": 10.283,
"p95_ms": 171.818,
"p99_ms": 231.998,
"p999_ms": 335.242,
"max_ms": 335.242,
"histogram": [
0,
0,
6,
19,
13,
27,
42,
31,
53,
36,
22,
25,
20,
19,
10,
8,
7,
6,
11,
15,
23,
2,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 405,
"seconds": 12.043,
"avg_fps": 33.63,
"low_1pct_fps": 4.765,
"low_01pct_fps": 4.492,
"p50_ms": 14.686,
"p95_ms": 115.893,
"p99_ms": 196.822,
"p999_ms": 222.615,
"max_ms": 222.615,
"histogram": [
0,
0,
0,
5,
6,
16,
18,
49,
42,
36,
22,
30,
35,
32,
23,
10,
10,
22,
22,
16,
11,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 502,
"seconds": 12.003,
"avg_fps": 41.822,
"low_1pct_fps": 6.09,
"low_01pct_fps": 5.463,
"p50_ms": 15.264,
"p95_ms": 76.822,
"p99_ms": 144.7,
"p999_ms": 183.045,
"max_ms": 183.045,
"histogram": [
0,
0,
0,
9,
14,
12,
13,
47,
43,
52,
41,
40,
52,
40,
50,
15,
26,
17,
17,
9,
5,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 353,
"seconds": 12.06,
"avg_fps": 29.271,
"low_1pct_fps": 2.986,
"low_01pct_fps": 2.224,
"p50_ms": 10.806,
"p95_ms": 150.281,
"p99_ms": 268.607,
"p999_ms": 449.56,
"max_ms": 449.56,
"histogram": [
0,
0,
0,
29,
16,
24,
36,
35,
23,
31,
13,
16,
12,
18,
14,
9,
8,
16,
18,
17,
12,
6,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 624,
"seconds": 12.005,
"avg_fps": 51.98,
"low_1pct_fps": 7.137,
"low_01pct_fps": 5.088,
"p50_ms": 12.033,
"p95_ms": 75.22,
"p99_ms": 115.345,
"p999_ms": 196.553,
"max_ms": 196.553,
"histogram": [
0,
0,
4,
29,
48,
34,
38,
40,
61,
56,
50,
57,
54,
41,
33,
13,
15,
15,
28,
6,
2,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 835,
"seconds": 12.004,
"avg_fps": 69.562,
"low_1pct_fps": 6.339,
"low_01pct_fps": 2.06,
"p50_ms": 9.818,
"p95_ms": 37.917,
"p99_ms": 81.491,
"p999_ms": 485.481,
"max_ms": 485.481,
"histogram": [
0,
0,
6,
62,
63,
59,
75,
84,
75,
94,
59,
77,
55,
31,
36,
27,
10,
10,
5,
5,
1,
1,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 954,
"seconds": 12.004,
"avg_fps": 79.471,
"low_1pct_fps": 10.109,
"low_01pct_fps": 4.832,
"p50_ms": 7.91,
"p95_ms": 38.672,
"p99_ms": 79.104,
"p999_ms": 206.935,
"max_ms": 206.935,
"histogram": [
0,
0,
13,
55,
70,
136,
119,
109,
93,
98,
52,
49,
48,
21,
30,
17,
14,
11,
17,
1,
1,
0,
0,
0
]
}
],
"label": "vanilla-mods-m4pro-1080p"
}
@@ -207,6 +207,20 @@ impl ToggleKeys {
#[cfg(test)]
mod tests {
/// `KeyMapping.resetToggleKeys` (a new `LocalPlayer`): a sneak and a sprint toggled on in one
/// world are off in the next.
#[test]
fn resetting_the_toggle_keys_puts_every_mapping_up() {
let mut keys = ToggleKeys::default();
keys.key(0, KeyAction::Press, false, true);
keys.key(1, KeyAction::Press, false, true);
assert!(keys.is_down(0) && keys.is_down(1));
for key in &mut keys.0 {
key.reset();
}
assert!(!keys.is_down(0) && !keys.is_down(1));
}
use super::*;
#[test]
fn toggle_flips_on_press_and_ignores_release() {
@@ -93,6 +93,8 @@ pub struct RenderAssets {
pub model_gallery: Vec<Value>,
pub animations: Vec<crate::sprites::SpriteAnimation>,
pub sprite_layers: std::collections::BTreeMap<String, u32>,
/// OptiFine's emissive block overlays: base sprite and overlay sprite names.
pub emissive: Vec<(String, String)>,
pub translucent_layers: Vec<bool>,
/// Each layer's `MipmapGenerator` settings.
pub sprite_mips: Vec<crate::mipmap::Settings>,
@@ -242,6 +244,7 @@ impl RenderAssets {
for stage in 0..10 {
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
}
let mut emissive = Vec::new();
let connected = if draws {
crate::native_emissive::load(root, &mut sprites);
// A side's sprite, for rules that connect by tile: the first quad facing it.
@@ -270,6 +273,11 @@ impl RenderAssets {
} else {
Default::default()
};
// After the connected tiles: a tile identical to a block shares its layer, and the
// overlays skip shared layers.
if draws {
emissive = crate::native_emissive::load_optifine(root, &mut sprites);
}
sprites.save_disk_cache();
if draws {
sprites.finish();
@@ -289,6 +297,7 @@ impl RenderAssets {
model_gallery: models.states,
animations: sprites.animations,
sprite_layers: sprites.layers,
emissive,
translucent_layers: sprites.translucent,
sprite_mips: sprites.mips,
pbr_normals: sprites.normals,
@@ -79,8 +79,15 @@ struct StateInfo {
axis: Option<Axis>,
/// `half=upper`: `linked` random tiles follow the lower half.
upper: bool,
/// A pane's arms (`north`, `east`, `south`, `west` true), as bits of [`ARMS`].
arms: u8,
/// Rules name the state or one of its sides' sprites ([`ConnectedTextures::stacked_pane_hides`]).
has_rules: bool,
}
/// A pane's arm properties, in [`StateInfo::arms`]' bit order.
const ARMS: [&str; 4] = ["north", "east", "south", "west"];
#[derive(Default)]
pub struct ConnectedTextures {
rules: Vec<LoadedRule>,
@@ -196,6 +203,23 @@ impl ConnectedTextures {
})
.collect();
}
// Panes whose sprites tile rules name (`matchTiles=glass`) are connected too, by a rule
// that can draw on them: here, one naming no block (a rule naming the pane is in its
// own list already).
for id in (0..out.states.len()).filter(|_| !out.by_layer.is_empty()) {
let pane = registry
.state(BlockStateId(id as u32))
.is_some_and(|s| s.name.ends_with("glass_pane"));
if pane && !out.states[id].has_rules {
out.states[id].has_rules = Face::ALL.into_iter().any(|f| {
face_layer(BlockStateId(id as u32), f).is_some_and(|l| {
out.list(&out.by_layer, l as usize)
.iter()
.any(|&i| out.rules[usize::from(i)].rule.match_blocks.is_empty())
})
});
}
}
out
}
@@ -299,6 +323,13 @@ impl ConnectedTextures {
_ => None,
},
upper: prop("half") == Some("upper"),
arms: ARMS
.iter()
.enumerate()
.filter(|&(_, arm)| prop(arm) == Some("true"))
.fold(0, |bits, (i, _)| bits | 1 << i),
// Set below, with the state's rule list.
has_rules: false,
});
list.clear();
if let Some(candidates) = by_block.get(name) {
@@ -308,6 +339,7 @@ impl ConnectedTextures {
.filter(|&&i| out.rules[usize::from(i)].rule.matches_block(name, prop)),
);
}
out.states.last_mut().expect("pushed above").has_rules = !list.is_empty();
let range = push_list(&mut out.lists, &list);
out.by_state.push(range);
}
@@ -318,6 +350,35 @@ impl ConnectedTextures {
self.rules.is_empty()
}
/// Whether a pane's top or bottom strip, centred at `centre` (block-local x, z), is hidden by
/// the pane `other` stacked on it (Continuity's "Glass Pane Culling Fix"): only for a pane
/// with connected-texture rules, under or over the same block, where `other` has the same
/// part (the post, or the arm toward the strip).
pub fn stacked_pane_hides(&self, state: u32, other: u32, [x, z]: [f32; 2]) -> bool {
let (Some(own), Some(theirs)) = (
self.states.get(state as usize),
self.states.get(other as usize),
) else {
return false;
};
if own.block != theirs.block || !own.has_rules {
return false;
}
let (dx, dz) = (x - 0.5, z - 0.5);
// The post is 2 pixels wide; an arm runs from it to the block's edge.
if dx.abs() <= 1.0 / 16.0 && dz.abs() <= 1.0 / 16.0 {
return true;
}
let arm = if dz.abs() > dx.abs() {
if dz < 0.0 { 0 } else { 2 }
} else if dx > 0.0 {
1
} else {
3
};
theirs.arms & 1 << arm != 0
}
fn list(&self, ranges: &[(u32, u32)], i: usize) -> &[u16] {
ranges.get(i).map_or(&[], |&(start, len)| {
&self.lists[start as usize..(start + len) as usize]
@@ -978,6 +1039,51 @@ mod tests {
assert_eq!((layers, pick), (vec![1001, 3000], Pick::Keep));
}
#[test]
fn stacked_panes_hide_the_strips_they_share() {
let props = |pairs: &[(&str, &str)]| -> BlockProperties {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
};
// 0 a pane with north and east arms, 1 the same pane with only a north arm, 2 another
// pane block, 3 a pane no rule names.
let states = [
(
"minecraft:glass_pane",
props(&[("north", "true"), ("east", "true")]),
),
("minecraft:glass_pane", props(&[("north", "true")])),
(
"minecraft:white_stained_glass_pane",
props(&[("north", "true")]),
),
("minecraft:iron_bars", props(&[("north", "true")])),
];
let ctm = ConnectedTextures::build(
rules(&[(
"pane.properties",
"matchBlocks=glass_pane white_stained_glass_pane\nmethod=ctm\ntiles=0-46",
)]),
states.iter().map(|(n, p)| Some((*n, p))),
|_| None,
);
let (post, north, east) = ([0.5, 0.5], [0.5, 0.2], [0.8, 0.5]);
assert!(ctm.stacked_pane_hides(0, 1, post));
assert!(ctm.stacked_pane_hides(0, 1, north));
// The pane above has no east arm: the strip shows; one with it hides it.
assert!(!ctm.stacked_pane_hides(0, 1, east));
assert!(ctm.stacked_pane_hides(1, 0, east));
let (south, west) = ([0.5, 0.8], [0.2, 0.5]);
assert!(!ctm.stacked_pane_hides(1, 0, south));
assert!(!ctm.stacked_pane_hides(1, 0, west));
assert!(ctm.stacked_pane_hides(1, 0, north));
// Another block, or a pane without rules, hides nothing.
assert!(!ctm.stacked_pane_hides(1, 2, post));
assert!(!ctm.stacked_pane_hides(3, 3, post));
}
#[test]
fn sprite_names() {
assert_eq!(
+1 -1
View File
@@ -24,7 +24,7 @@ struct Globals {
dynamic_lights: array<vec4<f32>, 16>,
colored_lights: array<vec4<f32>, 32>,
colored_count: vec4<u32>,
emissive_layers: array<vec4<u32>, 32>,
emissive_layers: array<vec4<u32>, 128>,
emissive_count: vec4<u32>,
sunrise_fan: array<vec4<f32>, 32>,
filtering: vec4<f32>,
+240 -83
View File
@@ -6,7 +6,7 @@ use crate::{
environment::Sky,
graphics::GraphicsApi,
hud::{HudBatch, HudRenderer},
mesher::{SectionMesh, Sides, TerrainVertex, Vertex},
mesher::{Part as Quads, SectionMesh, Sides, TerrainVertex, Vertex},
section_mask::SectionMask,
sprites::SpriteAnimation,
};
@@ -838,7 +838,7 @@ pub struct Renderer {
border_vertices: wgpu::Buffer,
/// Screen effects: alpha-blended, then vignette (`dst * (1 - src)`)
/// pipelines, each for every sampling (index `blend * 4 + sampling`).
screen_pipelines: [wgpu::RenderPipeline; 8],
screen_pipelines: [wgpu::RenderPipeline; 16],
screen_buffer: wgpu::Buffer,
screen_capacity: u64,
entity_group: wgpu::BindGroup,
@@ -959,6 +959,12 @@ pub struct Renderer {
/// When each section was first uploaded (`RenderSection.uploadedTime`): kept across meshing
/// it again, gone with its column. Sections that fade in over far terrain are not in it.
section_uploaded: crate::fast_hash::FxHashMap<SectionKey, std::time::Instant>,
/// When `section_uploaded` last gained a section: once the fade time has passed since,
/// every section is whole and frames skip the per-section lookups.
newest_upload: Option<std::time::Instant>,
/// When a section last began to fade in over far terrain (`SectionGpu::fading`), for the
/// same shortcut.
newest_lod_fade: Option<std::time::Instant>,
/// Changes whenever `block_entities` does (unique across renderers).
block_entity_revision: u64,
/// The light sources of each section and their revision, for `block_light`.
@@ -1809,7 +1815,7 @@ impl Renderer {
glint: (&[u8], u32, u32),
glint_armor: (&[u8], u32, u32),
forcefield: (&[u8], u32),
panorama_faces: &panorama::Faces,
panorama_faces: Arc<panorama::Faces>,
prepared: Option<PreparedTextures>,
previous: Option<&Self>,
api: GraphicsApi,
@@ -1851,7 +1857,10 @@ impl Renderer {
device_lost: renderer.device_lost.clone(),
}
}
None => open_device(&window, offscreen, api, layers).await?,
None => {
let _step = crate::spans::startup::step("renderer.open_device");
open_device(&window, offscreen, api, layers).await?
}
};
// Computed once: the sprite arrays, the shaders' constants and the
// shader pack atlas all cut the sprites by it.
@@ -1931,6 +1940,8 @@ impl Renderer {
if previous.is_none() {
startup_step(format_args!("creating the pipelines"));
}
let pipelines_step = crate::spans::startup::step("renderer.pipelines_textures");
let terrain_step = crate::spans::startup::step("renderer.terrain_pipelines");
let gpu_timer = timing::GpuTimer::new(&device, &queue);
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon terrain"),
@@ -2127,7 +2138,7 @@ impl Renderer {
true,
None,
);
// `solid` in two passes over the same draws (terrain.wgsl's `opaque_quad`), for the
// `solid` in two passes (terrain.wgsl's `opaque_quad`, `mesher::Part`), for the
// world pass without a shader pack: no cutout first, through a shader that never
// discards, then the rest.
let solid_opaque = pipeline(
@@ -2277,6 +2288,7 @@ impl Renderer {
false,
Some(alpha_blend),
);
drop(terrain_step);
let globals = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon globals"),
size: std::mem::size_of::<Globals>() as u64,
@@ -2588,6 +2600,7 @@ impl Renderer {
&glint_sampler,
)?;
hud.atlas_texture = Some(atlas.clone());
let entity_step = crate::spans::startup::step("renderer.entity_pipelines");
let entity_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon entities"),
source: wgpu::ShaderSource::Wgsl(entity_shader().into()),
@@ -3159,6 +3172,7 @@ impl Renderer {
multiview_mask: None,
cache: None,
});
drop(entity_step);
// WorldBorderRenderer: misc/forcefield.png as a MipmappedTexture
// (repeat, linear minification with mipmaps, nearest magnification).
let forcefield_size = forcefield.1;
@@ -3353,7 +3367,7 @@ impl Renderer {
};
// One per blend and `.png.mcmeta` sampling (screen_effects::SAMPLE_*):
// the shader is specialised, so a pixel pays for none of the others.
let screen_pipelines: [wgpu::RenderPipeline; 8] = std::array::from_fn(|i| {
let screen_pipelines: [wgpu::RenderPipeline; 16] = std::array::from_fn(|i| {
let blur = i as u32 & crate::screen_effects::SAMPLE_BLUR != 0;
let (entry, blend) = match (i & 4 == 0, blur) {
(true, false) => ("fs_screen", alpha_blend),
@@ -3389,7 +3403,12 @@ impl Renderer {
targets: &[Some(wgpu::ColorTargetState {
format: config.format.remove_srgb_suffix(),
blend: Some(blend),
write_mask: wgpu::ColorWrites::ALL,
// 8 and up: colour only, for a tint of the world in HDR output.
write_mask: if i & 8 == 0 {
wgpu::ColorWrites::ALL
} else {
wgpu::ColorWrites::COLOR
},
})],
}),
multiview_mask: None,
@@ -3418,6 +3437,9 @@ impl Renderer {
mapped_at_creation: false,
});
let flat_wanted = flat::wanted(&adapter, &device);
// Metal draws solid terrain in two passes (`split_solid`): meshes put all
// their opaque quads first, so each pass draws its part in a run or two.
crate::mesher::group_parts(adapter.get_info().backend == wgpu::Backend::Metal);
// Readable as storage where flat draws run: a shader pack's terrain program reads them.
let arena = arena::Arena::new(
&device,
@@ -3526,12 +3548,9 @@ impl Renderer {
(&entity_shader, &entity_layout, &entity_vertex),
config.format.remove_srgb_suffix(),
);
let panorama = panorama::Panorama::new(
&device,
&queue,
config.format.remove_srgb_suffix(),
panorama_faces,
);
let panorama =
panorama::Panorama::new(&device, config.format.remove_srgb_suffix(), panorama_faces);
drop(pipelines_step);
if previous.is_none() {
startup_step(format_args!(
"pipelines ready (multi-draw {}, occlusion culling {}, GPU timer {})",
@@ -3695,6 +3714,8 @@ impl Renderer {
shadow_runs: page_runs::PageRuns::default(),
block_entities: Default::default(),
section_uploaded: Default::default(),
newest_upload: None,
newest_lod_fade: None,
block_entity_revision: next_block_entity_revision(),
light_blocks: Default::default(),
light_revision: 0,
@@ -4022,12 +4043,17 @@ impl Renderer {
}
let fading = match self.sections.find(key) {
Some(old) => old.fading,
None => (!self.lods.is_empty()).then(std::time::Instant::now),
None if !self.lods.is_empty() => {
let now = std::time::Instant::now();
self.newest_lod_fade = Some(now);
Some(now)
}
None => None,
};
if fading.is_none() {
self.section_uploaded
.entry(key)
.or_insert_with(std::time::Instant::now);
if fading.is_none() && !self.section_uploaded.contains_key(&key) {
let now = std::time::Instant::now();
self.section_uploaded.insert(key, now);
self.newest_upload = Some(now);
}
self.release_section(key);
let section = self
@@ -4366,14 +4392,32 @@ impl Renderer {
) -> impl Iterator<Item = &([i32; 3], u32)> {
let fade = fade_millis(fade_seconds);
let now = std::time::Instant::now();
let all = !self.sections_fading_in(fade, now);
self.block_entities
.iter()
.filter(move |(key, _)| {
chunk_visibility(self.section_elapsed_ms(**key, now), fade)
all || chunk_visibility(self.section_elapsed_ms(**key, now), fade)
>= CHUNK_VISIBILITY_THRESHOLD
})
.flat_map(|(_, blocks)| blocks)
}
/// Whether a section may still be fading in over `fade_ms` (vanilla's chunk fade): one was
/// uploaded more recently than that. Otherwise every section is whole.
fn sections_fading_in(&self, fade_ms: u64, now: std::time::Instant) -> bool {
fade_ms > 0
&& self
.newest_upload
.is_some_and(|t| (now.saturating_duration_since(t).as_millis() as u64) < fade_ms)
}
/// Whether a section may be fading in or out over far terrain (`SectionGpu::fade`):
/// otherwise every section's code is 0.
fn sections_fading_over_lods(&self, now: std::time::Instant) -> bool {
!self.lod_retiring.is_empty()
|| !self.lod_leaving.is_empty()
|| self
.newest_lod_fade
.is_some_and(|t| now.saturating_duration_since(t) < lod::FADE)
}
/// Milliseconds since `key` was uploaded; a section not in `section_uploaded` is old.
fn section_elapsed_ms(&self, key: SectionKey, now: std::time::Instant) -> u64 {
self.section_uploaded.get(&key).map_or(u64::MAX, |t| {
@@ -4388,6 +4432,8 @@ impl Renderer {
pub fn clear_sections(&mut self, layout: crate::chunk::ColumnLayout) {
self.block_entities.clear();
self.section_uploaded.clear();
self.newest_upload = None;
self.newest_lod_fade = None;
self.block_entity_revision = next_block_entity_revision();
self.sections.clear();
self.lods.clear();
@@ -5470,12 +5516,15 @@ impl Renderer {
) * view_proj;
if frame.title {
self.panorama.prepare(
&self.device,
&self.queue,
self.config.width,
self.config.height,
frame.yaw,
frame.pitch,
);
} else {
self.panorama.release();
}
let view = views.primary().view;
// The hand's bob and view (`hand_globals`).
@@ -5802,11 +5851,21 @@ impl Renderer {
}
self.origins.clear();
let (sections, retiring) = (&self.sections, &self.lod_retiring);
// `LevelRenderer.extractSectionDrawGroups`: each section's `getVisibility`.
// `LevelRenderer.extractSectionDrawGroups`: each section's `getVisibility`. Once
// nothing is fading (the usual frame), every section is whole without a lookup.
let fade_ms = fade_millis(frame.chunk_fade);
let fading_in = self.sections_fading_in(fade_ms, now);
let fading_over_lods = self.sections_fading_over_lods(now);
let fade_code = move |section: &SectionGpu, key: &SectionKey| {
if fading_over_lods {
section.fade(now, retiring.contains(key))
} else {
0
}
};
let uploaded = &self.section_uploaded;
let visibility = |key: SectionKey| {
if fade_ms == 0 {
let visibility = move |key: SectionKey| {
if !fading_in {
return 1.0;
}
uploaded.get(&key).map_or(1.0, |t| {
@@ -5820,7 +5879,7 @@ impl Renderer {
visible
.iter()
.map(|v| {
let code = sections.get(v.slot).fade(now, retiring.contains(&v.key));
let code = fade_code(sections.get(v.slot), &v.key);
section_instance(v.origin, code, visibility(v.key))
})
.chain(visible_lods.iter().map(|v| lod::instance(v.origin, v.fade)))
@@ -5870,6 +5929,18 @@ impl Renderer {
self.phase2_pages.clear();
self.solid_runs.clear();
self.phase2_runs.clear();
// On Metal, phase 1 goes the opaque quads of every list, then the cutout
// ones (`Part`), each solid pass drawing its own: `[..opaque_draws]`,
// then the rest. A fading list's quads all draw in the cutout pass.
let split_solid = self.info.backend == wgpu::Backend::Metal;
let parts = |fading: bool| -> &'static [(u8, Quads)] {
match (split_solid, fading) {
(false, _) => &[(0, Quads::All)],
(true, false) => &[(0, Quads::Opaque), (1, Quads::Cutout)],
(true, true) => &[(1, Quads::All)],
}
};
let mut opaque_draws = 0;
for (i, v) in visible.iter().enumerate() {
let s = self.sections.get(v.slot);
let first_phase = cull.as_ref().is_none_or(|c| c.first(v.slot));
@@ -5878,35 +5949,47 @@ impl Renderer {
} else {
(&mut self.phase2_draws, &mut self.phase2_pages)
};
let start = draws.len();
let instances = u32::from(first_phase);
let mut indices = 0;
facing_runs_from(&s.sides, eye, mirror_low, v.origin, |first, quads| {
indices += quads * 6;
let base = s.start as u32 + first * 4;
draws.push([quads * 6, instances, 0, base, i as u32]);
pages.push((s.page, None));
});
let runs = if first_phase {
&mut self.solid_runs
// Phase 2 draws every quad, in both passes.
let parts = if first_phase {
parts(fade_code(s, &v.key) != 0)
} else {
&mut self.phase2_runs
&[(0, Quads::All)]
};
runs.push(
v.distance,
s.page,
start,
draws.len() - start,
[i as u32, v.slot, indices],
);
let whole = draws.len();
for &(group, part) in parts {
let start = draws.len();
facing_runs_from(&s.sides, eye, mirror_low, v.origin, part, |first, quads| {
indices += quads * 6;
let base = s.start as u32 + first * 4;
draws.push([quads * 6, instances, 0, base, i as u32]);
pages.push((s.page, None));
});
let len = draws.len() - start;
if first_phase {
if group == 0 {
opaque_draws += len;
}
self.solid_runs
.push_in(group, v.distance, s.page, start, len, [0; 3]);
}
}
if !first_phase {
self.phase2_runs.push(
v.distance,
s.page,
whole,
draws.len() - whole,
[i as u32, v.slot, indices],
);
}
if let Some(c) = &mut cull
&& first_phase
{
c.drawn(i, v.slot, indices);
}
}
self.solid_runs
.regroup(&mut self.draws, &mut self.draw_pages, 0);
// Phase 2's draws are tested where grouping left them.
let runs = self
.phase2_runs
@@ -5918,23 +6001,33 @@ impl Renderer {
}
}
// Distant terrain tiles are never occlusion tested: phase 1.
let tiles = self.draws.len();
self.solid_runs.clear();
for (i, v) in visible_lods.iter().enumerate() {
let s = self.lods[&v.key].drawn();
let start = self.draws.len();
let instance = (visible.len() + i) as u32;
facing_runs_from(&s.sides, eye, mirror_low, v.origin, |first, quads| {
let base = s.start as u32 + first * 4;
self.draws.push([quads * 6, 1, 0, base, instance]);
self.draw_pages.push((s.page, None));
});
self.solid_runs
.push(v.distance, s.page, start, self.draws.len() - start, [0; 3]);
for &(group, part) in parts(v.fade != 0) {
let start = self.draws.len();
facing_runs_from(&s.sides, eye, mirror_low, v.origin, part, |first, quads| {
let base = s.start as u32 + first * 4;
self.draws.push([quads * 6, 1, 0, base, instance]);
self.draw_pages.push((s.page, None));
});
let len = self.draws.len() - start;
if group == 0 {
opaque_draws += len;
}
self.solid_runs
.push_in(group, v.distance, s.page, start, len, [0; 3]);
}
}
self.solid_runs
.regroup(&mut self.draws, &mut self.draw_pages, tiles);
.regroup(&mut self.draws, &mut self.draw_pages, 0);
let phase1_draws = self.draws.len() as u32;
// Draws `[..opaque_end]` hold opaque quads, `[cutout_start..phase1_draws]` cutout ones.
let (opaque_end, cutout_start) = if split_solid {
(opaque_draws, opaque_draws)
} else {
(phase1_draws as usize, 0)
};
self.draws.extend_from_slice(&self.phase2_draws);
self.draw_pages.extend_from_slice(&self.phase2_pages);
let culling = cull.is_some();
@@ -6456,7 +6549,7 @@ impl Renderer {
{
// They read translucent quads in the section arena (`translucent_split`).
debug_assert!(self.translucent_arena.is_none(), "flat draws, split");
let (sections, retiring) = (&self.sections, &self.lod_retiring);
let sections = &self.sections;
let lods = &self.lods;
let sorted = &self.sorted_indices;
let phase2 = self.cull.as_ref().filter(|_| culling).map(|c| &c.phase2);
@@ -6470,13 +6563,14 @@ impl Renderer {
phase1_draws as usize,
solid_draws as usize,
[shadow_first, shadow_first + shadow_solid].map(|d| d as usize),
[opaque_end, cutout_start],
phase2,
visible
.iter()
.chain(&shadow_visible)
.filter_map(|v| {
let section = sections.get(v.slot);
let code = section.fade(now, retiring.contains(&v.key));
let code = fade_code(section, &v.key);
Some((
section.flat_id?,
section_instance(v.origin, code, visibility(v.key)),
@@ -7088,24 +7182,30 @@ impl Renderer {
}
Some(flat) => {
let vertices = |p| self.arena.buffer(p);
// Without a pack, on Metal, each list draws twice: the opaque
// pass, then the cutout one (terrain.wgsl's `opaque_quad`).
let passes: &[&wgpu::RenderPipeline] = match pack_flat {
Some(p) => &[&p.0],
None if split_solid => &[&flat.solid_opaque, &flat.solid_cutout],
None => &[&flat.solid],
// Without a pack, on Metal, the opaque pass, then the cutout one
// (terrain.wgsl's `opaque_quad`), each over phase 1's lists with
// quads for it, and over all of phase 2's.
let [opaque, cutout] = flat.pass_calls;
let passes: &[(&wgpu::RenderPipeline, std::ops::Range<usize>)] = match pack_flat
{
Some(p) => &[(&p.0, 0..flat.phase1_calls)],
None if split_solid => &[
(&flat.solid_opaque, 0..opaque),
(&flat.solid_cutout, cutout..flat.phase1_calls),
],
None => &[(&flat.solid, 0..flat.phase1_calls)],
};
let draw = |pass: &mut wgpu::RenderPass<'_>, calls: &[flat::Call]| {
for &pipeline in passes {
let draw = |pass: &mut wgpu::RenderPass<'_>| {
for (pipeline, calls) in passes {
pass.set_pipeline(pipeline);
flat.draw(pass, calls, vertices);
flat.draw(pass, &flat.calls[calls.clone()], vertices);
}
};
match cull {
// As below: phase 1, the test, then phase 2's draws
// that the test saw, copied after it.
Some(cull) => {
draw(&mut pass, &flat.calls[..flat.phase1_calls]);
draw(&mut pass);
if surface.is_some() {
drop(pass);
pass = plain_pass(&mut encoder);
@@ -7127,22 +7227,28 @@ impl Renderer {
};
pass.set_bind_group(0, &self.group, &[]);
pack_frame(&mut pass);
for &pipeline in passes {
for (pipeline, _) in passes {
pass.set_pipeline(pipeline);
flat.draw_phase2(&mut pass, vertices);
}
}
None => draw(&mut pass, &flat.calls[..flat.solid_calls]),
// Without culling, phase 1 is all the solid calls.
None => draw(&mut pass),
}
pass.set_bind_group(0, &self.group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
}
None => {
// Without a native material, on Metal, each list draws twice:
// the opaque pass, then the cutout one (terrain.wgsl's `opaque_quad`).
let passes: &[&wgpu::RenderPipeline] = match material {
None if split_solid => &[&self.solid_opaque, &self.solid_cutout],
_ => &[solid_pipeline],
// Without a native material, on Metal, the opaque pass, then the
// cutout one (terrain.wgsl's `opaque_quad`), each over phase 1's
// lists with quads for it, and over all of phase 2's.
let (opaque, cutout) = (opaque_end as u32, cutout_start as u32);
let passes: &[(&wgpu::RenderPipeline, std::ops::Range<u32>)] = match material {
None if split_solid => &[
(&self.solid_opaque, 0..opaque),
(&self.solid_cutout, cutout..phase1_draws),
],
_ => &[(solid_pipeline, 0..phase1_draws)],
};
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(1, self.instances.slice(..));
@@ -7152,9 +7258,15 @@ impl Renderer {
// its pass, or one of their own on Metal), then
// what they found in sight, in a pass that loads
// both targets (it reads the test's results).
for &pipeline in passes {
for (pipeline, draws) in passes {
pass.set_pipeline(pipeline);
self.draw_sections(&mut pass, &self.indirect, 0, 0, phase1_draws);
self.draw_sections(
&mut pass,
&self.indirect,
0,
draws.start,
draws.len() as u32,
);
}
if surface.is_some() {
drop(pass);
@@ -7181,7 +7293,7 @@ impl Renderer {
wgpu::IndexFormat::Uint32,
);
pass.set_vertex_buffer(1, self.instances.slice(..));
for &pipeline in passes {
for (pipeline, _) in passes {
pass.set_pipeline(pipeline);
self.draw_sections(
&mut pass,
@@ -7192,10 +7304,17 @@ impl Renderer {
);
}
}
// Without culling, phase 1 is all the solid draws.
None => {
for &pipeline in passes {
for (pipeline, draws) in passes {
pass.set_pipeline(pipeline);
self.draw_sections(&mut pass, &self.indirect, 0, 0, solid_draws);
self.draw_sections(
&mut pass,
&self.indirect,
0,
draws.start,
draws.len() as u32,
);
}
}
}
@@ -8232,9 +8351,47 @@ impl Renderer {
crate::screen_effects::Blend::Alpha => 0,
crate::screen_effects::Blend::Vignette => 4,
};
pass.set_pipeline(&self.screen_pipelines[blend + (quad.sampling & 3) as usize]);
let tint = if quad.world_tint && self.hdr.is_some() {
8
} else {
0
};
pass.set_pipeline(
&self.screen_pipelines[tint + blend + (quad.sampling & 3) as usize],
);
pass.draw_indexed(0..6, (i * 4) as i32, 0..1);
}
drop(pass);
// A tint leaves the frame's alpha and goes over the kept world too: light sources
// under water still show the world, and still shine.
if let Some(view) = self.hdr.as_ref().and_then(|h| h.hand_light_view())
&& frame.screen.iter().any(|quad| quad.world_tint)
{
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Screen tints over the kept world"),
timestamp_writes: None,
color_attachments: &attachments,
..Default::default()
});
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(0, self.screen_buffer.slice(..));
for (i, quad) in frame.screen.iter().enumerate() {
if quad.world_tint {
pass.set_pipeline(&self.screen_pipelines[8 + (quad.sampling & 3) as usize]);
pass.draw_indexed(0..6, (i * 4) as i32, 0..1);
}
}
}
}
// GameRenderer.render3dHud: the debug crosshair after the hand and
// the screen effects.
@@ -8713,7 +8870,7 @@ fn projection_center(view_proj: Mat4) -> Option<Vec3> {
/// face `eye` (`projection_center`), the mesh at the camera-relative
/// `origin`; all of them when there is no eye.
fn facing_runs(sides: &Sides, eye: Option<Vec3>, origin: [f32; 3], draw: impl FnMut(u32, u32)) {
facing_runs_from(sides, eye, None, origin, draw)
facing_runs_from(sides, eye, None, origin, Quads::All, draw)
}
/// `facing_runs` for the eye and, with `low`, every eye straight below it down to that
/// camera-relative height (the water mirror's eye).
@@ -8722,16 +8879,16 @@ fn facing_runs_from(
eye: Option<Vec3>,
low: Option<f32>,
origin: [f32; 3],
mut draw: impl FnMut(u32, u32),
part: Quads,
draw: impl FnMut(u32, u32),
) {
match eye {
Some(eye) => {
let eye = eye - Vec3::from(origin);
let low = low.map_or(eye.y, |y| (y - origin[1]).min(eye.y));
sides.visible_from(eye.to_array(), low, draw)
sides.visible_from(eye.to_array(), low, part, draw)
}
None if sides.quads() > 0 => draw(0, sides.quads()),
None => {}
None => sides.every(part, draw),
}
}
fn frustum(m: Mat4) -> [Vec4; 6] {
+63 -3
View File
@@ -42,6 +42,8 @@ struct Plan {
solid_calls: usize,
/// Where the shadow camera's solid and translucent calls begin.
shadow_calls: [usize; 2],
/// Where Metal's opaque pass ends and its cutout pass begins in phase 1's calls.
pass_calls: [usize; 2],
total: u64,
}
const QUADS: u32 = u32::MAX;
@@ -107,6 +109,9 @@ pub struct Flat {
pub calls: Vec<Call>,
pub phase1_calls: usize,
pub solid_calls: usize,
/// Metal's two solid passes over phase 1: the opaque one draws
/// `calls[..pass_calls[0]]`, the cutout one `calls[pass_calls[1]..phase1_calls]`.
pub pass_calls: [usize; 2],
/// Where a shader pack's shadow camera's solid and translucent calls
/// begin (`calls.len()` without them): the camera's translucent calls
/// end at the first.
@@ -216,7 +221,8 @@ fn buffer(
/// apart. A phase 2 range names its draw in phase 2 and its call in phase 2
/// instead of a destination: the call's room holds all of its draws. Draws
/// from `shadow[0]` (solid) and `shadow[1]` (translucent) on are a shader
/// pack's shadow camera's, with calls of their own.
/// pack's shadow camera's, with calls of their own. Phase 1's draws before
/// `passes[0]` and from `passes[1]` on, the two solid passes' on Metal, too.
#[allow(clippy::too_many_arguments)]
fn plan(
draws: &[[u32; 5]],
@@ -224,6 +230,7 @@ fn plan(
phase1_draws: usize,
solid_draws: usize,
shadow: [usize; 2],
passes: [usize; 2],
align: u64,
calls: &mut Vec<Call>,
ranges: &mut Vec<Range>,
@@ -234,6 +241,7 @@ fn plan(
segments.clear();
let (mut phase1_calls, mut solid_calls) = (None, None);
let mut shadow_calls = [None; 2];
let mut pass_calls = [None; 2];
let mut total = 0u64;
let mut by_source: Vec<(Source, Vec<Range>)> = Vec::new();
for (i, (d, &(page, sorted))) in draws.iter().zip(pages).enumerate() {
@@ -248,7 +256,13 @@ fn plan(
shadow_calls[k].get_or_insert(calls.len());
}
}
let starts = i == phase1_draws || i == solid_draws || shadow.contains(&i);
for (k, &from) in passes.iter().enumerate() {
if i == from {
pass_calls[k].get_or_insert(calls.len());
}
}
let starts =
i == phase1_draws || i == solid_draws || shadow.contains(&i) || passes.contains(&i);
match calls.last_mut() {
Some(last) if last.page == page && !starts => last.count += d[0],
_ => calls.push(Call {
@@ -283,10 +297,12 @@ fn plan(
ranges.resize(padded as usize, [0; 4]);
}
let solid_calls = solid_calls.unwrap_or(calls.len());
let phase1_calls = phase1_calls.unwrap_or(calls.len()).min(solid_calls);
Plan {
phase1_calls: phase1_calls.unwrap_or(calls.len()).min(solid_calls),
phase1_calls,
solid_calls,
shadow_calls: shadow_calls.map(|c| c.unwrap_or(calls.len())),
pass_calls: pass_calls.map(|c| c.unwrap_or(calls.len()).min(phase1_calls)),
total,
}
}
@@ -438,6 +454,7 @@ impl Flat {
calls: Vec::new(),
phase1_calls: 0,
solid_calls: 0,
pass_calls: [0; 2],
shadow_calls: [0; 2],
min_sections,
range_align: (u64::from(device.limits().min_storage_buffer_offset_alignment) / 16)
@@ -519,6 +536,7 @@ impl Flat {
phase1_draws: usize,
solid_draws: usize,
shadow: [usize; 2],
passes: [usize; 2],
phase2: Option<&wgpu::Buffer>,
origins: impl Iterator<Item = (u32, [f32; 4])>,
sorted_page: impl Fn(u32) -> wgpu::Buffer,
@@ -563,6 +581,7 @@ impl Flat {
phase1_calls,
solid_calls,
shadow_calls,
pass_calls,
total,
} = plan(
draws,
@@ -570,6 +589,7 @@ impl Flat {
phase1_draws,
solid_draws,
shadow,
passes,
self.range_align,
&mut self.calls,
&mut self.range_data,
@@ -578,11 +598,13 @@ impl Flat {
self.phase1_calls = phase1_calls;
self.solid_calls = solid_calls;
self.shadow_calls = shadow_calls;
self.pass_calls = pass_calls;
if total == 0 || total * 4 > device.limits().max_storage_buffer_binding_size {
self.calls.clear();
self.phase1_calls = 0;
self.solid_calls = 0;
self.shadow_calls = [0; 2];
self.pass_calls = [0; 2];
return false;
}
if self.range_data.len() as u64 > self.range_capacity {
@@ -870,6 +892,7 @@ mod tests {
solid,
solid,
[draws.len(); 2],
[draws.len(), 0],
4,
&mut calls,
&mut ranges,
@@ -904,6 +927,39 @@ mod tests {
assert_eq!(ranges.len(), 12);
}
/// Metal's opaque pass draws the lists before `passes[0]`, its cutout
/// pass those from `passes[1]`: each bound starts a call of its own,
/// though its page goes on.
#[test]
fn plan_splits_calls_where_the_solid_passes_end_and_begin() {
let (draws, pages, solid) = sample();
let (mut calls, mut ranges, mut segments) = (Vec::new(), Vec::new(), Vec::new());
let plan = plan(
&draws,
&pages,
solid,
solid,
[draws.len(); 2],
[3, 1],
4,
&mut calls,
&mut ranges,
&mut segments,
);
assert_eq!((plan.pass_calls, plan.solid_calls), ([3, 1], 4));
let call = |page, first, count| Call { page, first, count };
assert_eq!(
calls,
[
call(0, 0, 18),
call(0, 18, 12),
call(1, 30, 6),
call(0, 36, 24),
call(0, 60, 18)
]
);
}
/// A shader pack's shadow draws follow the camera's, in calls of their
/// own even from the same page: solid ones, then translucent ones.
#[test]
@@ -919,6 +975,7 @@ mod tests {
solid,
solid,
[camera, camera + 2],
[camera, 0],
4,
&mut calls,
&mut ranges,
@@ -946,6 +1003,7 @@ mod tests {
2,
solid,
[draws.len(); 2],
[draws.len(), 0],
4,
&mut calls,
&mut ranges,
@@ -957,6 +1015,7 @@ mod tests {
phase1_calls: 1,
solid_calls: 3,
shadow_calls: [4, 4],
pass_calls: [1, 0],
total: 6 * 13
}
);
@@ -1061,6 +1120,7 @@ mod tests {
phase1,
solid,
[draws.len(); 2],
[draws.len(), 0],
align,
&mut calls,
&mut ranges,
+2 -1
View File
@@ -441,7 +441,8 @@ impl Hdr {
pass.draw(0..3, 0..1);
}
/// Where the hand's marks go: the kept world's alpha, which HDR output reads for it too.
/// The world as `keep_world` kept it: where the hand's marks go (its alpha, which HDR output
/// reads for it too), and what tints the world rather than covering it.
pub fn hand_light_view(&self) -> Option<wgpu::TextureView> {
let t = self.targets.as_ref()?;
Some(t.world.create_view(&Default::default()))
@@ -16,6 +16,8 @@
/// The width of a band, in blocks.
const BAND: f64 = 64.0;
/// Where a run's group sits in its sort key, above band and page.
const GROUP_SHIFT: u32 = 62;
/// One section's draws: `start..start + len` of the list.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -41,10 +43,24 @@ impl PageRuns {
/// The draws `start..start + len` of a section `distance` from the
/// camera (squared, as bits) in arena `page`; runs come nearest first.
pub fn push(&mut self, distance: u64, page: u32, start: usize, len: usize, tag: [u32; 3]) {
self.push_in(0, distance, page, start, len, tag);
}
/// As `push`, in `group` (0 to 3): regrouping puts each group's runs
/// before the next group's, then bands and pages within it.
pub fn push_in(
&mut self,
group: u8,
distance: u64,
page: u32,
start: usize,
len: usize,
tag: [u32; 3],
) {
if len > 0 {
let band = (f64::from_bits(distance).sqrt() / BAND) as u64;
self.runs.push(Run {
key: band << 32 | u64::from(page),
key: u64::from(group) << GROUP_SHIFT | band << 32 | u64::from(page),
start: start as u32,
len: len as u32,
tag,
@@ -61,7 +77,8 @@ impl PageRuns {
pages: &mut [(u32, Option<u32>)],
base: usize,
) -> &[Run] {
let page = |r: &Run| r.key as u32;
// Group and page: one of each needs no reordering.
let page = |r: &Run| r.key & (3 << GROUP_SHIFT | u64::from(u32::MAX));
if self.runs.windows(2).all(|w| page(&w[0]) == page(&w[1])) {
return &self.runs;
}
@@ -157,6 +174,24 @@ mod tests {
assert_eq!(order, [0, 1, 2]);
}
/// Metal's two solid passes draw a prefix and a suffix of the list:
/// groups come whole and in order, whatever their distance and page.
#[test]
fn groups_come_before_bands_and_pages() {
let mut grouped = PageRuns::default();
let mut draws: Vec<_> = (0..5).map(|n| [n, 0, 0, 0, 0]).collect();
let mut pages = vec![(0, None); 5];
for (n, (group, distance)) in [(2, 10.0), (0, 300.0), (1, 20.0), (0, 30.0), (2, 5.0)]
.into_iter()
.enumerate()
{
grouped.push_in(group, bits(distance), 0, n, 1, [0; 3]);
}
grouped.regroup(&mut draws, &mut pages, 0);
let order: Vec<_> = draws.iter().map(|d| d[0]).collect();
assert_eq!(order, [3, 1, 2, 0, 4]);
}
#[test]
fn one_page_and_an_offset_list() {
let (mut grouped, mut draws, mut pages) = list(&[(10.0, 3, 2), (90.0, 3, 1)]);
+182 -70
View File
@@ -1,7 +1,10 @@
//! Vanilla 26.3's CubeMapTexture and CubeMap, separate from the pixel-filtered GUI atlas.
use crate::{Result, hud::decode};
use crate::{Result, hud::decode_bytes};
use glam::Mat4;
use std::path::Path;
use std::{
path::Path,
sync::{Arc, Mutex},
};
use wgpu::util::DeviceExt;
/// Native cube order: +X, -X, +Y, -Y, +Z, -Z (CubeMapTexture.SUFFIXES).
@@ -9,10 +12,18 @@ const FACE_ORDER: [u8; 6] = [1, 3, 5, 4, 0, 2];
/// Where the cube's faces live in a resource root.
const BACKGROUND: &str = "assets/minecraft/textures/gui/title/background";
/// The title screen's six faces. The cube is on the GPU only while the title
/// screen shows, and its texels are decoded from the faces' files each time it
/// is made: kept decoded, they held 24 MiB through the whole game.
#[derive(Debug)]
pub struct Faces {
pub size: u32,
pub pixels: Vec<u8>,
/// The faces' PNG files in cube order; none for a one-colour cube.
files: Vec<Vec<u8>>,
/// The texel of a one-colour cube.
colour: [u8; 4],
/// The texels decoded while loading, which the first cube takes.
decoded: Mutex<Option<Vec<u8>>>,
}
impl Faces {
@@ -28,18 +39,26 @@ impl Faces {
"Horizon: no title panorama ({error}); prepare the game again in the launcher \
(or run horizon-prepare panorama)"
);
Self {
size: 1,
pixels: [96, 110, 111, 255].repeat(6),
}
Self::colour([96, 110, 111, 255])
})
}
/// A one-texel cube of one colour.
pub fn colour(colour: [u8; 4]) -> Self {
Self {
size: 1,
files: Vec::new(),
colour,
decoded: Mutex::new(None),
}
}
fn try_load(root: &Path, defaults: &Path) -> Result<Self> {
let mut size = 0;
let mut pixels = Vec::new();
let mut files = Vec::with_capacity(FACE_ORDER.len());
let mut texels = Vec::new();
for face in FACE_ORDER {
let (path, w, h, data) = load_face(root, defaults, face)?;
let (path, file, (w, h, data)) = load_face(root, defaults, face)?;
if w != h || (size != 0 && w != size) {
return Err(format!(
"{}: panorama faces must be equally sized squares",
@@ -50,21 +69,80 @@ impl Faces {
size = w;
// CubeMapTexture copies each original image with flipY=true. Keep every texel.
for row in data.chunks_exact((w * 4) as usize).rev() {
pixels.extend_from_slice(row);
texels.extend_from_slice(row);
}
files.push(file);
}
Ok(Self { size, pixels })
// Decoded once here to check them, and kept for the first cube.
Ok(Self {
size,
files,
colour: [0; 4],
decoded: Mutex::new(Some(texels)),
})
}
/// The cube's texels, layer after layer.
pub fn texels(&self) -> Vec<u8> {
if self.files.is_empty() {
return self.colour.repeat(6);
}
let loaded = self
.decoded
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take();
loaded
.or_else(|| decode_faces(&self.files).ok().map(|(_, texels)| texels))
.unwrap_or_else(|| vec![0; (self.size * self.size * 4 * 6) as usize])
}
}
/// The faces' files decoded, side by side, into one array of six layers of
/// equal squares.
fn decode_faces(files: &[Vec<u8>]) -> Result<(u32, Vec<u8>)> {
let decoded: Vec<_> = std::thread::scope(|scope| {
let threads: Vec<_> = files
.iter()
.map(|file| scope.spawn(|| decode_bytes(file).map_err(|e| e.to_string())))
.collect();
threads
.into_iter()
.map(|t| t.join().unwrap_or_else(|p| std::panic::resume_unwind(p)))
.collect()
});
let mut size = 0;
let mut texels = Vec::new();
for (face, image) in FACE_ORDER.iter().zip(decoded) {
let (w, h, data) = image?;
if w != h || (size != 0 && w != size) {
return Err(format!(
"panorama_{face}.png: panorama faces must be equally sized squares"
)
.into());
}
if size == 0 {
size = w;
texels.reserve_exact((w * w * 4) as usize * files.len());
}
// CubeMapTexture copies each original image with flipY=true. Keep every texel.
for row in data.chunks_exact((w * 4) as usize).rev() {
texels.extend_from_slice(row);
}
}
Ok((size, texels))
}
/// `panorama_{face}.png` from the first layer that has one, with the path it
/// came from. Reading a face that no layer provides fails naming every path
/// tried, so a report of a missing panorama says where the game looked.
/// came from, its file and its decoded image. Reading a face that no layer
/// provides fails naming every path tried, so a report of a missing panorama
/// says where the game looked.
#[allow(clippy::type_complexity)]
fn load_face(
root: &Path,
defaults: &Path,
face: u8,
) -> Result<(std::path::PathBuf, u32, u32, Vec<u8>)> {
) -> Result<(std::path::PathBuf, Vec<u8>, (u32, u32, Vec<u8>))> {
let name = format!("panorama_{face}.png");
let mut tried: Vec<(std::path::PathBuf, String)> = Vec::new();
for layer in [root, defaults] {
@@ -73,9 +151,15 @@ fn load_face(
if tried.first().is_some_and(|(seen, _)| *seen == path) {
continue;
}
match decode(&path) {
Ok((w, h, data)) => return Ok((path, w, h, data)),
Err(error) => tried.push((path, error.to_string())),
let read = std::fs::read(&path)
.map_err(|e| e.to_string())
.and_then(|file| {
let image = decode_bytes(&file).map_err(|e| e.to_string())?;
Ok((file, image))
});
match read {
Ok((file, image)) => return Ok((path, file, image)),
Err(error) => tried.push((path, error)),
}
}
let tried: Vec<String> = tried
@@ -99,40 +183,20 @@ fn projection(width: u32, height: u32, yaw: f32, pitch: f32) -> Mat4 {
pub(super) struct Panorama {
pipeline: wgpu::RenderPipeline,
group: wgpu::BindGroup,
layout: wgpu::BindGroupLayout,
sampler: wgpu::Sampler,
uniform: wgpu::Buffer,
faces: Arc<Faces>,
/// The cube and its bindings, while the title screen shows.
cube: Option<wgpu::BindGroup>,
}
impl Panorama {
pub(super) fn new(
device: &wgpu::Device,
queue: &wgpu::Queue,
format: wgpu::TextureFormat,
faces: &Faces,
faces: Arc<Faces>,
) -> Self {
let texture = device.create_texture_with_data(
queue,
&wgpu::TextureDescriptor {
label: Some("Title panorama cube"),
size: wgpu::Extent3d {
width: faces.size,
height: faces.size,
depth_or_array_layers: 6,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&faces.pixels,
);
let view = texture.create_view(&wgpu::TextureViewDescriptor {
dimension: Some(wgpu::TextureViewDimension::Cube),
..Default::default()
});
// CubeMapTexture's blur=true metadata: linear minification and magnification, no mips.
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Title panorama linear filtering"),
@@ -177,24 +241,6 @@ impl Panorama {
},
],
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Title panorama resources"),
layout: &layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: uniform.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&sampler),
},
],
});
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Title panorama shader"),
source: wgpu::ShaderSource::Wgsl(include_str!("panorama.wgsl").into()),
@@ -240,13 +286,18 @@ impl Panorama {
});
Self {
pipeline,
group,
layout,
sampler,
uniform,
faces,
cube: None,
}
}
/// Ready a title screen frame: the cube, made if it is not on the GPU.
pub(super) fn prepare(
&self,
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
width: u32,
height: u32,
@@ -258,11 +309,68 @@ impl Panorama {
0,
bytemuck::cast_slice(&projection(width, height, yaw, pitch).to_cols_array()),
);
if self.cube.is_none() {
self.cube = Some(self.cube(device, queue));
}
}
/// Give the cube's video memory back: no frame draws it until the title
/// screen shows again.
pub(super) fn release(&mut self) {
self.cube = None;
}
fn cube(&self, device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::BindGroup {
let texture = device.create_texture_with_data(
queue,
&wgpu::TextureDescriptor {
label: Some("Title panorama cube"),
size: wgpu::Extent3d {
width: self.faces.size,
height: self.faces.size,
depth_or_array_layers: 6,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&self.faces.texels(),
);
let view = texture.create_view(&wgpu::TextureViewDescriptor {
dimension: Some(wgpu::TextureViewDimension::Cube),
..Default::default()
});
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Title panorama resources"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.uniform.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
],
})
}
/// Draw the cube `prepare` made for this frame.
pub(super) fn draw(&self, pass: &mut wgpu::RenderPass<'_>) {
let Some(cube) = &self.cube else {
return;
};
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &self.group, &[]);
pass.set_bind_group(0, cube, &[]);
pass.draw(0..36, 0..1);
}
}
@@ -354,14 +462,17 @@ mod tests {
let f = Fixture::new(1024);
let faces = Faces::try_load(&f.0, &f.0).unwrap();
assert_eq!(faces.size, 1024);
assert_eq!(faces.pixels.len(), 6 * 1024 * 1024 * 4);
// The texels decoded while loading, then decoded again for the next cube.
let (loaded, again) = (faces.texels(), faces.texels());
assert_eq!(loaded, again);
assert_eq!(again.len(), 6 * 1024 * 1024 * 4);
// CubeMapTexture's actual layer suffixes, with each source row flipped vertically.
for (layer, source_face) in [1, 3, 5, 4, 0, 2].into_iter().enumerate() {
for y in 0..1024 {
for x in 0..1024 {
let at = ((layer * 1024 + y) * 1024 + x) * 4;
assert_eq!(
faces.pixels[at..at + 4],
again[at..at + 4],
[source_face, (x % 251) as u8, ((1023 - y) % 253) as u8, 255]
);
}
@@ -381,7 +492,7 @@ mod tests {
.contains("equally sized squares")
);
let fallback = Faces::load(&f.0, &f.0);
assert_eq!(fallback.pixels.len(), 24);
assert_eq!(fallback.texels(), [96, 110, 111, 255].repeat(6));
f.write(5, 2, 2);
assert!(Faces::try_load(&f.0, &f.0).is_err());
fs::remove_file(f.background().join("panorama_5.png")).unwrap();
@@ -398,11 +509,12 @@ mod tests {
pack.write(2, 4, 4);
let faces = Faces::try_load(&pack.0, &vanilla.0).unwrap();
assert_eq!(faces.size, 4);
assert_eq!(faces.pixels.len(), 6 * 4 * 4 * 4);
let texels = faces.texels();
assert_eq!(texels.len(), 6 * 4 * 4 * 4);
// Each face's red channel is its index (`face` above), and the cube
// holds them in FACE_ORDER: layer 5 is face 2, the pack's, and layer 4
// is face 0, which only the game's resources have.
let red = |layer: usize, texel: usize| faces.pixels[(layer * 16 + texel) * 4];
let red = |layer: usize, texel: usize| texels[(layer * 16 + texel) * 4];
assert_eq!(red(5, 0), 2);
assert_eq!(red(4, 0), 0);
}
@@ -421,7 +533,7 @@ mod tests {
// No layer completes the cube: the one-pixel grey placeholder, and the
// window still has a title.
let fallback = Faces::load(&pack.0, &vanilla.0);
assert_eq!((fallback.size, fallback.pixels.len()), (1, 24));
assert_eq!((fallback.size, fallback.texels().len()), (1, 24));
}
#[test]
+16 -10
View File
@@ -456,7 +456,7 @@ static TRANSLUCENT_BATCHING_OFFERED: std::sync::atomic::AtomicBool =
/// Whether the device can batch translucent quads (`Graphics::translucent_batching`): set when
/// the renderer starts; not without indirect multi-draw or on OpenGL, which loops over a
/// multi-draw's entries on the CPU, nor where vanilla frames draw flat (they read the section
/// arena's translucent quads).
/// arena's translucent quads). Where it can't, the settings grey its switch out.
pub fn offer_translucent_batching(on: bool) {
TRANSLUCENT_BATCHING_OFFERED.store(on, std::sync::atomic::Ordering::Relaxed);
}
@@ -601,9 +601,11 @@ pub struct Graphics {
/// The graphics API (Windows only; see `GraphicsApi`).
pub api: GraphicsApi,
/// Translucent quads in vertex buffers of their own (`gfx`'s translucent arena), so their
/// back-to-front draws go out in a few calls, for a little more video memory. Off by
/// default for now; read when a renderer is made (start, resource-pack reload), where it is
/// offered (`translucent_batching_offered`).
/// back-to-front draws go out in a few calls, for a little more video memory. On by
/// default (Translucent Batching, Advanced graphics); read when a renderer is made (start,
/// resource-pack reload), where it is offered (`translucent_batching_offered`). Its key is
/// `batchTranslucent`: `translucentBatching`, which every save wrote as false while it was
/// off by default and had no switch, stays ignored.
pub translucent_batching: bool,
/// The shader pack picked in `shaderpacks/` (`shader_pack_list`); empty: none. Its key is
/// not `shaderPack`, which versions before the clean-room rewrite saved and which stays
@@ -646,7 +648,7 @@ impl Default for Graphics {
battery: false,
shadow_cache: false,
api: GraphicsApi::Auto,
translucent_batching: false,
translucent_batching: true,
shader_pack: String::new(),
}
}
@@ -739,7 +741,7 @@ impl Graphics {
"hdrKey" if value.starts_with("key.") => g.hdr_key = value.to_owned(),
"batteryMode" => g.battery = flag(g.battery),
"shadowCache" => g.shadow_cache = flag(g.shadow_cache),
"translucentBatching" => g.translucent_batching = flag(g.translucent_batching),
"batchTranslucent" => g.translucent_batching = flag(g.translucent_batching),
"graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(),
"pickedShaderPack" => g.shader_pack = value.to_owned(),
"overlay" => {
@@ -756,7 +758,7 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nleafCulling:{}\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:{}\ntranslucentBatching:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nleafCulling:{}\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:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
@@ -948,7 +950,7 @@ mod tests {
battery: true,
shadow_cache: true,
api: GraphicsApi::Dx12,
translucent_batching: true,
translucent_batching: false,
shader_pack: "Golden Hour".into(),
};
assert_eq!(Graphics::parse(&g.serialize()), g);
@@ -968,8 +970,12 @@ mod tests {
EntityCullingMode::Off
);
assert_eq!(Graphics::parse(""), Graphics::default());
// Translucent Batching is opt-in for now: only the file turns it on.
assert!(!Graphics::default().translucent_batching);
// Translucent Batching is on, in files from before it too; only false turns it off,
// and only under its key: the old one held the false every save wrote while it was
// off by default, not a choice.
assert!(Graphics::default().translucent_batching);
assert!(!Graphics::parse("batchTranslucent:false\n").translucent_batching);
assert!(Graphics::parse("translucentBatching:false\n").translucent_batching);
// 0 is Auto; anything else is a peak the slider reaches.
assert_eq!(Graphics::parse("hdrPeak:0\n").hdr_peak, 0);
assert_eq!(Graphics::parse("hdrPeak:50\n").hdr_peak, 200);
@@ -15,7 +15,7 @@ pub enum Cost {
Low,
Medium,
High,
/// Turning it on makes frames faster (culling).
/// Turning it on makes frames faster (culling, caching, batching).
Saves,
}
impl Cost {
@@ -61,6 +61,7 @@ pub enum H {
BatteryMode,
ShadowCache,
GraphicsApi,
TranslucentBatching,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -357,6 +358,11 @@ pub fn rows(level: Level) -> Vec<Row> {
None,
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.",
),
h(
H::TranslucentBatching,
Saves,
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.",
),
h(
H::UploadBudget,
Low,
@@ -370,11 +376,13 @@ pub fn rows(level: Level) -> Vec<Row> {
),
],
};
// Only Windows and Linux have more than one graphics API.
// Only Windows and Linux have more than one graphics API, and translucent batching
// (Metal draws flat).
if cfg!(target_os = "macos") {
rows.retain(
|row| !matches!(row, Row::Setting(s) if s.item == Item::Horizon(H::GraphicsApi)),
);
rows.retain(|row| {
!matches!(row, Row::Setting(s)
if matches!(s.item, Item::Horizon(H::GraphicsApi | H::TranslucentBatching)))
});
}
rows
}
@@ -623,6 +631,12 @@ impl H {
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
H::ShadowCache => labelled("Shadow Cache", on_off(g.shadow_cache)),
H::GraphicsApi => labelled("Graphics API", g.api.label()),
// Greyed out where the device can't (`Item::blocked`): Off, as it is there,
// while the saved value waits for a device that can.
H::TranslucentBatching => labelled(
"Translucent Batching",
on_off(g.translucent_batching && crate::graphics::translucent_batching_offered()),
),
H::Overlay => labelled(
"Performance Overlay",
match g.overlay {
@@ -718,6 +732,7 @@ impl H {
H::BatteryMode => g.battery = !g.battery,
H::ShadowCache => g.shadow_cache = !g.shadow_cache,
H::GraphicsApi => g.api = step(GraphicsApi::choices(), g.api, back),
H::TranslucentBatching => g.translucent_batching = !g.translucent_batching,
H::Pacing => {
g.pacing = match g.pacing {
Pacing::Efficient => Pacing::Precise,
@@ -748,7 +763,8 @@ impl Item {
/// the display offers HDR output, whatever `horizon-graphics.txt` says (a copied game
/// folder, a display swapped for an SDR one: the renderer leaves HDR off there, and
/// the saved values wait for an HDR display), and its sliders do something only
/// while it is on.
/// while it is on; Translucent Batching switches only where the device can batch
/// (`graphics::translucent_batching_offered`), its saved value waiting for one that can.
fn blocked(self, s: &Settings) -> Option<&'static str> {
if !self.available() {
return Some("This feature is not yet available in Horizon.");
@@ -764,6 +780,11 @@ impl Item {
| H::HdrPaperWhite
| H::HdrUiBrightness,
) if !s.horizon.hdr => Some("Turn HDR on to use it."),
Item::Horizon(H::TranslucentBatching)
if !crate::graphics::translucent_batching_offered() =>
{
Some("Your GPU or graphics API doesn't support it.")
}
_ => None,
}
}
@@ -780,6 +801,7 @@ impl Item {
Item::Horizon(H::Hdr) => g.hdr,
Item::Horizon(H::BatteryMode) => g.battery,
Item::Horizon(H::ShadowCache) => g.shadow_cache,
Item::Horizon(H::TranslucentBatching) => g.translucent_batching,
Item::Vanilla(Opt::ViewBobbing) => s.view_bobbing,
Item::Vanilla(Opt::Vsync) => s.vsync,
Item::Vanilla(Opt::Fullscreen) => s.fullscreen,
@@ -843,6 +865,7 @@ impl Item {
| H::BatteryMode
| H::ShadowCache
| H::GraphicsApi
| H::TranslucentBatching
) | Item::Vanilla(
Opt::GuiScale
| Opt::Fullscreen
@@ -1468,6 +1491,12 @@ impl GraphicsUi {
&& let Some(target) = self.focus
{
match target {
// A click focuses the greyed row; Enter there does nothing, as the click didn't.
Target::Item(item @ Item::Horizon(H::TranslucentBatching))
if item.blocked(s).is_some() =>
{
return (Vec::new(), true);
}
Target::Item(item) => {
if item.slider(s).is_some() {
self.can_change_value = !self.can_change_value;
@@ -1986,6 +2015,66 @@ mod tests {
assert_eq!(item.label(&s).plain(), "Entity Culling: Asynchronous");
}
/// Translucent Batching: a switch on Advanced (not Simple), on by default, no part of a
/// preset, and greyed out, reading Off, where the device can't batch.
#[test]
fn translucent_batching_is_an_advanced_switch_where_the_device_can() {
use winit::keyboard::KeyCode as K;
let item = Item::Horizon(H::TranslucentBatching);
let has = |level| {
rows(level)
.iter()
.any(|r| matches!(r, Row::Setting(x) if x.item == item))
};
if cfg!(target_os = "macos") {
// Metal draws flat: never offered, so not shown.
assert!(!has(Level::Advanced));
return;
}
assert!(has(Level::Advanced) && !has(Level::Simple));
let mut s = Settings::default();
s.horizon.preset = Preset::Balanced;
assert_eq!(item.switch(&s), Some(true));
let mut ui = GraphicsUi::default();
let size = (480.0, 2000.0);
let (widgets, _, _) = ui.layout(Level::Advanced, &s, size.0, size.1);
let w = widgets
.iter()
.find(|w| w.target == Target::Item(item))
.unwrap();
let point = (w.x + 1.0, w.y + 1.0);
let click = |ui: &mut GraphicsUi, s: &mut Settings| {
ui.click(Level::Advanced, s, point, size, [480, 2000], false, false)
};
// Where the device can't, the saved On reads Off, and neither a click nor Enter
// after it (the click focuses the row) does anything: no sound, no change, no save.
crate::graphics::offer_translucent_batching(false);
assert!(item.blocked(&s).is_some());
assert_eq!(item.label(&s).plain(), "Translucent Batching: Off");
assert!(click(&mut ui, &mut s).is_empty());
let enter = ui.key_modifiers(Level::Advanced, &mut s, K::Enter, false, size, [480, 2000]);
assert!(enter.0.is_empty());
assert!(s.horizon.translucent_batching);
// Where it can, a click switches it and keeps the preset.
crate::graphics::offer_translucent_batching(true);
assert_eq!(item.label(&s).plain(), "Translucent Batching: On");
assert_eq!(
click(&mut ui, &mut s),
[Action::ButtonClick, Action::Changed]
);
assert!(!s.horizon.translucent_batching);
assert_eq!(item.label(&s).plain(), "Translucent Batching: Off");
assert_eq!(s.horizon.preset, Preset::Balanced);
// No preset sets it.
for on in [false, true] {
s.horizon.translucent_batching = on;
for preset in Preset::ALL {
crate::graphics::apply_preset(preset, &mut s);
assert_eq!(s.horizon.translucent_batching, on, "{preset:?}");
}
}
}
#[test]
fn graphics_slider_keyboard_navigation_and_selection_toggle_parity() {
use winit::keyboard::KeyCode as K;
+71 -13
View File
@@ -144,6 +144,18 @@ pub fn decode_bytes(data: &[u8]) -> Result<(u32, u32, Vec<u8>)> {
/// The most texels of OptiFine random entity variants the entity atlas packs: 2048 rows of
/// its 4096, which keeps the default atlas (4096x5120) under the 8192 rows many GPUs take.
const RANDOM_TEXELS: u64 = 4096 * 2048;
/// The variant images (and `overlays`) the atlas packs: every variant before any overlay, by
/// name, while their sizes add up to at most `budget` texels. A full budget drops glows before
/// looks.
fn budgeted<'a>(
files: impl Iterator<Item = &'a PathBuf>,
overlays: &HashSet<PathBuf>,
budget: u64,
) -> HashSet<PathBuf> {
let mut files: Vec<&PathBuf> = files.collect();
files.sort_by_key(|p| (overlays.contains(*p), *p));
within_budget(&files, budget)
}
/// The `files` (in order) whose sizes add up to at most `budget` texels.
fn within_budget(files: &[&PathBuf], budget: u64) -> HashSet<PathBuf> {
let mut texels = 0u64;
@@ -739,16 +751,18 @@ impl HudAtlas {
} else {
Default::default()
};
let emissive = std::sync::Arc::new(if folders.contains(&"entity") {
horizon_optifine::emissive::Emissive::scan(root, &random_textures)
} else {
Default::default()
});
let blinks = if folders.contains(&"entity") {
horizon_optifine::blink::Blinks::scan(root, &random_textures)
} else {
Default::default()
};
let emissive = std::sync::Arc::new(if folders.contains(&"entity") {
let mut e = horizon_optifine::emissive::Emissive::scan(root, &random_textures);
e.pair_beside(root, blinks.files());
e
} else {
Default::default()
});
let mut named: HashMap<PathBuf, String> = HashMap::new();
let extra = random_textures
.files()
@@ -761,18 +775,38 @@ impl HudAtlas {
// A pack's variants can't grow the shared atlas past what a GPU takes: every variant
// (those beside their texture too) counts, in name order, and those past the budget
// stay out (their mobs wear the texture itself), not the whole pack.
// Overlays only after every variant: a full budget drops glows, not looks.
let overlays: HashSet<PathBuf> = emissive.files().map(|(f, _)| root.join(f)).collect();
let mut variants: Vec<&PathBuf> = named.keys().collect();
variants.sort_by_key(|p| (overlays.contains(*p), *p));
let kept = within_budget(&variants, RANDOM_TEXELS);
if kept.len() < variants.len() {
// Overlays and blink frames only after every variant.
let overlays: HashSet<PathBuf> = emissive
.files()
.chain(blinks.files())
.map(|(f, _)| root.join(f))
.collect();
let kept = budgeted(named.keys(), &overlays, RANDOM_TEXELS);
if kept.len() < named.len() {
log::warn!(
"{} random entity textures left out of the atlas: past its budget",
variants.len() - kept.len()
named.len() - kept.len()
);
}
files.retain(|p| !named.contains_key(p) || kept.contains(p));
// A dropped variant beside its texture takes its walked overlay and blink frames
// (and theirs) with it: nothing would draw them.
let png = format!("{}.png", emissive.suffix);
let png = png.as_str();
let orphans: HashSet<PathBuf> = named
.keys()
.filter(|p| !kept.contains(*p))
.filter_map(|p| p.to_str()?.strip_suffix(".png").map(str::to_owned))
.flat_map(|stem| {
["", "_blink", "_blink2"]
.into_iter()
.flat_map(move |frame| {
[format!("{stem}{frame}.png"), format!("{stem}{frame}{png}")]
})
})
.map(PathBuf::from)
.filter(|p| !named.contains_key(p))
.collect();
files.retain(|p| (!named.contains_key(p) || kept.contains(p)) && !orphans.contains(p));
// Those beside their texture are walked already.
let walked: HashSet<&PathBuf> = files.iter().collect();
let mut extra: Vec<PathBuf> = kept
@@ -2567,7 +2601,24 @@ mod tests {
&[[200, 0, 0, 255]],
None,
);
// A variant's blink frame, with its own overlay.
write_strip(
&random.join("zombie3_blink.png"),
64,
64,
&[[0, 0, 200, 255]],
None,
);
write_strip(
&random.join("zombie3_blink_e.png"),
64,
64,
&[[9, 9, 9, 255]],
None,
);
let atlas = HudAtlas::build(&root).unwrap();
let rect = atlas.sprites["minecraft:optifine/random/entity/zombie/zombie3_blink_e"];
assert_eq!(texel_at(&atlas, rect, 0, 0), [9, 9, 9, 255]);
let rect = atlas.sprites["minecraft:optifine/random/entity/zombie/zombie3_e"];
assert_eq!(texel_at(&atlas, rect, 0, 0), [200, 0, 0, 255]);
assert_eq!(
@@ -2596,6 +2647,13 @@ mod tests {
let kept = within_budget(&refs, 600);
assert_eq!(kept, files[..2].iter().cloned().collect());
assert_eq!(within_budget(&refs, 768).len(), 3);
// a2's overlay sorts before a3 by name, but every variant comes first.
let overlay = dir.join("a2_e.png");
write_strip(&overlay, 16, 16, &[[1, 2, 3, 255]], None);
let overlays: HashSet<PathBuf> = [overlay.clone()].into();
let all = [&files[0], &overlay, &files[1]];
let kept = budgeted(all.into_iter(), &overlays, 600);
assert_eq!(kept, [files[0].clone(), files[1].clone()].into());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
+44 -6
View File
@@ -16,6 +16,7 @@ use crate::{
graphics::LeafCullingMode,
models::catalog::BakedFace,
};
use horizon_assets::model::Direction as ModelDirection;
use horizon_core::{
biome::BiomeZoom, geometry::Direction, position::BlockPos, random::position_seed,
};
@@ -25,7 +26,7 @@ use horizon_world::{
fluid::{FluidKind, FluidState},
registry::BlockStateId,
};
pub use sides::Sides;
pub use sides::{Part, Sides, group_parts};
use std::sync::Arc;
/// An entity, item and effect vertex (the entity pipelines), with a face
@@ -474,6 +475,9 @@ struct Info {
overlay: bool,
/// `LeavesBlock`: with opaque leaves the faces between two leaf blocks are skipped.
leaves: bool,
/// A glass pane: connected-texture tiles take the cube's UVs on it (OptiFine's pane models
/// drop vanilla's mirrored faces).
pane: bool,
/// A full opaque cube: `RenderShape::Model`, `can_occlude`, `solid_render`
/// and a full collision shape. The only block leaf culling treats as solid.
opaque_cube: bool,
@@ -654,6 +658,7 @@ impl MeshTables {
legacy_solid: s.legacy_solid,
overlay: s.has_class("HalfTransparentBlock") || s.has_class("LeavesBlock"),
leaves: s.has_class("LeavesBlock"),
pane: s.has_class("IronBarsBlock") && name.ends_with("glass_pane"),
opaque_cube: leaf_culling::opaque_cube(
s.render_shape,
s.can_occlude,
@@ -1251,11 +1256,34 @@ impl Mesher<'_> {
)
.unwrap_or(true);
}
let connected = &self.render.connected;
for model in &models {
for face in &model.faces {
if face.quad.cull.is_some_and(|d| !visible[d as usize]) {
continue;
}
// A pane's top and bottom strips between stacked panes, with
// connected textures.
if state.pane
&& !connected.is_empty()
&& let Some(dy) = match face.quad.facing {
ModelDirection::Up => Some(1),
ModelDirection::Down => Some(-1),
_ => None,
}
{
let p = &face.quad.positions;
// Only on the block's top or bottom plane: a pack's own pane
// model may have ledges between.
let plane = if dy > 0 { 1.0 } else { 0.0 };
let on_edge = p.iter().all(|v| (v[1] - plane).abs() < 1e-4);
let centre =
[0, 2].map(|k| p.iter().map(|v| v[k]).sum::<f32>() / 4.0);
let other = pad.blocks[pi(x, y + dy, z)];
if on_edge && connected.stacked_pane_hides(id, other, centre) {
continue;
}
}
self.face(
pad,
&mut tints,
@@ -1377,7 +1405,18 @@ impl Mesher<'_> {
)
})
};
let corners = vertices(tint, q.uv);
// Vanilla's pane models mirror some faces' textures; OptiFine's don't, so a tile's borders
// land where the neighbours are: on a pane, tiles take the cube's own UVs.
let standard = || q.positions.map(|p| standard_uv(q.facing, p));
let tiled = !matches!(pick, Pick::Keep);
let corners = vertices(
tint,
if state.pane && tiled {
standard()
} else {
q.uv
},
);
// Opaque leaves (forceOpaque) and leaves a leaf-culling mode proved internal draw in the
// solid layer whatever their sprite.
let solid_leaves = !self.cutout_leaves && state.leaves || interior_leaf;
@@ -1426,10 +1465,9 @@ impl Mesher<'_> {
Pick::Tile(t) => self.translucent(tile(t)),
Pick::Quarters(tiles) => tiles.into_iter().any(|t| self.translucent(tile(t))),
};
// Overlay tiles face the standard way (Continuity: "overlay textures always have the
// Overlay tiles face the standard way too (Continuity: "overlay textures always have the
// standard orientation"), whatever turns the model's own UVs (dirt's and sand's random
// rotations): the cube's UVs at the quad's corners.
let standard = q.positions.map(|p| standard_uv(q.facing, p));
// rotations).
// Over a `ctm_compact` face, the same pieces: the same depths and sort centres.
let cuts = match pick {
Pick::Quarters(_) => cuts(&corners),
@@ -1447,7 +1485,7 @@ impl Mesher<'_> {
translucent: overlay.translucent || face_translucent,
opaque: false,
};
let whole = vertices(tint, standard);
let whole = vertices(tint, standard());
for s in cuts.windows(2) {
for t in cuts.windows(2) {
let piece = [(s[0], t[0]), (s[1], t[0]), (s[1], t[1]), (s[0], t[1])]
@@ -322,6 +322,7 @@ mod tests {
legacy_solid: false,
overlay: leaves,
leaves,
pane: false,
opaque_cube: opaque,
blocks_flow: false,
ice: false,
+220 -39
View File
@@ -16,6 +16,15 @@
//! each. Coplanar quads facing one way keep their model order, so an overlay
//! on its base still draws in that order. Solid cutout quads are otherwise
//! ordered from the outside toward the section interior to reduce overdraw.
//!
//! The opaque quads (`OPAQUE_LAYER`) and the cutout ones of each bucket are
//! apart, so that each of Metal's two solid passes draws only its own quads
//! (`Part`). Where the renderer draws them in two passes (`group_parts`), all
//! the opaque buckets come first, then all the cutout ones, so each part is
//! one or two runs; elsewhere each bucket holds its opaque quads, then its
//! cutout ones, so one pass over all of them draws the runs it drew before.
use std::sync::atomic::{AtomicBool, Ordering};
use super::{OPAQUE_LAYER, TerrainVertex};
/// The buckets in mesh order: +X, +Z, other, +Y, -Z, -X, -Y. Seen from
@@ -44,12 +53,37 @@ enum Bucket {
Other,
}
/// Where each bucket of a mesh's solid quads ends, and the planes that tell
/// whether it can face the eye.
/// Whether meshes sorted from now on put every bucket's opaque quads before
/// any cutout ones: set by the renderer when it draws them in two passes.
static GROUPED: AtomicBool = AtomicBool::new(false);
/// Sorts later meshes for two solid passes (`true`) or one.
pub fn group_parts(on: bool) {
GROUPED.store(on, Ordering::Relaxed);
}
/// The bucket and whether it holds cutout quads of the `n`th part of a
/// mesh, in mesh order.
fn slot(grouped: bool, n: usize) -> (usize, bool) {
if grouped {
(n % 7, n >= 7)
} else {
(n / 2, n % 2 == 1)
}
}
/// Where each part of each bucket of a mesh's solid quads ends, and the
/// planes that tell whether a bucket can face the eye.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Sides {
/// The solid quads up to the end of each bucket, in `BUCKETS` order.
ends: [u32; 7],
/// The solid quads up to the end of each part of each bucket, in mesh
/// order (`slot`).
ends: [u32; 14],
/// Whether all the opaque parts come first (`group_parts`).
grouped: bool,
/// Whether opaque and cutout quads are apart; else either part is every
/// quad.
split: bool,
/// Per bucket facing a positive direction, its lowest plane; facing a
/// negative one, its highest (unused for the other bucket).
planes: [f32; 7],
@@ -59,43 +93,62 @@ impl Sides {
/// `quads` solid quads in one bucket drawn from everywhere: for meshes
/// that were not sorted.
pub fn unsorted(quads: u32) -> Self {
let mut ends = [0; 7];
ends[OTHER..].fill(quads);
let mut ends = [0; 14];
ends[OTHER * 2..].fill(quads);
Self {
ends,
grouped: false,
split: false,
planes: [0.0; 7],
}
}
/// The number of solid quads the buckets hold.
pub fn quads(&self) -> u32 {
self.ends[6]
self.ends[13]
}
/// Calls `draw(first, count)` for each run of quads an eye at `eye`
/// (relative to the mesh's origin) can see the front of, nearest the
/// start first. Empty buckets do not break a run.
pub fn visible(&self, eye: [f32; 3], draw: impl FnMut(u32, u32)) {
self.visible_from(eye, eye[1], draw);
self.visible_from(eye, eye[1], Part::All, draw);
}
/// `visible` for every eye from `eye` straight down to the height `low`: the camera and
/// its water mirror's eye below the plane, which differ only in y.
pub fn visible_from(&self, eye: [f32; 3], low: f32, mut draw: impl FnMut(u32, u32)) {
/// its water mirror's eye below the plane, which differ only in y. Only the quads of
/// `part`, but every quad of a mesh whose parts are not split.
pub fn visible_from(&self, eye: [f32; 3], low: f32, part: Part, draw: impl FnMut(u32, u32)) {
let seen = |i: usize| match BUCKETS[i] {
Bucket::Side(axis, true) => eye[axis] > self.planes[i] - MARGIN,
Bucket::Side(1, false) => low < self.planes[i] + MARGIN,
Bucket::Side(axis, false) => eye[axis] < self.planes[i] + MARGIN,
Bucket::Other => true,
};
self.runs(seen, part, draw);
}
/// 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);
}
/// Calls `draw` for each run of `part`'s quads in the buckets `seen` keeps.
fn runs(&self, seen: impl Fn(usize) -> bool, part: Part, mut draw: impl FnMut(u32, u32)) {
let mut start = 0;
let mut run: Option<u32> = None;
for (i, bucket) in BUCKETS.iter().enumerate() {
let end = self.ends[i];
for (n, &end) in self.ends.iter().enumerate() {
if end == start {
continue;
}
let seen = match *bucket {
Bucket::Side(axis, true) => eye[axis] > self.planes[i] - MARGIN,
Bucket::Side(1, false) => low < self.planes[i] + MARGIN,
Bucket::Side(axis, false) => eye[axis] < self.planes[i] + MARGIN,
Bucket::Other => true,
let (bucket, cutout) = slot(self.grouped, n);
let wanted = match part {
Part::All => true,
_ if !self.split => true,
Part::Opaque => !cutout,
Part::Cutout => cutout,
};
match (seen, run) {
match (wanted && seen(bucket), run) {
(true, None) => run = Some(start),
(false, Some(first)) => {
draw(first, start - first);
@@ -111,6 +164,15 @@ impl Sides {
}
}
/// The quads `Sides::visible_from` draws: all of them, or those of one of
/// Metal's two solid passes (terrain.wgsl's `opaque_quad`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Part {
All,
Opaque,
Cutout,
}
/// The bucket of a quad: the axis plane it lies in and the side its
/// triangles (corners 0, 1, 2 and 0, 2, 3, as the renderer draws them) face.
fn bucket(quad: &[TerrainVertex; 4]) -> usize {
@@ -139,18 +201,34 @@ fn bucket(quad: &[TerrainVertex; 4]) -> usize {
OTHER
}
/// Reorders `solid`'s quads by bucket. Solid cutout quads are also ordered
/// front to back within each axis-facing bucket; stable sorting preserves the
/// mesher's order for coplanar overlays.
/// Reorders `solid`'s quads by bucket, opaque ones first in each. Solid
/// cutout quads are also ordered front to back within each axis-facing
/// bucket; stable sorting preserves the mesher's order for coplanar overlays.
pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
sort_parts(solid, GROUPED.load(Ordering::Relaxed))
}
/// `sort`, with all the opaque parts first when `grouped`.
fn sort_parts(solid: &mut Vec<TerrainVertex>, grouped: bool) -> Sides {
let (quads, rest) = solid.as_chunks::<4>();
debug_assert!(rest.is_empty(), "solid vertices come in quads");
let buckets: Vec<u8> = quads.iter().map(|q| bucket(q) as u8).collect();
let has_cutout = quads.iter().any(|q| q[0].layer & OPAQUE_LAYER == 0);
let cutout = |q: &[TerrainVertex; 4]| q[0].layer & OPAQUE_LAYER == 0;
// Each quad's part, by its place in mesh order (`slot`).
let position = |b: usize, cutout: bool| match grouped {
true => b + 7 * usize::from(cutout),
false => b * 2 + usize::from(cutout),
};
let slots: Vec<u8> = quads
.iter()
.map(|q| position(bucket(q), cutout(q)) as u8)
.collect();
let has_cutout = quads.iter().any(cutout);
let mut counts = [0u32; 7];
let mut slot_counts = [0u32; 14];
let mut planes = [0f32; 7];
for (quad, &b) in quads.iter().zip(&buckets) {
let b = usize::from(b);
for (quad, &part) in quads.iter().zip(&slots) {
slot_counts[usize::from(part)] += 1;
let b = slot(grouped, usize::from(part)).0;
if let Bucket::Side(axis, positive) = BUCKETS[b] {
let p = quad[0].pos[axis];
planes[b] = match (counts[b], positive) {
@@ -161,26 +239,26 @@ pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
}
counts[b] += 1;
}
let mut ends = [0u32; 7];
let mut slot_ends = [0u32; 14];
let mut total = 0;
for (end, count) in ends.iter_mut().zip(counts) {
for (end, count) in slot_ends.iter_mut().zip(slot_counts) {
total += count;
*end = total;
}
if has_cutout || counts.iter().filter(|&&c| c > 0).count() > 1 {
let mut next: [u32; 7] = std::array::from_fn(|b| ends[b] - counts[b]);
let mut next: [u32; 14] = std::array::from_fn(|s| slot_ends[s] - slot_counts[s]);
let mut sorted = vec![quads[0]; quads.len()];
for (quad, &b) in quads.iter().zip(&buckets) {
let slot = &mut next[usize::from(b)];
sorted[*slot as usize] = *quad;
*slot += 1;
for (quad, &part) in quads.iter().zip(&slots) {
let at = &mut next[usize::from(part)];
sorted[*at as usize] = *quad;
*at += 1;
}
if has_cutout {
let mut start = 0;
for (i, bucket) in BUCKETS.iter().enumerate() {
let end = ends[i] as usize;
for (s, &end) in slot_ends.iter().enumerate() {
let end = end as usize;
let slice = &mut sorted[start..end];
if let Bucket::Side(axis, positive) = *bucket {
if let Bucket::Side(axis, positive) = BUCKETS[slot(grouped, s).0] {
slice.sort_by(|a, b| {
let a = a[0].pos[axis];
let b = b[0].pos[axis];
@@ -196,7 +274,12 @@ pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
}
*solid = sorted.into_flattened();
}
Sides { ends, planes }
Sides {
ends: slot_ends,
grouped,
split: true,
planes,
}
}
#[cfg(test)]
@@ -225,6 +308,12 @@ mod tests {
.collect()
}
/// Where each bucket ends, in a mesh sorted for one pass.
fn bucket_ends(sides: &Sides) -> [u32; 7] {
assert!(!sides.grouped);
std::array::from_fn(|b| sides.ends[b * 2 + 1])
}
fn runs(sides: &Sides, eye: [f32; 3]) -> Vec<(u32, u32)> {
let mut out = Vec::new();
sides.visible(eye, |first, count| out.push((first, count)));
@@ -261,7 +350,7 @@ mod tests {
v.layer = (i / 4) as u16;
}
let sides = sort(&mut solid);
assert_eq!(sides.ends, [1, 2, 2, 3, 4, 5, 6]);
assert_eq!(bucket_ends(&sides), [1, 2, 2, 3, 4, 5, 6]);
let layers: Vec<u16> = solid.iter().step_by(4).map(|v| v.layer).collect();
// +X, +Z, +Y, -Z, -X, -Y.
assert_eq!(layers, [0, 4, 2, 5, 1, 3]);
@@ -348,7 +437,7 @@ mod tests {
let sides = sort(&mut solid);
let both = |eye: [f32; 3], low: f32| {
let mut out = Vec::new();
sides.visible_from(eye, low, |first, count| out.push((first, count)));
sides.visible_from(eye, low, Part::All, |first, count| out.push((first, count)));
out
};
// Above the block: +X, +Z and +Y; with the mirror's eye below it, -Y too.
@@ -360,6 +449,98 @@ mod tests {
assert_eq!(both([-8.0, 8.0, -8.0], -8.0), [(2, 4)]);
}
/// Opaque and cutout quads of each bucket split: each pass's runs hold
/// exactly its own quads facing the eye, and all of them together the
/// same runs as one pass.
#[test]
fn each_part_draws_its_own_quads_facing_the_eye() {
// Two blocks: one opaque, one cutout, then an opaque one again, so
// every bucket holds both kinds out of order.
let mut solid = Vec::new();
let mut id = 0u16;
for (origin, opaque) in [
([0.0, 0.0, 0.0], true),
([4.0, 2.0, 6.0], false),
([9.0, 5.0, 3.0], true),
] {
for q in cube(origin, 0).chunks(4) {
let layer = id | if opaque { OPAQUE_LAYER } else { 0 };
solid.extend(q.iter().map(|v| TerrainVertex { layer, ..*v }));
id += 1;
}
}
let unsorted = solid;
for grouped in [false, true] {
let mut solid = unsorted.clone();
let sides = sort_parts(&mut solid, grouped);
let layers: Vec<u16> = solid
.as_chunks::<4>()
.0
.iter()
.map(|q| q[0].layer)
.collect();
for eye in [[20.0, 30.0, -10.0], [-5.0, 1.0, 4.0], [5.0, 3.0, 7.0]] {
let quads = |part| {
let mut out = Vec::new();
sides.visible_from(eye, eye[1], part, |first, count| {
out.extend(
layers[first as usize..(first + count) as usize]
.iter()
.copied(),
)
});
out.sort_unstable();
out
};
let mut facing = front_facing(&solid, eye);
facing.sort_unstable();
let opaque = quads(Part::Opaque);
let cutout = quads(Part::Cutout);
assert!(opaque.iter().all(|l| l & OPAQUE_LAYER != 0), "{eye:?}");
assert!(cutout.iter().all(|l| l & OPAQUE_LAYER == 0), "{eye:?}");
for l in &facing {
let mine = if l & OPAQUE_LAYER != 0 {
&opaque
} else {
&cutout
};
assert!(
mine.contains(l),
"{eye:?}: quad {l} facing the eye left out"
);
}
let mut both = [opaque, cutout].concat();
both.sort_unstable();
assert_eq!(both, quads(Part::All), "{eye:?}");
}
// Every way: each part once, whole.
let mut every = Vec::new();
sides.every(Part::Opaque, |first, count| every.push((first, count)));
sides.every(Part::Cutout, |first, count| every.push((first, count)));
let total: u32 = every.iter().map(|r| r.1).sum();
assert_eq!(total, sides.quads());
// Two passes: from above a corner, each part is one or two runs.
if grouped {
let mut count = 0;
sides.visible_from([20.0, 30.0, -10.0], 30.0, Part::Opaque, |_, _| count += 1);
assert_eq!(count, 2);
}
}
}
/// A mesh that was not sorted has no parts: each pass draws all of it
/// (the shader leaves out the other pass's quads).
#[test]
fn unsorted_meshes_draw_everything_in_each_part() {
let sides = Sides::unsorted(9);
for part in [Part::Opaque, Part::Cutout] {
let mut out = Vec::new();
sides.every(part, |first, count| out.push((first, count)));
assert_eq!(out, [(0, 9)]);
}
}
#[test]
fn unsorted_meshes_draw_everything() {
let sides = Sides::unsorted(9);
@@ -385,7 +566,7 @@ mod tests {
.collect();
let before = solid.clone();
let sides = sort(&mut solid);
assert_eq!(sides.ends, [3; 7]);
assert_eq!(bucket_ends(&sides), [3; 7]);
assert_eq!(
solid.iter().map(|v| v.layer).collect::<Vec<_>>(),
before.iter().map(|v| v.layer).collect::<Vec<_>>()
@@ -88,6 +88,7 @@ fn info(solid: bool) -> Info {
legacy_solid: false,
overlay: false,
leaves: false,
pane: false,
opaque_cube: solid,
blocks_flow: false,
ice: false,
@@ -1,8 +1,13 @@
//! Optional, texture-matched native overlays. No extra draw calls or world light changes.
//! Optional, texture-matched overlays drawn full bright over their base texture in the same
//! terrain fragment: native mods' (`<ns>:emissive/...`) and OptiFine's emissive block textures
//! (`block/stone_e.png` beside `block/stone.png`). No extra draw calls or world light changes.
use crate::sprites::Sprites;
use std::{collections::BTreeMap, path::Path};
pub const MAX: usize = 32;
/// Overlay pairs the terrain shader reads (`Globals.emissive_layers`, terrain.wgsl).
pub const MAX: usize = 128;
/// Native mods' own `emissive/` textures, as their API documents.
const NATIVE: usize = 32;
/// Load only bounded, explicitly named emissive textures from the active pack.
pub fn load(root: &Path, sprites: &mut Sprites) {
@@ -12,7 +17,7 @@ pub fn load(root: &Path, sprites: &mut Sprites) {
name.split_once(':')
.is_some_and(|(_, p)| p.starts_with("emissive/"))
})
.take(MAX)
.take(NATIVE)
.collect();
names.sort();
for name in names {
@@ -23,6 +28,80 @@ pub fn load(root: &Path, sprites: &mut Sprites) {
}
}
/// OptiFine's emissive overlays of the block sprites already loaded (what the terrain draws;
/// items draw from the HUD atlas): for each
/// `<ns>:block/<name>` whose `<name><suffix>.png` sits beside it, the overlay is loaded too and
/// paired with it, first by name, at most [`MAX`] - [`NATIVE`] (native mods keep their share of
/// the table). A pack without such files adds nothing.
pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)> {
let suffix = horizon_optifine::emissive::suffix(root);
// Blocks with identical pixels share a layer (and the shader pairs by layer): a shared
// base would glow on every block that shares it, so it gets no overlay.
let mut sharing: BTreeMap<u32, usize> = BTreeMap::new();
for layer in sprites.layers.values() {
*sharing.entry(*layer).or_default() += 1;
}
let bases: Vec<String> = sprites
.layers
.iter()
.filter(|(name, layer)| {
sharing[layer] == 1
&& name
.split_once(':')
.is_some_and(|(_, p)| p.starts_with("block/") && !p.ends_with(&suffix))
})
.map(|(name, _)| name.clone())
.collect();
let mut pairs = Vec::new();
for base in bases {
let overlay = format!("{base}{suffix}");
let file = crate::atlas_sources::texture_path(root, &overlay, "textures");
if !std::fs::metadata(&file).is_ok_and(|m| m.is_file()) {
continue;
}
if pairs.len() == MAX - NATIVE {
log::warn!(
"Horizon: more than {} emissive block overlays: {overlay} and later skipped",
MAX - NATIVE
);
break;
}
if std::fs::metadata(&file).is_ok_and(|m| m.len() > 1 << 20) {
log::warn!("Horizon: emissive overlay {overlay} is over 1 MiB, skipped");
continue;
}
// A file that doesn't load would glow as the missing texture: no pair instead.
match sprites.try_insert(root, &overlay) {
Ok(_) => pairs.push((base, overlay)),
Err(e) => log::warn!("Horizon: emissive overlay {overlay} skipped: {e}"),
}
}
pairs
}
/// The overlay layer's flag (its high bit) for an OptiFine overlay, which draws cutout: opaque
/// where its alpha is at least 0.1 (OptiFine renders SOLID textures' overlays as CUTOUT).
pub const CUTOUT: u32 = 1 << 31;
/// The frame's overlay table: native mods' pairs (at most [`NATIVE`] requested), then the packs'
/// OptiFine pairs flagged [`CUTOUT`] (at most [`MAX`] - [`NATIVE`], as [`load_optifine`] loads),
/// one per base layer with native mods' first, sorted by base layer for the shader's early exit.
pub fn table<'a>(
layers: &BTreeMap<String, u32>,
native: impl IntoIterator<Item = (&'a str, &'a str)>,
optifine: &'a [(String, String)],
) -> Vec<[u32; 4]> {
let mut by_base: BTreeMap<u32, [u32; 4]> = BTreeMap::new();
for pair in mappings(layers, native.into_iter().take(NATIVE)) {
by_base.insert(pair[0], pair);
}
let optifine = optifine.iter().map(|(b, o)| (b.as_str(), o.as_str()));
for [b, o, z, w] in mappings(layers, optifine) {
by_base.entry(b).or_insert([b, o | CUTOUT, z, w]);
}
by_base.into_values().collect()
}
/// Only loaded base/overlay pairs are sent to the GPU; sorted for vertex lookup.
pub fn mappings<'a>(
layers: &BTreeMap<String, u32>,
@@ -40,6 +119,120 @@ pub fn mappings<'a>(
#[cfg(test)]
mod tests {
use super::*;
/// A 16x16 image of one colour, `shade` its red (images of different shades never share a
/// sprite layer).
fn png(path: &Path, shade: u8) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let file = std::io::BufWriter::new(std::fs::File::create(path).unwrap());
let mut encoder = png::Encoder::new(file, 16, 16);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
let pixels: Vec<u8> = (0..16 * 16).flat_map(|_| [shade, 9, 9, 255]).collect();
writer.write_image_data(&pixels).unwrap();
}
#[test]
fn optifine_overlays_pair_with_their_loaded_blocks() {
let root =
std::env::temp_dir().join(format!("horizon-block-emissive-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let block = root.join("assets/minecraft/textures/block");
let names = [
"stone",
"stone_e",
"dirt",
"glowstone_e",
"deepslate",
"deepslate_glow",
"andesite",
"granite",
];
for (i, name) in names.iter().enumerate() {
png(&block.join(format!("{name}.png")), i as u8 * 20);
}
// Same pixels as andesite: the two share a layer, so neither gets an overlay.
png(&block.join("diorite.png"), 6 * 20);
png(&block.join("diorite_e.png"), 200);
// A broken overlay: skipped, not drawn as the missing texture.
std::fs::write(block.join("granite_e.png"), b"not a png").unwrap();
png(&root.join("assets/minecraft/textures/item/stick.png"), 210);
png(
&root.join("assets/minecraft/textures/item/stick_e.png"),
220,
);
let mut sprites = Sprites::default();
for name in [
"stone",
"dirt",
"deepslate",
"andesite",
"diorite",
"granite",
] {
sprites
.insert(&root, &format!("minecraft:block/{name}"))
.unwrap();
}
// Items draw from the HUD atlas, not the terrain: no pair.
sprites.insert(&root, "minecraft:item/stick").unwrap();
// glowstone isn't loaded: its overlay pairs with nothing.
let pairs = load_optifine(&root, &mut sprites);
assert_eq!(
pairs,
[(
"minecraft:block/stone".into(),
"minecraft:block/stone_e".into()
)]
);
assert!(sprites.layers.contains_key("minecraft:block/stone_e"));
assert!(!sprites.layers.contains_key("minecraft:block/granite_e"));
// The pack's own suffix.
let props = root.join("assets/minecraft/optifine/emissive.properties");
std::fs::create_dir_all(props.parent().unwrap()).unwrap();
std::fs::write(&props, "suffix.emissive=_glow").unwrap();
let pairs = load_optifine(&root, &mut sprites);
assert_eq!(
pairs,
[(
"minecraft:block/deepslate".into(),
"minecraft:block/deepslate_glow".into()
)]
);
std::fs::remove_dir_all(&root).unwrap();
}
#[test]
fn the_table_keeps_native_pairs_first_and_flags_optifine_cutout() {
let layers: BTreeMap<String, u32> =
[("a", 5), ("a_e", 6), ("b", 2), ("b_e", 3), ("native", 9)]
.map(|(n, l)| (n.to_owned(), l))
.into();
let optifine = [
("a".to_owned(), "a_e".to_owned()),
("b".to_owned(), "b_e".to_owned()),
];
// A native mod pairs `a` too: its pair wins; `b` stays the pack's, flagged.
let t = table(&layers, [("a", "native")], &optifine);
assert_eq!(
t,
[[2, 3 | CUTOUT, 0, 0], [5, 9, 0, 0]],
"sorted by base layer"
);
// Native mods keep their 32: of 40 requests on 40 bases, the first 32.
let layers: BTreeMap<String, u32> =
(0..41u32).map(|i| (format!("s{i}"), 100 + i)).collect();
let names: Vec<String> = (1..41).map(|i| format!("s{i}")).collect();
let many = names.iter().map(|n| (n.as_str(), "s0"));
let t = table(&layers, many, &[]);
assert_eq!(t.len(), 32);
assert_eq!(
t.last(),
Some(&[132, 100, 0, 0]),
"s32 last; s33 to s40 cut"
);
}
#[test]
fn only_existing_pairs_are_mapped() {
let layers = [
@@ -44,6 +44,9 @@ pub struct ScreenQuad {
pub blend: Blend,
/// How the sprite is sampled: [`SAMPLE_BLUR`] and [`SAMPLE_CLAMP`].
pub sampling: u32,
/// A tint of the whole view (the underwater overlay), not something in front of it: in HDR
/// output the world shines through it (`Renderer::draw_overlay`).
pub world_tint: bool,
}
/// What the camera sees this frame.
@@ -160,6 +163,7 @@ impl ScreenEffects {
}),
blend: Blend::Alpha,
sampling,
world_tint: false,
};
let full = [[-1.0, -1.0], [1.0, -1.0], [1.0, 1.0], [-1.0, 1.0]];
// ScreenEffectRenderer.submitBlockSprite: u1/v1 at (-1, -1), colour 0xFF191919.
@@ -180,13 +184,16 @@ impl ScreenEffects {
{
let (u, v) = (-yaw / 64.0, pitch / 64.0);
let uv = [[u + 4.0, v + 4.0], [u, v + 4.0], [u, v], [u + 4.0, v]];
out.push(hand_quad(
full,
uv,
rect,
[brightness, brightness, brightness, 0.1],
texture_sampling("misc/underwater"),
));
out.push(ScreenQuad {
world_tint: true,
..hand_quad(
full,
uv,
rect,
[brightness, brightness, brightness, 0.1],
texture_sampling("misc/underwater"),
)
});
}
if input.on_fire
&& let Some(rect) = sprite("block/fire_1")
@@ -224,6 +231,7 @@ impl ScreenEffects {
}),
blend,
sampling,
world_tint: false,
};
let rgba = |c: u32| {
let [a, r, g, b] = c.to_be_bytes();
@@ -397,6 +405,9 @@ mod tests {
});
let sampling: Vec<_> = quads.iter().map(|q| q.sampling).collect();
assert_eq!(sampling, [0, SAMPLE_CLAMP, SAMPLE_BLUR]);
// Only the underwater overlay tints the world; the block in the way covers it.
let tints: Vec<_> = quads.iter().map(|q| q.world_tint).collect();
assert_eq!(tints, [false, true, false]);
}
#[test]
fn mcmeta_texture_section() {
+155
View File
@@ -92,6 +92,161 @@ pub fn describe(spans: &[Span]) -> String {
list.join(", ")
}
/// The start's timeline: `HORIZON_STARTUP_TRACE=FILE` writes every named step of
/// the start (resource tables, the renderer, the title, a world's open, a
/// server's join, a dimension change) on whichever thread ran it, one Chrome
/// trace event per line (`tools/startup_trace.py` reads it; wrapped in `[...]`,
/// Perfetto opens it). Times are microseconds since the process started. Off,
/// a step costs one relaxed load.
pub mod startup {
use std::{
io::Write as _,
sync::{
Mutex, OnceLock,
atomic::{AtomicBool, AtomicU32, Ordering},
},
time::Instant,
};
static ON: AtomicBool = AtomicBool::new(false);
static SINK: OnceLock<Option<Mutex<std::fs::File>>> = OnceLock::new();
static ORIGIN: OnceLock<Instant> = OnceLock::new();
static NEXT_THREAD: AtomicU32 = AtomicU32::new(1);
thread_local! {
/// This thread's trace id, given (with its name) on its first event.
static THREAD: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
}
/// Opens the trace named by `HORIZON_STARTUP_TRACE`, timed from `origin`
/// (the process's start). Call once, first thing.
pub fn init(origin: Instant) {
ORIGIN.get_or_init(|| origin);
let sink = SINK.get_or_init(|| {
let path = std::env::var_os("HORIZON_STARTUP_TRACE")?;
std::fs::File::create(&path).ok().map(Mutex::new)
});
ON.store(sink.is_some(), Ordering::Relaxed);
}
/// Whether a trace is being written.
#[inline]
pub fn on() -> bool {
ON.load(Ordering::Relaxed)
}
fn micros(at: Instant) -> u128 {
ORIGIN.get().map_or(0, |origin| {
at.saturating_duration_since(*origin).as_micros()
})
}
fn write(event: std::fmt::Arguments) {
let Some(Some(sink)) = SINK.get() else {
return;
};
let tid = THREAD.with(|id| {
if id.get() == 0 {
id.set(NEXT_THREAD.fetch_add(1, Ordering::Relaxed));
let name = std::thread::current()
.name()
.unwrap_or("unnamed")
.replace(['"', '\\'], "_");
let mut file = sink.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let _ = writeln!(
file,
r#"{{"ph":"M","name":"thread_name","pid":1,"tid":{},"args":{{"name":"{name}"}}}}"#,
id.get()
);
}
id.get()
});
let mut file = sink
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _ = writeln!(file, r#"{{"pid":1,"tid":{tid},{event}}}"#);
}
/// A step running until it is dropped.
#[must_use = "the step ends when this is dropped"]
pub struct Step {
name: &'static str,
started: Instant,
}
impl Drop for Step {
fn drop(&mut self) {
let end = Instant::now();
write(format_args!(
r#""ph":"X","name":"{}","ts":{},"dur":{}"#,
self.name,
micros(self.started),
end.saturating_duration_since(self.started).as_micros()
));
}
}
/// Starts a step named `name` (a literal: no quotes or backslashes).
#[inline]
pub fn step(name: &'static str) -> Option<Step> {
on().then(|| Step {
name,
started: Instant::now(),
})
}
/// Runs `f` as a step named `name`.
#[inline]
pub fn time<T>(name: &'static str, f: impl FnOnce() -> T) -> T {
let _step = step(name);
f()
}
/// A moment of the start: the title drawn, the world in view.
#[inline]
pub fn mark(name: &'static str) {
if on() {
write(format_args!(
r#""ph":"i","s":"g","name":"{name}","ts":{}"#,
micros(Instant::now())
));
}
}
/// The first time `name` happens, as a mark. `once` is the caller's flag.
#[inline]
pub fn mark_once(once: &AtomicBool, name: &'static str) {
if on() && !once.swap(true, Ordering::Relaxed) {
mark(name);
}
}
/// Set while a world opens, a server is joined or the dimension changes,
/// until the view first fills ([`view_filled`]).
static FILLING: AtomicBool = AtomicBool::new(false);
/// A world starts loading: the next full view is marked.
pub fn arm(name: &'static str) {
if on() {
mark(name);
FILLING.store(true, Ordering::Relaxed);
}
}
/// Called each frame in a world: marks `world.view_full` the first frame
/// after [`arm`] where nothing waits to load, generate, light, mesh or
/// upload and the resident columns cover perf_bench's share of the view
/// (0.8 pi r^2: corners are never loaded).
#[inline]
pub fn view_filled(resident: usize, pending: usize, radius: u32) {
if FILLING.load(Ordering::Relaxed)
&& pending == 0
&& resident as f64 >= 0.8 * std::f64::consts::PI * f64::from(radius * radius)
&& FILLING.swap(false, Ordering::Relaxed)
{
mark("world.view_full");
}
}
}
#[cfg(test)]
mod tests {
use super::Span;
@@ -302,6 +302,14 @@ impl Sprites {
}
}
}
/// [`Sprites::insert`] without the missing-texture fallback: a file that doesn't load is an
/// error, and nothing is added.
pub fn try_insert(&mut self, root: &Path, name: &str) -> Result<u32> {
match self.layers.get(name) {
Some(layer) => Ok(*layer),
None => self.load(root, name),
}
}
/// The layer of a texture, added on first use. A texture that is missing
/// or does not load (a resource pack's filter or a broken file) becomes
/// vanilla's missing texture, as in a stitched atlas.
@@ -241,6 +241,7 @@ impl Session {
pub fn title_shown(&mut self) {
if !self.title_measured {
self.title_measured = true;
crate::spans::startup::mark("title.shown");
let ui_ms = UI_READY_MS.load(Ordering::Relaxed);
self.to_title_ms = if ui_ms != u32::MAX {
Some(ui_ms)
@@ -252,6 +253,9 @@ impl Session {
/// A world starts loading.
pub fn world_opening(&mut self) {
if self.opening.is_none() {
crate::spans::startup::arm("world.opening");
}
self.opening.get_or_insert_with(Instant::now);
}
@@ -262,10 +266,11 @@ impl Session {
/// A frame drawn while playing (not in menus or loading).
pub fn frame(&mut self, ms: f32) {
if let Some(opening) = self.opening.take()
&& self.to_world_ms.is_none()
{
self.to_world_ms = Some(opening.elapsed().as_millis() as u32);
if let Some(opening) = self.opening.take() {
crate::spans::startup::mark("world.first_frame");
if self.to_world_ms.is_none() {
self.to_world_ms = Some(opening.elapsed().as_millis() as u32);
}
}
self.frames.record(ms);
self.played_ms += ms;
+19 -9
View File
@@ -26,7 +26,7 @@ struct Globals {
dynamic_lights: array<vec4<f32>, 16>,
colored_lights: array<vec4<f32>, 32>,
colored_count: vec4<u32>,
emissive_layers: array<vec4<u32>, 32>,
emissive_layers: array<vec4<u32>, 128>,
emissive_count: vec4<u32>,
// The sunrise fan's 16 triangles (gfx/sunrise_fan.rs): xyz the apex weight's normal, w the
// triangle's volume with the camera; then each rim vertex's normal against the apex.
@@ -311,10 +311,10 @@ fn fade_code(w: f32) -> u32 {
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
origin.w);
}
// Solid terrain draws in two passes from the same draws: first the quads with no
// cutout that are not fading, through `fs_opaque`, which never discards, so a tiled
// GPU (Apple's) keeps hiding the pixels behind them; then the rest through
// `fs_solid`. One discard anywhere in a pipeline's shader turned that hiding off for
// Solid terrain draws in two passes, each over its own part of every mesh's quads
// (`mesher::Part`): first the quads with no cutout that are not fading, through
// `fs_opaque`, which never discards, so a tiled GPU (Apple's) keeps hiding the pixels
// behind them; then the rest through `fs_solid`. One discard anywhere in a pipeline's shader turned that hiding off for
// all the terrain it drew. A quad the pass leaves out is decided before any of the
// vertex's work and lies outside the clip volume: it makes no pixel.
fn opaque_quad(layer: u32, w: f32) -> bool {
@@ -370,9 +370,15 @@ fn terrain_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f3
out.uv = uv;
out.layer = layer;
out.emissive_layer = 0xffffffffu;
for (var i = 0u; i < min(globals.emissive_count.x, 32u); i++) {
if globals.emissive_layers[i].x == (layer & 0x7fffu) {
out.emissive_layer = globals.emissive_layers[i].y;
// The pairs are sorted by base layer: stop at the first past this one.
let sprite_layer = layer & 0x7fffu;
for (var i = 0u; i < min(globals.emissive_count.x, 128u); i++) {
let pair = globals.emissive_layers[i];
if pair.x == sprite_layer {
out.emissive_layer = pair.y;
break;
}
if pair.x > sprite_layer {
break;
}
}
@@ -462,7 +468,11 @@ fn solid_finish(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>) -> vec4<f32>
}
}
if in.emissive_layer != 0xffffffffu {
let mask = sprite(in.uv, in.emissive_layer);
// The high bit: an OptiFine overlay, drawn cutout (opaque where alpha >= 0.1).
var mask = sprite(in.uv, in.emissive_layer & 0x7fffffffu);
if (in.emissive_layer & 0x80000000u) != 0u {
mask.a = select(0.0, 1.0, mask.a >= 0.1);
}
return apply_fog(vec4(mix(color.rgb, mask.rgb, mask.a), mix(emissive, hdr_mark(3u, 1.0), mask.a)), in.spherical, in.cylindrical);
}
return apply_fog(vec4(color.rgb, emissive), in.spherical, in.cylindrical);
@@ -99,6 +99,7 @@
"UI Brightness": "UI Brightness",
"Battery Mode": "Battery Mode",
"Graphics API": "Graphics API",
"Translucent Batching": "Translucent Batching",
"Performance Overlay": "Performance Overlay",
"Full Detail Distance": "Full Detail Distance",
"On": "On",
@@ -146,6 +147,7 @@
"This feature is not yet available in Horizon.": "This feature is not yet available in Horizon.",
"Your display or system doesn't offer HDR output.": "Your display or system doesn't offer HDR output.",
"Turn HDR on to use it.": "Turn HDR on to use it.",
"Your GPU or graphics API doesn't support it.": "Your GPU or graphics API doesn't support it.",
"%s chunks": "%s chunks",
"%s per frame": "%s per frame",
"%s MiB/frame": "%s MiB/frame",
@@ -205,6 +207,7 @@
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 FPS cap (30 in menus or unfocused), half the workers: less power.",
"Threads that generate, light and mesh chunks. Next start.": "Threads that generate, light and mesh chunks. Next start.",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.",
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Mesh data sent to the GPU per frame. Lower is smoother while loading.",
"Frame rate and frame times over the game, with a graph.": "Frame rate and frame times over the game, with a graph.",
"horizon.multiplayer.finding_friend": "Finding your friend's world...",
+2 -2
View File
@@ -752,8 +752,8 @@ impl Ticker<'_, '_, '_, '_> {
if !self.st.was_touching_water {
self.p.velocity = self.p.velocity.scale(f64::from(0.99f32));
}
if physics && !self.st.in_ground {
// Entity.applyGravity: AbstractArrow.getDefaultGravity.
if physics && !self.st.in_ground && !self.p.no_gravity {
// Entity.applyGravity: AbstractArrow.getDefaultGravity, none with NoGravity.
self.add_velocity(-0.05);
}
// Projectile.tick, then Entity.baseTick's fluid and fire phases.
+60 -2
View File
@@ -62,6 +62,7 @@ fn slime_at(world: &World, facts: &BlockFacts, x: i32, y: i32, z: i32) -> bool {
}
mod fluids;
pub use fluids::update_entity_fluids;
#[derive(Clone, Debug)]
pub struct ItemEntity {
@@ -88,6 +89,8 @@ pub struct ItemEntity {
/// `Entity.stuckSpeedMultiplier`: set after a move by the cobweb or powder snow
/// the item is in, scales (and stops) the next one.
pub stuck: Vec3,
/// `Entity.noGravity` (`NoGravity` in its save): `applyGravity` does nothing.
pub no_gravity: bool,
}
/// Environment results needed by the entity owner, including lethal lava damage.
#[derive(Debug)]
@@ -228,7 +231,9 @@ impl ItemEntity {
self.velocity.z * 0.95,
);
} else {
self.velocity.y -= 0.04;
if !self.no_gravity {
self.velocity.y -= 0.04;
}
}
let horizontal = self.velocity.x * self.velocity.x + self.velocity.z * self.velocity.z;
// ItemEntity.tick: skip move 3 of 4 ticks while grounded with no
@@ -658,6 +663,8 @@ pub struct ExperienceOrb {
pub ticks: u32,
/// `ExperienceOrb.health`: damage wears it down from 5 (`hurtServer`).
pub health: i32,
/// `Entity.noGravity`: `ExperienceOrb.tick`'s `applyGravity` does nothing.
pub no_gravity: bool,
}
impl ExperienceOrb {
/// `ExperienceOrb.hurtServer`: the damage comes off the health as an
@@ -774,7 +781,7 @@ impl ExperienceOrb {
(self.velocity.y + 5.0e-4).min(0.06),
self.velocity.z * 0.99,
);
} else if !colliding {
} else if !colliding && !self.no_gravity {
self.velocity.y -= 0.03;
}
if facts.get(block).fluid == Fluid::Lava {
@@ -1481,6 +1488,7 @@ mod fire_environment_tests {
)
.unwrap();
let make = |id: &str| ItemEntity {
no_gravity: false,
id: 1,
thrower: None,
target: None,
@@ -1627,6 +1635,7 @@ mod merge_tests {
fn item(id: &str, count: u32, x: f64, age: u32) -> ItemEntity {
let at = Vec3::new(x, 64.0, 0.5);
ItemEntity {
no_gravity: false,
id: 0,
thrower: None,
target: None,
@@ -1915,9 +1924,57 @@ mod honey_slide_tests {
);
}
/// `Entity.sendBubbleColumnParticles` is for the top of a column only: an item
/// inside the column shows nothing, one in its surface block shows the block.
#[test]
fn an_item_at_the_top_of_a_bubble_column_is_at_its_surface() {
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 collision = crate::collisions::load(&root, assets.registry).unwrap();
let mut world = World::test_scene(&assets, &render, 3).unwrap();
let mut column = assets
.registry
.state(
assets
.registry
.default_state("minecraft:bubble_column")
.unwrap(),
)
.unwrap()
.clone();
column.properties.insert("drag".into(), "false".into());
let column = assets.registry.resolve(&column).unwrap();
for y in 4..=8 {
world.set(20, y, 20, column).unwrap();
}
let surfaces = |y: f64| {
let item = falling_item(20.5, y, 20.5, 0.0, 0.0, 0.0);
crate::motion::bubble_column_surfaces(
&world,
assets.registry,
&facts,
&collision,
item.bounds(),
)
};
assert_eq!(surfaces(8.2), vec![BlockPos::new(20, 8, 20)]);
assert_eq!(surfaces(5.0), Vec::new());
// Straddling the two top blocks, it is still at the top of one column.
assert_eq!(surfaces(7.9), vec![BlockPos::new(20, 8, 20)]);
assert_eq!(surfaces(9.0), Vec::new());
}
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 {
no_gravity: false,
id: 1,
thrower: None,
target: None,
@@ -2047,6 +2104,7 @@ mod slime_bounce_tests {
fn grounded_item() -> ItemEntity {
let at = Vec3::new(0.5, 2.0, 0.5);
ItemEntity {
no_gravity: false,
id: 1,
thrower: None,
target: None,
+55 -1
View File
@@ -26,7 +26,12 @@ struct EntityFluidWorld<'a> {
world: &'a World,
fluids: &'a FluidResources,
}
fn update_entity_fluids(
/// `Entity.baseTick`'s fluid step for an entity that is pushed by fluid: its
/// immersion heights, then the current of each fluid it sits in, added to
/// `velocity` with vanilla's scales (water 0.014, lava 0.007 fast or
/// 0.0023333 slow). Items and armour stands call it for themselves; a boat
/// calls it from its own tick, before `floatBoat` scales the velocity.
pub fn update_entity_fluids(
bounds: Aabb,
position: Vec3,
eye_y: f64,
@@ -224,6 +229,7 @@ mod tests {
}
let at = Vec3::new(20.5, 4.0, 20.5);
let item = ItemEntity {
no_gravity: false,
id: 1,
health: 5,
fire_ticks: 0,
@@ -286,6 +292,54 @@ mod tests {
assert!(carried.position.x > at.x + 0.5);
}
/// `Entity.baseTick` pushes a boat by the current it sits in: a boat box
/// in water[level=1..7] gains velocity along the flow, and the same box in
/// still source water does not. Vanilla's boat tick runs this before
/// `floatBoat` scales the velocity, which is what `game::boats` does.
#[test]
fn a_boat_box_is_pushed_by_the_current_it_sits_in() {
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 = FluidResources::new(&assets, &facts).unwrap();
let mut world = World::test_scene(&assets, &render, 3).unwrap();
let stone = assets.registry.default_state("minecraft:stone").unwrap();
let water = assets.registry.default_state("minecraft:water").unwrap();
let mut state = assets.registry.state(water).unwrap().clone();
state.properties.insert("level".into(), "1".into());
let flowing = assets.registry.resolve(&state).unwrap();
for x in 19..=21 {
for z in 19..=21 {
world.set(x, 3, z, stone).unwrap();
world.set(x, 4, z, water).unwrap();
}
}
// Boat dimensions: 1.375 wide, 0.5625 tall, eye at its top.
let at = Vec3::new(20.5, 4.0, 20.5);
let half = 1.375 / 2.0;
let bounds = Aabb::new(
Vec3::new(at.x - half, at.y, at.z - half),
Vec3::new(at.x + half, at.y + 0.5625, at.z + half),
);
let mut still = Vec3::ZERO;
update_entity_fluids(bounds, at, at.y + 0.5625, &mut still, &world, &fluids);
assert_eq!(still.x, 0.0, "source water has no current");
world.set(21, 4, 20, flowing).unwrap();
let mut carried = Vec3::ZERO;
update_entity_fluids(bounds, at, at.y + 0.5625, &mut carried, &world, &fluids);
assert!(
carried.x > 0.0,
"the flow pushes the boat along +x, got {}",
carried.x
);
}
#[test]
fn armor_stands_follow_flowing_water_but_markers_and_still_water_stay_in_place() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
+37 -2
View File
@@ -41,7 +41,7 @@ pub fn motion(tag: &Tag) -> Vec3 {
pub fn rotation(tag: &Tag) -> [f32; 2] {
read_doubles::<2>(tag.get("Rotation")).map_or([0.0; 2], |r| [r[0] as f32, r[1] as f32])
}
fn flag(tag: &Tag, key: &str) -> bool {
pub fn flag(tag: &Tag, key: &str) -> bool {
number(tag.get(key)).unwrap_or(0) != 0
}
/// `Entity.saveWithoutId` fields every entity has, over the loaded compound.
@@ -2083,6 +2083,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 {
no_gravity: flag(tag, "NoGravity"),
health: number(tag.get("Health")).unwrap_or(5) as i32,
fire_ticks: number(tag.get("Fire")).unwrap_or(0) as i32,
id,
@@ -2171,6 +2172,10 @@ pub fn orb_to_nbt(o: &ExperienceOrb, uuid: [i32; 4]) -> Tag {
] {
put(&mut tag, key, value);
}
// Entity.saveWithoutId writes NoGravity only when set.
if o.no_gravity {
put(&mut tag, "NoGravity", Tag::Byte(1));
}
tag
}
/// One orb with its merged `Count`.
@@ -2180,6 +2185,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 {
no_gravity: flag(tag, "NoGravity"),
id: 0,
position,
previous: position,
@@ -2253,6 +2259,10 @@ pub fn arrow_to_nbt(
if let Some(state) = in_block {
put(&mut tag, "inBlockState", state);
}
// Entity.saveWithoutId writes NoGravity only when set.
if p.no_gravity {
put(&mut tag, "NoGravity", Tag::Byte(1));
}
if let Some(owner) = owner {
put(&mut tag, "Owner", Tag::IntArray(owner.to_vec()));
}
@@ -2283,6 +2293,7 @@ pub fn arrow_from_nbt(
[p.yaw, p.pitch] = rotation(tag);
p.left_owner = flag(tag, "LeftOwner");
p.owner_uuid = owner;
p.no_gravity = flag(tag, "NoGravity");
let a = p.arrow.as_mut()?;
a.life = number(tag.get("life")).unwrap_or(0) as i16 as i32;
a.shake = (number(tag.get("shake")).unwrap_or(0) as i8 as u8).into();
@@ -2308,7 +2319,9 @@ pub fn fireball_to_nbt(
) -> Option<Tag> {
let power = match p.kind {
ProjectileKind::LargeFireball { power } => Some(power),
ProjectileKind::SmallFireball => None,
ProjectileKind::SmallFireball
| ProjectileKind::WindCharge
| ProjectileKind::BreezeWindCharge => None,
_ => return None,
};
let base = Base {
@@ -2343,6 +2356,10 @@ fn put_projectile(tag: &mut Tag, p: &Projectile, owner: Option<[i32; 4]>) {
if p.left_owner {
put(tag, "LeftOwner", Tag::Byte(1));
}
// Entity.saveWithoutId writes NoGravity only when set.
if p.no_gravity {
put(tag, "NoGravity", Tag::Byte(1));
}
if p.age > 0 || tag.get("HasBeenShot").is_none() {
put(tag, "HasBeenShot", Tag::Byte(i8::from(p.age > 0)));
}
@@ -2458,6 +2475,7 @@ pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projecti
[p.yaw, p.pitch] = rotation(tag);
p.left_owner = flag(tag, "LeftOwner");
p.owner_uuid = owner;
p.no_gravity = flag(tag, "NoGravity");
p.acceleration = float(tag.get("acceleration_power")).unwrap_or(0.1);
Some(p)
}
@@ -3324,6 +3342,7 @@ mod tests {
#[test]
fn item_entities_round_trip() {
let e = ItemEntity {
no_gravity: false,
health: 2,
fire_ticks: 73,
thrower: Some([5, 6, 7, 8]),
@@ -3382,6 +3401,7 @@ mod tests {
#[test]
fn orbs_keep_their_count() {
let o = ExperienceOrb {
no_gravity: false,
id: 0,
position: Vec3::new(1.0, 2.0, 3.0),
previous: Vec3::ZERO,
@@ -4897,4 +4917,19 @@ mod mod_thrown_nbt_tests {
// a type no mod declares now is not a thrown kind.
assert!(throwable_kind("gone:bolt").is_none());
}
#[test]
fn no_gravity_survives_save_and_load_for_orbs() {
// Entity.saveWithoutId / load: NoGravity round-trips on a kind that
// fall by their own tick (orb, item, arrow, fireball).
let mut orb = orbs_from_nbt(&Tag::compound([
("Pos", doubles(&[0.0, 10.0, 0.0])),
("NoGravity", Tag::Byte(1)),
]))
.remove(0);
assert!(orb.no_gravity);
let back = orbs_from_nbt(&orb_to_nbt(&orb, [1; 4])).remove(0);
assert!(back.no_gravity);
orb.no_gravity = false;
assert!(orb_to_nbt(&orb, [1; 4]).get("NoGravity").is_none());
}
}
+1
View File
@@ -5,6 +5,7 @@ use crate::{crash, game, logging, ram_limit};
pub fn main() {
horizon_threads::configure_allocator();
horizon_client::telemetry::mark_start();
horizon_client::spans::startup::init(std::time::Instant::now());
let args: Vec<String> = std::env::args().skip(1).collect();
let game_args = args.iter().take_while(|a| *a != "--dedicated");
let help = game_args
+36 -2
View File
@@ -520,6 +520,17 @@ pub fn equip_displacement(height: f32, swap_animation_scale: f32) -> f32 {
swap_animation_scale * (1.0 - height)
}
/// `Mth.wrapDegrees`: an angle difference in -180..=180.
fn wrap_degrees(d: f32) -> f32 {
let d = d % 360.0;
if d >= 180.0 {
d - 360.0
} else if d < -180.0 {
d + 360.0
} else {
d
}
}
/// `LocalPlayer.xBob` and `yBob`: the hands lag behind the view when it
/// turns. `LocalPlayer.applyInput` eases them halfway to the rotation every
/// tick; `FirstPersonHandsAndItemsRenderer` turns the hands by a tenth of
@@ -537,7 +548,9 @@ impl HandSway {
self.y_bob_o = self.y_bob;
self.x_bob_o = self.x_bob;
self.x_bob += (x_rot - self.x_bob) * 0.5;
self.y_bob += (y_rot - self.y_bob) * 0.5;
// Vanilla's yaw runs on without wrapping, so `yRot - yBob` is the short way round. Ours
// is kept in -180..180 (a boat turns its rider through it), so take the short way here.
self.y_bob += wrap_degrees(y_rot - self.y_bob) * 0.5;
}
/// The degrees the hands turn about X, then Y, before either is drawn:
/// `(viewXRot - xBob) * 0.1` and `(viewYRot - yBob) * 0.1`, the bobs
@@ -545,7 +558,10 @@ impl HandSway {
pub fn rotation(&self, partial: f32, view_x_rot: f32, view_y_rot: f32) -> [f32; 2] {
let x_bob = crate::camera::lerp(partial, self.x_bob_o, self.x_bob);
let y_bob = crate::camera::lerp(partial, self.y_bob_o, self.y_bob);
[(view_x_rot - x_bob) * 0.1, (view_y_rot - y_bob) * 0.1]
[
(view_x_rot - x_bob) * 0.1,
wrap_degrees(view_y_rot - y_bob) * 0.1,
]
}
/// The hands' view space to camera-relative world space, turned by the
/// sway. The hand pass draws with the camera's view, whose rotation a
@@ -1384,4 +1400,22 @@ mod action_result_tests {
);
}
}
/// The hands' sway turns the short way round when the yaw wraps from 179 to -179 degrees (a
/// boat turning its rider), instead of swinging them 36 degrees the long way.
#[test]
fn hand_sway_takes_the_short_way_over_the_yaw_wrap() {
let mut sway = HandSway::default();
sway.tick(0.0, 179.0);
for _ in 0..20 {
sway.tick(0.0, 179.0);
}
// The view is still at 179; one tick later it reads -179 (2 degrees on).
sway.tick(0.0, -179.0);
let [_, y] = sway.rotation(1.0, 0.0, -179.0);
assert!(
y.abs() < 0.5,
"a 2 degree turn sways the hands by under half a degree: {y}"
);
}
}
+151 -57
View File
@@ -329,6 +329,7 @@ mod world_loading;
mod zombie_reinforcements;
mod zombie_villagers;
use event_loop::EventLoopCtl;
use horizon_client::spans::startup;
use horizon_client::{
Result,
assets::{Assets, RenderAssets},
@@ -1930,10 +1931,7 @@ impl UiResources {
pub fn server(root: &std::path::Path) -> Result<Self> {
Ok(Self {
atlas: HudAtlas::fonts(root)?,
panorama: Arc::new(horizon_client::gfx::panorama::Faces {
size: 1,
pixels: [0u8; 4].repeat(6),
}),
panorama: Arc::new(horizon_client::gfx::panorama::Faces::colour([0; 4])),
})
}
}
@@ -2896,6 +2894,9 @@ struct Game<'a> {
/// the operators (`DedicatedServer.shouldInformAdmins`,
/// `broadcast-console-to-ops`).
console_broadcast: Option<bool>,
/// The command the console is running came over RCON: its source is named `Rcon`
/// (`RconConsoleSource.createCommandSourceStack`), not `Server`.
rcon_source: bool,
/// A dedicated server's operator, whitelist and ban lists, for `/op`,
/// `/ban` and friends (`DedicatedPlayerList`).
server_lists: Option<std::sync::Arc<std::sync::Mutex<dedicated::Lists>>>,
@@ -3664,6 +3665,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 {
no_gravity: false,
health: 5,
fire_ticks: 0,
thrower: None,
@@ -3868,6 +3870,7 @@ impl Game<'_> {
let merging = self.remote.is_none();
let obstacles = horizon_client::entities::ObstacleGrid::new(self.item_entity_collisions());
let mut nearby = Vec::new();
let mut bubble_tops = Vec::new();
for i in 0..items.len() {
if removed[i] {
// Merged into an earlier item before its own tick: it never ticked.
@@ -3913,6 +3916,17 @@ impl Game<'_> {
&nearby,
);
cells.moved(i, item.previous, item.position);
// Entity.handleOnAboveBubbleColumn: the server sends the particles of
// the surface of a bubble column the item is at the top of.
if merging {
bubble_tops.extend(horizon_client::motion::bubble_column_surfaces(
&self.world,
self.pack.assets.registry,
self.res.facts(),
self.collision,
item.bounds(),
));
}
// ItemEntity.tick: merge every 2 ticks while moving between
// blocks, every 40 otherwise, before the lifetime check.
let block = |v: Vec3| (v.x.floor(), v.y.floor(), v.z.floor());
@@ -3960,6 +3974,9 @@ impl Game<'_> {
cells.remove(i, item.position);
}
}
for pos in bubble_tops {
self.bubble_column_particles(pos);
}
let mut removed = removed.into_iter();
items.retain(|_| !removed.next().unwrap_or(false));
let Some(p) = self.player.as_ref() else {
@@ -7555,6 +7572,17 @@ impl Game<'_> {
Ok(())
}
/// Keep the held-key state of toggled sneak and sprint in step with their mapping.
/// `KeyMapping.resetToggleKeys`: every toggle mapping up, and the latched sneak and sprint
/// keys with it.
fn reset_toggle_keys(&mut self) {
for key in &mut self.toggles.0 {
key.reset();
}
self.sync_toggle(0);
self.sync_toggle(1);
self.left = false;
self.right = false;
}
fn sync_toggle(&mut self, slot: usize) {
let key = [KeyCode::ShiftLeft, KeyCode::ControlLeft][slot];
if self.toggles.is_down(slot) {
@@ -7572,7 +7600,7 @@ impl Game<'_> {
/// pad latched, keeps its state: `set_down(false, needs_toggle)` leaves
/// those alone, and a toggle the player asked for is meant to outlive the
/// key. A mapping rebound away from its modifier is left alone too: the
/// modifier says nothing about it.
/// modifier says nothing about it, and so is one a controller holds.
///
/// Only `physical_keys` is cleared directly here; the logical `self.keys`
/// entries for sneak and sprint are left to the toggle loop below, because
@@ -7604,7 +7632,10 @@ impl Game<'_> {
Some(KeyCode::ControlLeft | KeyCode::ControlRight) => control,
_ => continue,
};
if !held {
// A controller's stick click acts as the key without setting the modifier: it is held
// while the pad holds it (or its release waits for a tick), not when the keyboard says.
let pad = self.pad.holds_action(&self.settings.controller, mapping);
if !held && !pad {
self.toggles.0[slot].set_down(false, needs_toggle);
self.sync_toggle(slot);
}
@@ -7894,7 +7925,8 @@ impl Game<'_> {
.set_cursor_grab(CursorGrabMode::Locked)
.or_else(|_| window.set_cursor_grab(CursorGrabMode::Confined))
.is_ok();
#[cfg(not(target_os = "macos"))]
// Windows already warped above, inside the clip's own position.
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
if self.captured && self.focused {
warp(window);
}
@@ -12252,6 +12284,9 @@ impl Game<'_> {
self.streaming.paused = true;
self.world = World::new(self.res.facts().air, 0);
self.player = None;
// `LocalPlayer`'s constructor: `KeyMapping.resetToggleKeys`, so a sneak or sprint toggled
// in this world is not still on in the next one.
self.reset_toggle_keys();
self.sleeping = None;
self.sleep_timer = 0;
self.player_beds.clear();
@@ -13199,6 +13234,7 @@ impl Game<'_> {
let p = Vec3::new(at[0], at[1], at[2]);
self.next_entity += 1;
self.orbs.push(ExperienceOrb {
no_gravity: false,
id: self.next_entity,
position: p,
previous: p,
@@ -15189,11 +15225,11 @@ impl Game<'_> {
let emissive_layers = if self.title.is_none() {
let mut requests = Vec::new();
self.native_emissive(&mut requests);
horizon_client::native_emissive::mappings(
// Native mods' overlays first, then the packs' OptiFine emissive blocks.
horizon_client::native_emissive::table(
&self.pack.render.sprite_layers,
requests
.into_iter()
.take(horizon_client::native_emissive::MAX),
requests,
&self.pack.render.emissive,
)
} else {
Vec::new()
@@ -16352,6 +16388,7 @@ impl ApplicationHandler for Game<'_> {
attributes, "horizon", "horizon",
);
// `--offscreen` renders into a texture: its window would only show white.
let window_step = startup::step("window.create");
let window = match event_loop.create_window(
attributes
.with_inner_size(winit::dpi::LogicalSize::new(size[0], size[1]))
@@ -16365,6 +16402,7 @@ impl ApplicationHandler for Game<'_> {
return;
}
};
drop(window_step);
horizon_client::telemetry::note_display(&window);
match self.new_renderer(window.clone()) {
Ok(r) => {
@@ -17811,6 +17849,15 @@ impl Game<'_> {
self.perf.title_shown();
} else if self.screen == Screen::Playing && self.player.is_some() {
self.perf.frame(elapsed.as_secs_f32() * 1000.0);
if startup::on() {
startup::view_filled(
self.world.chunks.len(),
self.streaming.pending()
+ self.pending_uploads.len()
+ self.remote_backlog(),
self.streaming.radius.max(0) as u32,
);
}
self.world_frames += 1;
}
self.frames += 1;
@@ -18427,6 +18474,7 @@ impl<'a> Game<'a> {
chat_capture: None,
admin_feedback: Vec::new(),
console_broadcast: None,
rcon_source: false,
server_lists: None,
world_tables: None,
cmds: Default::default(),
@@ -19024,7 +19072,7 @@ fn load_core(
parallel: bool,
progress: &Progress,
) -> std::result::Result<std::result::Result<CoreTables, String>, String> {
let assets = match Assets::load(root) {
let assets = match startup::time("core.assets", || Assets::load(root)) {
Ok(assets) => assets,
Err(e) => return Ok(Err(e.to_string())),
};
@@ -19036,18 +19084,22 @@ fn load_core(
let render = Step::spawn(scope, parallel, move || {
// A server reads no block sprites' pixels, which only the
// renderer draws (32 MB).
let render = if headless {
assets.reload_for_server(root)
} else {
assets.reload(root)
}
let render = startup::time("core.block_models_sprites", || {
if headless {
assets.reload_for_server(root)
} else {
assets.reload(root)
}
})
.map_err(|e| e.to_string())?;
let library = render.model_library.clone();
let item_renderer = if headless {
horizon_client::item_render::ItemRenderer::load_for_server(root, library)
} else {
horizon_client::item_render::ItemRenderer::load_with_library(root, library)
}
let item_renderer = startup::time("core.item_models", || {
if headless {
horizon_client::item_render::ItemRenderer::load_for_server(root, library)
} else {
horizon_client::item_render::ItemRenderer::load_with_library(root, library)
}
})
.map_err(|e| e.to_string());
Ok((render, item_renderer))
});
@@ -19057,16 +19109,21 @@ fn load_core(
if headless {
return Ok(horizon_world::block_render::FaceVisibility::empty());
}
models::load_visibility(root, registry).map_err(|e| e.to_string())
startup::time("core.visibility", || {
models::load_visibility(root, registry)
})
.map_err(|e| e.to_string())
});
let biomes = Step::spawn(scope, parallel, || {
Biomes::load(root).map_err(|e| e.to_string())
startup::time("core.biomes", || Biomes::load(root)).map_err(|e| e.to_string())
});
let environment = Step::spawn(scope, parallel, || {
Environment::load(root).map_err(|e| e.to_string())
startup::time("core.environment", || Environment::load(root))
.map_err(|e| e.to_string())
});
let collision = Step::spawn(scope, parallel, || {
collisions::load(root, registry).map_err(|e| e.to_string())
startup::time("core.collision", || collisions::load(root, registry))
.map_err(|e| e.to_string())
});
let gameplay = gameplay_tables(root, assets, parallel, progress);
(
@@ -19104,6 +19161,7 @@ fn gameplay_tables(
parallel: bool,
progress: &Progress,
) -> std::result::Result<GameplayTables, String> {
let facts_step = startup::step("core.block_facts");
let behaviour_json: serde_json::Value = serde_json::from_reader(std::io::BufReader::new(
std::fs::File::open(root.join("block-behaviours.json")).map_err(|e| e.to_string())?,
))
@@ -19117,13 +19175,19 @@ fn gameplay_tables(
.iter()
.any(|s| !s.name.starts_with("minecraft:")),
);
drop(facts_step);
progress.set(2);
let (light, motion) = std::thread::scope(|scope| {
let light = Step::spawn(scope, parallel, || {
LightTables::load(root, assets.registry, &facts).map_err(|e| e.to_string())
startup::time("core.light", || {
LightTables::load(root, assets.registry, &facts)
})
.map_err(|e| e.to_string())
});
let motion =
MotionResources::load_with_facts(root, assets, &facts).map_err(|e| e.to_string());
let motion = startup::time("core.motion", || {
MotionResources::load_with_facts(root, assets, &facts)
})
.map_err(|e| e.to_string());
(light.join(), motion)
});
Ok(GameplayTables {
@@ -19160,6 +19224,7 @@ fn load_resources(
// A dedicated server builds only the font (UiResources::server), as the
// integrated server's game does: the full atlas and panorama are 144 MB.
let load_ui = |root: &std::path::Path| {
let _step = startup::step("ui.load");
if headless {
UiResources::server(root)
} else {
@@ -19187,7 +19252,7 @@ fn load_resources(
let (core, loaded_gui) = loop {
let (ui, core) = std::thread::scope(|scope| {
let core = Step::spawn(scope, parallel, || {
load_core(&root, headless, parallel, progress)
startup::time("core", || load_core(&root, headless, parallel, progress))
});
let ui = load_ui(&root).map(|ui| offer(ui, &root));
(ui, core.join())
@@ -19236,7 +19301,10 @@ fn load_resources(
if let Some(folder) = launch_world {
world_loading::preload_launch_world(folder, &root, assets.registry, biomes.clone());
}
let tables = Arc::new(MeshTables::new(&assets, &render, &facts, visibility)?);
let tables = Arc::new(startup::time("pack.mesh_tables", || {
MeshTables::new(&assets, &render, &facts, visibility)
})?);
let pack_step = startup::step("pack.assemble");
let pack = PackResources::assemble(
&root,
assets.clone(),
@@ -19246,6 +19314,7 @@ fn load_resources(
biomes.clone(),
Arc::new(light),
);
drop(pack_step);
log::info!("load pack tables: {:.2}s", started.elapsed().as_secs_f32());
progress.set(4);
if !menu {
@@ -19278,8 +19347,14 @@ fn load_resources(
// Finish fallible CPU preparation before starting the tail, so an error
// cannot detach a tail worker that races the next pack-loader generation.
let prepared_at = Instant::now();
let game_cpu = menu.then(|| GameCpuResources::load(&root)).transpose()?;
let renderer_cpu = menu.then(|| prepare_renderer_textures(&pack));
let game_cpu = menu
.then(|| startup::time("handoff.game_cpu", || GameCpuResources::load(&root)))
.transpose()?;
let renderer_cpu = menu.then(|| {
startup::time("handoff.renderer_textures", || {
prepare_renderer_textures(&pack)
})
});
if menu {
log::info!(
"Horizon: handoff CPU resources prepared on worker in {} ms",
@@ -19297,6 +19372,7 @@ fn load_resources(
let handle = std::thread::Builder::new()
.name("world-rest-loader".into())
.spawn(move || {
let _step = startup::step("rest");
let rest = load_world_rest(
&root,
&resources,
@@ -19377,16 +19453,22 @@ fn load_world_rest(
// Independent tables load together, within the worker budget the settings
// allow. Only the recipes and the stations read another table: the items.
let load_items = || -> Result<_> {
let items = Items::load(root, registry, facts, overlay)?;
let items = startup::time("rest.items", || Items::load(root, registry, facts, overlay))?;
log::info!("load items: {:.2}s", started.elapsed().as_secs_f32());
progress.set(7);
let recipes = Recipes::load(root, &items, overlay)?;
let stations = horizon_client::stations::StationData::load(root, &items, overlay);
let recipes = startup::time("rest.recipes", || Recipes::load(root, &items, overlay))?;
let stations = startup::time("rest.stations", || {
horizon_client::stations::StationData::load(root, &items, overlay)
});
Ok((items, recipes, stations))
};
let load_behaviours =
|| Behaviours::load_with_gameplay(registry, root, behaviour_json, gameplay_projection);
let load_behaviours = || {
startup::time("rest.behaviours", || {
Behaviours::load_with_gameplay(registry, root, behaviour_json, gameplay_projection)
})
};
let load_block_entity_render = || {
let _step = startup::step("rest.block_entity_render");
if !draws {
return Ok(Default::default());
}
@@ -19395,6 +19477,7 @@ fn load_world_rest(
)
};
let load_animate = || -> Result<_> {
let _step = startup::step("rest.animate_fog");
let animate = if draws {
horizon_client::animate_tick::AnimateTables::load(root, registry, biomes)?
} else {
@@ -19403,6 +19486,7 @@ fn load_world_rest(
Ok((animate, horizon_client::fog::FogTables::load(root, biomes)))
};
let load_mobs = || -> Result<_> {
let _step = startup::step("rest.mobs");
let mob_types: HashMap<_, _> = MobTypes::load(root)?
.types
.into_iter()
@@ -19416,6 +19500,7 @@ fn load_world_rest(
Ok((mob_types, mob_data))
};
let load_transformers = || -> Result<_> {
let _step = startup::step("rest.transformers_models");
let transformers = horizon_client::block_transform::Transformers::load(root, registry)?;
let models = (
perspective::player_type(root),
@@ -19471,6 +19556,7 @@ fn load_world_rest(
threads
);
progress.set(8);
let _tail = startup::step("rest.tail");
Ok(WorldRest {
stations,
items,
@@ -19553,7 +19639,7 @@ fn build_renderer(
atlas.glint_armor.2,
),
(&pack.forcefield.2, pack.forcefield.0),
&gui.panorama,
gui.panorama.clone(),
prepared,
previous,
api,
@@ -19578,18 +19664,16 @@ fn loading_renderer(
window: Arc<Window>,
offscreen: bool,
atlas: Option<&horizon_client::hud::HudAtlas>,
panorama: Option<&horizon_client::gfx::panorama::Faces>,
panorama: Option<&Arc<horizon_client::gfx::panorama::Faces>>,
previous: Option<&Renderer>,
api: horizon_client::graphics::GraphicsApi,
) -> Result<Renderer> {
static FORCEFIELD: [u8; 4] = [0; 4];
static EMPTY_HUD: [u8; 4] = [0, 0, 0, 255];
static EMPTY_GLINT: [u8; 4] = [0; 4];
let empty_panorama = horizon_client::gfx::panorama::Faces {
size: 1,
pixels: [0u8; 4].repeat(6),
};
let panorama = panorama.unwrap_or(&empty_panorama);
let panorama = panorama
.cloned()
.unwrap_or_else(|| Arc::new(horizon_client::gfx::panorama::Faces::colour([0; 4])));
let (pixels, width, height) = atlas.map_or((EMPTY_HUD.as_slice(), 1, 1), |a| {
(a.pixels.as_slice(), a.width, a.height)
});
@@ -19979,6 +20063,7 @@ impl Loading {
attributes, "horizon", "horizon",
);
// `--offscreen` renders into a texture: its window would only show white.
let window_step = startup::step("window.create");
let window = match event_loop.create_window(
attributes
.with_inner_size(winit::dpi::LogicalSize::new(size[0], size[1]))
@@ -19992,6 +20077,7 @@ impl Loading {
return;
}
};
drop(window_step);
horizon_client::telemetry::note_display(&window);
// The renderer takes seconds to build and winit's window sits white
// until the first frame presents, so paint the boot screen on it and
@@ -20016,6 +20102,7 @@ impl Loading {
stop
});
let offscreen = self.options.as_ref().is_some_and(|o| o.offscreen);
let boot_step = startup::step("renderer.boot");
match loading_renderer(
window.clone(),
offscreen,
@@ -20035,6 +20122,7 @@ impl Loading {
return;
}
}
drop(boot_step);
// The wgpu frames take the window over: stop the boot paint thread
// before the first one presents.
#[cfg(target_os = "windows")]
@@ -20073,11 +20161,12 @@ impl Loading {
renderer.release_surface();
}
let bundle = self.gui.as_ref().expect("the menu bundle was just set");
let _step = startup::step("renderer.title_artwork");
match loading_renderer(
window,
offscreen,
Some(&bundle.atlas),
Some(bundle.panorama.as_ref()),
Some(&bundle.panorama),
self.renderer.as_ref(),
self.settings.horizon.api,
) {
@@ -20141,6 +20230,7 @@ impl Loading {
}
if !self.shown {
self.shown = true;
startup::mark("loading_screen.shown");
log::info!(
"Horizon: loading screen shown after {} ms",
self.started.elapsed().as_millis()
@@ -20161,17 +20251,19 @@ impl Loading {
renderer.release_surface();
}
let renderer_at = Instant::now();
let renderer = build_renderer(
window.clone(),
options.offscreen,
&loaded.pack,
&gui,
self.renderer.as_ref(),
loaded.renderer_cpu,
self.settings.horizon.api,
self.settings.horizon.translucent_batching,
shader_packs::picked_pack_draws_flat(&self.settings),
)?;
let renderer = startup::time("renderer.world", || {
build_renderer(
window.clone(),
options.offscreen,
&loaded.pack,
&gui,
self.renderer.as_ref(),
loaded.renderer_cpu,
self.settings.horizon.api,
self.settings.horizon.translucent_batching,
shader_packs::picked_pack_draws_flat(&self.settings),
)
})?;
log::info!(
"Horizon: world renderer ready after {} ms",
self.started.elapsed().as_millis()
@@ -20188,6 +20280,7 @@ impl Loading {
// like the single copy an integrated server would share.
let res: &'static Arc<Resources> = Box::leak(Box::new(loaded.resources));
let game_at = Instant::now();
let game_step = startup::step("handoff.start_game");
let Some(mut game) = start_game(
res,
loaded.pack,
@@ -20204,6 +20297,7 @@ impl Loading {
else {
return Ok(None);
};
drop(game_step);
let game_ms = game_at.elapsed().as_millis();
game.window = Some(window);
game.renderer = Some(renderer);
+5
View File
@@ -597,6 +597,11 @@ impl Game<'_> {
"height": layer.height,
})).collect::<Vec<_>>(),
}),
Mode::CreateBuffet { buffet, .. } => json!({
"mode": "customize-single-biome",
"biome": buffet.biome(),
"query": buffet.query(),
}),
Mode::Edit {
world,
name,
@@ -886,6 +886,17 @@ impl Game<'_> {
let assets = &self.armor_stand_assets;
let sprites = &self.gui.atlas.sprites;
let uv_of = |name: &str| sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
// A texture's OptiFine emissive overlay. The lock is read per lookup and let go:
// `wings_geometry` below reads it too.
let glow_of = |texture: &str| {
let emissive = self
.res
.mob_data()
.emissive
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
emissive.overlay(texture).and_then(uv_of)
};
let Some(wood) = uv_of("entity/armorstand/armorstand") else {
return;
};
@@ -925,6 +936,19 @@ impl Game<'_> {
0,
out,
);
if let Some(glow) = glow_of("entity/armorstand/armorstand") {
let full = horizon_client::mobs::render::FULL_BRIGHT;
horizon_client::mobs::render::draw(
model,
base,
&poses,
glow,
0xFFFF_FFFF,
full,
0,
out,
);
}
}
self.armor_stand_items(a, model, base, &posed, packed, out);
// HumanoidArmorLayer: head, chest, legs, feet.
@@ -990,6 +1014,19 @@ impl Game<'_> {
if i == 0 { overlay } else { 0 },
out,
);
// The layer's OptiFine emissive overlay, full bright without the glint.
if let Some(glow) = glow_of(texture) {
horizon_client::mobs::render::draw(
part,
base,
&layer_poses,
glow,
color,
horizon_client::mobs::render::FULL_BRIGHT,
0,
out,
);
}
}
// EquipmentLayerRenderer: the trim layer over the armour.
if let Some(trim) = assets
+15 -1
View File
@@ -70,6 +70,8 @@ pub(super) struct Boat {
pub hurt_time: i32,
pub hurt_dir: i32,
pub damage: f32,
/// `Entity.noGravity` (`NoGravity` in its save).
pub no_gravity: bool,
pub paddles: [f32; 2],
pub paddling: [bool; 2],
/// left, right, up, down
@@ -138,6 +140,7 @@ impl Boat {
hurt_time: 0,
hurt_dir: 1,
damage: 0.0,
no_gravity: false,
paddles: [0.0; 2],
paddling: [false; 2],
input: [false; 4],
@@ -577,8 +580,19 @@ impl Game<'_> {
b.paddling = [false; 2];
b.input = [false; 4];
}
// `Entity.baseTick`: the boat is immersed and pushed by the current it
// sits in (`isPushedByFluid`), before `floatBoat` scales the velocity.
let _ = horizon_client::entities::update_entity_fluids(
b.bounds(),
b.position,
b.position.y + HEIGHT,
&mut b.velocity,
&self.world,
&self.res.motion().fluids,
);
// floatBoat
let mut vspeed = -0.04;
// `-getGravity()`: none with NoGravity.
let mut vspeed = if b.no_gravity { 0.0 } else { -0.04 };
let mut buoyancy = 0.0;
let inv_friction: f32;
let obstacles = self.boat_move_obstacles(&b);
+5 -9
View File
@@ -770,15 +770,11 @@ impl Game<'_> {
}
/// A key (and the text it typed) while the chat screen is open.
pub(super) fn chat_key_code(&mut self, key: KeyCode, typed_text: Option<&str>) {
// InputQuirks.EDIT_SHORTCUT_KEY_MODIFIER: Cmd on macOS, Ctrl elsewhere.
let (left, right) = if cfg!(target_os = "macos") {
(KeyCode::SuperLeft, KeyCode::SuperRight)
} else {
(KeyCode::ControlLeft, KeyCode::ControlRight)
};
let ctrl = self.keys.contains(&left) || self.keys.contains(&right);
let shift =
self.keys.contains(&KeyCode::ShiftLeft) || self.keys.contains(&KeyCode::ShiftRight);
// The live modifiers, not `self.keys`: every ModifiersChanged runs
// sync_toggle, which drops ShiftLeft and ControlLeft from `self.keys`
// while a screen is open.
let ctrl = self.has_control_down_with_quirk();
let shift = self.has_shift_down();
match key {
// ChatScreen.keyPressed: the chat scrolls a page.
KeyCode::PageUp | KeyCode::PageDown
+26 -3
View File
@@ -258,10 +258,15 @@ impl Game<'_> {
self.cart_command_success(cart, message, false);
return;
}
if self.ui.rules.bool("send_command_feedback") {
if self.accepts_success() {
self.system_message(message);
}
}
/// `CommandSource.acceptsSuccess`: a player's source takes success feedback while
/// `send_command_feedback` is on; the server console's and an RCON client's always do.
fn accepts_success(&self) -> bool {
self.console_broadcast.is_some() || self.ui.rules.bool("send_command_feedback")
}
/// sendSuccess honouring `send_command_feedback`.
pub(super) fn feedback(&mut self, message: Text) {
if self.command_source.is_some_and(|s| s.silent) {
@@ -275,7 +280,7 @@ impl Game<'_> {
self.cart_command_success(cart, message, true);
return;
}
if self.ui.rules.bool("send_command_feedback") {
if self.accepts_success() {
self.system_message(message.clone());
}
self.broadcast_to_admins(message);
@@ -343,7 +348,9 @@ impl Game<'_> {
// the server's own source, "Server".
let who = match self.command_source.and_then(|s| s.entity) {
Some(entity) => self.target_name(entity),
None if self.console_broadcast.is_some() => Text::literal("Server".to_owned()),
None if self.console_broadcast.is_some() => {
Text::literal(if self.rcon_source { "Rcon" } else { "Server" }.to_owned())
}
None => self.player_display_name(),
};
let line = Text::translate("chat.type.admin", &[who, message])
@@ -1164,6 +1171,21 @@ impl Game<'_> {
if self.mode == mode {
return false;
}
// A server player's change is heard by the mods first, who may keep it.
if let Some(uuid) = self.lan_acting_uuid {
let name = self.acting_player_name().to_owned();
let mut cancel = false;
self.native_player_game_mode_changed(
uuid,
&name,
self.mode_id(),
super::game_mode::mode_id_of(mode),
&mut cancel,
);
if cancel {
return false;
}
}
self.set_mode(mode);
if self.screen == Screen::Inventory && (self.creative || self.spectator()) {
self.screen = Screen::Playing;
@@ -2156,6 +2178,7 @@ impl Game<'_> {
let at = self.unstuck_orb(pos);
self.next_entity += 1;
self.orbs.push(horizon_client::entities::ExperienceOrb {
no_gravity: false,
id: self.next_entity,
position: Vec3::new(at[0], at[1], at[2]),
previous: Vec3::new(at[0], at[1], at[2]),
@@ -149,7 +149,9 @@ impl Game<'_> {
self.other_to_nbt(&o, uuid)
}
Target::Orb(id) => {
entity_nbt::orb_to_nbt(self.orbs.iter().find(|o| o.id == id)?, random_uuid())
let tag =
entity_nbt::orb_to_nbt(self.orbs.iter().find(|o| o.id == id)?, random_uuid());
crate::game::persist::with_raw(tag, self.persist.raw.get(&id))
}
Target::Projectile(id) => {
let p = self.creatures.projectiles.iter().find(|p| p.id == id)?;
@@ -163,19 +165,22 @@ impl Game<'_> {
.as_ref()
.and_then(|a| a.last_state)
.map(|s| self.state_to_nbt(s));
entity_nbt::arrow_to_nbt(p, random_uuid(), owner, in_block, None)
.or_else(|| entity_nbt::fireball_to_nbt(p, random_uuid(), owner, None))
.or_else(|| entity_nbt::throwable_to_nbt(p, random_uuid(), owner, None))?
let raw = self.persist.raw.get(&id);
entity_nbt::arrow_to_nbt(p, random_uuid(), owner, in_block, raw)
.or_else(|| entity_nbt::fireball_to_nbt(p, random_uuid(), owner, raw))
.or_else(|| entity_nbt::throwable_to_nbt(p, random_uuid(), owner, raw))?
}
Target::Crystal(id) => {
let c = self.crystals.iter().find(|c| c.id == id)?;
let (uuid, raw) = self.persist_anon("minecraft:end_crystal", c.position);
c.to_nbt(uuid.unwrap_or_else(random_uuid), raw)
let tag = c.to_nbt(uuid.unwrap_or_else(random_uuid), raw);
crate::game::persist::with_raw(tag, self.persist.raw.get(&id))
}
Target::Dragon(id) => {
let d = self.dragon.as_ref().filter(|d| d.id == id)?;
let (uuid, raw) = self.persist_anon("minecraft:ender_dragon", d.position);
d.to_nbt(uuid.unwrap_or_else(random_uuid), raw)
let tag = d.to_nbt(uuid.unwrap_or_else(random_uuid), raw);
crate::game::persist::with_raw(tag, self.persist.raw.get(&id))
}
Target::Tnt(id) => {
let uuid = self.uuid_for(id);
@@ -309,6 +314,10 @@ impl Game<'_> {
// `tags_of` reads a primed TNT's compound; without this arm
// `/tag` wrote nothing and still reported success.
| Target::Tnt(id)
| Target::Orb(id)
| Target::Projectile(id)
| Target::Crystal(id)
| Target::Dragon(id)
| Target::Other(id) => Some(self.persist.raw.entry(id).or_insert_with(empty)),
_ => None,
}
+37 -33
View File
@@ -1713,9 +1713,38 @@ impl Game<'_> {
}
self.creatures.projectiles.push(p);
}
/// `Entity.sendBubbleColumnParticles` at the top of the column block `pos`: two
/// splashes and two bubbles over it, which the level random places, shown here
/// and to the players within range.
pub(super) fn bubble_column_particles(&mut self, pos: BlockPos) {
for _ in 0..2 {
let r = &mut self.world.random;
let splash = [
f64::from(pos.x) + r.next_double(),
f64::from(pos.y + 1),
f64::from(pos.z) + r.next_double(),
];
let bubble = [
f64::from(pos.x) + r.next_double(),
f64::from(pos.y + 1),
f64::from(pos.z) + r.next_double(),
];
let splash_options = ParticleOptions::simple(ParticleType::Splash);
self.particles
.send_particles(&splash_options, splash, [0.0; 3], 1.0, 1);
self.lan_level_particles(&splash_options, splash, [0.0; 3], 1.0, 1, None);
let bubble_options = ParticleOptions::simple(ParticleType::Bubble);
self.particles
.send_particles(&bubble_options, bubble, [0.0, 0.01, 0.0], 0.2, 1);
self.lan_level_particles(&bubble_options, bubble, [0.0, 0.01, 0.0], 0.2, 1, None);
}
}
pub(super) fn handle_mob_events(&mut self, events: Vec<MobEvent>) -> Result<()> {
for e in events {
match e {
MobEvent::BubbleColumnParticles { pos } => {
self.bubble_column_particles(BlockPos::new(pos[0], pos[1], pos[2]))
}
MobEvent::SquidCycle { mob, at } => self.lan_entity_event(mob as i32, 19, at),
MobEvent::RabbitHop { mob, at } => self.lan_entity_event(mob as i32, 1, at),
MobEvent::Drown { mob, at } => {
@@ -3885,36 +3914,7 @@ impl Game<'_> {
self.audio
.play(name, Some([at.x, at.y, at.z]), volume, pitch);
}
ArrowEvent::BubbleColumnParticles { pos } => {
// Entity.sendBubbleColumnParticles: the level random places them.
for _ in 0..2 {
let r = &mut self.world.random;
let splash = [
f64::from(pos.x) + r.next_double(),
f64::from(pos.y + 1),
f64::from(pos.z) + r.next_double(),
];
let bubble = [
f64::from(pos.x) + r.next_double(),
f64::from(pos.y + 1),
f64::from(pos.z) + r.next_double(),
];
self.particles.send_particles(
&ParticleOptions::simple(ParticleType::Splash),
splash,
[0.0; 3],
1.0,
1,
);
self.particles.send_particles(
&ParticleOptions::simple(ParticleType::Bubble),
bubble,
[0.0, 0.01, 0.0],
0.2,
1,
);
}
}
ArrowEvent::BubbleColumnParticles { pos } => self.bubble_column_particles(pos),
ArrowEvent::Block { pos, reaction } => match reaction {
BlockReaction::PrimeTnt => {
self.spawn_primed_tnt(pos, false, None, None);
@@ -6835,8 +6835,10 @@ fn knock_back(
source: HurtSource<'_>,
) {
let resistance = p.attribute("minecraft:knockback_resistance").unwrap_or(0.0);
// The native hook runs first, on the raw strength (`LivingKnockBackEvent`), then the
// resistance scales what it left.
let mut knock = Knock {
strength: power * (1.0 - resistance),
strength: power,
x: xd,
z: zd,
};
@@ -6848,6 +6850,7 @@ fn knock_back(
x: mut xd,
z: mut zd,
} = knock;
let power = power * (1.0 - resistance);
if power <= 0.0 {
return;
}
@@ -7229,8 +7232,9 @@ mod knockback_tests {
assert_eq!(seen(9201), [(9201, named, made)]);
// Twice the strength, direction turned around.
assert_eq!(push(9202, 0.0), (0.0, 0.4, 0.8));
// After the resistance: a 0.5 resistance hands the hook half the strength.
// Before the resistance (`LivingKnockBackEvent`): a 0.5 resistance still hands the hook
// the raw strength, and the push that follows is halved.
assert_eq!(push(9203, 0.5), (0.0, 0.2, -0.2));
assert_eq!(seen(9203)[0].2.strength, 0.2);
assert_eq!(seen(9203)[0].2.strength, 0.4);
}
}
+29 -4
View File
@@ -561,7 +561,9 @@ impl Game<'_> {
structures: props.generate_structures,
bonus_chest: false,
};
horizon_client::save::write_world_gen_settings_with(dir, props.seed, options, settings)
horizon_client::save::write_world_gen_settings_with(
dir, props.seed, options, settings, None,
)
}
}
@@ -607,6 +609,8 @@ struct ServerState {
paused: bool,
/// `broadcast-console-to-ops`.
broadcast_console: bool,
/// `broadcast-rcon-to-ops` (`MinecraftServer.shouldRconBroadcast`).
broadcast_rcon: bool,
/// `op-permission-level`: what `/op` grants (operatorUserPermissions).
op_level: i32,
/// `horizon-report`: the ticks being recorded.
@@ -979,6 +983,7 @@ impl Game<'_> {
ticks: Default::default(),
paused: false,
broadcast_console: props.broadcast_console_to_ops,
broadcast_rcon: props.broadcast_rcon_to_ops,
op_level: props.op_permission_level,
report: None,
view: (
@@ -1258,7 +1263,20 @@ impl Game<'_> {
/// One console line, then the operators' share of its feedback.
fn console_line(&mut self, state: &mut ServerState, line: &str) {
self.console_broadcast = Some(state.broadcast_console);
self.console_line_telling(state, line, state.broadcast_console);
}
/// One console line run as `RconConsoleSource` runs an RCON command: the same console at level 4,
/// its source named `Rcon`, and the operators told when `broadcast-rcon-to-ops` is on.
fn rcon_line(&mut self, state: &mut ServerState, line: &str) {
self.rcon_source = true;
self.console_line_telling(state, line, state.broadcast_rcon);
self.rcon_source = false;
}
/// `console_line` with `inform` as `CommandSource.shouldInformAdmins`.
fn console_line_telling(&mut self, state: &mut ServerState, line: &str, inform: bool) {
self.console_broadcast = Some(inform);
self.console_command(state, line);
self.console_broadcast = None;
if let Some(mut host) = self.lan.take() {
@@ -1604,9 +1622,16 @@ impl Game<'_> {
// SayCommand from the console: an unsigned system chat
// message of type say_command, logged as not secure.
let message = rest.join(" ");
log("INFO", &format!("[Not Secure] [Server] {message}"));
// An RCON client's `say` is `[Rcon] message`, and its reply stays empty: the line is
// the console's own log, not a message to the source.
let name = if self.rcon_source { "Rcon" } else { "Server" };
let held = self.rcon_source.then(|| CAPTURE.take());
log("INFO", &format!("[Not Secure] [{name}] {message}"));
if let Some(held) = held {
CAPTURE.set(held);
}
if let Some(h) = &self.lan {
h.hub.say("Server", &message);
h.hub.say(name, &message);
}
}
_ => {
@@ -58,6 +58,18 @@ impl Game<'_> {
.collect();
json!({ "output": output })
}
// An RCON command: run as the console, with the `Rcon` source, whose feedback the
// operators are told of when `broadcast-rcon-to-ops` is on.
Some(("rcon", line)) => {
let output: Vec<String> = capture_log(|| self.rcon_line(state, line))
.into_iter()
.map(|l| {
l.strip_prefix("System chat: ")
.map_or(l.clone(), str::to_owned)
})
.collect();
json!({ "output": output })
}
_ => json!({ "error": format!("unknown request {:?}", request.line) }),
};
let _ = request.reply.send(reply.to_string());
+13
View File
@@ -2752,6 +2752,19 @@ impl Game<'_> {
if let Some(mask) = mask {
dissolve_mask(&mut out[first..], skin, mask);
}
// The skin's OptiFine emissive overlay, full bright.
let glow = self
.res
.mob_data()
.emissive
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.overlay("entity/enderdragon/dragon")
.and_then(sprite);
if let Some(glow) = glow.filter(|_| mask.is_none()) {
let full = horizon_client::mobs::render::FULL_BRIGHT;
horizon_client::mobs::render::draw(model, base, &poses, glow, color, full, 0, out);
}
// RenderTypes.eyes: the glowing eyes, added on top.
let mut eye_quads = Vec::new();
horizon_client::mobs::render::draw(
+39 -26
View File
@@ -278,8 +278,8 @@ impl Game<'_> {
}
/// `Entity.getHandHoldingItemAngle(Items.FIREWORK_ROCKET)` for the local
/// player: half a block out towards the hand holding the rocket while
/// gliding, zero otherwise. The main arm is the right one (the only arm
/// this client has), so a rocket only in the offhand is in the left hand.
/// gliding, zero otherwise. A rocket only in the offhand is in the arm
/// opposite the main one.
fn rocket_hand_angle(&self) -> [f64; 3] {
if !self.player.as_ref().is_some_and(|p| p.fall_flying) {
return [0.0; 3];
@@ -288,10 +288,44 @@ impl Game<'_> {
|s: Option<&ItemStack>| s.is_some_and(|s| &*s.item == "minecraft:firework_rocket");
let main = self.stack_in(self.selected);
let offhand_only = is_rocket(self.offhand.as_ref()) && !is_rocket(main);
let arm = if offhand_only { -80 } else { 80 };
let right = offhand_only == self.settings.main_hand_left;
let arm = if right { 80 } else { -80 };
let v = view_vector(self.yaw + arm as f32, 0.0);
[v[0] * 0.5, v[1] * 0.5, v[2] * 0.5]
}
/// `FireworkRocketEntity.tick` for a rocket attached to the local player:
/// a glide is boosted towards the look, and the rocket sits at the rocket
/// 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);
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,
);
}
r.position = p.position.add_vector(Vec3::new(hand[0], hand[1], hand[2]));
r.velocity = p.velocity;
}
/// The trail (`FireworkRocketEntity.tick` on a client): a spark each tick
/// at the rocket, drifting against its motion.
pub(super) fn rocket_spark(&mut self, r: &Rocket) {
let g = |g: &mut horizon_core::random::LegacyRandom| g.next_gaussian() * 0.05;
let v = [
g(&mut self.world.random),
-r.velocity.y * 0.5,
g(&mut self.world.random),
];
let at = [r.position.x, r.position.y, r.position.z];
self.particles
.add(&ParticleOptions::simple(ParticleType::Firework), at, v);
}
/// `FireworkRocketItem.use` while gliding: a rocket attached to the player.
pub(super) fn boost_firework(&mut self, stack: &ItemStack) -> bool {
if &*stack.item != "minecraft:firework_rocket"
@@ -350,20 +384,7 @@ impl Game<'_> {
r.position = Vec3::new(g.position[0], g.position[1], g.position[2]);
}
} else if r.attached {
let hand = self.rocket_hand_angle();
if let Some(p) = self.player.as_mut() {
if p.fall_flying {
let look = view_vector(self.yaw, self.pitch);
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,
);
}
r.position = p.position.add_vector(Vec3::new(hand[0], hand[1], hand[2]));
r.velocity = p.velocity;
}
self.follow_own_player(&mut r);
hit = self.rocket_struck(&targets, &r, r.velocity, skip);
} else {
// getHitResultOnMoveVector before the move.
@@ -402,15 +423,7 @@ impl Game<'_> {
);
}
r.life += 1;
// The trail: a spark each tick drifting against the rocket's motion.
let g = |g: &mut horizon_core::random::LegacyRandom| g.next_gaussian() * 0.05;
let v = [
g(&mut self.world.random),
-r.velocity.y * 0.5,
g(&mut self.world.random),
];
self.particles
.add(&ParticleOptions::simple(ParticleType::Firework), at, v);
self.rocket_spark(&r);
if r.life > r.lifetime {
self.explode_rocket(&r);
continue;
+11 -4
View File
@@ -17,6 +17,7 @@ use super::{
world_loading::{WorldLoader, WorldRequest},
};
use horizon_client::level_loading::{STATUS_RADIUS, Stage};
use horizon_client::spans::startup;
use horizon_client::{
Result,
audio::Audio,
@@ -685,9 +686,11 @@ impl Game<'_> {
self.native_server_about_to_start(false);
self.native_lifecycle_open_started();
// `load_world`, with the world `serve` started preparing.
self.join_world_rest()?;
startup::time("server.wait_rest", || self.join_world_rest())?;
self.perf.world_opening();
let open_step = startup::step("server.open_world");
self.finish_open_world(opening.world?.finish()?)?;
drop(open_step);
if let Some(sync) = opening.sync {
sync.adopt(&self.pack_files());
}
@@ -751,7 +754,9 @@ impl Game<'_> {
guest_commands: false,
force_game_mode: false,
};
self.lan_host(settings, net, config, None)?;
startup::time("server.lan_host", || {
self.lan_host(settings, net, config, None)
})?;
let commands = self.allow_commands();
let pipe = {
let host = self
@@ -781,7 +786,7 @@ impl Game<'_> {
view.start(Stage::LoadInitialChunks, 0);
view.finish(Stage::LoadInitialChunks);
let _ = ready.send(Ok((pipe, self.level.name.clone())));
self.settle_spawn()
startup::time("server.settle_spawn", || self.settle_spawn())
}
/// `MinecraftServer.stopServer`: the players are saved and sent away,
@@ -1141,7 +1146,9 @@ fn serve(
world,
sync: preload_sync_data(&options.resources),
};
let mut server = match server_game(res, pack, options, settings) {
let mut server = match startup::time("server.game_new", || {
server_game(res, pack, options, settings)
}) {
Ok(server) => server,
Err(e) => {
// The world's session lock is let go before the failure is told.
+3
View File
@@ -1563,11 +1563,14 @@ impl Game<'_> {
let Some(id) = self.restore_saved_vehicle(uuid, raw.as_ref()) else {
continue;
};
host.hub.seat_order += 1;
let order = host.hub.seat_order;
let g = &mut host.hub.guests[i];
if let Some(horizon_world::nbt::Tag::Compound(p)) = &mut g.saved {
p.remove(&"RootVehicle".into());
}
g.vehicle = Some(id);
g.vehicle_order = order;
g.saved_vehicle = None;
// Pair the stationary restored entities before their passenger packet.
g.moved = true;
+23 -2
View File
@@ -825,12 +825,23 @@ impl Game<'_> {
// inventory key while riding).
let guest_mount = std::mem::replace(&mut self.mount_menu, host_mount_menu).opened_mob();
self.keys = host_keys;
// `Entity.addPassenger`: a rider that gets in now sits behind the
// players already in the vehicle, which is what tells its
// `getControllingPassenger` apart from this one's.
let seat_order = (guest_vehicle.is_some() && host.hub.guests[i].vehicle != guest_vehicle)
.then(|| {
host.hub.seat_order += 1;
host.hub.seat_order
});
let g = &mut host.hub.guests[i];
let left_vehicle = g.vehicle.filter(|_| guest_vehicle.is_none());
if guest_vehicle.is_none() {
g.vehicle_good = None;
}
g.vehicle = guest_vehicle;
if let Some(order) = seat_order {
g.vehicle_order = order;
}
let fell_asleep = bed.is_some() && g.bed.is_none();
(g.bed, g.sleep_timer, g.spawn) = (bed, guest_sleep_timer, guest_spawn);
g.respawn_elsewhere = guest_respawn.map(|c| c.saved());
@@ -3258,8 +3269,18 @@ impl Game<'_> {
// ServerGamePacketListenerImpl.handleClientCommand: a hardcore world's
// respawned player only watches.
if self.level.hardcore {
host.hub
.set_game_mode(conn, SPECTATOR, &self.world, Some(self.collision));
let (uuid, name, from) = {
let g = &host.hub.guests[i];
(g.uuid, g.name.clone(), g.game_mode)
};
let mut cancel = false;
if from != SPECTATOR {
self.native_player_game_mode_changed(uuid, &name, from, SPECTATOR, &mut cancel);
}
if !cancel {
host.hub
.set_game_mode(conn, SPECTATOR, &self.world, Some(self.collision));
}
}
if let Some(config) = elsewhere {
self.respawn_trip(conn, config);
+23 -2
View File
@@ -419,7 +419,25 @@ impl Game<'_> {
g.position = [seat.x, seat.y, seat.z];
}
/// `handlePaddleBoat`: the paddles of the boat the player drives.
/// `Entity.getControllingPassenger` of a vehicle a server player rides: a
/// boat has two seats, and only its first player passenger drives it. The
/// window player sits first when it rides; of the server's players it is
/// the one that got in first.
pub(super) fn guest_controls_vehicle(&self, host: &LanHost, conn: u32, vehicle: u32) -> bool {
if self.riding == Some(vehicle) {
return false;
}
host.hub
.guests
.iter()
.filter(|g| g.vehicle == Some(vehicle))
.min_by_key(|g| g.vehicle_order)
.is_some_and(|g| g.conn == conn)
}
/// `handlePaddleBoat`: the paddles of the boat the player controls
/// (`player.getControlledVehicle()`); a passenger in the second seat moves
/// no paddle.
pub(super) fn guest_paddle_boat(&mut self, host: &LanHost, conn: u32, paddles: [bool; 2]) {
let vehicle = host
.hub
@@ -427,7 +445,10 @@ impl Game<'_> {
.iter()
.find(|g| g.conn == conn)
.and_then(|g| g.vehicle);
if let Some(b) = self.boats.iter_mut().find(|b| Some(b.id) == vehicle) {
let Some(vehicle) = vehicle.filter(|&v| self.guest_controls_vehicle(host, conn, v)) else {
return;
};
if let Some(b) = self.boats.iter_mut().find(|b| b.id == vehicle) {
b.paddling = paddles;
}
}
@@ -1392,6 +1392,7 @@ mod item_column_tests {
fn item(id: u32, x: f64, y: f64, z: f64) -> ItemEntity {
let at = Vec3::new(x, y, z);
ItemEntity {
no_gravity: false,
health: 5,
fire_ticks: 0,
id,
+4 -2
View File
@@ -1655,10 +1655,12 @@ impl Game<'_> {
("data", Tag::compound([("map", Tag::Int(last))])),
("DataVersion", Tag::Int(horizon_world::DATA_VERSION)),
]);
let _ = horizon_client::save::write_dat(
if let Err(e) = horizon_client::save::write_dat(
&self.world_root.join("data/minecraft/maps/last_id.dat"),
tag,
);
) {
log::warn!("Horizon: the next map id is not saved: {e}");
}
}
}
/// The atlas slot showing map `id`, its texture uploaded when it changed.
+7 -1
View File
@@ -111,6 +111,8 @@ pub(super) struct Minecart {
pub hurt_time: i32,
pub hurt_dir: i32,
pub damage: f32,
/// `Entity.noGravity` (`NoGravity` in its save).
pub no_gravity: bool,
pub items: Vec<Option<ItemStack>>,
/// Furnace minecarts: fuel ticks and `MinecartFurnace.push`. Its y part
/// stays between ticks (it sets the push's length), only saving drops it.
@@ -266,6 +268,7 @@ impl Minecart {
hurt_time: 0,
hurt_dir: 1,
damage: 0.0,
no_gravity: false,
items: vec![None; kind.slots()],
fuel: 0,
push: Vec3::ZERO,
@@ -937,7 +940,9 @@ impl Game<'_> {
c.hurt_time = (c.hurt_time - 1).max(0);
c.damage = (c.damage - 1.0).max(0.0);
// applyGravity
c.velocity.y -= if self.cart_in_water(c) { 0.005 } else { 0.04 };
if !c.no_gravity {
c.velocity.y -= if self.cart_in_water(c) { 0.005 } else { 0.04 };
}
let pos = self.rail_block(c.position);
if let Some(rail) = self.rail(pos[0], pos[1], pos[2]) {
if self.minecart_improvements() {
@@ -2233,6 +2238,7 @@ impl Minecart {
c.velocity = velocity;
c.yaw = yaw;
c.previous_yaw = yaw;
c.no_gravity = horizon_client::entity_nbt::flag(tag, "NoGravity");
let int = |k: &str| match tag.get(k) {
Some(Tag::Byte(v)) => Some(i32::from(*v)),
Some(Tag::Short(v)) => Some(i32::from(*v)),
+13 -1
View File
@@ -92,7 +92,14 @@ pub(super) fn content_root(
{
continue;
}
let content = match Content::read(path, m) {
// The items this mod's native code declares may be a file item's crafting remainder.
let declared: Vec<String> = native_declared()
.1
.iter()
.filter(|i| i.id.split_once(':').is_some_and(|(ns, _)| ns == m.id))
.map(|i| i.id.clone())
.collect();
let content = match Content::read_declaring(path, m, &declared) {
Ok(content) => content,
Err(e) => {
log::warn!("Horizon: mod {} content not loaded: {e}", m.id);
@@ -130,6 +137,11 @@ pub(super) fn content_root(
for e in mod_content::accept_declared(&mut mods, blocks, items, known) {
log::warn!("Horizon: {e}");
}
// A file item's remainder named a code-declared item that was refused: that mod
// alone is left out, not every content mod through the overlay's check.
for e in mod_content::drop_dangling_remainders(&mut mods) {
log::warn!("Horizon: {e}");
}
}
}
match mod_content::resource_root(root, &mods, &here.join(".horizon-cache")) {
+23 -1
View File
@@ -2617,6 +2617,27 @@ impl Game<'_> {
) {
let _ = (player, command, ran);
}
/// A server player's game mode is about to change (NeoForge's
/// `PlayerChangeGameModeEvent`): from `from` to `to` (0 survival, 1
/// creative, 2 adventure, 3 spectator, as `NativeServerPlayer`'s
/// `game_mode`), `player` being the UUID and `name` the name. Heard for
/// the `/gamemode` command, the F3+F4 switcher's packet and a hardcore
/// world's respawn into spectator, once for each change to a different
/// mode; not for the world's default mode a player joins with. Set
/// `cancel` and the player keeps their mode (nothing is sent, the
/// command counts it as no change). For a change the game makes inside
/// a player's turn their state is swapped in; for the hardcore respawn
/// it is not. @API:native_player_game_mode_changed@.
pub(super) fn native_player_game_mode_changed(
&mut self,
player: u128,
name: &str,
from: i32,
to: i32,
cancel: &mut bool,
) {
let _ = (player, name, from, to, cancel);
}
/// `player` (`name`) changed dimension (NeoForge's
/// `PlayerChangedDimensionEvent`): `from` and `to` are dimension ids
/// (`minecraft:overworld`, `minecraft:the_nether`, `minecraft:the_end`).
@@ -3653,7 +3674,8 @@ impl Game<'_> {
}
/// `LivingEntity.knockback` is about to push entity `entity` of type `kind`
/// (`minecraft:zombie`, `minecraft:player`) `strength` away from the direction (`x`, `z`),
/// after the entity's knockback resistance took its share and before its motion changes
/// on the raw strength, before the entity's knockback resistance takes its share and its motion
/// changes
/// (docs/native-mods.md, API 125). Vanilla's direction points at the
/// source of a hit (the attacker's side), and the entity is pushed the other way. Change
/// `strength`, `x` or `z`, or set `canceled`, and the push follows them: canceled, the
+63 -23
View File
@@ -116,6 +116,32 @@ fn is_arrow(p: &horizon_client::projectiles::Projectile) -> bool {
horizon_client::projectiles::ProjectileKind::Arrow { .. }
)
}
/// The projectiles besides arrows that a portal sends on: the thrown items
/// and fireballs a chunk saves. A pearl has its own rules (it keeps its owner's
/// dimension) and a wind charge is not saved, so both stay.
fn is_portal_projectile(p: &horizon_client::projectiles::Projectile) -> bool {
use horizon_client::projectiles::ProjectileKind;
!is_arrow(p)
&& !matches!(
p.kind,
ProjectileKind::EnderPearl
| ProjectileKind::WindCharge
| ProjectileKind::BreezeWindCharge
)
&& horizon_client::entity_nbt::saved_with_chunk(p)
}
/// Width and height of a projectile that goes through a portal.
fn projectile_box(p: &horizon_client::projectiles::Projectile) -> (f32, f32) {
if is_arrow(p) {
(
horizon_client::arrows::WIDTH,
horizon_client::arrows::HEIGHT,
)
} else {
let size = p.kind.size() as f32;
(size, size)
}
}
fn doubles(values: [f64; 3]) -> Tag {
Tag::List(values.iter().map(|v| Tag::Double(v.to_bits())).collect())
}
@@ -668,6 +694,9 @@ impl Game<'_> {
for t in &self.tnts {
self.portals.before.insert(t.id, t.position);
}
for o in &self.orbs {
self.portals.before.insert(o.id, o.position);
}
// Only an entity in a portal, or cooling down from one, has a state
// here: whether the others could use a portal is not worth asking
// for each of them on every tick.
@@ -696,10 +725,15 @@ impl Game<'_> {
}
}
for p in &self.creatures.projectiles {
if tracked.contains_key(&p.id) && is_arrow(p) {
if tracked.contains_key(&p.id) && (is_arrow(p) || is_portal_projectile(p)) {
ids.push((p.id, true));
}
}
for o in &self.orbs {
if tracked.contains_key(&o.id) {
ids.push((o.id, true));
}
}
}
let present: std::collections::HashSet<u32> = ids.iter().map(|(id, _)| *id).collect();
self.portals.entities.retain(|id, _| present.contains(id));
@@ -837,6 +871,7 @@ impl Game<'_> {
.dropped
.iter()
.map(|e| (e.id, e.position, 0.25, 0.25))
.chain(self.orbs.iter().map(|o| (o.id, o.position, 0.5, 0.5)))
.collect();
// PrimedTnt is 0.98 across; one that carries a mob stays (the rider would be left behind).
let tnts: Vec<(u32, Vec3, f32, f32)> = self
@@ -876,16 +911,10 @@ impl Game<'_> {
// The arrow's `previous` is where it stood at the start of its tick,
// which is also where an arrow shot this tick began. A stuck arrow
// that did not move this tick enters no block.
for p in self
.creatures
.projectiles
.iter()
.filter(|p| is_arrow(p) && (!p.stuck || p.previous != p.position))
{
let (width, height) = (
horizon_client::arrows::WIDTH,
horizon_client::arrows::HEIGHT,
);
for p in self.creatures.projectiles.iter().filter(|p| {
(is_arrow(p) && (!p.stuck || p.previous != p.position)) || is_portal_projectile(p)
}) {
let (width, height) = projectile_box(p);
if let Some(pos) =
self.swept_portal(p.previous, p.position, width, height, &mut portal_sections)
{
@@ -1029,6 +1058,17 @@ impl Game<'_> {
self.forget_entity(id);
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
} else if let Some(i) = self.orbs.iter().position(|o| o.id == id) {
let plan = self.entity_plan(entry, self.orbs[i].position, 0.5, 0.5, false);
if !self.may_enter(plan.dim) {
return;
}
let uuid = self.uuid_for(id);
let tag = horizon_client::entity_nbt::orb_to_nbt(&self.orbs[i], uuid);
self.orbs.remove(i);
self.forget_entity(id);
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
} else if let Some(i) = self.mobs.iter().position(|m| m.id == id) {
let m = &self.mobs[i];
let plan = self.entity_plan(entry, m.position, m.width(), m.height(), true);
@@ -1102,13 +1142,10 @@ impl Game<'_> {
.creatures
.projectiles
.iter()
.position(|p| p.id == id && is_arrow(p))
.position(|p| p.id == id && (is_arrow(p) || is_portal_projectile(p)))
{
let p = &self.creatures.projectiles[i];
let (width, height) = (
horizon_client::arrows::WIDTH,
horizon_client::arrows::HEIGHT,
);
let (width, height) = projectile_box(p);
let plan = self.entity_plan(entry, p.position, width, height, false);
if !self.may_enter(plan.dim) {
return;
@@ -1120,13 +1157,16 @@ impl Game<'_> {
.as_ref()
.and_then(|a| a.last_state)
.map(|s| self.state_to_nbt(s));
let Some(tag) = horizon_client::entity_nbt::arrow_to_nbt(
p,
uuid,
p.owner_uuid,
in_block,
self.persist_raw(id),
) else {
let raw = self.persist_raw(id);
let Some(tag) =
horizon_client::entity_nbt::arrow_to_nbt(p, uuid, p.owner_uuid, in_block, raw)
.or_else(|| {
horizon_client::entity_nbt::fireball_to_nbt(p, uuid, p.owner_uuid, raw)
})
.or_else(|| {
horizon_client::entity_nbt::throwable_to_nbt(p, uuid, p.owner_uuid, raw)
})
else {
return;
};
self.creatures.projectiles.remove(i);
@@ -24,10 +24,7 @@ pub(super) fn show(looked_in: &std::path::Path, settings: &Settings) -> Result<(
renderer: None,
gui: UiResources {
atlas: HudAtlas::build(looked_in)?,
panorama: Arc::new(horizon_client::gfx::panorama::Faces {
size: 1,
pixels: [0u8; 4].repeat(6),
}),
panorama: Arc::new(horizon_client::gfx::panorama::Faces::colour([0; 4])),
},
screen: NoResources::new(looked_in),
pointer: (0.0, 0.0),
@@ -109,7 +106,7 @@ impl ApplicationHandler for Missing {
window.clone(),
false,
Some(&self.gui.atlas),
Some(self.gui.panorama.as_ref()),
Some(&self.gui.panorama),
None,
self.api,
)?;
+44 -3
View File
@@ -356,7 +356,10 @@ impl Game<'_> {
self.next_entity += 1;
orb.id = self.next_entity;
let (id, p) = (orb.id, orb.position);
// An orb that came through a portal keeps its cooldown.
self.restore_portal_cooldown(id, &tag);
self.orbs.push(orb);
self.remember_tags(id, &tag);
let kind = "minecraft:experience_orb";
if !moved
&& !self.native_entity_joins(id, kind, [p.x, p.y, p.z], from_disk)
@@ -380,6 +383,7 @@ impl Game<'_> {
self.next_entity += 1;
p.id = self.next_entity;
self.restore_portal_cooldown(p.id, &tag);
self.remember_tags(p.id, &tag);
self.add_projectile(p);
}
}
@@ -389,13 +393,23 @@ impl Game<'_> {
| "minecraft:breeze_wind_charge" => {
if let Some(mut p) = entity_nbt::fireball_from_nbt(&tag, player_uuid) {
self.claim_owner(&mut p);
// A projectile that came through a portal keeps its cooldown.
self.next_entity += 1;
p.id = self.next_entity;
self.restore_portal_cooldown(p.id, &tag);
self.add_projectile(p);
self.remember_last_projectile_tags(&tag);
}
}
id if entity_nbt::throwable_kind(id).is_some() => {
if let Some(mut p) = entity_nbt::throwable_from_nbt(&tag, player_uuid) {
self.claim_owner(&mut p);
// A projectile that came through a portal keeps its cooldown.
self.next_entity += 1;
p.id = self.next_entity;
self.restore_portal_cooldown(p.id, &tag);
self.add_projectile(p);
self.remember_last_projectile_tags(&tag);
}
}
"minecraft:falling_block" => {
@@ -460,6 +474,7 @@ impl Game<'_> {
_ => 80,
};
self.tnts.push(super::tnt::PrimedTnt {
no_gravity: entity_nbt::flag(&tag, "NoGravity"),
id,
position,
previous: position,
@@ -492,6 +507,7 @@ impl Game<'_> {
position,
yaw,
);
b.no_gravity = entity_nbt::flag(&tag, "NoGravity");
if chest {
b = b.with_chest();
b.items = horizon_client::save::decode_items(
@@ -535,6 +551,7 @@ impl Game<'_> {
// parts' from the entity counter.
d.id = self.next_entity + 1;
self.next_entity += 9;
self.remember_tags(d.id, &tag);
self.dragon = Some(d);
}
}
@@ -545,6 +562,7 @@ impl Game<'_> {
// entity counter.
self.next_entity += 1;
c.id = self.next_entity;
self.remember_tags(c.id, &tag);
self.crystals.push(c);
}
}
@@ -1011,6 +1029,22 @@ impl Game<'_> {
&biome,
));
}
/// An entity Horizon keeps no compound for (orbs, projectiles, the
/// crystals and the dragon) still keeps its `Tags`, by entity id, so
/// `@e[tag=]`, `/tag` and `/data get` see them.
pub(super) fn remember_tags(&mut self, id: u32, tag: &Tag) {
if let Some(tags @ Tag::List(_)) = tag.get("Tags") {
let mut map = horizon_world::nbt::Compound::new();
map.insert("Tags".into(), tags.clone());
self.persist.raw.insert(id, Tag::Compound(map));
}
}
/// The `Tags` of the projectile `add_projectile` just pushed.
fn remember_last_projectile_tags(&mut self, tag: &Tag) {
if let Some(id) = self.creatures.projectiles.last().map(|p| p.id) {
self.remember_tags(id, tag);
}
}
fn remember(&mut self, id: u32, uuid: Option<[i32; 4]>, tag: Tag) {
self.restore_portal_cooldown(id, &tag);
if let Some(u) = uuid {
@@ -1091,9 +1125,10 @@ impl Game<'_> {
for o in &self.orbs {
if keys.contains(&chunk_of(o.position)) {
let (uuid, raw) = anon(&self.persist, "minecraft:experience_orb", o.position);
out.entry(chunk_of(o.position))
.or_default()
.push(with_raw(entity_nbt::orb_to_nbt(o, uuid), raw.as_ref()));
out.entry(chunk_of(o.position)).or_default().push(with_raw(
with_raw(entity_nbt::orb_to_nbt(o, uuid), raw.as_ref()),
self.persist.raw.get(&o.id),
));
}
}
for p in &self.creatures.projectiles {
@@ -1111,6 +1146,7 @@ impl Game<'_> {
.or_else(|| entity_nbt::fireball_to_nbt(p, uuid, owner, raw.as_ref()))
.or_else(|| entity_nbt::throwable_to_nbt(p, uuid, owner, raw.as_ref()));
if let Some(tag) = tag {
let tag = with_raw(tag, self.persist.raw.get(&p.id));
out.entry(chunk_of(p.position)).or_default().push(tag);
}
}
@@ -1463,6 +1499,11 @@ impl Game<'_> {
.chain(self.item_frames.iter().map(|e| e.id))
.chain(self.paintings.iter().map(|e| e.id))
.chain(self.others.iter().map(|e| e.id))
.chain(self.orbs.iter().map(|e| e.id))
.chain(self.creatures.projectiles.iter().map(|e| e.id))
.chain(self.crystals.iter().map(|e| e.id))
.chain(self.dragon.iter().map(|e| e.id))
.chain(self.tnts.iter().map(|e| e.id))
.collect();
self.persist.uuids.retain(|id, _| alive.contains(id));
self.persist.raw.retain(|id, _| alive.contains(id));
+40 -11
View File
@@ -897,25 +897,54 @@ impl Game<'_> {
return;
};
let base = base * glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.0, 0.125));
let pose = |part: &horizon_client::mobs::Part| {
let mut p = part.pose;
if part.name == "left_wing" || part.name == "right_wing" {
let invert = if part.name == "left_wing" { 1.0 } else { -1.0 };
p[1] = if crouching { 3.0 } else { 0.0 };
p[3] = angles[0];
p[4] = invert * angles[1];
p[5] = invert * angles[2];
}
p
};
horizon_client::mobs::render_model(
model,
base,
&|part| {
let mut p = part.pose;
if part.name == "left_wing" || part.name == "right_wing" {
let invert = if part.name == "left_wing" { 1.0 } else { -1.0 };
p[1] = if crouching { 3.0 } else { 0.0 };
p[3] = angles[0];
p[4] = invert * angles[1];
p[5] = invert * angles[2];
}
p
},
&pose,
[rect.u0, rect.v0, rect.u1, rect.v1],
light,
overlay,
out,
);
// The elytra texture's OptiFine emissive overlay (not a cape's), full bright.
let glow = cape
.and_then(|c| sprites.get(c))
.is_none()
.then(|| {
let emissive = self
.res
.mob_data()
.emissive
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
emissive
.overlay(texture)
.and_then(|e| sprites.get(e))
.copied()
})
.flatten();
if let Some(g) = glow {
horizon_client::mobs::render_model(
model,
base,
&pose,
[g.u0, g.v0, g.u1, g.v1],
horizon_client::mobs::render::FULL_BRIGHT,
0,
out,
);
}
}
/// `CapeLayer` for a player wearing `chest`: no cape under an elytra's
/// wings, and moved out over a chestplate.
@@ -162,6 +162,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 {
no_gravity: false,
id: 0,
position: at,
previous: at,
+121 -7
View File
@@ -2391,6 +2391,7 @@ impl Game<'_> {
// Its value (ExperienceOrb.DATA_VALUE) follows in SetEntityData.
self.orbs.retain(|o| o.id != id as u32);
self.orbs.push(ExperienceOrb {
no_gravity: false,
id: id as u32,
position: at,
previous: at,
@@ -2971,6 +2972,9 @@ impl Game<'_> {
&p.spawn.dimension,
self.dim.id(),
);
if dimension_changed {
horizon_client::spans::startup::arm("dimension.change");
}
self.enter_remote_dimension(&p.spawn, true, reason);
if dimension_changed {
// LevelExtractor.setLevel: a new level drops the highlights.
@@ -5190,6 +5194,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 {
no_gravity: false,
health: 5,
fire_ticks: 0,
thrower: None,
@@ -5462,6 +5467,9 @@ impl Game<'_> {
/// One client tick while connected (Minecraft.tick with a ClientLevel).
pub(super) fn tick_remote(&mut self) -> Result<()> {
self.tick_remote_level()?;
// The rockets boosting this player tick after it, also while it waits
// for chunks.
self.tick_own_rockets();
// Client mods tick on any server, single player's own included.
self.native_net.to_server = 0;
self.native_tick();
@@ -7213,7 +7221,7 @@ impl Game<'_> {
self.with_hand(hand, Game::swing_arm);
return true;
} else if let Some(stack) = held.as_ref()
&& (self.predict_item_on_block(stack, &hit)
&& (self.predict_item_on_block(stack, &hit, hand)
|| self.predict_map_on_banner(stack, &hit))
{
// Item.useOn succeeded here (InteractionResult.SUCCESS):
@@ -7279,9 +7287,26 @@ impl Game<'_> {
/// `playSound(player, ...)`: the server sends them to the others only):
/// honeycomb waxing copper, and an axe, hoe or shovel transforming a
/// block (stripping, scraping, removing wax, tilling, making a path).
/// A cushion succeeds where it may be placed and spawns on the server.
/// A cushion succeeds where it may be placed and spawns on the server, a
/// firework rocket wherever it may be used, and the server launches it.
/// Whether the item's use succeeded, which ends the click.
fn predict_item_on_block(&mut self, stack: &ItemStack, hit: &super::Hit) -> bool {
fn predict_item_on_block(&mut self, stack: &ItemStack, hit: &super::Hit, hand: Hand) -> bool {
// FireworkRocketItem.useOn: SUCCESS on the client too, so neither
// UseItem nor the off hand follows. It passes while gliding (the use
// boosts instead), cooling down (MultiPlayerGameMode.performUseItemOn
// skips useOn) and in adventure mode off its can_place_on
// (ItemStack.useOn).
if &*stack.item == "minecraft:firework_rocket" {
let cooling = self.items().get(&stack.item).is_some_and(|info| {
let group = horizon_client::cooldowns::group(stack, info.use_cooldown.as_ref());
self.cooldowns.is_on_cooldown(&group)
});
let gliding = self.player.as_ref().is_some_and(|p| p.fall_flying);
return !cooling
&& !gliding
&& (self.may_build()
|| self.with_hand(hand, |g| g.can_place_on_in_adventure(hit.pos)));
}
let at = [
f64::from(hit.pos[0]) + 0.5,
f64::from(hit.pos[1]) + 0.5,
@@ -7679,6 +7704,8 @@ impl Game<'_> {
// Item.use: Equippable.swapWithEquipmentSlot runs on this client too, so
// the armor slot and the hand change at once (the server confirms).
if info.equip_slot.is_some() && self.with_hand(hand, |g| g.equip_from_hand(&held)) {
// Equippable.swapWithEquipmentSlot's SUCCESS (SwingSource.CLIENT).
self.with_hand(hand, Game::swing_arm);
return true;
}
// `ItemStack.use` runs on this client too: a bucket's own ray and its
@@ -7707,12 +7734,20 @@ impl Game<'_> {
};
self.audio.play(&name, Some(at), volume, pitch);
}
// BucketItem.use's SUCCESS (SwingSource.CLIENT) on this client.
if succeeded {
self.with_hand(hand, Game::swing_arm);
}
return succeeded;
}
// SpawnEggItem.use on the client succeeds after a SOURCE_ONLY block
// hit; the server alone tests LiquidBlock and the placement permission.
if held.item.ends_with("_spawn_egg") {
return self.spawn_egg_allowed(&held.item) && self.bucket_hit().is_some();
let succeeded = self.spawn_egg_allowed(&held.item) && self.bucket_hit().is_some();
if succeeded {
self.with_hand(hand, Game::swing_arm);
}
return succeeded;
}
// Food starts being eaten (Consumable.startConsuming). A food that
// can't be eaten (full and not always-edible) fails the use
@@ -7736,6 +7771,8 @@ impl Game<'_> {
self.apply_use_cooldown(&held);
}
if self.with_hand(hand, |g| g.remote_use_book(&held)) {
// WritableBookItem.use's SUCCESS (SwingSource.CLIENT).
self.with_hand(hand, Game::swing_arm);
return true;
}
// BowItem.use and CrossbowItem.use FAIL with nothing to shoot: no
@@ -7744,8 +7781,23 @@ impl Game<'_> {
return false;
}
let gliding = self.player.as_ref().is_some_and(|p| p.fall_flying);
// FireworkRocketItem.use while gliding: SUCCESS (SwingSource.PREDICTED),
// so startUseItem swings this hand itself; a vanilla server swings it
// for the other players only.
if gliding && &*held.item == "minecraft:firework_rocket" {
self.with_hand(hand, Game::swing_arm);
}
// A stack's own `blocks_attacks` raises it as a shield's does.
use_ends_click(&info, gliding) || self.blocks_attacks(&held).is_some()
let blocks = self.blocks_attacks(&held).is_some();
let ends = use_ends_click(&info, gliding) || blocks;
// MultiPlayerGameMode.useItem: `ItemStack.use`'s SUCCESS swings
// (SwingSource.CLIENT); CONSUME does not — a bow, crossbow, trident
// or spear starting its charge, and `ItemUtils.startUsingInstantly`'s
// food, drink, spyglass, shield and goat horn.
if ends && !blocks && remote_use_swings(&held, info.consumable.is_some()) {
self.with_hand(hand, Game::swing_arm);
}
ends
}
/// Whether `held`'s `Item.use` needs a target this client sees none of
@@ -9645,6 +9697,27 @@ fn use_ends_click(info: &horizon_client::items::ItemInfo, fall_flying: bool) ->
item_has_use(info) || (fall_flying && &*info.id == "minecraft:firework_rocket")
}
/// Whether `Item.use` of this item answers SUCCESS on this client, which
/// swings (`MultiPlayerGameMode.useItem`'s `result.shouldSwing()`), rather
/// than CONSUME, which only ends the click: a bow, crossbow, trident or
/// spear starting its charge, `ItemUtils.startUsingInstantly`'s food, drink,
/// spyglass, shield and goat horn, and the ender eye's SUCCESS_SERVER (the
/// server's swing source, not this client's).
fn remote_use_swings(held: &ItemStack, consumable: bool) -> bool {
let id = &*held.item;
!matches!(
id,
"minecraft:bow"
| "minecraft:crossbow"
| "minecraft:trident"
| "minecraft:spyglass"
| "minecraft:goat_horn"
| "minecraft:ender_eye"
| "minecraft:brush"
) && !consumable
&& horizon_client::spear::Kinetic::of(id).is_none()
}
/// Whether `Item.use` usually acts on this item (eats, drinks, equips, raises,
/// throws, shoots, fills or places), which ends `Minecraft.startUseItem` at
/// its hand. Tools and materials pass, and the off hand gets its turn. The
@@ -9959,10 +10032,51 @@ fn client_millis() -> u64 {
#[cfg(test)]
mod tests {
use super::{keep_client_state, synced_local_use};
use horizon_client::containers::{Beacon, BlockEntity};
use super::{keep_client_state, remote_use_swings, synced_local_use};
use horizon_client::{
containers::{Beacon, BlockEntity},
items::ItemStack,
};
use horizon_world::nbt::Tag;
/// `MultiPlayerGameMode.useItem`'s `shouldSwing`: the SUCCESS answers
/// swing, CONSUME's do not (#6425).
#[test]
fn a_thrown_item_swinging_on_a_server_and_a_charge_not() {
let stack = |id: &str| ItemStack::new(id, 1);
// SUCCESS: the throwables, a bucket, a boat, a bundle, a book, a map.
for id in [
"minecraft:ender_pearl",
"minecraft:splash_potion",
"minecraft:wind_charge",
"minecraft:snowball",
"minecraft:experience_bottle",
"minecraft:bucket",
"minecraft:oak_boat",
"minecraft:bundle",
"minecraft:writable_book",
"minecraft:map",
] {
assert!(remote_use_swings(&stack(id), false), "{id} swings");
}
// CONSUME: the charges and startUsingInstantly's uses, a drinkable
// potion among them (`consumable` set).
for id in [
"minecraft:bow",
"minecraft:crossbow",
"minecraft:trident",
"minecraft:spyglass",
"minecraft:goat_horn",
"minecraft:ender_eye",
] {
assert!(!remote_use_swings(&stack(id), false), "{id} does not swing");
}
// The kinetic weapons charge like a trident.
assert!(!remote_use_swings(&stack("minecraft:iron_spear"), false));
// A consumable (food, a drink) starts its use without a swing.
assert!(!remote_use_swings(&stack("minecraft:apple"), true));
}
#[test]
fn a_beacon_update_tag_keeps_the_beam_the_client_walked() {
let old = BlockEntity::Beacon(Box::new(Beacon {
@@ -2,7 +2,7 @@
//! end crystals on a server's client (`ClientPacketListener.handleAddEntity`
//! and `handleSetEntityData`): the server simulates them, the client draws
//! them where it says and interpolates their moves.
use super::{FallStep, FallingBlock, Game, fireworks::fireworks_of, view_vector};
use super::{FallStep, FallingBlock, Game, fireworks::fireworks_of};
use horizon_client::items::ItemStack;
use horizon_client::projectiles::{Hit, Projectile, ProjectileKind, ProjectileWorld};
use horizon_client::{
@@ -316,6 +316,7 @@ impl Game<'_> {
match kind {
"minecraft:evoker_fangs" => self.insert_fangs(id, position, yaw, 0, 0),
"minecraft:tnt" => self.tnts.push(super::tnt::PrimedTnt {
no_gravity: false,
id,
position: at,
previous: at,
@@ -562,11 +563,7 @@ impl Game<'_> {
/// `Entity.getEyePosition` of a projectile's owner: this game's player or
/// another player the server sent.
fn remote_eye(&self, id: u32) -> Option<Vec3> {
let me = self
.remote
.as_ref()
.and_then(|r| u32::try_from(r.entity_id).ok());
if me == Some(id) {
if self.remote_player_id() == Some(id) {
let eye = self.eye_position()?;
return Some(Vec3::new(eye[0], eye[1], eye[2]));
}
@@ -578,6 +575,34 @@ impl Game<'_> {
))
}
/// This client's own player's entity id on the server.
fn remote_player_id(&self) -> Option<u32> {
self.remote
.as_ref()
.and_then(|r| u32::try_from(r.entity_id).ok())
}
/// `FireworkRocketEntity.tick` for the rockets attached to this client's
/// own player, once the player has moved (it ticks before them in
/// `ClientLevel.tickEntities`), each with its spark.
pub(super) fn tick_own_rockets(&mut self) {
let Some(me) = self.remote_player_id() else {
return;
};
if !self.clock.runs_normally()
|| self.player.is_none()
|| !self.rockets.iter().any(|r| r.attached_to == Some(me))
{
return;
}
let mut rockets = std::mem::take(&mut self.rockets);
for r in rockets.iter_mut().filter(|r| r.attached_to == Some(me)) {
self.follow_own_player(r);
self.rocket_spark(r);
}
self.rockets = rockets;
}
/// One tick of interpolation, as `tick_remote_objects`.
pub(super) fn tick_remote_projectiles(&mut self, mut step: impl FnMut(u32, Vec3) -> Vec3) {
self.tick_remote_fangs();
@@ -646,13 +671,10 @@ impl Game<'_> {
.collect();
self.smoke_trails(trails);
self.shulker_bullet_trails();
// FireworkRocketEntity.tick: a rocket attached to an entity follows it
// and boosts it client-side too (attachedToEntity.isFallFlying), and
// every rocket leaves its spark trail on a client.
let me = self
.remote
.as_ref()
.and_then(|r| u32::try_from(r.entity_id).ok());
// FireworkRocketEntity.tick: a rocket attached to another entity
// follows it, and every rocket leaves its spark trail on a client. One
// boosting this client's own player ticks after it (`tick_own_rockets`).
let me = self.remote_player_id();
let mut rockets = std::mem::take(&mut self.rockets);
for r in &mut rockets {
r.remote_step(&mut SceneCollisions {
@@ -662,20 +684,9 @@ impl Game<'_> {
});
if let Some(target) = r.attached_to {
if me == Some(target) {
if let Some(p) = self.player.as_mut() {
if p.fall_flying {
let look = view_vector(self.yaw, self.pitch);
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,
);
}
r.position = p.position;
r.velocity = p.velocity;
}
} else if let Some(m) = self.mobs.iter().find(|m| m.id == target) {
continue;
}
if let Some(m) = self.mobs.iter().find(|m| m.id == target) {
// Another player's motion is not known here: its step.
r.velocity = Vec3::new(
m.position.x - r.previous.x,
@@ -685,17 +696,7 @@ impl Game<'_> {
r.position = m.position;
}
}
let g = |g: &mut horizon_core::random::LegacyRandom| g.next_gaussian() * 0.05;
let v = [
g(&mut self.world.random),
-r.velocity.y * 0.5,
g(&mut self.world.random),
];
self.particles.add(
&ParticleOptions::simple(ParticleType::Firework),
[r.position.x, r.position.y, r.position.z],
v,
);
self.rocket_spark(r);
}
self.rockets = rockets;
for c in &mut self.crystals {
+4 -1
View File
@@ -220,6 +220,10 @@ impl Game<'_> {
| Target::Boat(id)
| Target::Cart(id)
| Target::Tnt(id)
| Target::Orb(id)
| Target::Projectile(id)
| Target::Crystal(id)
| Target::Dragon(id)
| Target::Other(id) => self.persist.raw.get(&id),
Target::Player => self.level.player_extra.as_ref(),
// A server's player: the saved compound the guest's file is written from.
@@ -234,7 +238,6 @@ impl Game<'_> {
.iter()
.find(|f| f.id == id)
.and_then(|f| f.saved.as_ref().map(|s| &s.1)),
Target::Orb(_) | Target::Projectile(_) | Target::Crystal(_) | Target::Dragon(_) => None,
};
match raw.and_then(|r| r.get("Tags")) {
Some(Tag::List(l)) => l
+4 -4
View File
@@ -16,10 +16,10 @@
//! data (`Resources`, the packs), one audio output and P1's server. Not one game process per
//! player: that needs GPU textures, input and audio shared across processes.
//!
//! Controllers: P1's is the first used in a menu (or alone). In P1's world another controller
//! pressing a button is offered to join, on every view (A joins, B says no); a controller that
//! disconnects leaves its player waiting, its view dimmed, until it comes back or another takes
//! over (`pads`).
//! Controllers: P1's is the first used in a menu. In P1's world a controller it isn't using
//! pressing a button is offered to join, on every view (A joins, Y gives it to P1, B says no); a
//! controller that disconnects leaves its player waiting, its view dimmed, until it comes back
//! or another takes over (`pads`).
//!
//! Horizon Settings > Splitscreen... enables joining (off by default).
//! `HORIZON_SPLIT=N` (testing) enables it too, and
+61 -12
View File
@@ -92,7 +92,7 @@ impl Split<'_> {
state
}
/// Controllers: P1's is the first used in a menu, or alone; in a world another one is
/// Controllers: P1's is the first used in a menu; in a world a controller nobody holds is
/// offered to join (A), or to drive P1 (Y) while P1 has none. The pads whose players
/// should join now.
/// `solo`: no compositor, P1 alone (its controller's output is lent to it after this).
@@ -123,19 +123,31 @@ impl Split<'_> {
if !self.pads.connected(id) {
continue;
}
// In a menu, or alone in a world, the first controller pressed is P1's (alone it
// drives P1 anyway).
let offered = self.offers.iter().any(|(o, _)| *o == id);
// P1 with controllers off plays on keyboard and mouse: a pad given to it is dead.
let p1_takes = self.p1_pad.is_none() && self.host.settings.controller.enabled;
if !self.in_world() || (self.guests.is_empty() && p1_takes && !offered) {
if p1_takes {
self.p1_pad = Some(id);
self.fresh.push(id);
}
// In a menu the first controller pressed is P1's, so it can drive the menu, and so
// it is when P1's own disconnected and its view asks for another. Elsewhere in a
// world it is offered to join (A joins as the next player, Y gives the pad to P1):
// the one controller pressed while P1 plays with the keyboard and mouse makes a
// second player rather than taking P1's controls.
let in_world = self.in_world();
// The players waiting for a controller come first: the same kind back takes back
// its player, another takes over the one waiting longest (`claim`). Only what that
// passes on, with P1 the one waiting, goes to P1 here.
if in_world && self.claim_for_lost(id, button) {
continue;
}
if self.claim_for_lost(id, button) {
let p1_waits = self.lost.iter().any(|l| l.seat == 1);
let claims_p1 = p1_takes(
in_world,
p1_waits,
self.host.settings.controller.enabled,
self.p1_pad.is_some(),
);
if claims_p1 {
self.p1_pad = Some(id);
self.fresh.push(id);
continue;
}
if !in_world {
continue;
}
match self.offers.iter().position(|(o, _)| *o == id) {
@@ -315,6 +327,16 @@ impl Split<'_> {
}
}
/// Whether a controller nobody holds becomes P1's on its first press: in a menu, where it drives
/// the menus, or in a world while P1's own is gone and P1's view asks for another ("Use
/// another"). Elsewhere in a world it is offered to join, so a controller pressed while P1 plays
/// with the keyboard and mouse starts the join flow instead of taking P1's controls; Y in that
/// offer gives the pad to P1 when it is free. P1 with controllers off plays on the keyboard and
/// mouse: a pad given to it is dead either way.
fn p1_takes(in_world: bool, p1_waits: bool, controllers: bool, p1_has_pad: bool) -> bool {
(!in_world || p1_waits) && controllers && !p1_has_pad
}
/// Whether P1 gets the split's reader for its turn (`lend_output`): solo, with a controller.
fn lends_output(solo: bool, p1_has_pad: bool) -> bool {
solo && p1_has_pad
@@ -338,6 +360,33 @@ mod tests {
}
}
#[test]
fn a_controller_pressed_in_a_world_is_offered_to_join_not_taken_by_p1() {
// P1 alone on the keyboard and mouse: the press is offered (A joins, Y gives it to P1).
assert!(
!p1_takes(true, false, true, false),
"a controller pressed in a world never takes P1's controls"
);
// P1's own disconnected and its view asks for another: "Use another" hands it over.
assert!(
p1_takes(true, true, true, false),
"a player waiting for a controller takes the next one"
);
// The menus still hand the first controller to P1, so it can drive them.
assert!(
p1_takes(false, false, true, false),
"a menu's first controller is P1's"
);
assert!(
!p1_takes(false, false, false, false),
"controllers off: P1 has none"
);
assert!(
!p1_takes(false, false, true, true),
"P1 has a controller already"
);
}
#[test]
fn a_lone_p1_keeps_its_lent_output_between_frames() {
assert!(
@@ -1215,10 +1215,9 @@ mod tests {
fn gui(root: &std::path::Path) -> UiResources {
UiResources {
atlas: HudAtlas::fonts(root).unwrap(),
panorama: Arc::new(horizon_client::gfx::panorama::Faces {
size: 1,
pixels: [96, 110, 111, 255].repeat(6),
}),
panorama: Arc::new(horizon_client::gfx::panorama::Faces::colour([
96, 110, 111, 255,
])),
}
}
+9 -1
View File
@@ -23,6 +23,8 @@ pub(super) struct PrimedTnt {
pub on_ground: bool,
pub owner: Option<Who>,
pub guest: Option<u32>,
/// `Entity.noGravity` (`NoGravity` in its save).
pub no_gravity: bool,
}
impl PrimedTnt {
const SIZE: f64 = 0.98;
@@ -45,7 +47,9 @@ impl PrimedTnt {
facts: &horizon_client::blocks::BlockFacts,
) -> Result<()> {
self.previous = self.position;
self.velocity.y -= 0.04;
if !self.no_gravity {
self.velocity.y -= 0.04;
}
let mut scene = SceneCollisions {
world,
binding: collision,
@@ -170,6 +174,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 {
no_gravity: false,
id: self.next_entity,
position,
previous: position,
@@ -584,6 +589,7 @@ mod tests {
use horizon_core::geometry::Direction;
use horizon_world::piston_push::bounce_velocity;
let mut tnt = PrimedTnt {
no_gravity: false,
id: 0,
position: Vec3::new(0.5, 1.0, 0.5),
previous: Vec3::new(0.5, 1.0, 0.5),
@@ -626,6 +632,7 @@ mod tests {
world.set(21, 4, 20, flowing).unwrap();
let at = Vec3::new(20.5, 4.0, 20.5);
let mut tnt = PrimedTnt {
no_gravity: false,
id: 0,
position: at,
previous: at,
@@ -720,6 +727,7 @@ mod tests {
// (Entity.handleOnInsideBubbleColumn / handleOnAboveBubbleColumn).
let at = Vec3::new(20.5, 6.0, 20.5);
let mut tnt = PrimedTnt {
no_gravity: false,
id: 0,
position: at,
previous: at,
+23
View File
@@ -800,6 +800,16 @@ impl Game<'_> {
else {
return;
};
// The texture's OptiFine emissive overlay, drawn again full bright.
let glow = self
.res
.mob_data()
.emissive
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.overlay("entity/trident/trident")
.and_then(|e| self.gui.atlas.sprites.get(e))
.map(|r| [r.u0, r.v0, r.u1, r.v1]);
// AbstractArrow.shouldRenderAtSqrDistance: ten times the default.
let range = 10.0
* super::entity_culling::default_range(
@@ -858,6 +868,19 @@ impl Game<'_> {
0,
out,
);
if let Some(glow) = glow {
let full = horizon_client::mobs::render::FULL_BRIGHT;
horizon_client::mobs::render::draw(
model,
base,
&poses,
glow,
0xFFFF_FFFF,
full,
0,
out,
);
}
}
}
}
@@ -174,6 +174,7 @@ impl WorldRequest {
level.seed,
o.world_gen(),
o.flat_settings(),
o.single_biome(),
)?;
// WorldCreationUiState.getGameRules, written with the level.
level.game_rules = o.rules.by_name();
+10 -8
View File
@@ -64,8 +64,9 @@ pub struct Knock {
pub z: f64,
}
/// The game's native knockback hook (`native_living_knockback`), set once by
/// the game: `LivingEntity.knockback`, after the knockback resistance and
/// before the motion changes. It may change the [`Knock`]; true cancels it.
/// the game: `LivingEntity.knockback`, at its start (`LivingKnockBackEvent`), on the raw strength
/// and before the knockback resistance and the motion change. It may change the [`Knock`]; true
/// cancels it.
pub static KNOCKBACK: OnceLock<fn(Hurting<'_>, &mut Knock) -> bool> = OnceLock::new();
/// Whether [`KNOCKBACK`] canceled the knockback (never while it is unset). A
/// value it leaves that is not a finite number is put back as it was.
@@ -1259,14 +1260,15 @@ mod tests {
assert_eq!(velocity(&turned), (0.8, 0.4, 0.0));
// Numbers that are not finite are put back as the game made them.
assert_eq!(velocity(&push(9103, 0.0)), (-0.4, 0.4, 0.0));
// After the resistance: half of 0.4 is what the hook is handed.
// Before the resistance (`LivingKnockBackEvent`): the hook is handed the raw 0.4, and
// the push that follows is halved.
let resisted = push(9104, 0.5);
assert_eq!(velocity(&resisted), (-0.2, 0.2, 0.0));
assert_eq!(seen(9104)[0].1.strength, 0.2);
// A full resistance leaves a strength of 0, which a mod may raise.
let raised = push(9105, 1.0);
assert_eq!(seen(9105)[0].1.strength, 0.0);
assert_eq!(velocity(&raised), (-0.4, 0.4, 0.0));
assert_eq!(seen(9104)[0].1.strength, 0.4);
// A full resistance takes all of what the hook leaves: no push.
let stopped = push(9105, 1.0);
assert_eq!(seen(9105)[0].1.strength, 0.4);
assert_eq!(velocity(&stopped), (0.0, 0.0, 0.0));
// The game's own hit on a mob reaches the hook with its damage.
let mut hurt = mob(9106, 0.0);
assert!(hurt.damage(2.0, hit));
+149 -32
View File
@@ -19,10 +19,12 @@ pub struct ServerAdvancements {
tree: std::sync::Arc<Tree>,
kids: Vec<Vec<usize>>,
layout: Vec<Option<(i32, f32)>>,
/// Recipe files by id, as compact JSON text: a book entry parses its
/// recipe when a player gets it (a few µs), and the ~1,400 parsed files
/// would keep about 5 MB alive for the server's whole run.
recipes: BTreeMap<String, Box<str>>,
/// Recipe files by id, as compact JSON text with their ingredient
/// objects already resolved to flat item lists (`network_recipes`): a
/// book entry parses its recipe when a player gets it (a few µs), and
/// the ~1,400 parsed files would keep about 5 MB alive for the server's
/// whole run.
pub(super) recipes: BTreeMap<String, Box<str>>,
displays: RecipeIndex,
/// RecipeManager.unpackRecipeInfo's group numbers.
groups: HashMap<String, i32>,
@@ -36,24 +38,15 @@ pub struct FirstTick {
}
impl ServerAdvancements {
pub fn load(resources: &Path, overlay: &horizon_world::loot::PackOverlay) -> Self {
pub fn load(
resources: &Path,
overlay: &horizon_world::loot::PackOverlay,
recipes: BTreeMap<String, Box<str>>,
groups: HashMap<String, i32>,
) -> Self {
let tree = Tree::shared(&resources.join("data"), overlay);
let kids = tree.children();
let layout = tree.layout();
let mut recipes = BTreeMap::new();
super::recipes::recipes(&resources.join("data/minecraft/recipe"), "", &mut recipes);
super::recipes::overlay_recipes(overlay, &mut recipes);
let mut groups = HashMap::new();
for r in recipes.values() {
if let Some(g) = r["group"].as_str().filter(|g| !g.is_empty()) {
let next = groups.len() as i32;
groups.entry(g.to_owned()).or_insert(next);
}
}
let recipes = recipes
.into_iter()
.map(|(id, r)| (id, r.to_string().into_boxed_str()))
.collect();
Self {
tree,
kids,
@@ -371,7 +364,7 @@ impl ServerAdvancements {
};
let r = &r;
let kind = r["type"].as_str().unwrap_or("");
let item = |n: &str| sync.id("minecraft:item", n).unwrap_or(0);
let item = |n: &str| sync.id("minecraft:item", n);
let slot = |n: &str| sync.id("minecraft:slot_display", n).unwrap_or(0);
let mut w = Writer::new();
w.varint(ids.start);
@@ -391,16 +384,19 @@ impl ServerAdvancements {
// The result as its ItemStackSlotDisplay, the station as its item.
let result = |w: &mut Writer| {
w.varint(slot("minecraft:item_stack"));
w.varint(item(
horizon_world::loot::recipe_result_id(&r["result"]).unwrap_or("minecraft:air"),
));
w.varint(
item(
horizon_world::loot::recipe_result_id(&r["result"]).unwrap_or("minecraft:air"),
)
.unwrap_or(0),
);
w.varint(r["result"]["count"].as_i64().unwrap_or(1) as i32);
w.varint(0);
w.varint(0);
};
let station = |w: &mut Writer, name: &str| {
w.varint(slot("minecraft:item"));
w.varint(item(name));
w.varint(item(name).unwrap_or(0));
};
// The display, then the ingredients the craftable filter checks and
// the category of the tab that lists it.
@@ -551,20 +547,25 @@ fn shrink(rows: &[Value]) -> Vec<Vec<char>> {
}
/// ByteBufCodecs.holderSet for an ingredient: a tag by name, or the items.
fn holder_set(w: &mut Writer, v: &Value, item: &dyn Fn(&str) -> i32) {
/// An item the registries do not know is left out, not sent as id 0.
fn holder_set(w: &mut Writer, v: &Value, item: &dyn Fn(&str) -> Option<i32>) {
match v {
Value::String(s) if s.starts_with('#') => {
w.varint(0);
w.string(&s[1..]);
}
Value::String(s) => {
w.varint(2);
w.varint(item(s));
}
Value::String(s) => match item(s) {
Some(id) => {
w.varint(2);
w.varint(id);
}
None => w.varint(1),
},
Value::Array(a) => {
w.varint(a.len() as i32 + 1);
for s in a {
w.varint(item(s.as_str().unwrap_or("")));
let ids: Vec<i32> = a.iter().filter_map(|s| item(s.as_str()?)).collect();
w.varint(ids.len() as i32 + 1);
for id in ids {
w.varint(id);
}
}
_ => w.varint(1),
@@ -637,6 +638,7 @@ mod tests {
let dir = root.join("data/minecraft/recipe");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(root.join("registries.json"), REGISTRIES).unwrap();
std::fs::write(root.join("items.json"), "{}").unwrap();
for (id, json) in recipes {
std::fs::write(dir.join(format!("{id}.json")), json).unwrap();
}
@@ -657,6 +659,121 @@ mod tests {
(sync, entry)
}
/// A shaped recipe whose key uses one of NeoForge's ingredient shapes
/// reaches the client with the shape's items, not an empty list.
#[test]
fn a_neoforge_ingredient_in_the_key_reaches_the_client_with_items() {
let root = resources(
"compound_key",
&[(
"compound_key",
r#"{"type":"minecraft:crafting_shaped",
"pattern":["X"],
"key":{"X":{"type":"neoforge:compound","children":["minecraft:raw_copper","minecraft:redstone"]}},
"result":{"id":"minecraft:copper_ingot"}}"#,
)],
);
let (sync, e) = entry(&root, "minecraft:compound_key");
let item = |n: &str| sync.id("minecraft:item", n).unwrap();
let raw = item("minecraft:raw_copper");
let redstone = item("minecraft:redstone");
let req = e.requirements.as_ref().unwrap();
assert_eq!(req.len(), 1, "one cell: {req:?}");
match &req[0] {
HolderSet::Items(items) => {
assert!(
items.contains(&raw) && items.contains(&redstone),
"the compound's items: {items:?}"
);
}
other => panic!("not an item list: {other:?}"),
}
let _ = std::fs::remove_dir_all(&root);
}
/// A recipe whose ingredient kind no loader knows stays out of the book:
/// the server does not load it, and an empty ingredient slot in its
/// place would mislead the client.
#[test]
fn a_recipe_with_an_unknown_ingredient_kind_stays_out_of_the_book() {
let root = resources(
"mymod_kind",
&[(
"mymod_kind",
r#"{"type":"minecraft:crafting_shaped",
"pattern":["X"],
"key":{"X":{"type":"mymod:x"}},
"result":{"id":"minecraft:copper_ingot"}}"#,
)],
);
let sync = SyncData::load(&root, &horizon_world::loot::PackOverlay::NONE).unwrap();
assert!(
!sync
.advancements
.recipes
.contains_key("minecraft:mymod_kind"),
"the network copy drops the recipe"
);
assert!(
sync.advancements
.book_entries("minecraft:mymod_kind", &sync, NOTIFY_AND_HIGHLIGHT)
.is_empty()
);
let _ = std::fs::remove_dir_all(&root);
}
/// The property sets and the stonecutter read a NeoForge shape's items
/// too, not an empty slot: UpdateRecipes carries them.
#[test]
fn update_recipes_sends_a_neoforge_shapes_items() {
let root = resources(
"compound_sets",
&[
(
"compound_smelting",
r#"{"type":"minecraft:smelting",
"ingredient":{"type":"neoforge:compound","children":["minecraft:raw_copper","minecraft:redstone"]},
"result":{"id":"minecraft:copper_ingot"}}"#,
),
(
"compound_stonecutting",
r#"{"type":"minecraft:stonecutting",
"ingredient":{"type":"neoforge:compound","children":["minecraft:raw_copper"]},
"result":{"id":"minecraft:copper_ingot"}}"#,
),
],
);
let sync = SyncData::load(&root, &horizon_world::loot::PackOverlay::NONE).unwrap();
let item = |n: &str| sync.id("minecraft:item", n).unwrap();
let raw = item("minecraft:raw_copper");
let redstone = item("minecraft:redstone");
let mut r = Reader::new(&sync.recipes);
// The property sets, then the stonecutter's recipes.
let mut furnace_input = None;
for _ in 0..r.varint().unwrap() {
let key = r.string(64).unwrap();
let ids: Vec<i32> = (0..r.varint().unwrap())
.filter_map(|_| r.varint().ok())
.collect();
if key == "minecraft:furnace_input" {
furnace_input = Some(ids);
}
}
let ids = furnace_input.expect("furnace_input set present");
assert!(
ids.contains(&raw) && ids.contains(&redstone),
"the compound's items in furnace_input: {ids:?}"
);
// The stonecutter's entry: the compound's items, then the result.
let stone = r.varint().unwrap();
assert_eq!(stone, 1, "one stonecutting recipe");
let len = r.varint().unwrap();
assert!(len >= 2, "an item list, not empty ({len}) nor a tag");
assert_eq!(len - 1, 1, "the compound's one item");
assert_eq!(r.varint().unwrap(), raw);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn smelting_reaches_the_furnace_book() {
let root = resources(
+8 -2
View File
@@ -198,6 +198,8 @@ impl SyncData {
advancements: super::advancements::ServerAdvancements::load(
Path::new(""),
&horizon_world::loot::PackOverlay::NONE,
BTreeMap::new(),
HashMap::new(),
),
content_identity: None,
}
@@ -264,9 +266,13 @@ impl SyncData {
features: vec!["minecraft:vanilla".to_owned()],
commands: super::commands::CommandTree::load(resources),
recipes: Vec::new(),
advancements: super::advancements::ServerAdvancements::load(resources, overlay),
advancements: {
let (recipes, groups) = super::recipes::network_recipes(resources, overlay);
super::advancements::ServerAdvancements::load(resources, overlay, recipes, groups)
},
};
sync.recipes = super::recipes::update_recipes(resources, &sync, overlay);
sync.recipes =
super::recipes::update_recipes(&sync.advancements.recipes, resources, &sync, overlay);
Ok(sync)
}
+38 -10
View File
@@ -738,6 +738,9 @@ pub struct Entities {
probes: Vec<Probe>,
chunks: Vec<(i32, i32)>,
chunk_index: crate::fast_hash::FxHashMap<(i32, i32), u32>,
/// The entities in each of `chunks` (indices into `probes`): a client
/// that moved checks only the chunks within its view distance.
chunk_members: Vec<Vec<u32>>,
held: Vec<u8>,
flags: Vec<Vec<u8>>,
/// Whether a client starts tracking each entity this tick.
@@ -787,6 +790,9 @@ impl Entities {
self.probes.clear();
self.chunks.clear();
self.chunk_index.clear();
for members in &mut self.chunk_members {
members.clear();
}
self.moved.clear();
self.is_moved.clear();
self.is_moved.resize(entities.len(), false);
@@ -822,7 +828,11 @@ impl Entities {
let chunk = *self.chunk_index.entry(at).or_insert(next);
if chunk == next {
self.chunks.push(at);
if self.chunk_members.len() == chunk as usize {
self.chunk_members.push(Vec::new());
}
}
self.chunk_members[chunk as usize].push(index as u32);
self.probes.push(Probe {
x: e.position[0],
z: e.position[2],
@@ -861,6 +871,7 @@ impl Entities {
self.held.clear();
self.held.resize(self.chunks.len(), 0);
let (chunks, held, pairs) = (&self.chunks, &mut self.held, &mut self.pairs);
let members = &self.chunk_members;
let mut kept = 0;
// A client that moved (ChunkMap.move), or whose own entity
// appeared or changed section (ChunkMap.tick's movedPlayers), has
@@ -871,17 +882,34 @@ impl Entities {
.get(&v.entity_id)
.is_none_or(|s| s.seen != now || self.is_moved[s.index]);
if all {
flags.extend(self.probes.iter().zip(pairs.iter_mut()).map(|(p, pairs)| {
if p.id == v.entity_id || !sees(v, p, chunks, held) {
0
} else if v.tracker.seen.contains_key(&p.id) {
kept += 1;
SEES | TRACKS
} else {
*pairs = true;
SEES
// `sees` caps the range at its view distance, so only the
// entities in chunks that far from its own can be seen; the
// rest stay 0, as checking each of them gave.
flags.resize(self.probes.len(), 0);
let reach = v.view_distance + 1;
let (vx, vz) = (
(v.position[0].floor() as i32) >> 4,
(v.position[2].floor() as i32) >> 4,
);
for (&(cx, cz), members) in chunks.iter().zip(members) {
if (cx - vx).abs() > reach || (cz - vz).abs() > reach {
continue;
}
}));
for &index in members {
let index = index as usize;
let p = &self.probes[index];
if p.id == v.entity_id || !sees(v, p, chunks, held) {
continue;
}
if v.tracker.seen.contains_key(&p.id) {
flags[index] = SEES | TRACKS;
kept += 1;
} else {
flags[index] = SEES;
pairs[index] = true;
}
}
}
} else if v.tracker.seen.len() * 4 > self.probes.len() {
// Otherwise its pairs stay as they were, but for the entities
// that appeared or changed section; a client tracking much
+6
View File
@@ -108,6 +108,11 @@ pub struct Guest {
pub input: u8,
/// The boat or minecart the player rides (`Entity.getVehicle`).
pub vehicle: Option<u32>,
/// When the player last got into its vehicle, from `Hub.seat_order`:
/// `Entity.addPassenger` seats a rider behind the players already in the
/// vehicle, so the smallest order on a boat is its controlling rider
/// (`getControllingPassenger`).
pub vehicle_order: u64,
/// `ServerGamePacketListenerImpl.lastGoodX/Y/Z` of the vehicle it controls.
pub vehicle_good: Option<[f64; 3]>,
pub sprinting: bool,
@@ -354,6 +359,7 @@ impl Guest {
sneaking: false,
input: 0,
vehicle: None,
vehicle_order: 0,
vehicle_good: None,
sprinting: false,
fall_flying: false,
+3
View File
@@ -236,6 +236,8 @@ pub struct Hub {
pub guests: Vec<Guest>,
pub(super) sync: Arc<SyncData>,
pub ticks: u64,
/// The next `Guest.vehicle_order`: a rider that boards later sits behind.
pub seat_order: u64,
/// `Level.soundSeedGenerator`: a fresh `nextLong` per `playSound`.
pub(super) sound_seeds: Mutex<LegacyRandom>,
/// The `player_movement_check` and `elytra_movement_check` game rules,
@@ -536,6 +538,7 @@ impl Hub {
guests: Vec::new(),
sync,
ticks: 0,
seat_order: 0,
sound_seeds: Mutex::new(LegacyRandom::new(crate::sound_engine::unique_seed())),
immediate_respawn: false,
movement_checks: (true, true),
+134 -15
View File
@@ -6,7 +6,7 @@ use super::data::SyncData;
use horizon_protocol::buf::Writer;
use serde_json::Value;
use std::{
collections::{BTreeMap, BTreeSet},
collections::{BTreeMap, BTreeSet, HashMap},
path::Path,
sync::{Arc, Mutex},
};
@@ -81,18 +81,135 @@ pub(super) fn overlay_recipes(
}
}
/// The packet's payload for the vanilla data pack in `resources`.
/// The network copy's recipes: every recipe file and overlay recipe, each
/// ingredient object — NeoForge's shapes, a brewing slot's item predicate —
/// replaced by the flat item list the crafting copy resolves it to. The
/// crafting copy resolves these shapes where it loads them; this copy reads
/// the files again, and `holder_set` writes any object it meets as an empty
/// list. A recipe that keeps an object the loader cannot parse (a mod's own
/// ingredient kind) is left out: the server does not load it, and an empty
/// ingredient in its place misleads the client.
pub(super) fn network_recipes(
resources: &Path,
overlay: &horizon_world::loot::PackOverlay,
) -> (BTreeMap<String, Box<str>>, HashMap<String, i32>) {
let mut all = BTreeMap::new();
recipes(&resources.join("data/minecraft/recipe"), "", &mut all);
overlay_recipes(overlay, &mut all);
match horizon_client::items::Items::catalog(resources, overlay) {
Ok(items) => all.retain(|_, r| normalize_ingredients(r, &items)),
Err(e) => log::warn!("recipes: NeoForge's ingredient shapes stay unresolved: {e}"),
}
// RecipeManager.unpackRecipeInfo's group numbers, while the files are
// still parsed.
let mut groups = HashMap::new();
for r in all.values() {
if let Some(g) = r["group"].as_str().filter(|g| !g.is_empty()) {
let next = groups.len() as i32;
groups.entry(g.to_owned()).or_insert(next);
}
}
// Each file as its compact JSON text: a book entry parses the one it
// needs, and the parsed files would keep about 5 MB alive for the
// server's whole run.
let all = all
.into_iter()
.map(|(id, r)| (id, r.to_string().into_boxed_str()))
.collect();
(all, groups)
}
/// An ingredient object's flat item list: the shape NeoForge's standard
/// kinds resolve to, or a brewing predicate's `item`. `None`: the object is
/// a kind the loader does not know, a mod's own.
fn flat(ing: &Value, items: &horizon_client::items::Items) -> Option<Value> {
if !ing.is_object() {
return None;
}
let parsed = horizon_client::crafting::Ingredient::parse(ing, items).or_else(|| {
// A brewing slot's item predicate: its `ingredient`, as the
// property sets have always read it.
ing.get("item")
.and_then(|v| horizon_client::crafting::Ingredient::parse(v, items))
})?;
Some(Value::Array(
parsed.1.iter().map(|i| Value::String(i.clone())).collect(),
))
}
/// Replace each of a recipe's ingredient objects with its flat item list,
/// so `holder_set` writes the items instead of an empty list. `false`: the
/// recipe keeps an object the loader cannot parse.
fn normalize_ingredients(r: &mut Value, items: &horizon_client::items::Items) -> bool {
let known = r["type"]
.as_str()
.is_some_and(|t| t.starts_with("minecraft:"));
if !known {
return true;
}
let mut parsed = true;
// The shaped recipe's key: a map of symbols to ingredients.
if let Some(map) = r.get_mut("key").and_then(|k| k.as_object_mut()) {
for ing in map.values_mut() {
if let Some(replacement) = flat(ing, items) {
*ing = replacement;
} else if ing.is_object() {
parsed = false;
}
}
}
// The list and single-ingredient keys, the smithing trio, the brewing
// pair.
for key in [
"ingredients",
"ingredient",
"base",
"addition",
"template",
"input",
"reagent",
] {
let Some(ing) = r.get_mut(key) else { continue };
if let Some(replacement) = flat(ing, items) {
*ing = replacement;
continue;
}
if let Value::Array(a) = ing {
for entry in a.iter_mut() {
if let Some(replacement) = flat(entry, items) {
*entry = replacement;
} else if entry.is_object() {
parsed = false;
}
}
} else if ing.is_object() {
parsed = false;
}
}
parsed
}
/// The packet's payload for the network copy's recipes (`network_recipes`).
pub fn update_recipes(
all: &BTreeMap<String, Box<str>>,
resources: &Path,
sync: &SyncData,
overlay: &horizon_world::loot::PackOverlay,
) -> Vec<u8> {
let mut all = BTreeMap::new();
recipes(&resources.join("data/minecraft/recipe"), "", &mut all);
overlay_recipes(overlay, &mut all);
// The data's recipe filters, as the server's own tables apply them.
// The data's recipe filters, as the server's own tables apply them. A
// filter can also swap a recipe's inputs and outputs, so it runs on
// this packet's own parsed values, the book's copy keeping its files
// as loaded. Parsing every recipe takes about a millisecond.
let filters = horizon_items::recipe_filters::RecipeFilters::load(resources, overlay);
all.retain(|id, v| filters.apply(id, v));
let mut chosen: Vec<Value> = Vec::new();
for (id, text) in all {
let Ok(mut own) = serde_json::from_str::<Value>(text) else {
continue;
};
if filters.apply(id, &mut own) {
chosen.push(own);
}
}
let tags: BTreeMap<&str, &[i32]> = sync
.tags
.registries
@@ -101,21 +218,22 @@ pub fn update_recipes(
.flat_map(|r| r.tags.iter())
.map(|t| (t.name.trim_start_matches("minecraft:"), t.ids.as_slice()))
.collect();
let item = |name: &str| sync.id("minecraft:item", name).unwrap_or(0);
// An item the registries do not know is left out, not sent as id 0.
let item = |name: &str| sync.id("minecraft:item", name);
let items = |i: &Ingredient| -> Vec<i32> {
match i {
Ingredient::Tag(t) => tags
.get(t.trim_start_matches("minecraft:"))
.map(|ids| ids.to_vec())
.unwrap_or_default(),
Ingredient::Items(list) => list.iter().map(|n| item(n)).collect(),
Ingredient::Items(list) => list.iter().filter_map(|n| item(n)).collect(),
}
};
let mut w = Writer::new();
w.varint(PROPERTY_SETS.len() as i32);
for (key, kind, fields) in PROPERTY_SETS {
let mut set = BTreeSet::new();
for recipe in all.values() {
for recipe in &chosen {
let t = recipe["type"].as_str().unwrap_or("");
let matches = if *kind == "smithing" {
t.starts_with("minecraft:smithing_")
@@ -138,8 +256,8 @@ pub fn update_recipes(
}
// SelectableRecipe.SingleInputSet: each stonecutting recipe's input and
// its result as an ItemStackSlotDisplay.
let stonecutting: Vec<&Value> = all
.values()
let stonecutting: Vec<&Value> = chosen
.iter()
.filter(|r| r["type"] == "minecraft:stonecutting")
.collect();
let item_stack = sync
@@ -162,9 +280,10 @@ pub fn update_recipes(
None => w.varint(1),
}
w.varint(item_stack);
w.varint(item(
horizon_world::loot::recipe_result_id(&r["result"]).unwrap_or("minecraft:air"),
));
w.varint(
item(horizon_world::loot::recipe_result_id(&r["result"]).unwrap_or("minecraft:air"))
.unwrap_or(0),
);
w.varint(r["result"]["count"].as_i64().unwrap_or(1) as i32);
w.varint(0); // no components added
w.varint(0); // nor removed
+9 -1
View File
@@ -1283,6 +1283,11 @@ pub enum MobEvent {
count: u32,
},
Sound(&'static str, [f64; 3], f32, f32),
/// `Entity.sendBubbleColumnParticles`: a mob at the top of the bubble column
/// block at `pos` (the splash and bubble particles over it).
BubbleColumnParticles {
pos: [i32; 3],
},
/// `UseBonemeal`: the villager's bone meal grows the crop at `pos`
/// (`BoneMealItem.growCrop`, then level event 1505).
Bonemeal {
@@ -3052,8 +3057,10 @@ impl Mob {
if creaking::is_creaking(self) && !creaking::can_move(self) {
return;
}
// The native hook runs first, on the raw strength (NeoForge's `LivingKnockBackEvent` fires
// at the start of `LivingEntity.knockback`), then the resistance scales what it left.
let mut knock = crate::health::Knock {
strength: power * (1.0 - self.attrs.knockback_resistance),
strength: power,
x: xd,
z: zd,
};
@@ -3066,6 +3073,7 @@ impl Mob {
x: mut xd,
z: mut zd,
} = knock;
let power = power * (1.0 - self.attrs.knockback_resistance);
if power <= 0.0 {
return;
}
+5 -4
View File
@@ -93,10 +93,11 @@ impl MobData {
.unwrap_or_default();
data.villager_hats = std::sync::RwLock::new(super::villager_hats::VillagerHats::load(root));
let random = horizon_optifine::random_textures::RandomTextures::scan(root);
data.emissive = std::sync::RwLock::new(std::sync::Arc::new(
horizon_optifine::emissive::Emissive::scan(root, &random),
));
data.blinks = std::sync::RwLock::new(horizon_optifine::blink::Blinks::scan(root, &random));
let blinks = horizon_optifine::blink::Blinks::scan(root, &random);
let mut emissive = horizon_optifine::emissive::Emissive::scan(root, &random);
emissive.pair_beside(root, blinks.files());
data.emissive = std::sync::RwLock::new(std::sync::Arc::new(emissive));
data.blinks = std::sync::RwLock::new(blinks);
data.random_textures = std::sync::RwLock::new(random);
data
}
+10 -2
View File
@@ -630,17 +630,25 @@ impl Mob {
// Entity.checkSupportingBlock
self.supporting = self.supporting_block(w, moved);
self.movement_emission(w, moved, events);
// Entity.applyEffectsFromBlocks: bubble columns push the mob.
if crate::motion::bubble_column_push(
// Entity.applyEffectsFromBlocks: bubble columns push the mob, and the
// server shows the surface of one it is at the top of.
let mut tops = Vec::new();
if crate::motion::bubble_column_push_over(
w.world,
w.registry,
w.facts,
w.collision,
self.bounds(),
&mut self.velocity,
|pos| tops.push(pos),
) {
self.fall_distance = 0.0;
}
for pos in tops {
events.push(MobEvent::BubbleColumnParticles {
pos: [pos.x, pos.y, pos.z],
});
}
}
/// LivingEntity.checkFallDamage: the fluid state refreshed out of water
/// (touching water resets the fall), then Entity.checkFallDamage.
+67 -3
View File
@@ -39,7 +39,7 @@ fn scroll_u(u: f32, scroll: f32) -> f32 {
if scroll == 0.0 { u } else { (u + scroll) % 1.0 }
}
/// Full sky and block light in a vertex's `light`.
const FULL_BRIGHT: u32 = 240 | 240 << 8;
pub const FULL_BRIGHT: u32 = 240 | 240 << 8;
/// How brightly the eyes render type's layers (spiders', endermen's and
/// phantoms' eyes, the breeze's, the creaking's, the copper golem's) shine in
/// HDR output, as `mesher::hdr_tier`.
@@ -3136,8 +3136,9 @@ mod emissive_tests {
std::fs::write(path, text).unwrap();
}
let random = RandomTextures::scan(&root);
let emissive = Emissive::scan(&root, &random);
let blinks = Blinks::scan(&root, &random);
let mut emissive = Emissive::scan(&root, &random);
emissive.pair_beside(&root, blinks.files());
let _ = std::fs::remove_dir_all(&root);
MobData {
states: Vec::new(),
@@ -3220,6 +3221,43 @@ mod emissive_tests {
assert!(shut(&uvs(89 + 150)));
}
#[test]
fn a_blink_shows_its_own_glow_or_none() {
let atlas = |name: &str| {
let u = match name {
"entity/silverfish" => 0.0,
"entity/silverfish_e" => 0.25,
"entity/silverfish_blink" => 0.5,
"entity/silverfish_blink_e" => 0.75,
_ => return None,
};
Some([u, 0.0, u + 0.25, 1.0])
};
let base = [
"assets/minecraft/textures/entity/silverfish.png",
"assets/minecraft/textures/entity/silverfish_e.png",
"assets/minecraft/textures/entity/silverfish_blink.png",
];
let at = |data: &MobData, age: u32| {
let mut m = silverfish();
m.age = age;
drawn(&m, data, &atlas)
};
// Age 89 is shut (see above), 0 open.
let plain = data(&base);
let open = at(&plain, 0);
assert_eq!(open.len(), 8);
assert!(inside(&open[4..], 0.25), "the texture's glow");
let shut = at(&plain, 89);
assert_eq!(shut.len(), 4, "no glowing open eyes over the shut ones");
assert!(inside(&shut, 0.5));
let mut own = base.to_vec();
own.push("assets/minecraft/textures/entity/silverfish_blink_e.png");
let shut = at(&data(&own), 89);
assert_eq!(shut.len(), 8);
assert!(inside(&shut[4..], 0.75), "the frame's own glow");
}
/// Draws `m` and returns each vertex's (uv, light).
fn drawn(m: &Mob, data: &MobData, atlas: Atlas) -> Vec<([f32; 2], u32)> {
let mut out = Vec::new();
@@ -3306,10 +3344,36 @@ mod emissive_tests {
assert_eq!(v.len(), 8);
assert!(inside(&v[4..], 0.75), "its own overlay, not silverfish_e");
} else {
assert_eq!(v.len(), 4, "zombie_e.png doesn't glow over zombie3.png");
assert_eq!(v.len(), 4, "silverfish_e doesn't glow over silverfish2");
}
}
}
#[test]
fn worn_armour_glows_when_its_extra_layer_says_so() {
let m = silverfish();
let data = data(&[]);
let atlas = |_: &str| Some([0.0, 0.0, 0.5, 0.5]);
let part = m.kind.model.clone();
let extra = [
(part.as_ref(), [0.5, 0.0, 1.0, 0.5], 0xFFFF_FFFF, 0, false),
(part.as_ref(), [0.5, 0.5, 1.0, 1.0], 0xFFFF_FFFF, 0, true),
];
let mut out = Vec::new();
render_with(
&m,
0.0,
glam::DVec3::ZERO,
&atlas,
7,
&data,
&extra,
&mut out,
);
assert_eq!(out.len(), 12, "body, armour, armour's glow");
assert!(out[..8].iter().all(|v| v.light == 7));
assert!(out[8..].iter().all(|v| v.light == FULL_BRIGHT));
}
}
#[cfg(test)]
+38
View File
@@ -34,6 +34,44 @@ pub struct ContentMod {
pub content: Content,
}
/// Leaves out a mod with an item whose `crafting_remainder` names an item the mod does not have:
/// a file item that named a code-declared one the declaration check then refused. Left in, the
/// overlay would fail on it and no content mod at all would load. Returns what was left out.
pub fn drop_dangling_remainders(mods: &mut Vec<ContentMod>) -> Vec<String> {
let mut dropped = Vec::new();
mods.retain(|m| {
// The mod's items and its blocks that have an item (`Content::read`'s own rule).
let ids: BTreeSet<&str> = m
.content
.items
.iter()
.map(|i| i.id.as_str())
.chain(
m.content
.blocks
.iter()
.filter(|b| b.item)
.map(|b| b.id.as_str()),
)
.collect();
let dangling = m.content.items.iter().find(|i| {
i.crafting_remainder
.as_deref()
.is_some_and(|r| !r.starts_with("minecraft:") && !ids.contains(r))
});
if let Some(item) = dangling {
dropped.push(format!(
"mod {} content not loaded: {}: crafting_remainder {:?} is not one of its items",
m.id,
item.id,
item.crafting_remainder.as_deref().unwrap_or_default()
));
}
dangling.is_none()
});
dropped
}
/// The mods' mob effects in registry order: by id, as items are. Their
/// network ids follow vanilla's in this order.
pub fn mob_effects(mods: &[ContentMod]) -> Vec<&MobEffect> {
@@ -3541,3 +3541,50 @@ fn blocks_and_items_declared_in_code_get_ids() {
assert_ne!(fs::read(root.join(STAMP)).unwrap(), stamp);
let _ = fs::remove_dir_all(&scratch);
}
#[test]
fn a_file_item_whose_remainder_was_refused_takes_only_its_mod_out() {
use horizon_mods::content::Item;
let item = |id: &str, remainder: Option<&str>| Item {
id: id.into(),
template: "minecraft:diamond".into(),
crafting_remainder: remainder.map(str::to_owned),
..Item::default()
};
let make = |id: &str, items: Vec<Item>| {
let mut content = Content::declared(id);
content.items = items;
ContentMod {
id: id.into(),
content,
}
};
let mut jar = make("jar", vec![item("jar:tool", Some("jar:jar"))]);
jar.content.blocks.push(horizon_mods::content::Block {
id: "jar:jar".into(),
template: "minecraft:glass".into(),
item: true,
..Default::default()
});
let mut mods = vec![
jar,
make(
"good",
vec![
item("good:tool", Some("good:wrench")),
item("good:wrench", None),
],
),
make("bad", vec![item("bad:tool", Some("bad:wrench"))]),
make(
"vanilla",
vec![item("vanilla:soup", Some("minecraft:bowl"))],
),
];
let dropped = drop_dangling_remainders(&mut mods);
assert_eq!(dropped.len(), 1, "{dropped:?}");
assert!(dropped[0].contains("bad"), "{dropped:?}");
let left: Vec<&str> = mods.iter().map(|m| m.id.as_str()).collect();
// A remainder that is the mod's own block item is kept too (`jar`).
assert_eq!(left, ["jar", "good", "vanilla"]);
}
+1 -1
View File
@@ -47,7 +47,7 @@ pub(crate) mod environment;
mod fluids;
pub use fluids::{
BubbleColumn, FluidResources, FluidTags, LAVA, PlayerFluids, RiddenBoat, WATER,
bubble_column_at, bubble_column_push,
bubble_column_at, bubble_column_push, bubble_column_push_over, bubble_column_surfaces,
};
pub struct MotionResources {
+52 -6
View File
@@ -144,25 +144,71 @@ pub fn bubble_column_push(
bounds: Aabb,
velocity: &mut Vec3,
) -> bool {
bubble_column_push_over(world, registry, facts, collision, bounds, velocity, |_| {})
}
/// [`bubble_column_push`], telling `surface` the position of each column block
/// the entity is at the top of: `handleOnAboveBubbleColumn` sends the splash and
/// bubble particles over it (`Entity.sendBubbleColumnParticles`).
pub fn bubble_column_push_over(
world: &World,
registry: &horizon_world::registry::BlockRegistry,
facts: &crate::blocks::BlockFacts,
collision: &horizon_world::block_collision::BlockCollisionRegistry<'_>,
bounds: Aabb,
velocity: &mut Vec3,
mut surface: impl FnMut(BlockPos),
) -> bool {
let mut reset_fall = false;
bubble_columns_touching(world, registry, facts, collision, bounds, |pos, column| {
reset_fall |= column.push(velocity);
if column.surface {
surface(pos);
}
});
reset_fall
}
/// The column blocks `bounds` touches that it is at the top of: where the
/// particles of `Entity.sendBubbleColumnParticles` start.
pub fn bubble_column_surfaces(
world: &World,
registry: &horizon_world::registry::BlockRegistry,
facts: &crate::blocks::BlockFacts,
collision: &horizon_world::block_collision::BlockCollisionRegistry<'_>,
bounds: Aabb,
) -> Vec<BlockPos> {
let mut surfaces = Vec::new();
bubble_columns_touching(world, registry, facts, collision, bounds, |pos, column| {
if column.surface {
surfaces.push(pos);
}
});
surfaces
}
/// Every bubble column block the box touches (`Entity.getInsideBlockPoints`).
fn bubble_columns_touching(
world: &World,
registry: &horizon_world::registry::BlockRegistry,
facts: &crate::blocks::BlockFacts,
collision: &horizon_world::block_collision::BlockCollisionRegistry<'_>,
bounds: Aabb,
mut each: impl FnMut(BlockPos, BubbleColumn),
) {
let b = bounds.deflate(Vec3::new(
f64::from(1.0e-5f32),
f64::from(1.0e-5f32),
f64::from(1.0e-5f32),
));
let (lo, hi) = (b.min_position(), b.max_position());
let mut reset_fall = false;
for x in lo.x.floor() as i32..=(hi.x.ceil() as i32 - 1) {
for y in lo.y.floor() as i32..=(hi.y.ceil() as i32 - 1) {
for z in lo.z.floor() as i32..=(hi.z.ceil() as i32 - 1) {
if let Some(column) =
bubble_column_at(world, registry, facts, collision, BlockPos::new(x, y, z))
{
reset_fall |= column.push(velocity);
let pos = BlockPos::new(x, y, z);
if let Some(column) = bubble_column_at(world, registry, facts, collision, pos) {
each(pos, column);
}
}
}
}
reset_fall
}
/// A bubble column block as `BubbleColumnBlock.entityInside` sees it: whether
/// it drags down (`DRAG_DOWN`), and whether it is the surface (nothing above it
+6 -3
View File
@@ -3342,16 +3342,19 @@ impl Default for WorldGen {
}
/// Writes a new world's `world_gen_settings` (seed, options and dimensions).
pub fn write_world_gen_settings(dir: &Path, seed: i64, options: WorldGen) -> Result<()> {
write_world_gen_settings_with(dir, seed, options, None)
write_world_gen_settings_with(dir, seed, options, None, None)
}
/// [`write_world_gen_settings`] with a flat overworld's own generator
/// settings (FlatLevelGeneratorSettings, as `flatgen::settings_from_json`
/// reads them) instead of the Superflat preset's classic layers.
/// reads them) instead of the Superflat preset's classic layers, and with the
/// biome the single-biome preset fixes (`CreateBuffetWorldScreen`'s choice,
/// `minecraft:plains` when none was made).
pub fn write_world_gen_settings_with(
dir: &Path,
seed: i64,
options: WorldGen,
flat_settings: Option<Tag>,
single_biome: Option<&str>,
) -> Result<()> {
fs::create_dir_all(dir)?;
// worldgen/world_preset/flat.json's overworld: the classic flat layers.
@@ -3428,7 +3431,7 @@ pub fn write_world_gen_settings_with(
"minecraft:overworld",
Tag::compound([
("type", string("minecraft:fixed")),
("biome", string("minecraft:plains")),
("biome", string(single_biome.unwrap_or("minecraft:plains"))),
]),
),
WorldPreset::Normal => dimension(
+4 -2
View File
@@ -67,13 +67,15 @@ impl Stopwatches {
.iter()
.map(|(k, w)| (k.as_str().into(), Tag::Long(w.elapsed_ms(now))))
.collect();
let _ = save::write_dat(
if let Err(e) = save::write_dat(
&file(root),
Tag::compound([
("data", Tag::compound([("stopwatches", Tag::Compound(map))])),
("DataVersion", Tag::Int(horizon_world::DATA_VERSION)),
]),
);
) {
log::warn!("Horizon: the stopwatches are not saved: {e}");
}
}
pub fn ids(&self) -> Vec<String> {
self.watches.keys().cloned().collect()
+106
View File
@@ -15,6 +15,7 @@ use std::{
sync::mpsc,
};
mod buffet;
mod confirm_experiments;
mod create_world;
mod experiments;
@@ -23,6 +24,7 @@ mod onboarding;
#[cfg(test)]
mod tests;
pub mod world_management;
pub use buffet::State as BuffetEditor;
pub use create_world::{DIFFICULTIES, WorldOptions};
pub use flat::{FlatLayer, FlatSettings, State as FlatEditor};
pub use onboarding::Onboarding;
@@ -342,6 +344,16 @@ pub enum Mode {
options: WorldOptions,
flat: flat::State,
},
/// CreateBuffetWorldScreen over Create New World: the single-biome
/// preset's biome list, over the fields that screen keeps.
CreateBuffet {
name: String,
seed: String,
creative: bool,
focus: u8,
options: WorldOptions,
buffet: buffet::State,
},
/// AccessibilityOnboardingScreen: the first screen of a first launch.
Onboarding(Onboarding),
/// `BanNoticeScreens`: a ConfirmLinkScreen `Gui.addInitialScreens` queued.
@@ -841,6 +853,45 @@ impl Title {
_ => {}
}
}
/// A press on the single-biome screen: its search box, list or buttons.
fn buffet_click(&mut self, x: f32, y: f32, size: (f32, f32), hit: Option<u8>) {
match hit {
Some(80) => self.close_buffet(true),
Some(81) => self.close_buffet(false),
None => {
if let Mode::CreateBuffet { buffet, .. } = &mut self.mode {
buffet::click(buffet, (x, y), size);
}
}
_ => {}
}
}
/// Return to Create New World, keeping (`apply`) the chosen biome
/// (`CreateBuffetWorldScreen`'s Done and Cancel).
fn close_buffet(&mut self, apply: bool) {
let mode = std::mem::replace(&mut self.mode, Mode::Main);
if let Mode::CreateBuffet {
name,
seed,
creative,
focus,
mut options,
buffet,
} = mode
{
if apply {
options.world_type = crate::save::WorldPreset::SingleBiomeSurface;
options.single_biome = buffet.biome.clone();
}
self.mode = Mode::Create {
name,
seed,
creative,
focus,
options,
};
}
}
/// Return to Create New World, keeping (`apply`) or dropping the layers.
fn close_flat(&mut self, apply: bool) {
let mode = std::mem::replace(&mut self.mode, Mode::Main);
@@ -1015,6 +1066,10 @@ impl Title {
flat::drag(flat, cursor, size);
return;
}
if let Mode::CreateBuffet { buffet, .. } = &mut self.mode {
buffet::drag(buffet, cursor, size);
return;
}
if let Mode::Select { worlds, .. } = &self.mode {
let (top, bottom, _, content) = self.world_list(worlds, size);
let mut scroll = self.scroll.get();
@@ -1033,6 +1088,9 @@ impl Title {
if let Mode::CreateFlat { flat, .. } = &mut self.mode {
flat::release(flat);
}
if let Mode::CreateBuffet { buffet, .. } = &mut self.mode {
buffet::release(buffet);
}
self.scroll.get_mut().release();
}
/// Mouse wheel over the classic world and server lists, or cycle buttons. Returns true if a button was cycled.
@@ -1049,6 +1107,10 @@ impl Title {
flat::wheel(flat, delta, cursor, size);
return false;
}
if let Mode::CreateBuffet { buffet, .. } = &mut self.mode {
buffet::wheel(buffet, delta, cursor, size);
return false;
}
if let Mode::Create {
creative, options, ..
} = &mut self.mode
@@ -1721,6 +1783,7 @@ impl Title {
creative, options, ..
} => create_world::buttons(w, h, *creative, options),
Mode::CreateFlat { flat, .. } => flat::buttons(w, h, flat),
Mode::CreateBuffet { buffet, .. } => buffet::buttons(w, h, buffet),
Mode::Edit { world, name, .. } => {
let x = (w / 2.0).floor() - 100.0;
let top = edit_top(h);
@@ -2065,6 +2128,13 @@ impl Title {
"Superflat Customization",
),
),
Mode::CreateBuffet { .. } => ScreenInfo::new(
"create_world/buffet",
get(
"createWorld.customize.buffet.title",
"Single Biome Customization",
),
),
Mode::Edit { .. } => {
ScreenInfo::new("edit_world", get("selectWorld.edit.title", "Edit World"))
}
@@ -2543,6 +2613,7 @@ Would you like to enable the Narrator or visit the Accessibility Settings?",
..
} => self.draw_create(atlas, batch, cursor, name, seed, *focus, options),
Mode::CreateFlat { flat, .. } => flat::draw(self, atlas, items, batch, cursor, flat),
Mode::CreateBuffet { buffet, .. } => buffet::draw(self, atlas, batch, cursor, buffet),
Mode::Edit { name, focused, .. } => {
crate::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, false);
centered(
@@ -3169,6 +3240,10 @@ Would you like to enable the Narrator or visit the Accessibility Settings?",
if hit.is_some() {
self.focused_button = hit;
}
if matches!(self.mode, Mode::CreateBuffet { .. }) {
self.buffet_click(x, y, (w, h), hit);
return (Action::None, hit.is_some());
}
if matches!(self.mode, Mode::CreateFlat { .. }) {
self.flat_click(x, y, (w, h), hit);
return (Action::None, hit.is_some());
@@ -3475,6 +3550,29 @@ Would you like to enable the Narrator or visit the Accessibility Settings?",
*self.rules.get_mut() = Some(RulesScreen::for_creation(&options.rules));
Action::None
}
// CreateBuffetWorldScreen for the single-biome preset.
(
Mode::Create {
name,
seed,
creative,
focus,
options,
},
Some(26),
) if options.world_type == crate::save::WorldPreset::SingleBiomeSurface => {
let buffet = buffet::State::new(&self.resources, &options.single_biome);
self.mode = Mode::CreateBuffet {
name: name.clone(),
seed: seed.clone(),
creative: *creative,
focus: *focus,
options: options.clone(),
buffet,
};
Action::None
}
// CreateFlatWorldScreen for the flat preset.
(
Mode::Create {
name,
@@ -3615,6 +3713,9 @@ Would you like to enable the Narrator or visit the Accessibility Settings?",
.filter(|(id, _)| id == focus)
.map(|(_, rect)| rect),
Mode::Multiplayer(screen) => screen.focused_box((w, h)),
Mode::CreateBuffet { buffet, .. } if buffet.query_focused => {
Some(buffet::search_box(w))
}
_ => None,
}
}
@@ -4266,6 +4367,7 @@ Would you like to enable the Narrator or visit the Accessibility Settings?",
self.close_flat(false);
}
}
Mode::CreateBuffet { .. } => self.close_buffet(false),
Mode::Create { .. } => self.cancel_create(),
// Screen.keyPressed: Escape closes the screen, which fades out.
Mode::Onboarding(_) => self.onboarding_close(),
@@ -4333,6 +4435,10 @@ fn caret_field<'a>(
Mode::CreateFlat { flat, .. } if flat.page == flat::Page::Presets && flat.share_focused => {
Some((FLAT_SHARE, &mut flat.share, 1230))
}
// CreateBuffetWorldScreen's search box.
Mode::CreateBuffet { buffet, .. } if buffet.query_focused => {
Some((buffet::SEARCH, &mut buffet.query, 50))
}
_ => None,
}
}
+325
View File
@@ -0,0 +1,325 @@
//! CreateBuffetWorldScreen: the single-biome preset's biome list with its
//! search box, and the Done and Cancel that hand a biome back to Create New
//! World (`WorldCreationUiState.updateDimensions`).
use super::{Button, Title};
use crate::{
hud::{HudAtlas, HudBatch},
lang,
text::Text,
ui,
};
use serde_json::Value;
use std::path::Path;
/// The search box's text field, for the caret, selection and clipboard.
pub(super) const SEARCH: crate::clipboard::FieldId = ("world.buffet-search", 0);
/// HeaderAndFooterLayout's header: the title's font line, 13 above it, the
/// 15-pixel search box 3 under it.
const HEADER: f32 = 40.0;
/// HeaderAndFooterLayout's default footer.
const FOOTER: f32 = 33.0;
/// The header's search box (`new EditBox(font, 200, 15, ...)`).
const SEARCH_WIDTH: f32 = 200.0;
const SEARCH_HEIGHT: f32 = 15.0;
/// The search box's top: 13 + the title's 9-pixel line + 3.
const SEARCH_TOP: f32 = 25.0;
/// `ObjectSelectionList`'s row: 220 wide and the list's default 20 high.
const ROW_WIDTH: f32 = 220.0;
const ROW_HEIGHT: f32 = 20.0;
/// One biome the screen offers: its registry id and the name it shows
/// (`Component.translatable(id.toLanguageKey("biome"))`, else the id).
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct Biome {
pub(super) id: String,
pub(super) name: String,
}
/// CreateBuffetWorldScreen's state: the biomes the prepared worldgen data
/// holds, the search text, and the biome the overworld will be made of.
#[derive(Clone, Debug)]
pub struct State {
/// `EditBox`'s text: the rows whose name contains it, case-folded.
pub(super) query: String,
pub(super) query_focused: bool,
/// The fixed biome (`CreateBuffetWorldScreen.biome`): plains until the
/// world says otherwise, and what `write_world_gen_settings` writes.
pub(super) biome: String,
pub(super) scroll: ui::Scroll,
biomes: Vec<Biome>,
}
impl State {
/// The screen over `biome`, the biome the selected preset fixes, with the
/// biomes of the prepared resource root.
pub(super) fn new(root: &Path, biome: &str) -> Self {
Self {
query: String::new(),
query_focused: false,
biome: biome.to_owned(),
scroll: ui::Scroll::default(),
biomes: load(root),
}
}
/// The biome the world will be made of, for the control socket's state.
pub fn biome(&self) -> &str {
&self.biome
}
/// The search text, for the control socket's state.
pub fn query(&self) -> &str {
&self.query
}
/// The rows the search text leaves, in the order the list shows them.
pub(super) fn rows(&self) -> Vec<usize> {
let query = self.query.to_lowercase();
self.biomes
.iter()
.enumerate()
.filter(|(_, biome)| query.is_empty() || biome.name.to_lowercase().contains(&query))
.map(|(index, _)| index)
.collect()
}
/// The selected row, while the search text still shows the biome.
pub(super) fn selected(&self) -> Option<usize> {
self.rows()
.iter()
.position(|index| self.biomes[*index].id == self.biome)
}
fn name(&self, row: usize) -> Option<&str> {
self.biomes.get(row).map(|biome| biome.name.as_str())
}
fn select(&mut self, row: usize) {
if let Some(biome) = self.biomes.get(row) {
self.biome = biome.id.clone();
}
}
}
/// The biome registry of the prepared resources, by the name each is shown
/// under: `Registry.listElements` sorted with a locale collator, which for
/// these names is a case-insensitive order with the id breaking ties.
fn load(root: &Path) -> Vec<Biome> {
let Ok(text) = std::fs::read_to_string(root.join("worldgen/biomes.json")) else {
return Vec::new();
};
let Ok(value) = serde_json::from_str::<Value>(&text) else {
return Vec::new();
};
let mut biomes: Vec<Biome> = value
.as_object()
.map(|entries| {
entries
.keys()
.map(|id| Biome {
id: id.clone(),
name: display_name(id),
})
.collect()
})
.unwrap_or_default();
biomes.sort_by_cached_key(|biome| (biome.name.to_lowercase(), biome.id.clone()));
biomes
}
/// `Identifier.toLanguageKey("biome")`: `biome.<namespace>.<path>` with the
/// path's separators turned into dots. An id the language has no name for is
/// shown as it is, as `CreateBuffetWorldScreen$BiomeList$Entry` does.
fn display_name(id: &str) -> String {
let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", id));
let key = format!("biome.{namespace}.{}", path.replace(['/', '.'], "."));
if lang::has(&key) {
Text::translate(&key, &[]).plain()
} else {
id.to_owned()
}
}
/// The list's area: under the header, above the footer, `ROW_WIDTH` wide in
/// the middle of the screen.
fn list_area(size: (f32, f32), rows: usize) -> (f32, f32, f32, f32) {
let (w, h) = (size.0.ceil(), size.1.ceil());
(
HEADER,
h - FOOTER,
((w - ROW_WIDTH) * 0.5).floor(),
rows as f32 * ROW_HEIGHT + 4.0,
)
}
pub(super) fn search_box(w: f32) -> [f32; 4] {
[
((w - SEARCH_WIDTH) * 0.5).floor(),
SEARCH_TOP,
SEARCH_WIDTH,
SEARCH_HEIGHT,
]
}
/// The row under the pointer, if any (`getEntryAtPosition`).
fn row_at(state: &State, cursor: (f32, f32), size: (f32, f32)) -> Option<usize> {
let rows = state.rows();
let (top, bottom, left, _) = list_area(size, rows.len());
if cursor.0 < left || cursor.0 >= left + ROW_WIDTH || cursor.1 < top || cursor.1 >= bottom {
return None;
}
let row = ((cursor.1 - top + state.scroll.amount.trunc()) / ROW_HEIGHT) as usize;
rows.get(row).copied()
}
/// A press on the screen: the search box, the list, or the scrollbar.
pub(super) fn click(state: &mut State, cursor: (f32, f32), size: (f32, f32)) {
let rows = state.rows();
let (top, bottom, left, content) = list_area(size, rows.len());
let field = search_box(size.0.ceil());
state.query_focused = ui::hovered(cursor, field[0], field[1], field[2], field[3]);
if state
.scroll
.press(cursor, left + ROW_WIDTH + 6.0, top, bottom, content)
{
return;
}
if let Some(row) = row_at(state, cursor, size) {
state.select(row);
}
}
pub(super) fn wheel(state: &mut State, delta: f32, cursor: (f32, f32), size: (f32, f32)) {
let rows = state.rows();
let (top, bottom, _, content) = list_area(size, rows.len());
if cursor.1 < top || cursor.1 >= bottom {
return;
}
state.scroll.wheel(delta, ROW_HEIGHT, top, bottom, content);
}
pub(super) fn drag(state: &mut State, cursor: (f32, f32), size: (f32, f32)) {
let rows = state.rows();
let (top, bottom, _, content) = list_area(size, rows.len());
state.scroll.drag(cursor.1, top, bottom, content);
}
pub(super) fn release(state: &mut State) {
state.scroll.release();
}
/// Done and Cancel, 150 wide and 8 apart in the footer
/// (`LinearLayout.horizontal().spacing(8)`); Done needs a biome.
pub(super) fn buttons(w: f32, h: f32, state: &State) -> Vec<Button> {
let x = ((w.ceil() - 308.0) * 0.5).trunc();
let b = |x: f32, key: &str, fallback: &str, enabled: bool, id: u8| Button {
x,
y: h.ceil() - 27.0,
w: 150.0,
label: lang::get_or(key, fallback),
enabled,
id,
icon: None,
trailing: None,
};
vec![
b(x, "gui.done", "Done", state.selected().is_some(), 80),
b(x + 158.0, "gui.cancel", "Cancel", true, 81),
]
}
/// The screen: the menu background, the title, the search box and the rows.
pub(super) fn draw(
title: &Title,
atlas: &HudAtlas,
batch: &mut HudBatch,
cursor: (f32, f32),
state: &State,
) {
let (w, h) = (batch.width.ceil(), batch.height.ceil());
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, false);
super::centered(
atlas,
batch,
&lang::get_or(
"createWorld.customize.buffet.title",
"Single Biome Customization",
),
8.0,
u32::MAX,
);
let field = search_box(w);
let focused = state.query_focused;
title.caret.borrow_mut().draw(
batch,
atlas,
(SEARCH, &state.query, 50),
field,
focused,
title.started,
);
crate::widgets::name_last_edit_box(|| {
lang::get_or("createWorld.customize.buffet.search", "Search...")
});
if state.query.is_empty() && !focused {
let box_ = ui::EditBox::new(field[0], field[1], field[2], field[3]);
batch.text_styled(
atlas,
&Text::translate("createWorld.customize.buffet.search", &[])
.with_color(crate::text::DARK_GRAY),
box_.text_x(),
box_.text_y(),
u32::MAX,
true,
);
}
let rows = state.rows();
let (top, bottom, left, content) = list_area((w, h), rows.len());
ui::list_background(batch, atlas, top, bottom, false);
crate::ui::separators(batch, atlas, top, bottom, false);
let first = batch.vertices.len();
let offset = state.scroll.amount.trunc();
let selected = state.selected();
let hovered = row_at(state, cursor, (w, h))
.and_then(|biome| rows.iter().position(|index| *index == biome));
// ObjectSelectionList: every row narrates "narrator.select" with its name.
crate::widgets::narratable(|| {
use crate::widgets::{ListKind, Narratable, Row};
let row = |at: usize| {
let name = rows.get(at).and_then(|index| state.name(*index));
let narration = Text::translate(
"narrator.select",
&[Text::literal(name.unwrap_or_default())],
)
.plain();
Row::new(at, narration)
};
Narratable::list(
ListKind::Object,
rows.len(),
hovered.map(row),
selected.map(row),
[left, top, ROW_WIDTH, bottom - top],
)
.key(crate::widgets::key_of(&"create_world/buffet"))
});
for (at, index) in rows.iter().enumerate() {
let y = top + 2.0 + at as f32 * ROW_HEIGHT - offset;
let target = [left, y - 2.0, ROW_WIDTH, ROW_HEIGHT];
if selected == Some(at) {
batch.fill(
atlas,
target[0],
target[1],
target[2],
target[3],
crate::hud::rgba(255, 255, 255, 70),
);
} else if hovered == Some(at) {
batch.fill(
atlas,
target[0],
target[1],
target[2],
target[3],
crate::hud::rgba(255, 255, 255, 25),
);
}
// `Entry.extractContent`: the name at contentX + 5, contentY + 2.
if let Some(biome) = state.biomes.get(*index) {
batch.text(atlas, &biome.name, left + 5.0, y, u32::MAX, true);
}
}
batch.clip_since(first, left, top, left + ROW_WIDTH, bottom);
ui::scrollbar(
batch,
atlas,
left + ROW_WIDTH + 6.0,
top,
bottom,
content,
state.scroll.amount,
);
}
@@ -32,6 +32,9 @@ pub struct WorldOptions {
pub commands: Option<bool>,
/// The world preset the world is created with.
pub world_type: crate::save::WorldPreset,
/// The biome the single-biome preset fixes (`CreateBuffetWorldScreen`'s
/// choice), `minecraft:plains` until another is picked.
pub single_biome: String,
/// CreateFlatWorldScreen's layers and biome, kept with the world type.
pub flat_settings: Option<super::flat::FlatSettings>,
pub structures: bool,
@@ -52,6 +55,7 @@ impl Default for WorldOptions {
difficulty: 2,
commands: None,
world_type: crate::save::WorldPreset::Normal,
single_biome: "minecraft:plains".into(),
flat_settings: None,
structures: true,
bonus_chest: false,
@@ -78,6 +82,7 @@ impl WorldOptions {
.unwrap_or(2),
commands: Some(commands),
world_type: generation.preset,
single_biome: fixed_biome(dir).unwrap_or_else(|| "minecraft:plains".into()),
flat_settings: (generation.preset == crate::save::WorldPreset::Flat)
.then(|| super::flat::FlatSettings::from_world(dir))
.flatten(),
@@ -108,6 +113,13 @@ impl WorldOptions {
bonus_chest: self.bonus_chest(),
}
}
/// The biome the single-biome preset fixes, as
/// `write_world_gen_settings_with` takes it; none for another preset,
/// which has no fixed biome to honour.
pub fn single_biome(&self) -> Option<&str> {
(self.world_type == crate::save::WorldPreset::SingleBiomeSurface)
.then_some(self.single_biome.as_str())
}
/// The custom layers, as `write_world_gen_settings_with` takes them.
pub fn flat_settings(&self) -> Option<horizon_world::nbt::Tag> {
self.flat_settings
@@ -144,6 +156,10 @@ impl WorldOptions {
// setWorldType starts the generator from the flat preset.
self.flat_settings = Some(super::flat::FlatSettings::default());
}
// ...and its dimensions replace the ones the preset before it
// had, so a fixed biome the player chose starts over at the
// single-biome preset's own.
self.single_biome = "minecraft:plains".into();
}
27 => self.structures = !self.structures,
28 => self.bonus_chest = !self.bonus_chest,
@@ -152,6 +168,17 @@ impl WorldOptions {
}
}
/// The biome a saved world's fixed biome source names
/// (`CreateBuffetWorldScreen`'s initial selection).
fn fixed_biome(dir: &std::path::Path) -> Option<String> {
let settings = crate::save::world_gen_settings(dir)?;
let generator = settings
.get("dimensions")?
.get("minecraft:overworld")?
.get("generator")?;
crate::save::text(generator.get("biome_source")?.get("biome"))
}
/// `Mth.roundToward`: up to a multiple of `step`.
fn round_toward(value: f32, step: f32) -> f32 {
(value / step).ceil() * step
@@ -280,13 +307,18 @@ pub(super) fn buttons(w: f32, h: f32, creative: bool, o: &WorldOptions) -> Vec<B
true,
25,
),
// CreateFlatWorldScreen: only a flat generator has a preset editor.
// PresetEditor.EDITORS: flat worlds have CreateFlatWorldScreen, the
// single-biome preset CreateBuffetWorldScreen; the other presets
// have no editor, so the button is disabled for them.
b(
gx + 160.0,
gy,
150.0,
tr("selectWorld.customizeType"),
o.world_type == crate::save::WorldPreset::Flat,
matches!(
o.world_type,
crate::save::WorldPreset::Flat | crate::save::WorldPreset::SingleBiomeSurface
),
26,
),
// SwitchGrid: 44-wide value-only buttons at the grid's right.
+162
View File
@@ -1865,3 +1865,165 @@ fn a_button_label_wider_than_its_button_stays_inside_it() {
"a fitting label starts at {lo}"
);
}
#[test]
fn the_single_biome_preset_is_customized_into_the_generator() {
let mut f = Fixture::new();
fs::create_dir_all(f.root.join("worldgen")).unwrap();
fs::write(
f.root.join("worldgen/biomes.json"),
r#"{"minecraft:jungle":{},"minecraft:plains":{},"minecraft:badlands":{}}"#,
)
.unwrap();
f.click("Singleplayer");
let size = (480.0, 262.0);
let labels = |f: &Fixture| -> Vec<(String, f32, f32, bool)> {
f.title
.buttons(size.0, size.1)
.into_iter()
.map(|b| (b.label, b.x, b.y, b.enabled))
.collect()
};
// The World tab: World Type and Customize (PresetEditor.EDITORS has a
// screen for the flat and the single-biome presets only).
f.title.click(54.0 + 124.0 + 10.0, 10.0, size);
let world = labels(&f);
let customize = world[1].clone();
assert!(!customize.3);
let cycle = (world[0].1 + 10.0, world[0].2 + 10.0);
f.title.click(cycle.0, cycle.1, size);
assert!(labels(&f)[1].3, "flat worlds have CreateFlatWorldScreen");
for _ in 0..3 {
f.title.click(cycle.0, cycle.1, size);
}
let Mode::Create { options, .. } = &f.title.mode else {
panic!()
};
assert_eq!(
options.world_type,
crate::save::WorldPreset::SingleBiomeSurface
);
assert!(labels(&f)[1].3, "single biome has CreateBuffetWorldScreen");
// Customize opens the biome screen over the worlds' registry data.
let (action, sound) = f.title.click(customize.1 + 10.0, customize.2 + 10.0, size);
assert!(sound && action == Action::None);
let Mode::CreateBuffet { buffet, .. } = &f.title.mode else {
panic!("{:?}", f.title.mode)
};
// Badlands, Jungle and Plains, by name; the preset's own biome is picked.
assert_eq!(buffet.rows().len(), 3);
assert_eq!(buffet.selected(), Some(2));
// A press on the list's second row (rows are 20 high from y 40).
f.title.click(240.0, 70.0, size);
let Mode::CreateBuffet { buffet, .. } = &f.title.mode else {
panic!()
};
assert_eq!(buffet.biome, "minecraft:jungle");
// Done hands it back to Create New World, which writes it as the
// overworld's fixed biome source.
let done = f
.title
.buttons(size.0, size.1)
.into_iter()
.find(|b| b.id == 80)
.unwrap();
assert!(done.enabled);
f.title.click(done.x + 10.0, done.y + 10.0, size);
let Mode::Create { options, .. } = &f.title.mode else {
panic!("{:?}", f.title.mode)
};
assert_eq!(options.single_biome, "minecraft:jungle");
let Action::Create { options, .. } = f.title.create_action() else {
panic!()
};
let dir = f.root.join("single_biome");
crate::save::write_world_gen_settings_with(
&dir,
1,
options.world_gen(),
None,
options.single_biome(),
)
.unwrap();
let settings = crate::save::world_gen_settings(&dir).unwrap();
let generator = settings
.get("dimensions")
.and_then(|d| d.get("minecraft:overworld"))
.and_then(|o| o.get("generator"))
.cloned()
.unwrap();
assert_eq!(
crate::save::text(generator.get("biome_source").and_then(|s| s.get("biome"))),
Some("minecraft:jungle".to_owned())
);
assert_eq!(
crate::save::WorldGen::read(&dir).map(|g| g.preset),
Some(crate::save::WorldPreset::SingleBiomeSurface)
);
// Re-Create starts the screen on the world's own biome.
let options = WorldOptions::of_world(
&crate::save::Level::read(&dir).unwrap_or_default(),
false,
&dir,
);
assert_eq!(options.single_biome, "minecraft:jungle");
}
#[test]
fn the_single_biome_screen_cancels_and_filters_without_choosing() {
let mut f = Fixture::new();
fs::create_dir_all(f.root.join("worldgen")).unwrap();
fs::write(
f.root.join("worldgen/biomes.json"),
r#"{"minecraft:jungle":{},"minecraft:plains":{},"minecraft:badlands":{}}"#,
)
.unwrap();
f.click("Singleplayer");
let size = (480.0, 262.0);
f.title.click(54.0 + 124.0 + 10.0, 10.0, size);
let cycle = (labels_at(&f).0, labels_at(&f).1 + 10.0);
for _ in 0..4 {
f.title.click(cycle.0, cycle.1, size);
}
let customize = labels_at(&f).2;
f.title.click(customize.0, customize.1, size);
// The search box narrows the rows, and a query that hides the chosen
// biome leaves Done disabled until a row is picked.
f.title.click(240.0, 32.0, size);
f.title.type_text("jung");
let Mode::CreateBuffet { buffet, .. } = &f.title.mode else {
panic!()
};
assert_eq!(buffet.rows().len(), 1);
assert_eq!(buffet.selected(), None);
let done = f
.title
.buttons(size.0, size.1)
.into_iter()
.find(|b| b.id == 80)
.unwrap();
assert!(!done.enabled);
// Cancel keeps the preset's own biome (plains).
let cancel = f
.title
.buttons(size.0, size.1)
.into_iter()
.find(|b| b.id == 81)
.unwrap();
f.title.click(cancel.x + 10.0, cancel.y + 10.0, size);
let Mode::Create { options, .. } = &f.title.mode else {
panic!("{:?}", f.title.mode)
};
assert_eq!(options.single_biome, "minecraft:plains");
}
/// The World tab's (World Type x, y; Customize centre) for a Create screen.
fn labels_at(f: &Fixture) -> (f32, f32, (f32, f32)) {
let buttons = f.title.buttons(480.0, 262.0);
let customize = &buttons[1];
(
buttons[0].x + 10.0,
buttons[0].y,
(customize.x + 10.0, customize.y + 10.0),
)
}
+178 -3
View File
@@ -234,9 +234,16 @@ fn server(resources: &Path) -> Server {
}
fn server_with(resources: &Path, net: NetConfig) -> Server {
let sync = Arc::new(
SyncData::load(resources, &horizon_world::loot::PackOverlay::NONE).expect("sync data"),
);
server_sync(
Arc::new(
SyncData::load(resources, &horizon_world::loot::PackOverlay::NONE).expect("sync data"),
),
net,
)
}
/// A server over already-built sync data (a test's packs layered in).
fn server_sync(sync: Arc<SyncData>, net: NetConfig) -> Server {
let lan = Lan::start_with(sync.clone(), net).unwrap();
let biomes: Vec<String> = sync
.dynamic
@@ -436,6 +443,174 @@ fn recipe_settings_packets_update_only_the_players_book_and_survive_save() {
}
}
/// A server whose pack carries recipes with NeoForge's ingredient shapes:
/// every guest that joins gets the shapes' items, not empty slots — the
/// book lists the shaped recipe's compound, the furnace's and the
/// stonecutter's input sets carry it — and no guest is disconnected over
/// them (#6368).
#[test]
fn neoforge_shapes_items_reach_two_guests() {
let Some(resources) = resources() else {
return;
};
let compound = r#"{"type":"neoforge:compound",
"children":["minecraft:raw_copper","minecraft:redstone"]}"#;
let packs = vec![
(
"advancement/minecraft:recipes/compound_book".to_owned(),
vec![
br#"{"criteria":{"tick":{"trigger":"minecraft:tick"}},
"rewards":{"recipes":["minecraft:compound_book"]}}"#
.to_vec(),
],
),
(
"recipe/minecraft:compound_book".to_owned(),
vec![
format!(
r#"{{"type":"minecraft:crafting_shaped","pattern":["X"],
"key":{{"X":{compound}}},
"result":{{"id":"minecraft:copper_ingot"}}}}"#
)
.into_bytes(),
],
),
(
"recipe/minecraft:compound_smelting".to_owned(),
vec![
format!(
r#"{{"type":"minecraft:smelting","ingredient":{compound},
"result":{{"id":"minecraft:copper_ingot"}}}}"#
)
.into_bytes(),
],
),
(
"recipe/minecraft:compound_stonecutting".to_owned(),
vec![
format!(
r#"{{"type":"minecraft:stonecutting","ingredient":{compound},
"result":{{"id":"minecraft:copper_ingot"}}}}"#
)
.into_bytes(),
],
),
];
let sync = Arc::new(
SyncData::load(
&resources,
&horizon_world::loot::PackOverlay { files: &packs },
)
.expect("sync data"),
);
let (raw_copper, redstone) = (
sync.id("minecraft:item", "minecraft:raw_copper").unwrap(),
sync.id("minecraft:item", "minecraft:redstone").unwrap(),
);
let mut s = server_sync(sync, NetConfig::lan(0, "Test - World".into(), 8));
let port = s.hub.lan.port;
// Guest one joins first and stays while guest two joins and reads its
// own join packets, then guest one reads its own.
let mut one = run(&mut s, move || connect(port, "CompoundOne", true));
let expected = (raw_copper, redstone);
let _two_stays = run(&mut s, move || {
let mut two = connect(port, "CompoundTwo", true)?;
read_join_recipes(&mut two, expected, "guest two")?;
Ok(two)
});
assert_eq!(s.hub.guests.len(), 2, "both guests stay connected");
run(&mut s, move || {
read_join_recipes(&mut one, expected, "guest one")
});
}
/// Reads a fresh guest's packets until the recipe update and the book add
/// arrived, checking the compound's items in each: the furnace's input
/// set, a stonecutting entry's input, and the book entry's requirements.
fn read_join_recipes(bot: &mut Bot, expected: (i32, i32), who: &str) -> Result<(), String> {
let (raw_copper, redstone) = expected;
let mut sets_ok = false;
let mut book_ok = false;
for _ in 0..400 {
let packet = bot.recv_play().map_err(|e| format!("{who}: {e}"))?;
match packet {
C::Unknown(id, payload)
if id == horizon_protocol::ids::play::clientbound::UPDATE_RECIPES =>
{
let mut r = horizon_protocol::buf::Reader::new(&payload);
let mut furnace_input = None;
let mut stonecutting = Vec::new();
for _ in 0..r.varint().map_err(|e| e.to_string())? {
let key = r.string(64).map_err(|e| e.to_string())?;
let ids: Vec<i32> = (0..r.varint().map_err(|e| e.to_string())?)
.map(|_| r.varint().unwrap_or(-1))
.collect();
if key == "minecraft:furnace_input" {
furnace_input = Some(ids);
}
}
let furnace_input = furnace_input.expect("furnace_input set");
assert!(
furnace_input.contains(&raw_copper) && furnace_input.contains(&redstone),
"{who}: furnace_input holds the compound: first ids {:?}",
&furnace_input[..furnace_input.len().min(8)]
);
for _ in 0..r.varint().map_err(|e| e.to_string())? {
if let horizon_protocol::packets::recipes::HolderSet::Items(ids) =
horizon_protocol::packets::recipes::HolderSet::read(&mut r)
.map_err(|e| e.to_string())?
{
stonecutting.push(ids);
}
// The result's ItemStackSlotDisplay: its type, the item,
// the count and the two empty component lists.
for _ in 0..5 {
r.varint().map_err(|e| e.to_string())?;
}
}
assert!(
stonecutting
.iter()
.any(|ids| ids.contains(&raw_copper) && ids.contains(&redstone)),
"{who}: a stonecutting input holds the compound"
);
sets_ok = true;
}
C::RecipeBookAdd(add) => {
// sendInitialRecipeBook's own add arrives first, empty
// until the first tick rewards its recipes.
if add.entries.is_empty() {
continue;
}
let compound = add.entries.iter().any(|e| {
e.requirements.as_deref().unwrap_or(&[]).iter().any(|set| {
matches!(set,
horizon_protocol::packets::recipes::HolderSet::Items(ids)
if ids.contains(&raw_copper) && ids.contains(&redstone))
})
});
assert!(
compound,
"{who}: the book lists the compound recipe ({} entries: {:?})",
add.entries.len(),
add.entries
.iter()
.map(|e| e.requirements.clone())
.collect::<Vec<_>>()
);
book_ok = true;
}
_ => {}
}
if sets_ok && book_ok {
return Ok(());
}
}
Err(format!(
"{who}: recipes {sets_ok} book {book_ok} never both arrived"
))
}
#[test]
fn sprint_commands_survive_keyboard_input_changes() {
let Some(resources) = resources() else {
+41 -3
View File
@@ -399,6 +399,16 @@ pub fn clash(a: (&str, &[String]), b: (&str, &[String])) -> Option<String> {
impl Content {
/// The content of the mod at `path` (a `.hmod` or a mod folder).
pub fn read(path: &Path, manifest: &Manifest) -> Result<Content, String> {
Self::read_declaring(path, manifest, &[])
}
/// [`Content::read`] for a mod whose native code also declares items (`native_items`):
/// a file's `crafting_remainder` may name one of those `declared` ids, which are not in the
/// files.
pub fn read_declaring(
path: &Path,
manifest: &Manifest,
declared: &[String],
) -> Result<Content, String> {
let (source, sized) = if path.is_dir() {
let mut out = Vec::new();
for top in ["assets", "data"] {
@@ -446,7 +456,7 @@ impl Content {
menu_types: Vec::new(),
namespaces: manifest.owned_namespaces().map(str::to_owned).collect(),
};
content.check(manifest)?;
content.check(manifest, declared)?;
Ok(content)
}
@@ -563,7 +573,7 @@ impl Content {
}
/// Where content may go, and the declarations.
fn check(&mut self, manifest: &Manifest) -> Result<(), String> {
fn check(&mut self, manifest: &Manifest, declared: &[String]) -> Result<(), String> {
let id = manifest.id.as_str();
if !self.files.is_empty() && !manifest.capabilities.contains(&Capability::Registries) {
return Err(format!(
@@ -699,7 +709,7 @@ impl Content {
"{name}: crafting_remainder {rest:?} is no remainder (leave it out)"
));
}
if !vanilla_id(&rest) && !ids.contains(&rest) {
if !vanilla_id(&rest) && !ids.contains(&rest) && !declared.contains(&rest) {
return Err(format!(
"{name}: crafting_remainder {rest:?} is not a vanilla item (minecraft:...) or one of this mod's"
));
@@ -1151,6 +1161,34 @@ capabilities = ["registries"]
assert_eq!(rests, [Some("minecraft:bowl"), Some("ruby:c_flask"), None]);
}
#[test]
fn a_file_item_may_leave_a_code_declared_item_as_its_remainder() {
// `native_items` declares ruby:wrench in code; a file item names it as its remainder.
let files: &[(&str, &[u8])] = &[(
"data/ruby/horizon/item/tool.json",
br#"{"template": "minecraft:iron_pickaxe", "crafting_remainder": "ruby:wrench"}"#,
)];
let read = |declared: &[String]| {
let dir = scratch("declared-remainder");
let path = dir.join("m.hmod");
std::fs::write(
&path,
Package::write_files(MANIFEST_TEXT, None, files).unwrap(),
)
.unwrap();
let package = Package::read(&path).unwrap();
let content = Content::read_declaring(&path, &package.manifest, declared);
std::fs::remove_dir_all(&dir).unwrap();
content
};
assert!(read(&[]).is_err(), "a remainder nobody declares is refused");
let content = read(&["ruby:wrench".to_owned()]).expect("the code-declared item is known");
assert_eq!(
content.items[0].crafting_remainder.as_deref(),
Some("ruby:wrench")
);
}
#[test]
fn items_patch_their_template_components() {
let sword = br#"{"template": "minecraft:iron_sword", "components": {
+21 -1
View File
@@ -105,7 +105,8 @@ impl Blink {
.and_then(|v| v.parse().ok())
.unwrap_or(default)
};
let length: u32 = number("blinkLength", LENGTH).max(1);
// At least a tick, and short of `u32::MAX` so the frequency can exceed it.
let length: u32 = number("blinkLength", LENGTH).clamp(1, u32::MAX - 1);
let half = format!("{stem}_blink2");
Self {
shut: name(&format!("{stem}_blink")),
@@ -221,4 +222,23 @@ mod tests {
);
let _ = fs::remove_dir_all(root);
}
#[test]
fn a_huge_blink_length_still_opens_the_eyes() {
let (root, b) = pack(
"huge",
&[
(&format!("{ENTITY}/pig/pig.png"), ""),
(&format!("{ENTITY}/pig/pig_blink.png"), ""),
(
&format!("{ENTITY}/pig/pig_blink.properties"),
"blinkLength=4294967295",
),
],
);
let pig = b.get("entity/pig/pig").unwrap();
assert_eq!((pig.length, pig.frequency), (u32::MAX - 1, u32::MAX));
assert_eq!(pig.frame(u32::MAX - 1, 0), None, "the one open tick");
let _ = fs::remove_dir_all(root);
}
}
+69 -33
View File
@@ -8,8 +8,10 @@
//! `zombie3`, never over `zombie`.
//!
//! This pairs entity textures, those under `textures/entity/` (mobs' and block entities'), by
//! the entity atlas's names ([`crate::random_textures`]); blocks and items have their own
//! atlas.
//! the entity atlas's names ([`crate::random_textures`]), and through [`Emissive::pair_beside`]
//! random variants and their blink frames wherever they are. A blink frame glows with its own
//! overlay only: the texture's would paint its open eyes over the shut ones. Blocks and items
//! have their own atlas.
//!
//! Written from OptiFine's `emissive.properties` and ETF's `EMISSIVE_GUIDE.md` and
//! `emissive.properties`; no mod's source was read.
@@ -40,22 +42,7 @@ const PROPERTIES: [&str; 3] = [
impl Emissive {
pub fn scan(root: &Path, random: &RandomTextures) -> Self {
// The first file that sets it (read as Java reads properties: Latin-1 when not UTF-8).
// A suffix is a name's end: no path separators or `..`, so it can't leave the folder.
let suffix = PROPERTIES
.iter()
.find_map(|p| {
let path = root.join(p);
if fs::metadata(&path).ok()?.len() > MAX_PROPERTIES {
return None;
}
let text = decode(fs::read(path).ok()?);
Properties::parse(&text)
.get("suffix.emissive")
.map(str::to_owned)
})
.filter(|s| !s.is_empty() && !s.contains(['/', '\\']) && !s.contains(".."))
.unwrap_or_else(|| "_e".to_owned());
let suffix = suffix(root);
let mut out = Self {
suffix,
..Self::default()
@@ -76,21 +63,32 @@ impl Emissive {
}
}
}
for (file, name) in random.files() {
out.pair_beside(root, random.files());
out
}
/// Pairs each of `files` (file from the root, atlas name: random variants, blink frames)
/// with an overlay beside it, and lists those outside `textures/` for the atlas.
pub fn pair_beside<'a>(
&mut self,
root: &Path,
files: impl Iterator<Item = (&'a str, &'a str)>,
) {
let png = format!("{}.png", self.suffix);
for (file, name) in files {
let Some(stem) = file.strip_suffix(".png") else {
continue;
};
let overlay = format!("{stem}{png}");
if root.join(&overlay).is_file() {
let overlay_name = format!("{name}{}", out.suffix);
out.overlays.insert(name.to_owned(), overlay_name.clone());
let overlay_name = format!("{name}{}", self.suffix);
self.overlays.insert(name.to_owned(), overlay_name.clone());
// Those under `textures/` are the atlas's already.
if !overlay.contains("/textures/") {
out.files.push((overlay, overlay_name));
self.files.push((overlay, overlay_name));
}
}
}
out
}
pub fn is_empty(&self) -> bool {
@@ -109,6 +107,27 @@ impl Emissive {
}
}
/// The pack's emissive suffix: `suffix.emissive` from the first of [`PROPERTIES`] that sets it,
/// `_e` without one.
pub fn suffix(root: &Path) -> String {
// The first file that sets it (read as Java reads properties: Latin-1 when not UTF-8).
// A suffix is a name's end: no path separators or `..`, so it can't leave the folder.
PROPERTIES
.iter()
.find_map(|p| {
let path = root.join(p);
if fs::metadata(&path).ok()?.len() > MAX_PROPERTIES {
return None;
}
let text = decode(fs::read(path).ok()?);
Properties::parse(&text)
.get("suffix.emissive")
.map(str::to_owned)
})
.filter(|s| !s.is_empty() && !s.contains(['/', '\\']) && !s.contains(".."))
.unwrap_or_else(|| "_e".to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -240,22 +259,39 @@ mod tests {
);
// Nothing that could leave the texture's folder.
for bad in [
&b"suffix.emissive=\\\\..\\\\x"[..],
&b"suffix.emissive=_a\\\\b"[..],
b"suffix.emissive=/x",
b"suffix.emissive=..",
] {
assert_eq!(
suffix(&[
(PROPERTIES[0], bad),
(PROPERTIES[1], b""),
(PROPERTIES[2], b""),
(PROPERTIES[2], b"")
]),
"_e"
);
assert_eq!(suffix(&[(PROPERTIES[0], bad)]), "_e");
}
}
#[test]
fn blink_frames_glow_with_their_own_overlay_only() {
let (root, mut e) = pack(
"blink",
&[
&format!("{RANDOM}/zombie/zombie3.png"),
&format!("{RANDOM}/zombie/zombie3_e.png"),
&format!("{RANDOM}/zombie/zombie3_blink.png"),
&format!("{RANDOM}/zombie/zombie3_blink_e.png"),
&format!("{ENTITY}/pig/pig.png"),
&format!("{ENTITY}/pig/pig_e.png"),
&format!("{ENTITY}/pig/pig_blink.png"),
],
None,
);
let blinks = crate::blink::Blinks::scan(&root, &RandomTextures::scan(&root));
e.pair_beside(&root, blinks.files());
let frame = "minecraft:optifine/random/entity/zombie/zombie3_blink";
assert_eq!(e.overlay(frame), Some(format!("{frame}_e").as_str()));
assert!(e.files().any(|(_, name)| name == format!("{frame}_e")));
// No pig_blink_e: the shut eye doesn't take the pig's glow (its open eyes, mostly).
assert_eq!(e.overlay("entity/pig/pig_blink"), None);
let _ = fs::remove_dir_all(root);
}
#[test]
fn no_overlays_no_entries() {
let (root, e) = pack("none", &[&format!("{ENTITY}/zombie/zombie.png")], None);
Loaded 100 of 132 files, more files were not shown because too many files have changed in this diff. Show more