mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge remote-tracking branch 'origin/main' into refactor/entity-trait-pistons
This commit is contained in:
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!(
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)]);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)?;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
])),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user