mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Compare commits
563
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4df2329711 | ||
|
|
7ca3fdeedd | ||
|
|
e4ca9aba95 | ||
|
|
9848261b44 | ||
|
|
7e66da154b | ||
|
|
3b03674d14 | ||
|
|
3fe5ad0d19 | ||
|
|
a1926a4901 | ||
|
|
8dd178353a | ||
|
|
0845a33141 | ||
|
|
e05360eb74 | ||
|
|
b967051f4f | ||
|
|
6c723039a7 | ||
|
|
a2c4c2d4fc | ||
|
|
3b5b0d8a3c | ||
|
|
15ebef6a69 | ||
|
|
40630164be | ||
|
|
c6a2fd5291 | ||
|
|
29d41a899b | ||
|
|
61d5d3bd8a | ||
|
|
99bf985e0a | ||
|
|
dc098b606b | ||
|
|
51f73ac425 | ||
|
|
ac9bd0527a | ||
|
|
8715111af1 | ||
|
|
790efa9cb7 | ||
|
|
ae46423a77 | ||
|
|
9da1e6f55f | ||
|
|
e27e97e58b | ||
|
|
02b75ab1f3 | ||
|
|
3be40a13a3 | ||
|
|
c69d499386 | ||
|
|
1a83468f0d | ||
|
|
1abb582ab4 | ||
|
|
d44210f9b4 | ||
|
|
3cefb44bba | ||
|
|
5c28d7f248 | ||
|
|
b1dae52509 | ||
|
|
341bd0ba5b | ||
|
|
6e423b9caa | ||
|
|
604c3a6654 | ||
|
|
b229c37e45 | ||
|
|
dcb6409ebd | ||
|
|
217b5e97f7 | ||
|
|
4fe3ed58b4 | ||
|
|
c15fa6c55c | ||
|
|
d6d3042086 | ||
|
|
c929d96fa5 | ||
|
|
5555cd8cda | ||
|
|
f85f824a27 | ||
|
|
e1a8d50c79 | ||
|
|
b69935ff31 | ||
|
|
900ce5e15b | ||
|
|
7435df4fae | ||
|
|
afce5629b7 | ||
|
|
7f22f76867 | ||
|
|
08cb78f385 | ||
|
|
ebaea75be6 | ||
|
|
ad2aabd6cf | ||
|
|
534feb4833 | ||
|
|
b7d134d2ef | ||
|
|
921f2fa118 | ||
|
|
0429cd882f | ||
|
|
b68f5b5327 | ||
|
|
d21170f02d | ||
|
|
6488524356 | ||
|
|
c9e8e4e8e0 | ||
|
|
b0abfbd196 | ||
|
|
2c0564db8c | ||
|
|
41a021365e | ||
|
|
ef36a05d90 | ||
|
|
ab4a0fd56e | ||
|
|
0e12cc64de | ||
|
|
4a6da129ce | ||
|
|
29f5ef342c | ||
|
|
672c9a7974 | ||
|
|
3d275f03bf | ||
|
|
1dd7423d00 | ||
|
|
9c0c0c7210 | ||
|
|
671a85a7f2 | ||
|
|
63a8290dcb | ||
|
|
fbd9b2ce23 | ||
|
|
e290e2c196 | ||
|
|
5671fc9619 | ||
|
|
436e839ebf | ||
|
|
860f3f05d5 | ||
|
|
88d7c0eb2c | ||
|
|
2cf505bbd7 | ||
|
|
cd218ea58e | ||
|
|
161c4e5677 | ||
|
|
a6dc594cdf | ||
|
|
013c3952a7 | ||
|
|
5365f2be63 | ||
|
|
0f23639844 | ||
|
|
997fe4515d | ||
|
|
5d16154c0b | ||
|
|
47e562e8b9 | ||
|
|
74b8550421 | ||
|
|
54c3a21b29 | ||
|
|
d6f0b3042f | ||
|
|
0422dbc79f | ||
|
|
914c1e9a3b | ||
|
|
62dd244cb6 | ||
|
|
0e446e4336 | ||
|
|
48344d0a6b | ||
|
|
87d4a87960 | ||
|
|
159b5c4be6 | ||
|
|
a392d4b3ba | ||
|
|
491710cd2f | ||
|
|
0932cefc79 | ||
|
|
77af961663 | ||
|
|
1ba320ca73 | ||
|
|
2f60199b75 | ||
|
|
033b4a9e60 | ||
|
|
6a0b9e435b | ||
|
|
485226a6fd | ||
|
|
e4ce68e271 | ||
|
|
151aa6c82b | ||
|
|
b5406e647a | ||
|
|
2d72b0b260 | ||
|
|
d79a36101f | ||
|
|
35f3dad207 | ||
|
|
5de5b6fd91 | ||
|
|
ca17c0c5ba | ||
|
|
23c3faedc1 | ||
|
|
ba3c3feebf | ||
|
|
ec298576f6 | ||
|
|
97c9d1a14f | ||
|
|
9099175f8a | ||
|
|
e1e39361db | ||
|
|
ef19179033 | ||
|
|
b189c72fcc | ||
|
|
3a229da6b3 | ||
|
|
e22ccc17d2 | ||
|
|
493dd75acf | ||
|
|
f4481233b0 | ||
|
|
63cbec701e | ||
|
|
a597387a66 | ||
|
|
80e3532c4a | ||
|
|
2d306dc52c | ||
|
|
4f6be12c2e | ||
|
|
8c93f81143 | ||
|
|
a20e9ce2d0 | ||
|
|
aba1a90e6f | ||
|
|
73a3c0348d | ||
|
|
c95f24bc32 | ||
|
|
ca0b945857 | ||
|
|
33be2a611a | ||
|
|
1199c117f2 | ||
|
|
2e749948f4 | ||
|
|
f5f96b1be4 | ||
|
|
522634ca7f | ||
|
|
f7543ccffa | ||
|
|
1fd2f02081 | ||
|
|
8d8c250f48 | ||
|
|
d21bc19b84 | ||
|
|
3afc51e740 | ||
|
|
17778b9722 | ||
|
|
abaa2a9f68 | ||
|
|
d1c1684fb5 | ||
|
|
fdc87aecdb | ||
|
|
52343380b3 | ||
|
|
dd0050e268 | ||
|
|
e2680a5450 | ||
|
|
56a578f0a6 | ||
|
|
5e7d248148 | ||
|
|
67f34e0fdd | ||
|
|
17a68d7b97 | ||
|
|
90b30ee17b | ||
|
|
5578753b58 | ||
|
|
6faf3ef3f3 | ||
|
|
ab44611814 | ||
|
|
8c612bc687 | ||
|
|
9debdf26d4 | ||
|
|
27a2eb565e | ||
|
|
d7ae75b441 | ||
|
|
bc017eec9b | ||
|
|
693178dc36 | ||
|
|
e5fe17622c | ||
|
|
b19634003c | ||
|
|
8711aabd75 | ||
|
|
76a5bd8a22 | ||
|
|
453d8b89ce | ||
|
|
845132a56b | ||
|
|
051e3dd546 | ||
|
|
ab5dfae985 | ||
|
|
19a6e9b81b | ||
|
|
de118feb4e | ||
|
|
e508e7919f | ||
|
|
f222616f7e | ||
|
|
0f89fd7e00 | ||
|
|
6889d9366b | ||
|
|
114b09babe | ||
|
|
0804e2219a | ||
|
|
1c6a22db1a | ||
|
|
0a85491338 | ||
|
|
8a9a6d5e3f | ||
|
|
d17e8fa343 | ||
|
|
6f374959b7 | ||
|
|
15a8b2a77c | ||
|
|
1206818cde | ||
|
|
12157a2d67 | ||
|
|
b029b9d04f | ||
|
|
9b89ff00ee | ||
|
|
2e13076a93 | ||
|
|
9974322832 | ||
|
|
b21f0c92f2 | ||
|
|
5f2e3cb14d | ||
|
|
a61d272adc | ||
|
|
d607c2f137 | ||
|
|
31545199d4 | ||
|
|
e54dcc07cf | ||
|
|
9a8781468e | ||
|
|
1117b98d15 | ||
|
|
8fe0e40d68 | ||
|
|
6079903ffc | ||
|
|
3a7f276eab | ||
|
|
f68c335eb2 | ||
|
|
beaad0e79d | ||
|
|
12dad67f8c | ||
|
|
922f00f52e | ||
|
|
ab37cfb5aa | ||
|
|
92b2977e3b | ||
|
|
1fe21fb861 | ||
|
|
68b03b5184 | ||
|
|
200615a30a | ||
|
|
1ba0db3c3c | ||
|
|
0ce0e67282 | ||
|
|
00d1e1a46e | ||
|
|
f178cbe003 | ||
|
|
0478b4deec | ||
|
|
b5a1d40dc6 | ||
|
|
c794b87841 | ||
|
|
1ee069b6ba | ||
|
|
c30dfd55e5 | ||
|
|
1a9c87059b | ||
|
|
4d8e70da2c | ||
|
|
8efbe1c75e | ||
|
|
df25b701c5 | ||
|
|
3890e57c13 | ||
|
|
2282116773 | ||
|
|
0f8cf58043 | ||
|
|
c74ff0070b | ||
|
|
af13271b86 | ||
|
|
0df4792709 | ||
|
|
51eae8d435 | ||
|
|
7142128a7a | ||
|
|
81df2a1864 | ||
|
|
edb550ac32 | ||
|
|
f2e081520f | ||
|
|
f1ea540f08 | ||
|
|
12dd25d421 | ||
|
|
c6c0a97e57 | ||
|
|
edab7b313f | ||
|
|
4924374fa5 | ||
|
|
b86fa679b9 | ||
|
|
45da47c08b | ||
|
|
04449d8b73 | ||
|
|
da0b4899ff | ||
|
|
50b1d6232e | ||
|
|
706cf3b3fa | ||
|
|
439b609a94 | ||
|
|
537291b26d | ||
|
|
dd93b0164a | ||
|
|
7622c02706 | ||
|
|
5ee3b9da9a | ||
|
|
307d82bb63 | ||
|
|
1e38170d49 | ||
|
|
b0600cec3c | ||
|
|
c8753a2ea4 | ||
|
|
3fc5044868 | ||
|
|
e44fc5af01 | ||
|
|
43e106cece | ||
|
|
1c8113ce32 | ||
|
|
4b5b667560 | ||
|
|
f898655ae6 | ||
|
|
73fa431ac5 | ||
|
|
e135804a52 | ||
|
|
6689a656a0 | ||
|
|
fc64f0ee27 | ||
|
|
4a20af007c | ||
|
|
aadec58092 | ||
|
|
a90c5f8179 | ||
|
|
c1e649f61b | ||
|
|
eff292b1e2 | ||
|
|
a6b863754b | ||
|
|
04b8ed64ca | ||
|
|
1c81831b12 | ||
|
|
547c7decbc | ||
|
|
a52b9e48f3 | ||
|
|
21a27ca73e | ||
|
|
17e7655cdf | ||
|
|
f8ab91d422 | ||
|
|
6b72630759 | ||
|
|
7cf67fa99d | ||
|
|
d4515b62ec | ||
|
|
ac02477732 | ||
|
|
4583362634 | ||
|
|
d00cf662b1 | ||
|
|
6deb10741a | ||
|
|
7770124ea0 | ||
|
|
78ab0c3b39 | ||
|
|
7c7d13c458 | ||
|
|
ed7114b4a0 | ||
|
|
acbb98aa5c | ||
|
|
385877d1fc | ||
|
|
771ccd721f | ||
|
|
b9e272b1ab | ||
|
|
37cee5b4fd | ||
|
|
8fe6762146 | ||
|
|
40dd7109bb | ||
|
|
1db1b34dcb | ||
|
|
d30b514728 | ||
|
|
6993f213b6 | ||
|
|
511771b9f9 | ||
|
|
344382682e | ||
|
|
edef044a68 | ||
|
|
47fd58c339 | ||
|
|
88a75f983b | ||
|
|
dbd5775947 | ||
|
|
0748416e9e | ||
|
|
5a65831d4a | ||
|
|
64f9bb1a61 | ||
|
|
991983679c | ||
|
|
c2b59b6f99 | ||
|
|
b844b69826 | ||
|
|
c2386a4616 | ||
|
|
c524b40874 | ||
|
|
d53411a24e | ||
|
|
db503217e3 | ||
|
|
dddf61b000 | ||
|
|
48986d7fd8 | ||
|
|
7746c0ef78 | ||
|
|
fbbe20dc9c | ||
|
|
b9928751e4 | ||
|
|
ed98ebc8a1 | ||
|
|
c0ca0cce68 | ||
|
|
984f2d9f06 | ||
|
|
3ed97c7339 | ||
|
|
3f1b851c07 | ||
|
|
aa7516532a | ||
|
|
43c987818b | ||
|
|
a94924cd72 | ||
|
|
fa9500a04d | ||
|
|
81a719f832 | ||
|
|
029358df0d | ||
|
|
ec62d53993 | ||
|
|
23b6a5da91 | ||
|
|
1e1e48831a | ||
|
|
8a38d8831f | ||
|
|
4595cfca2f | ||
|
|
d7889ba66c | ||
|
|
78634140b3 | ||
|
|
5ebfcbdf7f | ||
|
|
c3083e75cb | ||
|
|
67728a8568 | ||
|
|
9a1da6539a | ||
|
|
c9af605b1f | ||
|
|
b7772e4a84 | ||
|
|
199eaad7da | ||
|
|
2d05c9517f | ||
|
|
e3e18fc523 | ||
|
|
c082361b4d | ||
|
|
2860d47ffd | ||
|
|
a1ec90f7fc | ||
|
|
4d9d2cee62 | ||
|
|
ed53fe24ad | ||
|
|
9fc3a4f0fc | ||
|
|
c19b0817a0 | ||
|
|
3cb902bb21 | ||
|
|
9f8ce3033d | ||
|
|
7cad6a50ff | ||
|
|
9712ebdde7 | ||
|
|
70cedfb11a | ||
|
|
63d0b51776 | ||
|
|
8917e8a9cb | ||
|
|
354ba4a38a | ||
|
|
4bb27d7c90 | ||
|
|
073e47caed | ||
|
|
e36bbcfc63 | ||
|
|
5e90d9f10b | ||
|
|
faa8a4a52a | ||
|
|
6c0de3509d | ||
|
|
70ac0a01b8 | ||
|
|
85a5c9f340 | ||
|
|
4aaf71dc39 | ||
|
|
6a3359d943 | ||
|
|
6397e50017 | ||
|
|
fe36131256 | ||
|
|
50467de40e | ||
|
|
a7f5e08990 | ||
|
|
d4d2214cfb | ||
|
|
8368732e4e | ||
|
|
a26743d278 | ||
|
|
e8edf93a48 | ||
|
|
dce6093276 | ||
|
|
b8a6663a3a | ||
|
|
05a81f37b5 | ||
|
|
f834560860 | ||
|
|
1bc23d7a17 | ||
|
|
e297f7245b | ||
|
|
640dd0bcf5 | ||
|
|
175fbe9e52 | ||
|
|
fde182860f | ||
|
|
3fc0eb473e | ||
|
|
f9eb279790 | ||
|
|
69ca4f94b7 | ||
|
|
b322275555 | ||
|
|
b8d3a3b132 | ||
|
|
03c5e6b091 | ||
|
|
f4cdc0873c | ||
|
|
f802d6cc2c | ||
|
|
29a511747d | ||
|
|
3afa8e234b | ||
|
|
efe0b28747 | ||
|
|
c7d9d1aedd | ||
|
|
6c6efb8d74 | ||
|
|
fc711c3f6d | ||
|
|
945f40e8ff | ||
|
|
4aeb14b3ba | ||
|
|
b9854b1d82 | ||
|
|
f3fcb0ca25 | ||
|
|
84f6959921 | ||
|
|
f2c9af4827 | ||
|
|
0de6eeb024 | ||
|
|
3aadba6aba | ||
|
|
8c991c7472 | ||
|
|
593e9e05cc | ||
|
|
e03f69d607 | ||
|
|
9ffa81a748 | ||
|
|
7ce65988b2 | ||
|
|
eadca0ce30 | ||
|
|
3e88f5909b | ||
|
|
d64be662fe | ||
|
|
3b3f6aa00f | ||
|
|
e2268eb854 | ||
|
|
8b9e8417af | ||
|
|
1426129516 | ||
|
|
a932bc6c86 | ||
|
|
2aa9308e47 | ||
|
|
23c572b374 | ||
|
|
8158bcae73 | ||
|
|
62724ffccf | ||
|
|
52105d2c9b | ||
|
|
9782763e82 | ||
|
|
2eb6044dfc | ||
|
|
dd47001112 | ||
|
|
02e7620924 | ||
|
|
6564896c45 | ||
|
|
b775c66123 | ||
|
|
e3295a9974 | ||
|
|
8554155932 | ||
|
|
d114d2ef89 | ||
|
|
30a3b019ff | ||
|
|
4807f13288 | ||
|
|
1d72cf2ed8 | ||
|
|
5b9fe567ec | ||
|
|
9558ecf848 | ||
|
|
d6802414fa | ||
|
|
a7d2edbfb9 | ||
|
|
5d44519d5b | ||
|
|
7914cfe741 | ||
|
|
c3326aed5e | ||
|
|
3cdf31772f | ||
|
|
088b9e37e5 | ||
|
|
98cfa7ef55 | ||
|
|
a79aa4f977 | ||
|
|
55d7ad61f1 | ||
|
|
2b839bce3d | ||
|
|
929ad64e2a | ||
|
|
5345f12b9d | ||
|
|
b1e79509ac | ||
|
|
bf40027865 | ||
|
|
0289686b53 | ||
|
|
eeb3c04215 | ||
|
|
9af63f81d3 | ||
|
|
4fd7c5c271 | ||
|
|
c0aef4ac41 | ||
|
|
9e11182d53 | ||
|
|
854e12bf62 | ||
|
|
858582b279 | ||
|
|
c06d1d81c4 | ||
|
|
a44cdec48e | ||
|
|
03e72bc0b0 | ||
|
|
197ad9fcc5 | ||
|
|
58feda2a09 | ||
|
|
c02c215c62 | ||
|
|
008aba983e | ||
|
|
0c66644118 | ||
|
|
fdc26c707e | ||
|
|
01b3be1502 | ||
|
|
9c292718aa | ||
|
|
7b53924423 | ||
|
|
d4984f2ca0 | ||
|
|
dd2d21f0f9 | ||
|
|
5fa72fe13a | ||
|
|
0b23212f49 | ||
|
|
5db025666c | ||
|
|
c558b29549 | ||
|
|
37f8ebb618 | ||
|
|
ab98869192 | ||
|
|
bedf076c56 | ||
|
|
7416b4a5f6 | ||
|
|
26210e2da4 | ||
|
|
b97af5d3da | ||
|
|
5c05eacc6d | ||
|
|
0fe1d0e15d | ||
|
|
3f38b2d0bd | ||
|
|
ed8eaed845 | ||
|
|
0a6d5eb0a7 | ||
|
|
ef6f7a2170 | ||
|
|
425b65a99f | ||
|
|
a222a7e47a | ||
|
|
c4dcf2d2c8 | ||
|
|
effe6ea423 | ||
|
|
be7591c70b | ||
|
|
be3fbc2d2b | ||
|
|
7af3e1ec44 | ||
|
|
79fe067a7e | ||
|
|
c40c92e0bc | ||
|
|
cb62d074db | ||
|
|
796db06bf8 | ||
|
|
177548d416 | ||
|
|
5ca811b47a | ||
|
|
7c32d1c4d6 | ||
|
|
2ce4e8da11 | ||
|
|
3f1af338cb | ||
|
|
786bd54096 | ||
|
|
16cdc3afd9 | ||
|
|
69d388deb0 | ||
|
|
0855321d41 | ||
|
|
db6a8b7f44 | ||
|
|
3443abbde5 | ||
|
|
e08c7a3ee0 | ||
|
|
93365662aa | ||
|
|
751423b897 | ||
|
|
219a56f260 | ||
|
|
cc3ee16e46 | ||
|
|
42372aa760 | ||
|
|
d29c8eb63f | ||
|
|
8852c0e0b8 | ||
|
|
21f7031e72 | ||
|
|
7dde5f518c | ||
|
|
b65d5f5dcd | ||
|
|
f88c50772f | ||
|
|
d064372d9d | ||
|
|
ca0a90eee3 | ||
|
|
c6780b8daf | ||
|
|
aca15c91d4 | ||
|
|
01166b25ae | ||
|
|
311cf0d31f | ||
|
|
9e9078f17f | ||
|
|
d00c6bcd25 | ||
|
|
a96e04043e | ||
|
|
424f045246 | ||
|
|
dfd4b135ef | ||
|
|
baceff1b55 | ||
|
|
de808d2d30 | ||
|
|
44338647fa | ||
|
|
3f41fda7a1 | ||
|
|
57bc596fe1 | ||
|
|
4090de57e0 | ||
|
|
1d78698703 |
No files matched your search
@@ -152,23 +152,23 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|
||||
|
||||
| Subsystem | Files |
|
||||
|---|---|
|
||||
| Game loop, tick, frame | `game.rs`: `fn run`, `impl ApplicationHandler for Game` (`window_event`, `about_to_wait`), `fn tick`, `fn tick_world`, `fn render`; L: `clock.rs`, `spans.rs`; `horizon-client-input/src/window.rs` |
|
||||
| Rendering | L: `gfx.rs` (`Renderer::new`, `Renderer::render`), `gfx/` (cull, occlusion, translucency, upload, timing, lod, weather, stars, end_sky), `*.wgsl`, `particles.rs`, `block_entity_render.rs`, `item_render.rs`, `fog.rs`, `clouds.rs`, `camera.rs`, `first_person.rs`; G: `render_tick.rs`, `item_draw.rs` |
|
||||
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `server_terrain.rs` (a server's kept chunks), `bluemap.rs` + `bluemap_detect.rs` (far terrain from its web map), `ticket_levels.rs`; G: `world_loading.rs`, `postprocess.rs`; game.rs `fn update_world` |
|
||||
| Game loop, tick, frame | `game.rs`: `fn run`, `fn tick`, `fn tick_world`, `fn render`; G: `window_handler.rs` (`impl ApplicationHandler for Game`: `window_event`, `about_to_wait`); L: `clock.rs`, `spans.rs`; `horizon-client-input/src/window.rs` |
|
||||
| Rendering | L: `gfx.rs` (`Renderer::new`, `Renderer::render`), `gfx/` (cull, occlusion, translucency, upload, timing, lod, weather, stars, end_sky), `*.wgsl`, `particles.rs`, `block_entity_render.rs`, `item_render.rs`, `fog.rs`, `clouds.rs`, `camera.rs`, `first_person.rs`; G: `render_tick.rs`, `item_draw.rs`, `screen_backgrounds.rs` (panorama, menu blur, border wall) |
|
||||
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `server_terrain.rs` (a server's kept chunks), `bluemap.rs` + `bluemap_detect.rs` (far terrain from its web map), `ticket_levels.rs`; G: `world_loading.rs`, `world_entry.rs`, `postprocess.rs`; game.rs `fn update_world` |
|
||||
| World generation | `crates/horizon-world/src/worldgen/` (`generator*.rs`, `feature_graph.rs`, `structure/`, `carver/`) |
|
||||
| Block behaviour | `crates/horizon-world/src/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `redstone.rs`, `place.rs`, `interact.rs`); game.rs `fn tick_blocks`, `fn process_level_events` |
|
||||
| Mobs | L: `mobs.rs` (types), `mobs/` (`ai.rs`, `goals.rs`, `brain.rs`, `*_brain.rs`, `nav.rs`, `path.rs`, `species.rs`, `spawning.rs`, `render.rs`, `anim.rs`); G: `creatures.rs` (ticking, spawning, mob events), one file per mob family (`dragon.rs`, `wither.rs`, `villagers.rs`, `piglins.rs`, `golems.rs`...), `natural_spawning.rs` |
|
||||
| Other entities | L: `entities.rs`, `projectiles.rs`, `arrows.rs`; G: `boats.rs`, `minecarts.rs`, `mounts.rs`, `armor_stands.rs`, `paintings.rs`, `frames.rs`, `fireworks.rs`, `tnt.rs`, `explosion.rs` |
|
||||
| Player actions | game.rs (`fn use_hand`, `fn use_item`, `fn attack`, `fn tick_mining`, `fn break_block`); G: `hand.rs`, `combat.rs`, `consume.rs`, `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`; crate `horizon-items`: `items.rs`, `item_data.rs`, `enchant.rs`, `blocks.rs` |
|
||||
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs` |
|
||||
| Player actions | game.rs (`fn use_hand`, `fn tick_mining`, `fn break_block`); G: `item_actions.rs` (`fn use_item`), `melee.rs` (`fn attack`), `hand.rs`, `combat.rs`, `consume.rs`, `using_item.rs` (an item in use, tick by tick), `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`; crate `horizon-items`: `items.rs`, `item_data.rs`, `enchant.rs`, `blocks.rs` |
|
||||
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs`, `inventory_input.rs` (keys and clicks over a container), `inventory_slots.rs` (slot layout and drawing) |
|
||||
| Commands | parse: crate `horizon-command` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`; `horizon-client-core`: `nbt_path.rs`, `scoreboard.rs`; G: `functions.rs`, `command_blocks.rs` |
|
||||
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs` |
|
||||
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `loading_screen.rs` (the loading screen, the pre-game `App`), `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs`, `creative_screen.rs` |
|
||||
| Multiplayer client | L: `net.rs` (network thread); G: `remote.rs` (`fn handle_packet`, `fn tick_remote`), `remote_*.rs` (mob data, objects, projectiles, vehicles, stations) |
|
||||
| LAN host / server side | L: `lan/` (`conn.rs` handshake + login threads, `hub.rs` + `hub/` play state, `chunks.rs`, `entity.rs`, `tracking.rs`, `guest.rs`, `playerdata.rs`); G: `lan.rs` (`LanHost`), `lan_actions.rs` (`fn as_guest`, `fn tick_guests`), `lan_*.rs` per feature, `lan_entities.rs` (tracked entities) |
|
||||
| Integrated server | G: `integrated.rs` (the server thread), `levels.rs` (one level per dimension, `fn tick_server`), `server_travel.rs` |
|
||||
| Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server`, `horizon-anticheat` (L: `lan/anticheat.rs`) |
|
||||
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
|
||||
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs` |
|
||||
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities), `world_session.rs` (opening and closing a world); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
|
||||
| Input | game.rs `fn window_event`; G: `play_keys.rs` (`fn key_pressed`), `play_input.rs` (`fn play_mouse`, toggled keys, the hotbar wheel); crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs`, `pointer_capture.rs` (mouse look, pointer grab) |
|
||||
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
|
||||
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `mobs/cem.rs` + `mobs/cem_anim.rs` (OptiFine/EMF entity models and their animations); `horizon-client-core/src/lang.rs`; crates `horizon-assets`, `horizon-optifine`; G: `packs.rs` |
|
||||
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
|
||||
@@ -191,7 +191,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|
||||
`levels.rs` swaps whole dimensions in the same way. Changes reach clients through the block log, level
|
||||
events and the entity tracker (`lan/tracking.rs`, `game/lan_entities.rs`).
|
||||
- Shared read-only data: `Resources` (`res`: blocks, items, recipes, mob types, biomes) and `PackResources`
|
||||
(`pack`), both in game.rs.
|
||||
(`pack`), both in game.rs, their methods in `game/resource_tables.rs`.
|
||||
|
||||
## Single player runs on a server thread
|
||||
|
||||
|
||||
@@ -61,6 +61,10 @@ another project's source may also be read and used, **only when that project's l
|
||||
"source available" licenses, all rights reserved (OptiFine), and any project with no license or an unclear one.
|
||||
Don't read, copy, translate or paraphrase their source, and don't send code an AI tool wrote from it. Being able
|
||||
to download a mod or a pack does not give you the right to read its files.
|
||||
- **Java's own library is GPL too.** OpenJDK's source (`java.lang.Math`, `StrictMath`, `FdLibm`, `HashMap`, the
|
||||
sorts, `Double.toString`, HotSpot's intrinsics...) is GPL-2.0, so it is off limits like the rest. Reproduce Java's
|
||||
behaviour from its API documentation (the Javadoc) and from what a JVM returns, or from a permissive original such
|
||||
as Sun's fdlibm C sources (see [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)); see #6741.
|
||||
- **Check the license yourself.** Read the LICENSE file at the commit you use and the header of each file you take:
|
||||
some repositories mix licenses, and a file under another license is off limits. A license that changed: the one at
|
||||
your commit counts. In doubt, don't: ask in #contributors first.
|
||||
|
||||
+55
-2
@@ -1,5 +1,36 @@
|
||||
# Third-party notices
|
||||
|
||||
## Code removed for its license
|
||||
|
||||
Horizon once shipped three pieces of code under MIT that came from projects whose licenses don't allow that. All
|
||||
three are gone from main. They are listed here for anyone who holds an older commit, tag or release: in those
|
||||
versions, that code is not covered by Horizon's MIT license. It stays under its authors' copyright and its original
|
||||
license.
|
||||
|
||||
- **Shader packs written from Iris's source.** `crates/horizon-shaderpack` and the render stages that used it were
|
||||
written from the source of [Iris](https://github.com/IrisShaders/Iris) (LGPL-3.0, copyright the Iris contributors).
|
||||
They were added from 15 October 2026 (`b22583d057`), shipped in the game releases `v2026.10.16.02` to `v0.2.46`,
|
||||
and were removed on 24 October 2026 ([#1561](https://github.com/horizon-rust/horizon/pull/1561)) after a developer
|
||||
of Iris showed us. Shader packs came back in `crates/horizon-shaders`, first written from a description of the
|
||||
pack format.
|
||||
- **Math functions translated from OpenJDK.** In `crates/horizon-core/src/jmath.rs`, `fdlibm::{log, log10, cbrt,
|
||||
hypot}` were translated from OpenJDK's `java/lang/FdLibm.java` and `round_f32` from its `java/lang/Math.java`
|
||||
(GPL-2.0 with the Classpath Exception), and `aarch64::{sin, cos}` from HotSpot's `macroAssembler_aarch64_trig.cpp`
|
||||
(GPL-2.0); copyright Oracle and/or its affiliates, and Cavium for that file. The `fdlibm` and `aarch64` functions
|
||||
were added on 13 October 2026 (`558d140fca`, first released in `v2026.10.13`). `round_f32` was added on 14 October
|
||||
2026 as `java_round` in `crates/horizon-client/src/mipmap.rs` (`95db0fce9d`) and moved into `jmath.rs` on 3
|
||||
November ([#7770](https://github.com/horizon-rust/horizon/pull/7770)). All of them were replaced in
|
||||
[#11944](https://github.com/horizon-rust/horizon/pull/11944) by code written without OpenJDK's sources, from Sun's
|
||||
fdlibm C sources, the Java SE API documentation and a description of the behaviour, and checked against a JVM
|
||||
([#6741](https://github.com/horizon-rust/horizon/issues/6741)).
|
||||
- **Java's `HashMap` translated from OpenJDK.** `crates/horizon-world/src/java_hash_map.rs`, which reproduces the
|
||||
iteration order of `java.util.HashMap`, was translated from OpenJDK's `java/util/HashMap.java` (GPL-2.0 with the
|
||||
Classpath Exception; copyright Oracle and/or its affiliates). It was added on 7 October 2026 (`118e93ea51` and
|
||||
`194db0dbb4`, first released in `v2026.10.12`). It was replaced in
|
||||
[#12043](https://github.com/horizon-rust/horizon/pull/12043) by code written without OpenJDK's sources, from a
|
||||
description of the behaviour, the Java SE API documentation and the textbook red-black tree, and checked against a
|
||||
JVM ([#6741](https://github.com/horizon-rust/horizon/issues/6741)).
|
||||
|
||||
## Catnip / Ponder
|
||||
|
||||
`crates/horizon-client-render/src/fluid_render.rs` translates the still-fluid face tiling and
|
||||
@@ -15,8 +46,11 @@ original classes, game shaders, textures or prepared resources are distributed h
|
||||
`third_party/wgpu-hal` is [wgpu-hal](https://github.com/gfx-rs/wgpu) 30.0.1 by the gfx-rs
|
||||
developers, with upstream fixes applied
|
||||
([gfx-rs/wgpu#10124](https://github.com/gfx-rs/wgpu/pull/10124),
|
||||
[gfx-rs/wgpu#10714](https://github.com/gfx-rs/wgpu/pull/10714)). It is licensed under MIT or
|
||||
Apache-2.0; both licenses are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
|
||||
[gfx-rs/wgpu#10714](https://github.com/gfx-rs/wgpu/pull/10714)). Its GL backend also reports
|
||||
the context's sampled textures and samplers per shader stage (usually 32 on desktop GL,
|
||||
where WebGL2's minimum 16 was reported before), so a pipeline layout the driver accepts is
|
||||
not refused. It is licensed under MIT or Apache-2.0; both licenses are in that folder
|
||||
(`LICENSE.MIT`, `LICENSE.APACHE`).
|
||||
|
||||
## naga
|
||||
|
||||
@@ -256,3 +290,22 @@ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
## fdlibm
|
||||
|
||||
`crates/horizon-core/src/jmath/fdlibm.rs` and the `fdlibm_asin`, `fdlibm_atan` and
|
||||
`fdlibm_atan2` functions in `crates/horizon-core/src/geometry/trig.rs` are Rust ports of
|
||||
[fdlibm](https://www.netlib.org/fdlibm/) 5.3, Sun's freely distributable math library, whose
|
||||
results the Java SE API specifies for `StrictMath`. `fdlibm.rs` ports `e_log.c`, `e_log10.c`,
|
||||
`s_cbrt.c` and `e_hypot.c`, and, with fused multiply-adds, `s_sin.c`, `s_cos.c`, `k_sin.c`,
|
||||
`k_cos.c` and `e_rem_pio2.c`; `trig.rs` ports `e_asin.c`, `s_atan.c` and `e_atan2.c`. This
|
||||
notice also heads both files:
|
||||
|
||||
```
|
||||
Copyright (C) 1993 by Sun Microsystems, Inc. All rights reserved.
|
||||
|
||||
Developed at SunSoft, a Sun Microsystems, Inc. business.
|
||||
Permission to use, copy, modify, and distribute this
|
||||
software is freely granted, provided that this notice
|
||||
is preserved.
|
||||
```
|
||||
@@ -917,6 +917,46 @@ impl NumericProperty {
|
||||
/// (`None` removes a prototype component).
|
||||
pub type Components = BTreeMap<String, Value>;
|
||||
|
||||
/// A stack's components read in place: its patch first, then its item's defaults. Reading one copies
|
||||
/// nothing, where [`ItemModels::components`] clones the defaults for a stack with a patch (an enchanted
|
||||
/// sword's are a few dozen JSON values, on every draw of it).
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct ComponentsView<'a> {
|
||||
prototype: &'a Components,
|
||||
patch: &'a BTreeMap<String, Option<Value>>,
|
||||
}
|
||||
|
||||
impl<'a> ComponentsView<'a> {
|
||||
pub fn get(&self, key: &str) -> Option<&'a Value> {
|
||||
match self.patch.get(key) {
|
||||
Some(patched) => patched.as_ref(),
|
||||
None => self.prototype.get(key),
|
||||
}
|
||||
}
|
||||
pub fn contains_key(&self, key: &str) -> bool {
|
||||
self.get(key).is_some()
|
||||
}
|
||||
/// Components with no patch: `components` as they are.
|
||||
pub fn of(components: &'a Components) -> Self {
|
||||
static NO_PATCH: BTreeMap<String, Option<Value>> = BTreeMap::new();
|
||||
Self {
|
||||
prototype: components,
|
||||
patch: &NO_PATCH,
|
||||
}
|
||||
}
|
||||
/// The merged map, for what needs one whole.
|
||||
pub fn to_map(&self) -> Components {
|
||||
let mut out = self.prototype.clone();
|
||||
for (key, value) in self.patch {
|
||||
match value {
|
||||
Some(v) => out.insert(key.clone(), v.clone()),
|
||||
None => out.remove(key),
|
||||
};
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// An item stack as the resolver reads it.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct StackView {
|
||||
@@ -1087,14 +1127,15 @@ impl ItemModels {
|
||||
if stack.patch.is_empty() {
|
||||
return std::borrow::Cow::Borrowed(prototype);
|
||||
}
|
||||
let mut out = prototype.clone();
|
||||
for (key, value) in &stack.patch {
|
||||
match value {
|
||||
Some(v) => out.insert(key.clone(), v.clone()),
|
||||
None => out.remove(key),
|
||||
};
|
||||
std::borrow::Cow::Owned(self.components_view(stack).to_map())
|
||||
}
|
||||
/// The stack's components, read in place (see [`ComponentsView`]).
|
||||
pub fn components_view<'a>(&'a self, stack: &'a StackView) -> ComponentsView<'a> {
|
||||
static NONE: Components = BTreeMap::new();
|
||||
ComponentsView {
|
||||
prototype: self.prototypes.get(&stack.item).unwrap_or(&NONE),
|
||||
patch: &stack.patch,
|
||||
}
|
||||
std::borrow::Cow::Owned(out)
|
||||
}
|
||||
/// `ItemModelResolver.updateForTopItem`.
|
||||
pub fn resolve(&self, stack: &StackView, ctx: &Context, env: &dyn ItemEnv) -> ResolvedItem {
|
||||
@@ -1112,7 +1153,7 @@ impl ItemModels {
|
||||
ctx: &Context,
|
||||
env: &dyn ItemEnv,
|
||||
) {
|
||||
let components = self.components(stack);
|
||||
let components = self.components_view(stack);
|
||||
let Some(Value::String(model_id)) = components.get("minecraft:item_model") else {
|
||||
return;
|
||||
};
|
||||
@@ -1122,7 +1163,7 @@ impl ItemModels {
|
||||
let mut state = Resolve {
|
||||
models: self,
|
||||
stack,
|
||||
components: &components,
|
||||
components,
|
||||
ctx,
|
||||
env,
|
||||
};
|
||||
@@ -1140,7 +1181,7 @@ impl ItemModels {
|
||||
self.properties(stack).2
|
||||
}
|
||||
fn properties(&self, stack: &StackView) -> (bool, bool, f32) {
|
||||
match self.components(stack).get("minecraft:item_model") {
|
||||
match self.components_view(stack).get("minecraft:item_model") {
|
||||
Some(Value::String(id)) => identifier(id)
|
||||
.ok()
|
||||
.and_then(|id| self.definitions.get(&id))
|
||||
@@ -1159,7 +1200,7 @@ impl ItemModels {
|
||||
struct Resolve<'a> {
|
||||
models: &'a ItemModels,
|
||||
stack: &'a StackView,
|
||||
components: &'a Components,
|
||||
components: ComponentsView<'a>,
|
||||
ctx: &'a Context<'a>,
|
||||
env: &'a dyn ItemEnv,
|
||||
}
|
||||
@@ -1455,9 +1496,9 @@ impl Resolve<'_> {
|
||||
fn numeric(&self, property: &NumericProperty) -> f32 {
|
||||
let owner = self.ctx.owner;
|
||||
match property {
|
||||
NumericProperty::BundleFullness => {
|
||||
self.env.bundle_fullness(self.stack, self.components)
|
||||
}
|
||||
NumericProperty::BundleFullness => self
|
||||
.env
|
||||
.bundle_fullness(self.stack, &self.components.to_map()),
|
||||
NumericProperty::Compass {
|
||||
wobble,
|
||||
target,
|
||||
|
||||
@@ -63,7 +63,7 @@ pub(super) fn decode(m: &Map<String, Value>) -> Result<Value> {
|
||||
/// with a matching level. An empty predicate list still needs the component.
|
||||
pub(super) fn matches(
|
||||
predicate: &Value,
|
||||
components: &Components,
|
||||
components: ComponentsView<'_>,
|
||||
env: &dyn ItemEnv,
|
||||
) -> Option<bool> {
|
||||
let (id, expected) = predicate.as_object()?.iter().next()?;
|
||||
@@ -143,13 +143,17 @@ mod tests {
|
||||
json!({"minecraft:density":0}),
|
||||
)]);
|
||||
assert_eq!(
|
||||
matches(&json!({"minecraft:enchantments":[{}]}), &components, &NoEnv),
|
||||
matches(
|
||||
&json!({"minecraft:enchantments":[{}]}),
|
||||
ComponentsView::of(&components),
|
||||
&NoEnv
|
||||
),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
matches(
|
||||
&json!({"minecraft:enchantments":[{"enchantments":"minecraft:density"}]}),
|
||||
&components,
|
||||
ComponentsView::of(&components),
|
||||
&NoEnv
|
||||
),
|
||||
Some(false)
|
||||
@@ -166,12 +170,21 @@ mod tests {
|
||||
"minecraft:enchantments".into(),
|
||||
json!({"minecraft:density":3}),
|
||||
)]);
|
||||
assert_eq!(matches(&predicate, &components, &Tags), Some(true));
|
||||
assert_eq!(matches(&predicate, &components, &NoEnv), Some(false));
|
||||
assert_eq!(
|
||||
matches(&predicate, ComponentsView::of(&components), &Tags),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
matches(&predicate, ComponentsView::of(&components), &NoEnv),
|
||||
Some(false)
|
||||
);
|
||||
components.insert(
|
||||
"minecraft:enchantments".into(),
|
||||
json!({"minecraft:density":2}),
|
||||
);
|
||||
assert_eq!(matches(&predicate, &components, &Tags), Some(false));
|
||||
assert_eq!(
|
||||
matches(&predicate, ComponentsView::of(&components), &Tags),
|
||||
Some(false)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn identifier_adds_default_namespace() {
|
||||
@@ -111,3 +112,49 @@ fn custom_condition_validates_its_name_and_both_branches() {
|
||||
assert!(matches!(node("example:charged", empty).unwrap(),
|
||||
ItemModelNode::Condition { property: ConditionProperty::Custom(name), .. } if name == "example:charged"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_patched_stack_reads_its_patch_over_its_items_defaults() {
|
||||
let mut models = ItemModels::default();
|
||||
models.prototypes.insert(
|
||||
"minecraft:diamond_sword".into(),
|
||||
Components::from_iter([
|
||||
(
|
||||
"minecraft:item_model".into(),
|
||||
json!("minecraft:diamond_sword"),
|
||||
),
|
||||
("minecraft:max_damage".into(), json!(1561)),
|
||||
("minecraft:rarity".into(), json!("common")),
|
||||
]),
|
||||
);
|
||||
let mut stack = StackView::new("minecraft:diamond_sword", 1);
|
||||
stack.patch.insert(
|
||||
"minecraft:enchantments".into(),
|
||||
Some(json!({"minecraft:sharpness": 5})),
|
||||
);
|
||||
stack
|
||||
.patch
|
||||
.insert("minecraft:rarity".into(), Some(json!("rare")));
|
||||
stack.patch.insert("minecraft:max_damage".into(), None);
|
||||
let view = models.components_view(&stack);
|
||||
assert_eq!(view.get("minecraft:rarity"), Some(&json!("rare")));
|
||||
assert_eq!(view.get("minecraft:max_damage"), None, "a removed default");
|
||||
assert_eq!(
|
||||
view.get("minecraft:item_model"),
|
||||
Some(&json!("minecraft:diamond_sword"))
|
||||
);
|
||||
assert!(view.contains_key("minecraft:enchantments"));
|
||||
assert!(!view.contains_key("minecraft:custom_name"));
|
||||
// Every key reads as the merged map has it.
|
||||
let merged = models.components(&stack);
|
||||
for key in [
|
||||
"minecraft:rarity",
|
||||
"minecraft:max_damage",
|
||||
"minecraft:item_model",
|
||||
"minecraft:enchantments",
|
||||
"minecraft:custom_name",
|
||||
] {
|
||||
assert_eq!(view.get(key), merged.get(key), "{key}");
|
||||
}
|
||||
assert_eq!(view.to_map(), *merged);
|
||||
}
|
||||
@@ -290,12 +290,12 @@ pub fn normalize_quaternion(q: [f32; 4]) -> [f32; 4] {
|
||||
[x * inv, y * inv, z * inv, w * inv]
|
||||
}
|
||||
/// JOML `Math.sin(float)` without fast math.
|
||||
fn joml_sin(rad: f32) -> f32 {
|
||||
pub fn joml_sin(rad: f32) -> f32 {
|
||||
f64::from(rad).sin() as f32
|
||||
}
|
||||
/// JOML `Math.cosFromSin(float, float)` without fast math: the magnitude from
|
||||
/// the sine, the sign from the angle's quadrant (exact zeros at right angles).
|
||||
fn cos_from_sin(sin: f32, angle: f32) -> f32 {
|
||||
pub fn cos_from_sin(sin: f32, angle: f32) -> f32 {
|
||||
let cos = (1.0 - sin * sin).sqrt();
|
||||
let tau = (std::f64::consts::PI * 2.0) as f32;
|
||||
let a = angle + std::f32::consts::FRAC_PI_2;
|
||||
|
||||
@@ -217,9 +217,10 @@ pub fn stack(tag: &Tag) -> Option<Tag> {
|
||||
Some(Tag::compound(out))
|
||||
}
|
||||
/// `ItemStackTemplate.CODEC`: an item id or `{id, count?, components?}`.
|
||||
/// Encoding uses the primary record form, including for a bare item id.
|
||||
fn template(tag: &Tag) -> Option<Tag> {
|
||||
match tag {
|
||||
Tag::String(_) => Some(tag.clone()),
|
||||
Tag::String(_) => Some(Tag::compound([("id", tag.clone())])),
|
||||
Tag::Compound(_) => {
|
||||
let mut out = vec![("id", string(&text(tag.get("id"))?))];
|
||||
if let Some(count) = int(tag.get("count")).filter(|c| *c != 1) {
|
||||
@@ -1011,3 +1012,69 @@ mod tests {
|
||||
assert_eq!(items[1].get("count"), Some(&Tag::Int(5)));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pot_template_tests {
|
||||
use super::*;
|
||||
use horizon_world::nbt::parse_snbt;
|
||||
|
||||
#[test]
|
||||
fn decorated_pot_string_template_load_save_uses_item_compound() {
|
||||
let parse = |s: &str| parse_snbt(s).expect("valid input NBT").0;
|
||||
let input = |front: &str| {
|
||||
let mut tag = parse(
|
||||
r#"{id:"minecraft:decorated_pot",x:4,y:200,z:4,sherds:{back:{id:"minecraft:brick"},right:{id:"minecraft:archer_pottery_sherd"}},item:{id:"minecraft:wheat",count:3,components:{"minecraft:custom_name":"H290 item"}}}"#,
|
||||
);
|
||||
let Tag::Compound(root) = &mut tag else {
|
||||
unreachable!()
|
||||
};
|
||||
let Some(Tag::Compound(sides)) = root.get_mut(&"sherds".into()) else {
|
||||
unreachable!()
|
||||
};
|
||||
sides.insert("front".into(), parse(front));
|
||||
tag
|
||||
};
|
||||
let other_fields = |loaded: &Tag, saved: &Tag| {
|
||||
assert_eq!(saved.get("id"), loaded.get("id"));
|
||||
for key in ["x", "y", "z", "item"] {
|
||||
assert_eq!(saved.get(key), loaded.get(key), "{key}");
|
||||
}
|
||||
let loaded_sides = loaded.get("sherds").unwrap();
|
||||
let saved_sides = saved.get("sherds").unwrap();
|
||||
for side in ["back", "right"] {
|
||||
assert_eq!(saved_sides.get(side), loaded_sides.get(side), "{side}");
|
||||
}
|
||||
assert_eq!(saved_sides.get("left"), None);
|
||||
};
|
||||
let plain = r#"{id:"minecraft:angler_pottery_sherd"}"#;
|
||||
let defaults = r#"{id:"minecraft:angler_pottery_sherd",count:1,components:{}}"#;
|
||||
let rich = r#"{id:"minecraft:angler_pottery_sherd",count:3,components:{"minecraft:custom_data":{h290:1},"minecraft:custom_name":"H290 sherd"}}"#;
|
||||
// Actual canonical load/save entry, not the private template helper.
|
||||
for (front, expected) in [(plain, plain), (defaults, plain), (rich, rich)] {
|
||||
let loaded = input(front);
|
||||
let original = loaded.clone();
|
||||
let saved = canonical(&loaded).expect("actual decorated-pot codec");
|
||||
other_fields(&loaded, &saved);
|
||||
assert_eq!(
|
||||
saved.get("sherds").unwrap().get("front"),
|
||||
Some(&parse(expected))
|
||||
);
|
||||
assert_eq!(canonical(&saved).as_ref(), Some(&saved));
|
||||
assert_eq!(loaded, original, "read-only canonicalization");
|
||||
}
|
||||
println!(
|
||||
"H290 actual canonical compound/default/count/components, other sides/item/name and idempotency controls admitted"
|
||||
);
|
||||
let loaded = input(r#""minecraft:angler_pottery_sherd""#);
|
||||
let original = loaded.clone();
|
||||
let saved = canonical(&loaded).expect("actual decorated-pot codec");
|
||||
other_fields(&loaded, &saved);
|
||||
assert_eq!(loaded, original, "read-only canonicalization");
|
||||
assert_eq!(
|
||||
saved.get("sherds").unwrap().get("front"),
|
||||
Some(&parse(plain)),
|
||||
"string template must save the primary item compound",
|
||||
);
|
||||
assert_eq!(canonical(&saved).as_ref(), Some(&saved));
|
||||
}
|
||||
}
|
||||
@@ -245,6 +245,7 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
settings.fxaa,
|
||||
settings.taa,
|
||||
settings.upscale,
|
||||
0.85,
|
||||
);
|
||||
let mut notes = Vec::new();
|
||||
let mut note = |what: &str, result: horizon_client_render::Result<()>| {
|
||||
|
||||
@@ -1071,7 +1071,7 @@ impl Audio {
|
||||
listener: [0.0; 3],
|
||||
listener_yaw: 0.0,
|
||||
master: 1.0,
|
||||
random: LegacyRandom::new(crate::sound_engine::unique_seed()),
|
||||
random: crate::random_source::create(),
|
||||
source_override: None,
|
||||
capture: None,
|
||||
heard: Default::default(),
|
||||
@@ -1232,7 +1232,7 @@ impl Audio {
|
||||
listener: [0.0; 3],
|
||||
listener_yaw: 0.0,
|
||||
master: self.master,
|
||||
random: LegacyRandom::new(crate::sound_engine::unique_seed()),
|
||||
random: crate::random_source::create(),
|
||||
source_override: None,
|
||||
capture: None,
|
||||
heard: Default::default(),
|
||||
@@ -1482,7 +1482,7 @@ impl Audio {
|
||||
"minecraft:ui.button.click",
|
||||
1.0,
|
||||
0.25,
|
||||
SoundEngine::unseeded(),
|
||||
crate::random_source::create(),
|
||||
),
|
||||
false,
|
||||
);
|
||||
@@ -1624,7 +1624,7 @@ impl Audio {
|
||||
f64::from(pos[2]) + 0.5,
|
||||
];
|
||||
let event = crate::sound_events::normalize(event);
|
||||
let instance = SoundInstance::for_jukebox_song(&event, at, SoundEngine::unseeded());
|
||||
let instance = SoundInstance::for_jukebox_song(&event, at, crate::random_source::create());
|
||||
self.play_instance(instance, true).0
|
||||
}
|
||||
/// Stop and drop a held instance.
|
||||
|
||||
@@ -77,6 +77,9 @@ pub struct BlockEntities {
|
||||
/// The fluid tanks of mod blocks' block entities, by block state id;
|
||||
/// built with `states`, like the containers.
|
||||
mod_fluids: FxHashMap<u32, Arc<ModFluidTanks>>,
|
||||
/// State-aware native factory ownership. Shared across level snapshots;
|
||||
/// an empty slice means the declarations have not yet been compiled.
|
||||
stateful_entities: Arc<[bool]>,
|
||||
/// The energy buffers of mod blocks' block entities, by block state id;
|
||||
/// built with `states`, like the containers.
|
||||
mod_energy: FxHashMap<u32, Arc<ModEnergyBuffer>>,
|
||||
@@ -209,6 +212,21 @@ impl BlockEntities {
|
||||
pub fn mod_fluids(&self, state: u32) -> Option<&Arc<ModFluidTanks>> {
|
||||
self.mod_fluids.get(&state)
|
||||
}
|
||||
pub fn set_stateful_entities(&mut self, states: Vec<bool>) {
|
||||
self.stateful_entities = states.into();
|
||||
}
|
||||
pub fn has_stateful_entity_table(&self) -> bool {
|
||||
!self.stateful_entities.is_empty()
|
||||
}
|
||||
pub fn stateful_entities(&self) -> Arc<[bool]> {
|
||||
self.stateful_entities.clone()
|
||||
}
|
||||
pub fn stateful_entity(&self, state: u32) -> bool {
|
||||
self.stateful_entities
|
||||
.get(state as usize)
|
||||
.copied()
|
||||
.unwrap_or(false)
|
||||
}
|
||||
pub fn set_mod_energy(&mut self, energy: FxHashMap<u32, Arc<ModEnergyBuffer>>) {
|
||||
self.mod_energy = energy;
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
//! decorated pots, conduits, enchanting-table and lectern books and copper
|
||||
//! golem statues, End portals and End gateways. Models are vanilla's baked `LayerDefinitions` layers
|
||||
//! (`entity-models.json`); transforms, animation and textures follow each
|
||||
//! vanilla renderer. Faces are lit like the `entity` shader: the lightmap at
|
||||
//! the block plus `minecraft_mix_light` with the level's two diffuse lights.
|
||||
//! vanilla renderer. Faces carry their tint and world normal: the entity
|
||||
//! shader (and a shader pack) applies `minecraft_mix_light` from the normal, as
|
||||
//! vanilla's does.
|
||||
use crate::{
|
||||
Result,
|
||||
bidi::visual,
|
||||
@@ -128,6 +129,14 @@ fn pack_color(rgb: u32, alpha: u32, brightness: f32) -> u32 {
|
||||
let c = |shift: u32| (((rgb >> shift) & 255) as f32 * brightness) as u32;
|
||||
alpha << 24 | c(0) << 16 | c(8) << 8 | c(16)
|
||||
}
|
||||
/// A world normal as `Vertex::normal` holds it: RGBA8_SNORM, `w` positive so
|
||||
/// the entity shader and a shader pack both shade by it. Block-entity models
|
||||
/// carry no normals of their own (vanilla computes each face's from its
|
||||
/// polygon, then `minecraft_mix_light` uses it in the shader).
|
||||
fn pack_normal(normal: Vec3) -> [i8; 4] {
|
||||
let c = |v: f32| (v.clamp(-1.0, 1.0) * 127.0).round() as i8;
|
||||
[c(normal.x), c(normal.y), c(normal.z), 127]
|
||||
}
|
||||
|
||||
/// Everything a draw call needs besides the geometry.
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -165,6 +174,7 @@ pub fn draw_part(
|
||||
light,
|
||||
lights,
|
||||
glow,
|
||||
..
|
||||
} = paint;
|
||||
for poly in &part.polygons {
|
||||
let pos = poly.map(|v| m.transform_point3(Vec3::new(v[0], v[1], v[2]) / 16.0));
|
||||
@@ -177,11 +187,21 @@ pub fn draw_part(
|
||||
} else {
|
||||
normal
|
||||
};
|
||||
let color = pack_color(tint, 255, diffuse(normal, lights));
|
||||
// The colour is the tint alone when the caller shades by the level's
|
||||
// lights: the entity shader applies `minecraft_mix_light` from the
|
||||
// normal, as vanilla does, so a shader pack receives the unshaded
|
||||
// colour Iris gives it (a baked shade would be applied a second time
|
||||
// by the pack's lighting). Other lights (an item preview's
|
||||
// `Lighting.Entry`) are not the shader's to know, so those stay baked
|
||||
// into the colour, as before.
|
||||
let baked = !(lights == diffuse_lights(false) || lights == diffuse_lights(true));
|
||||
let brightness = if baked { diffuse(normal, lights) } else { 1.0 };
|
||||
let color = pack_color(tint, 255, brightness);
|
||||
let normal = if baked { [0; 4] } else { pack_normal(normal) };
|
||||
for (r, light) in std::iter::once((r, light)).chain(glow.map(|g| (g, FULL_BRIGHT))) {
|
||||
for (v, position) in poly.iter().zip(pos) {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
normal,
|
||||
pos: position.to_array(),
|
||||
uv: [r.u0 + (r.u1 - r.u0) * v[3], r.v0 + (r.v1 - r.v0) * v[4]],
|
||||
layer: 0,
|
||||
@@ -653,15 +673,16 @@ fn sign_text_calls(
|
||||
let line_height = data.line_height();
|
||||
let midpoint = (4.0 * line_height / 2.0).floor();
|
||||
let dark = 0xFF00_0000 | text.dark_color();
|
||||
let (color, outline, light) = if text.glowing {
|
||||
let (color, outline, light, tier) = if text.glowing {
|
||||
let c = text.text_color();
|
||||
(
|
||||
0xFF00_0000 | c,
|
||||
if c == 0 || near { dark } else { 0 },
|
||||
240 | 240 << 8,
|
||||
crate::mesher::LOW,
|
||||
)
|
||||
} else {
|
||||
(dark, 0, vertex_light(packed))
|
||||
(dark, 0, vertex_light(packed), 0)
|
||||
};
|
||||
let advance = |c: char| (scene.glyph)(c).advance;
|
||||
let mut calls: Vec<TextCall> = Vec::with_capacity(text.lines.len());
|
||||
@@ -693,6 +714,7 @@ fn sign_text_calls(
|
||||
color: run,
|
||||
outline,
|
||||
light,
|
||||
tier,
|
||||
}),
|
||||
}
|
||||
pen += span.text.chars().map(advance).sum::<f32>();
|
||||
@@ -707,6 +729,7 @@ fn sign_text_calls(
|
||||
color,
|
||||
outline,
|
||||
light,
|
||||
tier,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -761,7 +784,7 @@ pub fn text_vertices(glyph: &dyn Fn(char) -> Glyph, call: &TextCall, out: &mut V
|
||||
.transform_point3(Vec3::new(cx, cy, z))
|
||||
.to_array(),
|
||||
uv,
|
||||
layer: 0,
|
||||
layer: u32::from(call.tier) << 16,
|
||||
color: c,
|
||||
light: call.light,
|
||||
|
||||
@@ -830,6 +853,9 @@ pub struct TextCall {
|
||||
pub outline: u32,
|
||||
/// Horizon packed light (sky*16 | block*16 << 8).
|
||||
pub light: u32,
|
||||
/// HDR emission tier of its glyphs (`mesher::hdr_tier`), 0 for none: glowing sign text
|
||||
/// shines in HDR output and in shader packs (`horizonTier`), as full-bright text does in SDR.
|
||||
pub tier: u8,
|
||||
}
|
||||
|
||||
/// Inputs from the game for one frame of block-entity rendering.
|
||||
@@ -3774,6 +3800,105 @@ mod tests {
|
||||
// Straight down: ambient only.
|
||||
assert!((diffuse(-Vec3::Y, lights) - 0.4).abs() < 1e-6);
|
||||
}
|
||||
/// A face's colour carries its tint alone and its normal carries the
|
||||
/// shading: the entity shader applies `minecraft_mix_light` from it, as a
|
||||
/// shader pack's lighting does, so a baked shade would be applied twice.
|
||||
#[test]
|
||||
fn a_face_keeps_its_tint_and_normal_for_the_shader_to_shade() {
|
||||
let quad = |y: f32, z0: f32, z1: f32| {
|
||||
[
|
||||
[0.0, y, z0, 0.0, 0.0],
|
||||
[0.0, y, z1, 0.0, 1.0],
|
||||
[16.0, y, z1, 1.0, 1.0],
|
||||
[16.0, y, z0, 1.0, 0.0],
|
||||
]
|
||||
};
|
||||
// Opposite windings: the first faces up, the second down.
|
||||
let part = ModelPart {
|
||||
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
|
||||
polygons: vec![quad(0.0, 0.0, 16.0), quad(16.0, 16.0, 0.0)],
|
||||
children: vec![],
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
draw_part(
|
||||
&part,
|
||||
"root",
|
||||
Mat4::IDENTITY,
|
||||
&|_, _| {},
|
||||
Paint {
|
||||
sprite: Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 16,
|
||||
h: 16,
|
||||
},
|
||||
tint: 0x123456,
|
||||
light: 0,
|
||||
lights: diffuse_lights(false),
|
||||
glow: None,
|
||||
},
|
||||
&mut out,
|
||||
);
|
||||
assert_eq!(out.len(), 8);
|
||||
// The same tint on both faces: no directional shade baked in (`pack_color`
|
||||
// stores the tint in the vertex colour's BGR byte order).
|
||||
let tint = pack_color(0x12_3456, 255, 1.0);
|
||||
assert!(out.iter().all(|v| v.color == tint));
|
||||
let (up, down) = (&out[0], &out[4]);
|
||||
assert_eq!(up.normal, [0, 127, 0, 127]);
|
||||
assert_eq!(down.normal, [0, -127, 0, 127]);
|
||||
// The shader still gets the difference the old baked colours held.
|
||||
let lights = diffuse_lights(false);
|
||||
let up_normal = Vec3::new(0.0, f32::from(up.normal[1]) / 127.0, 0.0);
|
||||
let down_normal = Vec3::new(0.0, f32::from(down.normal[1]) / 127.0, 0.0);
|
||||
assert!(diffuse(up_normal, lights) > diffuse(down_normal, lights));
|
||||
}
|
||||
/// Lights the entity shader does not know (an item preview's
|
||||
/// `Lighting.Entry`) stay baked into the colour, and `normal.w` 0 tells
|
||||
/// the shader to keep it: `game/item_draw` passes those for block-entity
|
||||
/// items in inventories and hands.
|
||||
#[test]
|
||||
fn lights_other_than_the_levels_stay_baked() {
|
||||
let part = ModelPart {
|
||||
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
|
||||
polygons: vec![[
|
||||
[0.0, 0.0, 0.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 16.0, 0.0, 1.0],
|
||||
[16.0, 0.0, 16.0, 1.0, 1.0],
|
||||
[16.0, 0.0, 0.0, 1.0, 0.0],
|
||||
]],
|
||||
children: vec![],
|
||||
};
|
||||
let lights = (Vec3::X, Vec3::Z);
|
||||
let mut out = Vec::new();
|
||||
draw_part(
|
||||
&part,
|
||||
"root",
|
||||
Mat4::IDENTITY,
|
||||
&|_, _| {},
|
||||
Paint {
|
||||
sprite: Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 16,
|
||||
h: 16,
|
||||
},
|
||||
tint: 0x12_3456,
|
||||
light: 0,
|
||||
lights,
|
||||
glow: None,
|
||||
},
|
||||
&mut out,
|
||||
);
|
||||
let baked = pack_color(0x12_3456, 255, diffuse(Vec3::Y, lights));
|
||||
assert!(out.iter().all(|v| v.color == baked));
|
||||
assert!(out.iter().all(|v| v.normal == [0; 4]));
|
||||
assert_ne!(baked, pack_color(0x12_3456, 255, 1.0));
|
||||
}
|
||||
#[test]
|
||||
fn part_poses_compose_like_model_part() {
|
||||
let part = ModelPart {
|
||||
@@ -3917,6 +4042,7 @@ mod tests {
|
||||
color: 0xFFFF_FF00,
|
||||
outline: 0xFF33_3300,
|
||||
light: 240 | 240 << 8,
|
||||
tier: 0,
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
draw_text(&scene, &call, &mut out);
|
||||
@@ -3990,6 +4116,7 @@ mod tests {
|
||||
color: 0xFF00_0000,
|
||||
outline: 0,
|
||||
light: 240 | 240 << 8,
|
||||
tier: 0,
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
draw_text(&scene, &call, &mut out);
|
||||
|
||||
@@ -993,7 +993,9 @@ fn cached_closed_chests_match_the_direct_model_and_rebuild_when_opening() {
|
||||
scene.nether = true;
|
||||
let nether_closed = render_cached(&scene);
|
||||
assert_same_vertices(&nether_closed, &render_direct(&renderer, &scene, &blocks));
|
||||
assert_vertices_differ(&closed_again, &nether_closed);
|
||||
// The nether is the entity shader's flag now (`entity_light`'s `l1` and
|
||||
// `globals.time.y`), not a baked colour, so the cached geometry serves both.
|
||||
assert_same_vertices(&closed_again, &nether_closed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -620,6 +620,14 @@ impl Chunk {
|
||||
entities: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// The original `structures.starts`, including starts with unknown pieces.
|
||||
pub fn saved_structure_starts(&self) -> Option<&horizon_world::nbt::Compound> {
|
||||
let structures = self.saved.as_ref()?.root.get(&"structures".into())?;
|
||||
match structures.get("starts")? {
|
||||
horizon_world::nbt::Tag::Compound(starts) => Some(starts),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The minimum block Y of the column.
|
||||
#[inline]
|
||||
pub fn min_y(&self) -> i32 {
|
||||
|
||||
@@ -59,6 +59,9 @@ pub enum BlockEntity {
|
||||
CreakingHeart(Box<CreakingHeart>),
|
||||
/// `BannerBlockEntity`: the pattern layers.
|
||||
Banner(Banner),
|
||||
/// `CopperGolemStatueBlockEntity`: its own components, which hold the
|
||||
/// custom name of the golem it was (`createStatue`).
|
||||
CopperGolemStatue(Compound),
|
||||
/// `CrafterBlockEntity`.
|
||||
Crafter(Crafter),
|
||||
/// `SpawnerBlockEntity` (`crate::mobs::spawner`).
|
||||
|
||||
@@ -208,7 +208,7 @@ where
|
||||
Ok(resolve_world_movement(
|
||||
self,
|
||||
MovementRequest {
|
||||
entity: EntityIdentity(0),
|
||||
entity: context.entity().unwrap_or(EntityIdentity(0)),
|
||||
context,
|
||||
bounds,
|
||||
movement,
|
||||
@@ -279,7 +279,7 @@ impl SceneCollisions<'_, '_> {
|
||||
Ok(resolve_world_movement(
|
||||
self,
|
||||
MovementRequest {
|
||||
entity: EntityIdentity(0),
|
||||
entity: context.entity().unwrap_or(EntityIdentity(0)),
|
||||
context,
|
||||
bounds,
|
||||
movement,
|
||||
@@ -300,6 +300,7 @@ impl SceneCollisions<'_, '_> {
|
||||
CollisionContext::for_entity(
|
||||
CollisionEntity {
|
||||
identity: EntityIdentity(0),
|
||||
kind: horizon_world::collision_context::EntityContextKind::Living,
|
||||
y,
|
||||
descending: false,
|
||||
main_hand: Some(CollisionItem::EMPTY),
|
||||
@@ -335,6 +336,26 @@ where
|
||||
{
|
||||
no_collision_in(world, bounds, true)
|
||||
}
|
||||
/// `CollisionGetter.noCollision(entity, bounds)`: retain the real entity snapshot.
|
||||
pub fn no_collision_with_context<W, E>(
|
||||
world: &mut W,
|
||||
bounds: Aabb,
|
||||
context: CollisionContext<'_>,
|
||||
) -> bool
|
||||
where
|
||||
W: BlockCollisionWorld<Error = E> + EntityMovementWorld<Error = E>,
|
||||
{
|
||||
no_world_collision(
|
||||
world,
|
||||
CollisionCheckRequest {
|
||||
entity: context.entity().unwrap_or(EntityIdentity(0)),
|
||||
context,
|
||||
bounds,
|
||||
candidate_budget: 1_000_000,
|
||||
},
|
||||
)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
fn no_collision_in<W, E>(world: &mut W, bounds: Aabb, fluids: bool) -> bool
|
||||
where
|
||||
W: BlockCollisionWorld<Error = E> + EntityMovementWorld<Error = E>,
|
||||
|
||||
@@ -395,6 +395,9 @@ impl BlockEntity {
|
||||
if name == "minecraft:daylight_detector" {
|
||||
return Some(Self::DaylightDetector);
|
||||
}
|
||||
if name.ends_with("copper_golem_statue") {
|
||||
return Some(Self::CopperGolemStatue(Default::default()));
|
||||
}
|
||||
if name == "minecraft:beacon" {
|
||||
return Some(Self::Beacon(Box::new(Beacon {
|
||||
last_check_y: i32::MIN / 2,
|
||||
@@ -460,6 +463,7 @@ impl BlockEntity {
|
||||
Self::CommandBlock(_) => crate::command_block::Mode::of_block(name).is_some(),
|
||||
Self::Skull(_) => is_skull(name),
|
||||
Self::Banner(_) => name.ends_with("_banner"),
|
||||
Self::CopperGolemStatue(_) => name.ends_with("copper_golem_statue"),
|
||||
Self::Shelf(_) => name == "minecraft:shelf" || name.ends_with("_shelf"),
|
||||
Self::Mod(_) => !name.starts_with("minecraft:"),
|
||||
Self::Beehive(_) => matches!(name, "minecraft:beehive" | "minecraft:bee_nest"),
|
||||
@@ -510,6 +514,7 @@ impl BlockEntity {
|
||||
Self::Lectern(_) => "minecraft:lectern",
|
||||
Self::Sculk(s) => s.id(),
|
||||
Self::Banner(_) => "minecraft:banner",
|
||||
Self::CopperGolemStatue(_) => "minecraft:copper_golem_statue",
|
||||
Self::Crafter(_) => "minecraft:crafter",
|
||||
Self::CreakingHeart(_) => "minecraft:creaking_heart",
|
||||
Self::Spawner(_) => "minecraft:mob_spawner",
|
||||
@@ -558,7 +563,7 @@ impl BlockEntity {
|
||||
Self::Mod(m) => m.items.iter().flatten().cloned().collect(),
|
||||
Self::Lectern(l) => l.book.iter().cloned().collect(),
|
||||
Self::Sculk(_) => Vec::new(),
|
||||
Self::Banner(_) => Vec::new(),
|
||||
Self::Banner(_) | Self::CopperGolemStatue(_) => Vec::new(),
|
||||
Self::Crafter(c) => c.items.iter().flatten().cloned().collect(),
|
||||
Self::Campfire(c) => c.items.iter().flatten().cloned().collect(),
|
||||
Self::ChiseledBookshelf(b) => b.items.iter().flatten().cloned().collect(),
|
||||
@@ -575,6 +580,7 @@ impl BlockEntity {
|
||||
| Self::Lectern(_)
|
||||
| Self::Sculk(_)
|
||||
| Self::Banner(_)
|
||||
| Self::CopperGolemStatue(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::PotentSulfur(_)
|
||||
@@ -609,6 +615,7 @@ impl BlockEntity {
|
||||
| Self::Lectern(_)
|
||||
| Self::Sculk(_)
|
||||
| Self::Banner(_)
|
||||
| Self::CopperGolemStatue(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::PotentSulfur(_)
|
||||
|
||||
@@ -141,7 +141,8 @@ impl DataPack {
|
||||
pub fn discover(world: &Path) -> Vec<DataPack> {
|
||||
discover_folder(&world.join("datapacks"))
|
||||
}
|
||||
/// Packs in Create World's temporary pack folder, before a world exists.
|
||||
/// `FolderRepositorySource.loadPacks`: packs in Create World's temporary
|
||||
/// folder or a world's data-pack directory, with required folder metadata.
|
||||
pub fn discover_folder(dir: &Path) -> Vec<DataPack> {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return Vec::new();
|
||||
@@ -150,7 +151,7 @@ pub fn discover_folder(dir: &Path) -> Vec<DataPack> {
|
||||
for e in entries.flatten() {
|
||||
let name = e.file_name().to_string_lossy().into_owned();
|
||||
let path = e.path();
|
||||
if path.is_dir() && path.join("pack.mcmeta").exists() {
|
||||
if path.is_dir() && folder_metadata_present(&path) {
|
||||
packs.push(DataPack {
|
||||
id: format!("file/{name}"),
|
||||
source: Source::Dir(path),
|
||||
@@ -168,6 +169,22 @@ pub fn discover_folder(dir: &Path) -> Vec<DataPack> {
|
||||
packs.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
packs
|
||||
}
|
||||
/// `Pack.readPackMetadata`: even its fallback codec requires a pack section
|
||||
/// and description. Keep this folder gate separate from format compatibility
|
||||
/// and the existing interpretation of the description component.
|
||||
fn folder_metadata_present(path: &Path) -> bool {
|
||||
let Some(meta) = std::fs::read(path.join("pack.mcmeta"))
|
||||
.ok()
|
||||
.and_then(|bytes| crate::packs::parse_meta(&bytes))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
meta.as_object()
|
||||
.and_then(|root| root.get("pack"))
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.and_then(|pack| pack.get("description"))
|
||||
.is_some_and(|description| !description.is_null())
|
||||
}
|
||||
/// The resources root's built-in feature packs (`ServerPacksSource`
|
||||
/// scanning the game's `data/minecraft/datapacks`): every folder with a
|
||||
/// `pack.mcmeta`, a feature pack (`PackSource.FEATURE`). Off until a world
|
||||
@@ -608,7 +625,7 @@ pub struct DataStore {
|
||||
/// `item_modifier` files, for `/item modify`.
|
||||
pub item_modifiers: BTreeMap<String, serde_json::Value>,
|
||||
/// The content tables' raw files from the packs after the built-in one:
|
||||
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/`, `damage_type/`, `data_maps/` and `tags/…/` files, by
|
||||
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/`, `damage_type/`, `data_maps/`, `enchantment/` and `tags/…/` files, by
|
||||
/// `<kind-dir>/<ns>:<path>`, each id's files in pack order. The tables
|
||||
/// merge them over what they read from the resource root.
|
||||
pub files: BTreeMap<String, Vec<Vec<u8>>>,
|
||||
@@ -617,7 +634,7 @@ pub struct DataStore {
|
||||
}
|
||||
/// `<kind-dir>/<ns>:<path>` for a pack file the content tables read:
|
||||
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `trade_set/`,
|
||||
/// `villager_trade/`, `dialog/`, `damage_type/`, `data_maps/` or `tags/<sub>/`, where the id keeps the sub
|
||||
/// `villager_trade/`, `dialog/`, `damage_type/`, `data_maps/`, `enchantment/` or `tags/<sub>/`, where the id keeps the sub
|
||||
/// path so a nested tag kind stays addressable.
|
||||
fn content_id(file: &str) -> Option<String> {
|
||||
let rest = file.strip_prefix("data/")?;
|
||||
@@ -625,7 +642,7 @@ fn content_id(file: &str) -> Option<String> {
|
||||
let (kind, rest) = rest.split_once('/')?;
|
||||
let kind = match kind {
|
||||
"recipe" | "recipe_filter" | "loot_table" | "advancement" | "trade_set"
|
||||
| "villager_trade" | "dialog" | "damage_type" | "data_maps" => kind,
|
||||
| "villager_trade" | "dialog" | "damage_type" | "data_maps" | "enchantment" => kind,
|
||||
// A tag id is `{ns}:{path-under-tags}` (`tags/worldgen/biome/
|
||||
// test:sub/mytag`), the whole path after the namespace, so nested
|
||||
// registries keep their directory (`TagLoader`'s id shape).
|
||||
@@ -659,6 +676,7 @@ impl DataStore {
|
||||
"damage_type",
|
||||
"data_maps",
|
||||
"dialog",
|
||||
"enchantment",
|
||||
"function",
|
||||
"item_modifier",
|
||||
"loot_table",
|
||||
@@ -714,15 +732,55 @@ impl DataStore {
|
||||
}
|
||||
s
|
||||
}
|
||||
/// `FunctionManager.getTag`: the functions a tag expands to (nested tags
|
||||
/// included, each function once).
|
||||
/// `ServerFunctionLibrary.getTag`: only valid function tags expand
|
||||
/// (nested tags included, each function once).
|
||||
pub fn tag_functions(&self, id: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut seen_fns = std::collections::HashSet::new();
|
||||
let mut seen_tags = std::collections::HashSet::new();
|
||||
self.expand(id, &mut out, &mut seen_fns, &mut seen_tags);
|
||||
let invalid = self.invalid_function_tags(id);
|
||||
self.expand(id, &mut out, &mut seen_fns, &mut seen_tags, &invalid);
|
||||
out
|
||||
}
|
||||
/// `TagLoader.tryBuildTag`: missing required members invalidate their tag and
|
||||
/// required parents. Optional invalid children contribute nothing.
|
||||
/// Resolve against the current accepted function map, without caching a
|
||||
/// candidate store or changing the existing expansion's cycle handling.
|
||||
fn invalid_function_tags<'a>(&'a self, id: &'a str) -> std::collections::HashSet<&'a str> {
|
||||
let mut invalid = std::collections::HashSet::new();
|
||||
let mut parents: std::collections::HashMap<&str, Vec<&str>> =
|
||||
std::collections::HashMap::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut pending = vec![id];
|
||||
while let Some(tag) = pending.pop() {
|
||||
if !seen.insert(tag) {
|
||||
continue;
|
||||
}
|
||||
let Some(entries) = self.function_tags.get(tag) else {
|
||||
invalid.insert(tag);
|
||||
continue;
|
||||
};
|
||||
for (entry, required) in entries {
|
||||
if let Some(child) = entry.strip_prefix('#') {
|
||||
pending.push(child);
|
||||
if *required {
|
||||
parents.entry(child).or_default().push(tag);
|
||||
}
|
||||
} else if *required && !self.functions.contains_key(entry) {
|
||||
invalid.insert(tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
pending.extend(invalid.iter().copied());
|
||||
while let Some(child) = pending.pop() {
|
||||
for &parent in parents.get(child).into_iter().flatten() {
|
||||
if invalid.insert(parent) {
|
||||
pending.push(parent);
|
||||
}
|
||||
}
|
||||
}
|
||||
invalid
|
||||
}
|
||||
/// Depth-first walk keeping first-seen order. Each tag is expanded at most
|
||||
/// once, so reference cycles end and nesting depth is not capped.
|
||||
fn expand<'a>(
|
||||
@@ -731,18 +789,19 @@ impl DataStore {
|
||||
out: &mut Vec<String>,
|
||||
seen_fns: &mut std::collections::HashSet<&'a str>,
|
||||
seen_tags: &mut std::collections::HashSet<&'a str>,
|
||||
invalid: &std::collections::HashSet<&'a str>,
|
||||
) {
|
||||
if !seen_tags.insert(id) {
|
||||
if invalid.contains(id) || !seen_tags.insert(id) {
|
||||
return;
|
||||
}
|
||||
let Some(entries) = self.function_tags.get(id) else {
|
||||
return;
|
||||
};
|
||||
for (e, required) in entries {
|
||||
for (e, _) in entries {
|
||||
match e.strip_prefix('#') {
|
||||
Some(inner) => self.expand(inner, out, seen_fns, seen_tags),
|
||||
Some(inner) => self.expand(inner, out, seen_fns, seen_tags, invalid),
|
||||
None => {
|
||||
if (self.functions.contains_key(e) || *required) && seen_fns.insert(e) {
|
||||
if self.functions.contains_key(e) && seen_fns.insert(e) {
|
||||
out.push(e.clone());
|
||||
}
|
||||
}
|
||||
@@ -924,6 +983,97 @@ pub fn worldgen_overrides(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn folder_metadata_is_required_before_contents_are_discovered() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-folder-metadata-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
for (name, metadata) in [
|
||||
("broken", Some("{")),
|
||||
("root_empty", Some("{}")),
|
||||
("root_array", Some("[]")),
|
||||
("pack_empty", Some(r#"{"pack":{}}"#)),
|
||||
("pack_string", Some(r#"{"pack":"bad"}"#)),
|
||||
("missing", None),
|
||||
(
|
||||
"full",
|
||||
Some(
|
||||
r#"{"pack":{"description":"full","pack_format":121,"min_format":[121,0],"max_format":[121,0]}}"#,
|
||||
),
|
||||
),
|
||||
(
|
||||
"description",
|
||||
Some(r#"{"pack":{"description":"description only"}}"#),
|
||||
),
|
||||
("numeric", Some(r#"{"pack":{"description":42}}"#)),
|
||||
("null", Some(r#"{"pack":{"description":null}}"#)),
|
||||
("bom", Some("\u{feff}{\"pack\":{\"description\":\"BOM\"}}")),
|
||||
(
|
||||
"comment",
|
||||
Some("{\"pack\":{\"description\":\"comment\"}}\n// trailing"),
|
||||
),
|
||||
] {
|
||||
let dir = root.join(name);
|
||||
std::fs::create_dir_all(dir.join("data/h146/function")).unwrap();
|
||||
std::fs::write(
|
||||
dir.join(format!("data/h146/function/{name}.mcfunction")),
|
||||
"scoreboard players set admitted h146 1\n",
|
||||
)
|
||||
.unwrap();
|
||||
if let Some(metadata) = metadata {
|
||||
std::fs::write(dir.join("pack.mcmeta"), metadata).unwrap();
|
||||
}
|
||||
}
|
||||
let packs = discover_folder(&root);
|
||||
assert_eq!(
|
||||
packs.iter().map(|p| p.id.as_str()).collect::<Vec<_>>(),
|
||||
[
|
||||
"file/bom",
|
||||
"file/comment",
|
||||
"file/description",
|
||||
"file/full",
|
||||
"file/numeric"
|
||||
]
|
||||
);
|
||||
let store = DataStore::load(&packs.iter().collect::<Vec<_>>());
|
||||
assert_eq!(
|
||||
store
|
||||
.functions
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.collect::<Vec<_>>(),
|
||||
[
|
||||
"h146:bom",
|
||||
"h146:comment",
|
||||
"h146:description",
|
||||
"h146:full",
|
||||
"h146:numeric"
|
||||
]
|
||||
);
|
||||
let numeric = packs.iter().find(|p| p.id == "file/numeric").unwrap();
|
||||
let raw: serde_json::Value =
|
||||
serde_json::from_slice(&numeric.read("pack.mcmeta").unwrap()).unwrap();
|
||||
assert_eq!(raw["pack"]["description"], 42);
|
||||
// Builtin and feature construction still have their independent paths.
|
||||
let resources = root.join("resources");
|
||||
let feature = resources.join("data/minecraft/datapacks/feature");
|
||||
std::fs::create_dir_all(&feature).unwrap();
|
||||
std::fs::write(
|
||||
feature.join("pack.mcmeta"),
|
||||
r#"{"pack":{"description":"feature"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
feature_packs(&resources)
|
||||
.iter()
|
||||
.map(|p| p.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["feature"]
|
||||
);
|
||||
assert_eq!(DataPack::builtin(&resources).id, "vanilla");
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_map_files_are_content_files_keyed_by_map_namespace_and_registry() {
|
||||
assert_eq!(
|
||||
@@ -961,7 +1111,7 @@ mod tests {
|
||||
std::fs::write(path, t).unwrap();
|
||||
};
|
||||
for pack in ["a", "b", "off"] {
|
||||
write(pack, "pack.mcmeta", "{}");
|
||||
write(pack, "pack.mcmeta", r#"{"pack":{"description":"fixture"}}"#);
|
||||
write(pack, "data/minecraft/worldgen/noise/cave.json", pack);
|
||||
write(pack, "data/p/structure/room/a.nbt", pack);
|
||||
write(
|
||||
@@ -1132,6 +1282,20 @@ mod tests {
|
||||
#[test]
|
||||
fn tag_resolution_dedupes_fast_ends_cycles_and_nests_deeply() {
|
||||
let mut store = DataStore::default();
|
||||
// Every referenced function exists; missing required ones invalidate a tag.
|
||||
for id in (0..25_000)
|
||||
.map(|i| format!("t:f{i}"))
|
||||
.chain(["t:x", "t:y", "t:deep"].map(str::to_owned))
|
||||
{
|
||||
store.functions.insert(
|
||||
id.clone(),
|
||||
Function {
|
||||
id,
|
||||
lines: Vec::new(),
|
||||
parameters: Vec::new(),
|
||||
},
|
||||
);
|
||||
}
|
||||
let big: Vec<(String, bool)> = (0..50_000)
|
||||
.map(|i| (format!("t:f{}", i % 25_000), true))
|
||||
.collect();
|
||||
@@ -1216,13 +1380,13 @@ mod tests {
|
||||
write(
|
||||
"fns",
|
||||
"pack.mcmeta",
|
||||
r#"{"pack":{"min_format":121,"max_format":121}}"#,
|
||||
r#"{"pack":{"description":"fixture","min_format":121,"max_format":121}}"#,
|
||||
);
|
||||
write("fns", "data/x/function/f.mcfunction", "say hi");
|
||||
write(
|
||||
"off",
|
||||
"pack.mcmeta",
|
||||
r#"{"pack":{"min_format":121,"max_format":121}}"#,
|
||||
r#"{"pack":{"description":"fixture","min_format":121,"max_format":121}}"#,
|
||||
);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(dir.join("old.zip")).unwrap());
|
||||
|
||||
@@ -399,8 +399,11 @@ pub fn apply_weather(sky: &mut Sky, rain: f32, thunder: f32) {
|
||||
pub fn sky_darken(environment: &Environment, day_time: i64, rain: f32, thunder: f32) -> i32 {
|
||||
let mut sky = environment.sample(day_time, None, None);
|
||||
apply_weather(&mut sky, rain, thunder);
|
||||
// Level.updateSkyBrightness: darkening from the sky light level.
|
||||
(15.0 - 15.0 * sky.sky_light_level).round() as i32
|
||||
// Level.updateSkyBrightness: `(int)(15.0F - skyLightLevel)`, the float
|
||||
// truncated, not rounded (#11323): the darkening steps up only once the
|
||||
// light has fallen a whole level. `sky_light_level` is the attribute as a
|
||||
// fraction of 15, so the product is its raw value in `f32`.
|
||||
(15.0 - 15.0 * sky.sky_light_level) as i32
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::{
|
||||
environment::Sky,
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use std::path::Path;
|
||||
|
||||
/// `FogType` of the camera position.
|
||||
@@ -64,7 +65,7 @@ impl FogTables {
|
||||
}
|
||||
|
||||
/// Fog state that evolves over time.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FogState {
|
||||
/// `LocalPlayer.waterVisionTime` (0..600).
|
||||
pub water_vision_time: i32,
|
||||
@@ -72,24 +73,28 @@ pub struct FogState {
|
||||
pub rain_fog_multiplier: f32,
|
||||
/// `LightmapRenderStateExtractor.blockLightFlicker`.
|
||||
pub block_light_flicker: f32,
|
||||
flicker_seed: u64,
|
||||
/// `LightmapRenderStateExtractor.random`: `RandomSource.create()`.
|
||||
flicker_random: LegacyRandom,
|
||||
}
|
||||
impl Default for FogState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
water_vision_time: 0,
|
||||
rain_fog_multiplier: 0.0,
|
||||
block_light_flicker: 0.0,
|
||||
flicker_random: crate::random_source::create(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl FogState {
|
||||
fn next_float(&mut self) -> f32 {
|
||||
// splitmix64: the vanilla source is unseeded `RandomSource.create()`.
|
||||
self.flicker_seed = self.flicker_seed.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut z = self.flicker_seed;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
((z ^ (z >> 31)) >> 40) as f32 / (1u32 << 24) as f32
|
||||
}
|
||||
/// `LightmapRenderStateExtractor.tick`: the torch light flickers.
|
||||
pub fn tick_flicker(&mut self) {
|
||||
let r = &mut self.flicker_random;
|
||||
let (a, b, c, d) = (
|
||||
self.next_float(),
|
||||
self.next_float(),
|
||||
self.next_float(),
|
||||
self.next_float(),
|
||||
r.next_float(),
|
||||
r.next_float(),
|
||||
r.next_float(),
|
||||
r.next_float(),
|
||||
);
|
||||
self.block_light_flicker += (a - b) * c * d * 0.1;
|
||||
self.block_light_flicker *= 0.9;
|
||||
@@ -750,7 +755,7 @@ mod tests {
|
||||
rain_fog_multiplier: 0.1,
|
||||
..FogState::default()
|
||||
};
|
||||
let mut settled = start;
|
||||
let mut settled = start.clone();
|
||||
settle_rain_fog(&mut settled, 0.5, 15, true);
|
||||
assert_eq!(settled.rain_fog_multiplier, 0.5);
|
||||
let mut eased = start;
|
||||
|
||||
@@ -126,11 +126,16 @@ struct Cache {
|
||||
random: horizon_core::random::LegacyRandom,
|
||||
}
|
||||
pub struct Font {
|
||||
/// The atlas rects of the sheets under `textures/font`.
|
||||
/// The atlas rects of every texture, by resource name: a bitmap provider's
|
||||
/// `file` may name any texture, not only one under `textures/font`.
|
||||
sheets: HashMap<String, Rect>,
|
||||
missing: Glyph,
|
||||
white: Rect,
|
||||
root: PathBuf,
|
||||
/// The game's own resources, below a pack overlay: a font file the overlay
|
||||
/// lacks (a pack adds providers over vanilla's, or the overlay left a
|
||||
/// directory out) is read from here, as vanilla's resource stack does.
|
||||
fallback: Option<PathBuf>,
|
||||
/// Atlas size and the reserved dynamic region (y, height).
|
||||
atlas: (u32, u32),
|
||||
region: (u32, u32),
|
||||
@@ -199,6 +204,13 @@ fn parse_hex(text: &[u8], out: &mut HashMap<u32, HexGlyph>) {
|
||||
out.insert(codepoint, HexGlyph { rows, left, right });
|
||||
}
|
||||
}
|
||||
/// A resource file's bytes, from `root` or, when the overlay lacks it, from
|
||||
/// the game's own resources below (`defaults`).
|
||||
fn read_resource(root: &Path, defaults: Option<&Path>, path: &str) -> Option<Vec<u8>> {
|
||||
std::fs::read(root.join(path))
|
||||
.ok()
|
||||
.or_else(|| defaults.and_then(|d| std::fs::read(d.join(path)).ok()))
|
||||
}
|
||||
fn read_json(root: &Path, path: &str) -> Option<serde_json::Value> {
|
||||
serde_json::from_slice(&std::fs::read(root.join(path)).ok()?).ok()
|
||||
}
|
||||
@@ -281,6 +293,7 @@ fn hex_advance(h: &HexGlyph) -> f32 {
|
||||
/// pixels, copied into the atlas when first drawn.
|
||||
fn load_bitmap(
|
||||
root: &Path,
|
||||
defaults: Option<&Path>,
|
||||
p: &serde_json::Value,
|
||||
sheets: &HashMap<String, Rect>,
|
||||
pixels: &[u8],
|
||||
@@ -290,22 +303,36 @@ fn load_bitmap(
|
||||
let mut out = HashMap::new();
|
||||
let file = p["file"].as_str().unwrap_or("");
|
||||
let (ns, name) = file.split_once(':').unwrap_or(("minecraft", file));
|
||||
let in_atlas = (ns == "minecraft")
|
||||
.then(|| sheets.get(name.trim_end_matches(".png")).copied())
|
||||
.flatten();
|
||||
let name = name.trim_end_matches(".png");
|
||||
// The atlas names the default namespace's textures by their path and every
|
||||
// other namespace's as `namespace:path` (HudAtlas::build).
|
||||
let atlas_name = if ns == "minecraft" {
|
||||
name.to_owned()
|
||||
} else {
|
||||
format!("{ns}:{name}")
|
||||
};
|
||||
let in_atlas = sheets.get(&atlas_name).copied();
|
||||
// The sheet: its size, and its pixels when they are not in the atlas.
|
||||
let (sw, sh, sheet) = match in_atlas {
|
||||
Some(rect) => {
|
||||
let (w, h) = rect.texels(atlas);
|
||||
(w, h, None)
|
||||
}
|
||||
None => match crate::hud::decode(&root.join(resource_path(file, "textures", ""))) {
|
||||
Ok((w, h, rgba)) => (w, h, Some(Arc::new((w, rgba)))),
|
||||
Err(_) => {
|
||||
log::warn!("font sheet {file} could not be read");
|
||||
return out;
|
||||
None => {
|
||||
let path = resource_path(file, "textures", "");
|
||||
let sheet = crate::hud::decode(&root.join(&path)).or_else(|first| {
|
||||
defaults
|
||||
.and_then(|d| crate::hud::decode(&d.join(&path)).ok())
|
||||
.ok_or(first)
|
||||
});
|
||||
match sheet {
|
||||
Ok((w, h, rgba)) => (w, h, Some(Arc::new((w, rgba)))),
|
||||
Err(_) => {
|
||||
log::warn!("font sheet {file} could not be read");
|
||||
return out;
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
};
|
||||
let rows: Vec<Vec<char>> = p["chars"]
|
||||
.as_array()
|
||||
@@ -382,11 +409,13 @@ fn load_bitmap(
|
||||
}
|
||||
/// `TrueTypeGlyphProviderDefinition.load`: a TrueType file (not CFF) under
|
||||
/// `font/`, its Unicode characters but the `skip` ones.
|
||||
fn load_true_type(root: &Path, p: &serde_json::Value) -> Option<TrueType> {
|
||||
fn load_true_type(root: &Path, defaults: Option<&Path>, p: &serde_json::Value) -> Option<TrueType> {
|
||||
let file = p["file"].as_str()?;
|
||||
let data = std::fs::read(root.join(resource_path(file, "font", "")))
|
||||
.map_err(|e| log::warn!("font file {file}: {e}"))
|
||||
.ok()?;
|
||||
let path = resource_path(file, "font", "");
|
||||
let Some(data) = read_resource(root, defaults, &path) else {
|
||||
log::warn!("font file {file} could not be read");
|
||||
return None;
|
||||
};
|
||||
// FT_Get_Font_Format must say TrueType: CFF outlines ("OTTO") are refused.
|
||||
if data.starts_with(b"OTTO") {
|
||||
log::warn!("font file {file} is not in TTF format");
|
||||
@@ -486,13 +515,16 @@ impl TrueType {
|
||||
)
|
||||
}
|
||||
}
|
||||
fn load_hex(root: &Path, p: &serde_json::Value) -> HashMap<u32, HexGlyph> {
|
||||
fn load_hex(root: &Path, defaults: Option<&Path>, p: &serde_json::Value) -> HashMap<u32, HexGlyph> {
|
||||
let mut glyphs = HashMap::new();
|
||||
let Some(file) = p["hex_file"].as_str() else {
|
||||
return glyphs;
|
||||
};
|
||||
let path = root.join(resource_path(file, "", "").replace("//", "/"));
|
||||
let Ok(zip) = Zip::open(&path) else {
|
||||
let path = resource_path(file, "", "").replace("//", "/");
|
||||
let Some(zip) = Zip::open(&root.join(&path))
|
||||
.ok()
|
||||
.or_else(|| defaults.and_then(|d| Zip::open(&d.join(&path)).ok()))
|
||||
else {
|
||||
return glyphs;
|
||||
};
|
||||
let names: Vec<String> = zip
|
||||
@@ -603,7 +635,24 @@ impl Font {
|
||||
region: (u32, u32),
|
||||
white: Rect,
|
||||
) -> Self {
|
||||
Self::load_with(root, sprites, pixels, atlas, region, white, false)
|
||||
Self::load_with(root, None, sprites, pixels, atlas, region, white, false)
|
||||
}
|
||||
/// [`Font::load`] over a pack overlay: a sheet, TrueType file or Unihex
|
||||
/// file the overlay lacks is read from `defaults`, the game's own
|
||||
/// resources, as vanilla's resource stack does. (Definitions stay the
|
||||
/// overlay's: a pack's filter may hide one from a lower pack.)
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn load_over(
|
||||
root: &Path,
|
||||
defaults: &Path,
|
||||
sprites: &HashMap<String, Rect>,
|
||||
pixels: &[u8],
|
||||
atlas: (u32, u32),
|
||||
region: (u32, u32),
|
||||
white: Rect,
|
||||
) -> Self {
|
||||
let fallback = (root != defaults).then_some(defaults);
|
||||
Self::load_with(root, fallback, sprites, pixels, atlas, region, white, false)
|
||||
}
|
||||
/// A server measures text but never draws it. Decode bitmap sheets only
|
||||
/// to obtain advances, then release their pixels; never rasterize glyphs.
|
||||
@@ -616,10 +665,21 @@ impl Font {
|
||||
w: 1,
|
||||
h: 1,
|
||||
};
|
||||
Self::load_with(root, &HashMap::new(), &[], (1, 1), (0, 0), white, true)
|
||||
Self::load_with(
|
||||
root,
|
||||
None,
|
||||
&HashMap::new(),
|
||||
&[],
|
||||
(1, 1),
|
||||
(0, 0),
|
||||
white,
|
||||
true,
|
||||
)
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn load_with(
|
||||
root: &Path,
|
||||
fallback: Option<&Path>,
|
||||
sprites: &HashMap<String, Rect>,
|
||||
pixels: &[u8],
|
||||
atlas: (u32, u32),
|
||||
@@ -627,11 +687,7 @@ impl Font {
|
||||
white: Rect,
|
||||
metrics_only: bool,
|
||||
) -> Self {
|
||||
let sheets = sprites
|
||||
.iter()
|
||||
.filter(|(k, _)| k.starts_with("font/"))
|
||||
.map(|(k, v)| (k.clone(), *v))
|
||||
.collect();
|
||||
let sheets = sprites.clone();
|
||||
let font = Self {
|
||||
sheets,
|
||||
missing: Glyph {
|
||||
@@ -645,6 +701,7 @@ impl Font {
|
||||
},
|
||||
white,
|
||||
root: root.to_owned(),
|
||||
fallback: fallback.map(|p| p.to_owned()),
|
||||
atlas,
|
||||
region,
|
||||
metrics_only,
|
||||
@@ -656,11 +713,8 @@ impl Font {
|
||||
pending: Vec::new(),
|
||||
full: false,
|
||||
},
|
||||
random: horizon_core::random::LegacyRandom::new(
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_nanos() as i64),
|
||||
),
|
||||
// `Font.random`: `RandomSource.create()`.
|
||||
random: crate::random_source::create(),
|
||||
}),
|
||||
generation: std::cell::Cell::new(next_generation()),
|
||||
uniform: false,
|
||||
@@ -698,6 +752,7 @@ impl Font {
|
||||
Filter::of(p),
|
||||
load_bitmap(
|
||||
&self.root,
|
||||
self.fallback.as_deref(),
|
||||
p,
|
||||
&self.sheets,
|
||||
pixels,
|
||||
@@ -708,7 +763,7 @@ impl Font {
|
||||
"unihex" => Some(Provider::Unihex(Filter::of(p), p.clone(), None)),
|
||||
"ttf" => Some(Provider::TrueType(
|
||||
Filter::of(p),
|
||||
Box::new(load_true_type(&self.root, p)?),
|
||||
Box::new(load_true_type(&self.root, self.fallback.as_deref(), p)?),
|
||||
)),
|
||||
_ => None,
|
||||
})
|
||||
@@ -868,7 +923,8 @@ impl Font {
|
||||
}
|
||||
}
|
||||
Provider::Unihex(filter, def, glyphs) if filter.passes(uniform, japanese) => {
|
||||
let glyphs = glyphs.get_or_insert_with(|| load_hex(&self.root, def));
|
||||
let glyphs = glyphs
|
||||
.get_or_insert_with(|| load_hex(&self.root, self.fallback.as_deref(), def));
|
||||
if let Some(h) = glyphs.get(&u32::from(c)).copied()
|
||||
&& !(non_fishy && fishy_advance(hex_advance(&h)))
|
||||
{
|
||||
@@ -1026,7 +1082,8 @@ impl Font {
|
||||
}
|
||||
}
|
||||
Provider::Unihex(filter, def, glyphs) if filter.passes(uniform, japanese) => {
|
||||
let glyphs = glyphs.get_or_insert_with(|| load_hex(&self.root, def));
|
||||
let glyphs = glyphs
|
||||
.get_or_insert_with(|| load_hex(&self.root, self.fallback.as_deref(), def));
|
||||
if let Some(h) = glyphs.get(&u32::from(c)) {
|
||||
return Some(hex_advance(h));
|
||||
}
|
||||
@@ -1512,6 +1569,52 @@ mod tests {
|
||||
assert!(texels.iter().any(|t| t[0] == 255));
|
||||
}
|
||||
|
||||
/// A bitmap provider's `file` can name any texture. When the atlas has that
|
||||
/// texture (HUD packs keep their glyph sheets outside `textures/font`), the
|
||||
/// glyphs are read from it instead of being copied into the small run-time
|
||||
/// region one by one; the region then cannot fill up and turn later glyphs
|
||||
/// into the missing one, whose advance shifts the rest of a HUD line.
|
||||
#[test]
|
||||
fn a_bitmap_sheet_outside_font_is_read_from_the_atlas() {
|
||||
let sheet = png(16, 8, |x, _| x < 2 || (8..14).contains(&x));
|
||||
let (_, _, pixels) = crate::hud::decode_bytes(&sheet).unwrap();
|
||||
let rect = Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 16,
|
||||
h: 8,
|
||||
};
|
||||
for (ns, file, key) in [
|
||||
("minecraft", "minecraft:hud/sheet.png", "hud/sheet"),
|
||||
(
|
||||
"hudengine",
|
||||
"hudengine:font/sheet.png",
|
||||
"hudengine:font/sheet",
|
||||
),
|
||||
] {
|
||||
let def = format!(
|
||||
r#"{{"providers":[{{"type":"bitmap","file":"{file}","ascent":7,"chars":["ab"]}}]}}"#
|
||||
);
|
||||
// The image is only in the atlas: the pack's copy is not on disk.
|
||||
let root = root(
|
||||
ns,
|
||||
&[("assets/minecraft/font/default.json", def.as_bytes())],
|
||||
);
|
||||
let sprites = HashMap::from([(key.to_string(), rect)]);
|
||||
let font = Font::load(&root, &sprites, &pixels, (16, 8), (0, 8), white());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
assert_eq!(font.glyph('a').advance, 3.0, "{ns}");
|
||||
assert_eq!(font.glyph('b').advance, 7.0, "{ns}");
|
||||
assert!(font.glyph('a').rect.is_some(), "{ns}");
|
||||
assert!(
|
||||
font.take_patches().is_empty(),
|
||||
"{ns}: the atlas sheet is not copied into the run-time region"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styles_pick_their_font_and_unknown_fonts_draw_missing_glyphs() {
|
||||
let sheet = png(16, 8, |x, _| x < 2 || (8..14).contains(&x));
|
||||
|
||||
@@ -171,6 +171,7 @@ mod gpu_culling;
|
||||
mod hdr;
|
||||
mod hdr_icc;
|
||||
mod hdr_stats;
|
||||
pub(crate) mod hud_coverage;
|
||||
pub use hdr::Calibration as HdrCalibration;
|
||||
pub mod block_light;
|
||||
mod custom_sky;
|
||||
@@ -1171,6 +1172,7 @@ pub struct Renderer {
|
||||
debug_lines_opaque: wgpu::RenderPipeline,
|
||||
debug_lines: wgpu::RenderPipeline,
|
||||
debug_lines_on_top: wgpu::RenderPipeline,
|
||||
debug_crosshair: Option<wgpu::RenderPipeline>,
|
||||
debug_line_buffer: wgpu::Buffer,
|
||||
debug_line_capacity: u64,
|
||||
/// The terrain resources with the debug crosshair's projection
|
||||
@@ -1424,6 +1426,8 @@ pub struct Renderer {
|
||||
/// device draws and blends into it, or `LINEAR_FRAME` (`frame_format`). A 16-bit frame
|
||||
/// with HDR off since reaches the swapchain through `readback`.
|
||||
frame_format: wgpu::TextureFormat,
|
||||
/// The no-pack float HDR lane, with independent blending and an R8 coverage target.
|
||||
hud_coverage: bool,
|
||||
/// Whether a pack picked or dropped since the renderer was made has been told it runs on
|
||||
/// the frame that exists (`set_native_shader_pack`).
|
||||
frame_kept_logged: bool,
|
||||
@@ -2693,6 +2697,13 @@ impl Renderer {
|
||||
config.format,
|
||||
linear,
|
||||
);
|
||||
let hud_coverage = hud_coverage::supported(
|
||||
linear,
|
||||
hdr_output.is_some(),
|
||||
device.limits().max_color_attachments,
|
||||
adapter.get_downlevel_capabilities().flags,
|
||||
adapter.get_texture_format_features(hud_coverage::FORMAT),
|
||||
);
|
||||
if previous.is_none() {
|
||||
startup_step(format_args!("creating the pipelines"));
|
||||
}
|
||||
@@ -2843,8 +2854,9 @@ impl Renderer {
|
||||
cull: Option<wgpu::Face>,
|
||||
depth_write: bool,
|
||||
depth_test: bool,
|
||||
blend: Option<wgpu::BlendState>| {
|
||||
kept.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
blend: Option<wgpu::BlendState>,
|
||||
coverage: bool| {
|
||||
let desc = wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout: Some(layout),
|
||||
vertex: wgpu::VertexState {
|
||||
@@ -2885,7 +2897,17 @@ impl Renderer {
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
if coverage {
|
||||
hud_coverage::pipeline(
|
||||
&device,
|
||||
&desc,
|
||||
wgpu::ShaderSource::Wgsl(include_str!("terrain.wgsl").into()),
|
||||
)
|
||||
.expect("the debug crosshair's coverage")
|
||||
} else {
|
||||
kept.create_render_pipeline(&desc)
|
||||
}
|
||||
};
|
||||
let pipeline_in = |layout: &wgpu::PipelineLayout,
|
||||
label: &str,
|
||||
@@ -2909,6 +2931,7 @@ impl Renderer {
|
||||
depth_write,
|
||||
depth_test,
|
||||
blend,
|
||||
false,
|
||||
)
|
||||
};
|
||||
// A solid terrain pipeline (back faces culled, depth written, no blend) without HDR
|
||||
@@ -2931,6 +2954,7 @@ impl Renderer {
|
||||
true,
|
||||
true,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
};
|
||||
SolidPipeline {
|
||||
@@ -3175,6 +3199,22 @@ impl Renderer {
|
||||
false,
|
||||
Some(alpha_blend),
|
||||
);
|
||||
let debug_crosshair = hud_coverage.then(|| {
|
||||
pipeline_with(
|
||||
&sprite_constants,
|
||||
&layout,
|
||||
"3d crosshair coverage",
|
||||
"vs_debug_line",
|
||||
"fs_debug_line",
|
||||
&debug_line_buffers,
|
||||
wgpu::PrimitiveTopology::TriangleStrip,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
Some(alpha_blend),
|
||||
true,
|
||||
)
|
||||
});
|
||||
drop(terrain_step);
|
||||
let globals = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon globals"),
|
||||
@@ -3528,6 +3568,7 @@ impl Renderer {
|
||||
&atlas_view,
|
||||
&glint_view,
|
||||
&glint_sampler,
|
||||
hud_coverage,
|
||||
)?;
|
||||
hud.atlas_texture = Some(atlas.clone());
|
||||
let entity_step = crate::spans::startup::step("renderer.entity_pipelines");
|
||||
@@ -4746,6 +4787,7 @@ impl Renderer {
|
||||
debug_lines_opaque,
|
||||
debug_lines,
|
||||
debug_lines_on_top,
|
||||
debug_crosshair,
|
||||
debug_line_buffer,
|
||||
debug_line_capacity: 4096,
|
||||
crosshair_group,
|
||||
@@ -4905,6 +4947,7 @@ impl Renderer {
|
||||
emission_stats,
|
||||
hdr_output,
|
||||
frame_format,
|
||||
hud_coverage,
|
||||
frame_kept_logged: false,
|
||||
hdr_marker: None,
|
||||
pack_post: None,
|
||||
@@ -6497,8 +6540,9 @@ impl Renderer {
|
||||
DisplayWatch::default()
|
||||
};
|
||||
let format = self.frame_format;
|
||||
self.hdr =
|
||||
output.map(|output| hdr::Hdr::new(&self.device, &self.queue, format, output));
|
||||
self.hdr = output.map(|output| {
|
||||
hdr::Hdr::new(&self.device, &self.queue, format, output, self.hud_coverage)
|
||||
});
|
||||
if let Some(hdr) = &mut self.hdr {
|
||||
hdr.look = self.hdr_lighting_setting;
|
||||
}
|
||||
@@ -6590,7 +6634,16 @@ impl Renderer {
|
||||
self.frame_gen.on = on;
|
||||
}
|
||||
/// `upscale`: output pixels per rendered pixel, for TAA to rebuild (1: native).
|
||||
pub fn set_world_post(&mut self, scale: f32, antialias: bool, taa: bool, upscale: f32) {
|
||||
/// `sharpness`: how much TAA's upscaled picture is sharpened, 0..=1.
|
||||
pub fn set_world_post(
|
||||
&mut self,
|
||||
scale: f32,
|
||||
antialias: bool,
|
||||
taa: bool,
|
||||
upscale: f32,
|
||||
sharpness: f32,
|
||||
) {
|
||||
self.world_post.set_sharpness(&self.device, sharpness);
|
||||
let taa = taa && self.world_post.supports_taa();
|
||||
self.world_post.scale = scale.clamp(0.25, 2.0);
|
||||
self.world_post.antialias = antialias && !taa;
|
||||
@@ -11538,10 +11591,14 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
}
|
||||
let coverage = self.hdr.as_ref().and_then(|h| h.coverage_view());
|
||||
if let Some(view) = &coverage {
|
||||
hud_coverage::clear(encoder, view);
|
||||
}
|
||||
// GameRenderer.render3dHud: the debug crosshair after the hand and
|
||||
// the screen effects.
|
||||
if overlay.crosshair > 0 {
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
let mut attachments = vec![Some(wgpu::RenderPassColorAttachment {
|
||||
view: view_target,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
@@ -11550,6 +11607,9 @@ impl Renderer {
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
if let Some(view) = &coverage {
|
||||
attachments.push(Some(hud_coverage::attachment(view)));
|
||||
}
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("3d crosshair"),
|
||||
timestamp_writes: timing.as_mut().and_then(|t| t.writes("Crosshair")),
|
||||
@@ -11564,7 +11624,11 @@ impl Renderer {
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.debug_lines_on_top);
|
||||
pass.set_pipeline(if coverage.is_some() {
|
||||
self.debug_crosshair.as_ref().unwrap()
|
||||
} else {
|
||||
&self.debug_lines_on_top
|
||||
});
|
||||
pass.set_bind_group(0, &self.crosshair_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.debug_line_buffer.slice(..));
|
||||
let first = overlay.first_crosshair;
|
||||
@@ -11577,6 +11641,7 @@ impl Renderer {
|
||||
self.hud.draw_range(
|
||||
encoder,
|
||||
view_target,
|
||||
coverage.as_ref(),
|
||||
&self.depth,
|
||||
frame.hud,
|
||||
[self.config.width, self.config.height],
|
||||
@@ -11599,12 +11664,16 @@ impl Renderer {
|
||||
radius,
|
||||
timing.as_mut(),
|
||||
);
|
||||
if let Some(view) = &coverage {
|
||||
hud_coverage::clear(encoder, view);
|
||||
}
|
||||
} else if let Some(blur) = &mut self.menu_blur {
|
||||
blur.release();
|
||||
}
|
||||
self.hud.draw_range(
|
||||
encoder,
|
||||
view_target,
|
||||
coverage.as_ref(),
|
||||
&self.depth,
|
||||
frame.hud,
|
||||
[self.config.width, self.config.height],
|
||||
|
||||
@@ -73,6 +73,7 @@ struct Targets {
|
||||
size: [u32; 2],
|
||||
frame: wgpu::Texture,
|
||||
world: wgpu::Texture,
|
||||
coverage: Option<wgpu::Texture>,
|
||||
group: wgpu::BindGroup,
|
||||
}
|
||||
|
||||
@@ -155,6 +156,8 @@ pub struct Hdr {
|
||||
targets: Option<Targets>,
|
||||
/// `HORIZON_HDR_DEBUG=1`: grey what isn't expanded, tint what is magenta.
|
||||
debug: bool,
|
||||
coverage: bool,
|
||||
coverage_debug: bool,
|
||||
/// Paper white and peak brightness, in nits.
|
||||
pub paper_white: f32,
|
||||
pub peak: f32,
|
||||
@@ -181,10 +184,49 @@ impl Hdr {
|
||||
queue: &wgpu::Queue,
|
||||
format: wgpu::TextureFormat,
|
||||
output: Output,
|
||||
coverage: bool,
|
||||
) -> Self {
|
||||
let debug = std::env::var("HORIZON_HDR_DEBUG").ok();
|
||||
Self::with_debug(device, queue, format, output, coverage, debug.as_deref())
|
||||
}
|
||||
|
||||
fn with_debug(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
format: wgpu::TextureFormat,
|
||||
output: Output,
|
||||
coverage: bool,
|
||||
debug: Option<&str>,
|
||||
) -> Self {
|
||||
let coverage_debug = coverage && debug == Some("coverage");
|
||||
let mut source = include_str!("../hdr.wgsl").to_owned();
|
||||
if coverage_debug {
|
||||
// The debug view wraps the last pass as it is, so the picture under the tint is
|
||||
// exactly the one without it.
|
||||
let entry =
|
||||
"@fragment fn fs_hdr(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32>";
|
||||
assert!(
|
||||
source.contains(entry),
|
||||
"hdr.wgsl's fs_hdr changed its signature"
|
||||
);
|
||||
source = source.replace(entry, "fn picture_hdr(pos: vec4<f32>) -> vec4<f32>");
|
||||
source.push_str(
|
||||
r#"
|
||||
@group(0) @binding(4) var coverage: texture_2d<f32>;
|
||||
@fragment fn fs_hdr(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let picture = picture_hdr(pos);
|
||||
let c = textureLoad(coverage, vec2<i32>(pos.xy), 0).r;
|
||||
// Magenta at paper white, in the output's own encoding.
|
||||
let paper = vec3(params.paper, 0.0, params.paper);
|
||||
let magenta = select(paper, pq(BT709_TO_BT2020 * paper * 80.0), params.pq > 0.5);
|
||||
return vec4(mix(picture.rgb, magenta, c), picture.a);
|
||||
}
|
||||
"#,
|
||||
);
|
||||
}
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon HDR"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("../hdr.wgsl").into()),
|
||||
source: wgpu::ShaderSource::Wgsl(source.into()),
|
||||
});
|
||||
let texture = |binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
@@ -196,23 +238,27 @@ impl Hdr {
|
||||
},
|
||||
count: None,
|
||||
};
|
||||
let mut entries = vec![
|
||||
texture(0),
|
||||
texture(1),
|
||||
texture(3),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 2,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
];
|
||||
if coverage_debug {
|
||||
entries.push(texture(4));
|
||||
}
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Horizon HDR bindings"),
|
||||
entries: &[
|
||||
texture(0),
|
||||
texture(1),
|
||||
texture(3),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 2,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
entries: &entries,
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Horizon HDR"),
|
||||
@@ -308,7 +354,9 @@ impl Hdr {
|
||||
output,
|
||||
format,
|
||||
targets: None,
|
||||
debug: std::env::var_os("HORIZON_HDR_DEBUG").is_some_and(|v| v == "1"),
|
||||
debug: debug == Some("1"),
|
||||
coverage,
|
||||
coverage_debug,
|
||||
paper_white: 200.0,
|
||||
peak: 1000.0,
|
||||
black: 0.0,
|
||||
@@ -324,7 +372,7 @@ impl Hdr {
|
||||
/// The frame every pass draws into this frame, sized like the window.
|
||||
pub fn frame(&mut self, device: &wgpu::Device, size: [u32; 2]) -> wgpu::Texture {
|
||||
if self.targets.as_ref().is_none_or(|t| t.size != size) {
|
||||
let texture = |label, usage| {
|
||||
let texture = |label, format, usage| {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: wgpu::Extent3d {
|
||||
@@ -335,52 +383,50 @@ impl Hdr {
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: self.format,
|
||||
format,
|
||||
usage: usage | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
})
|
||||
};
|
||||
let frame = texture(
|
||||
"Horizon HDR frame",
|
||||
self.format,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
let world = texture(
|
||||
"Horizon HDR world",
|
||||
self.format,
|
||||
wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
);
|
||||
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Horizon HDR bindings"),
|
||||
layout: &self.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(&view(&frame)),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(&view(&world)),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: self.params.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::TextureView(&self.logo),
|
||||
},
|
||||
],
|
||||
let coverage = self.coverage.then(|| {
|
||||
texture(
|
||||
"HUD coverage",
|
||||
super::hud_coverage::FORMAT,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
)
|
||||
});
|
||||
let group = self.group(device, &frame, &world, coverage.as_ref(), &self.params);
|
||||
self.targets = Some(Targets {
|
||||
size,
|
||||
frame,
|
||||
world,
|
||||
coverage,
|
||||
group,
|
||||
});
|
||||
}
|
||||
self.targets.as_ref().unwrap().frame.clone()
|
||||
}
|
||||
|
||||
pub fn coverage_view(&self) -> Option<wgpu::TextureView> {
|
||||
Some(
|
||||
self.targets
|
||||
.as_ref()?
|
||||
.coverage
|
||||
.as_ref()?
|
||||
.create_view(&Default::default()),
|
||||
)
|
||||
}
|
||||
|
||||
/// The peak the HDR metadata gives: while calibration's peak step runs, PQ's own
|
||||
/// 10 000 nits, so a compositor that clips at the declared peak (KWin) lets the test
|
||||
/// patch through as bright as the display goes and the logo under it shows.
|
||||
@@ -517,29 +563,13 @@ impl Hdr {
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Horizon HDR screenshot bindings"),
|
||||
layout: &self.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(&view(&t.frame)),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(&view(&t.world)),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: self.capture_params.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::TextureView(&self.logo),
|
||||
},
|
||||
],
|
||||
});
|
||||
let group = self.group(
|
||||
device,
|
||||
&t.frame,
|
||||
&t.world,
|
||||
t.coverage.as_ref(),
|
||||
&self.capture_params,
|
||||
);
|
||||
queue.write_buffer(
|
||||
&self.capture_params,
|
||||
0,
|
||||
@@ -549,6 +579,40 @@ impl Hdr {
|
||||
Some(texture)
|
||||
}
|
||||
|
||||
fn group(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
frame: &wgpu::Texture,
|
||||
world: &wgpu::Texture,
|
||||
coverage: Option<&wgpu::Texture>,
|
||||
params: &wgpu::Buffer,
|
||||
) -> wgpu::BindGroup {
|
||||
let frame = frame.create_view(&Default::default());
|
||||
let world = world.create_view(&Default::default());
|
||||
let coverage = coverage.map(|t| t.create_view(&Default::default()));
|
||||
let texture = |binding, view| wgpu::BindGroupEntry {
|
||||
binding,
|
||||
resource: wgpu::BindingResource::TextureView(view),
|
||||
};
|
||||
let mut entries = vec![
|
||||
texture(0, &frame),
|
||||
texture(1, &world),
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: params.as_entire_binding(),
|
||||
},
|
||||
texture(3, &self.logo),
|
||||
];
|
||||
if self.coverage_debug {
|
||||
entries.push(texture(4, coverage.as_ref().unwrap()));
|
||||
}
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Horizon HDR bindings"),
|
||||
layout: &self.layout,
|
||||
entries: &entries,
|
||||
})
|
||||
}
|
||||
|
||||
/// hdr.wgsl's `Params`.
|
||||
fn params(&self, world: bool, pq: bool) -> [f32; 16] {
|
||||
let paper = self.paper_white.max(1.0);
|
||||
@@ -655,9 +719,95 @@ fn scene_gain(peak: f32, paper_white: f32) -> f32 {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Output, named, pick};
|
||||
use super::{Hdr, Output, named, pick};
|
||||
use wgpu::{SurfaceColorSpaces as S, TextureFormat as F};
|
||||
|
||||
#[test]
|
||||
fn coverage_target_resizes_resets_and_is_sampled_only_by_the_debug_view() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
for enabled in [false, true] {
|
||||
for debug in [None, Some("coverage")] {
|
||||
let mut hdr =
|
||||
Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, enabled, debug);
|
||||
hdr.frame(&device, [2, 1]);
|
||||
assert_eq!(hdr.coverage_view().is_some(), enabled);
|
||||
hdr.frame(&device, [4, 2]);
|
||||
let t = hdr.targets.as_ref().unwrap();
|
||||
if let Some(coverage) = &t.coverage {
|
||||
assert_eq!(coverage.size(), t.frame.size());
|
||||
}
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let frame_view = t.frame.create_view(&Default::default());
|
||||
let mut color = super::super::hud_coverage::attachment(&frame_view);
|
||||
color.ops.load = wgpu::LoadOp::Clear(wgpu::Color::BLACK);
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
color_attachments: &[Some(color)],
|
||||
..Default::default()
|
||||
});
|
||||
hdr.keep_world(&mut encoder, None);
|
||||
if let Some(view) = hdr.coverage_view() {
|
||||
super::super::hud_coverage::clear(&mut encoder, &view);
|
||||
let mut color = super::super::hud_coverage::attachment(&view);
|
||||
color.ops.load = wgpu::LoadOp::Clear(wgpu::Color::WHITE);
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
color_attachments: &[Some(color)],
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
let capture = hdr.capture(&device, &queue, &mut encoder, false).unwrap();
|
||||
let buffer = super::super::copy_frame(&device, &mut encoder, &capture, [4, 2]);
|
||||
queue.submit([encoder.finish()]);
|
||||
buffer.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let bytes = buffer.get_mapped_range(..).unwrap();
|
||||
let pixel = u32::from_le_bytes(bytes[..4].try_into().unwrap());
|
||||
let [r, g, b] = [0, 10, 20].map(|shift| (pixel >> shift) & 0x3ff);
|
||||
if enabled && debug.is_some() {
|
||||
// Fully covered: BT.709 magenta at paper white (200 nits here) in BT.2020
|
||||
// PQ, not PQ's 10 000 nits (1023, 0, 1023).
|
||||
let near = |got: u32, want: u32| got.abs_diff(want) <= 4;
|
||||
assert!(near(r, 550) && near(g, 346) && near(b, 583), "{r} {g} {b}");
|
||||
} else {
|
||||
assert_eq!(pixel, 0xc0000000);
|
||||
}
|
||||
if let Some(view) = hdr.coverage_view() {
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
super::super::hud_coverage::clear(&mut encoder, &view);
|
||||
let coverage = hdr.targets.as_ref().unwrap().coverage.as_ref().unwrap();
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 512,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_texture_to_buffer(
|
||||
coverage.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &buffer,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(256),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
coverage.size(),
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
buffer.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
assert_eq!(&buffer.get_mapped_range(..).unwrap()[..4], &[0; 4]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_shaders_validate() {
|
||||
let entity = crate::overlay::wgsl_constants() + include_str!("../entity.wgsl");
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
//! HUD coverage beside its unchanged colour and alpha, only on the float HDR lane.
|
||||
|
||||
pub(crate) const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::R8Unorm;
|
||||
|
||||
pub(crate) fn supported(
|
||||
lane: (bool, bool, bool),
|
||||
hdr_offered: bool,
|
||||
attachments: u32,
|
||||
flags: wgpu::DownlevelFlags,
|
||||
format: wgpu::TextureFormatFeatures,
|
||||
) -> bool {
|
||||
!lane.0
|
||||
&& lane.1
|
||||
&& lane.2
|
||||
&& hdr_offered
|
||||
&& attachments >= 2
|
||||
&& flags.contains(wgpu::DownlevelFlags::INDEPENDENT_BLEND)
|
||||
&& format
|
||||
.allowed_usages
|
||||
.contains(wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING)
|
||||
&& format
|
||||
.flags
|
||||
.contains(wgpu::TextureFormatFeatureFlags::BLENDABLE)
|
||||
}
|
||||
|
||||
pub(crate) fn attachment(view: &wgpu::TextureView) -> wgpu::RenderPassColorAttachment<'_> {
|
||||
wgpu::RenderPassColorAttachment {
|
||||
view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn clear(encoder: &mut wgpu::CommandEncoder, view: &wgpu::TextureView) {
|
||||
let mut color = attachment(view);
|
||||
color.ops.load = wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT);
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("HUD coverage reset"),
|
||||
color_attachments: &[Some(color)],
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) struct Pipeline {
|
||||
plain: wgpu::RenderPipeline,
|
||||
coverage: Option<wgpu::RenderPipeline>,
|
||||
}
|
||||
|
||||
impl Pipeline {
|
||||
/// `None` when `enabled` and the shader can't be given a coverage output (a resource
|
||||
/// pack's, which the caller then doesn't use).
|
||||
pub(crate) fn new(
|
||||
device: &wgpu::Device,
|
||||
desc: &wgpu::RenderPipelineDescriptor<'_>,
|
||||
source: wgpu::ShaderSource<'_>,
|
||||
enabled: bool,
|
||||
) -> Option<Self> {
|
||||
let coverage = if enabled {
|
||||
Some(pipeline(device, desc, source)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Some(Self {
|
||||
plain: device.create_render_pipeline(desc),
|
||||
coverage,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn get(&self, coverage: bool) -> &wgpu::RenderPipeline {
|
||||
if coverage {
|
||||
self.coverage.as_ref().expect("HUD coverage pipeline")
|
||||
} else {
|
||||
&self.plain
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the primary output and all its blend factors, including destination alpha. Wrap the
|
||||
/// final shader result, so pack text's alpha, early returns and discard all apply to coverage.
|
||||
/// `None` for a fragment shader without a colour output at location 0.
|
||||
pub(super) fn pipeline(
|
||||
device: &wgpu::Device,
|
||||
desc: &wgpu::RenderPipelineDescriptor<'_>,
|
||||
source: wgpu::ShaderSource<'_>,
|
||||
) -> Option<wgpu::RenderPipeline> {
|
||||
let mut desc = desc.clone();
|
||||
let fragment = desc.fragment.as_mut()?;
|
||||
let mut module = match source {
|
||||
wgpu::ShaderSource::Wgsl(s) => naga::front::wgsl::parse_str(&s).ok()?,
|
||||
wgpu::ShaderSource::Naga(m) => m.into_owned(),
|
||||
_ => return None,
|
||||
};
|
||||
let primary = fragment.targets.first()?.clone()?;
|
||||
let blend = primary.blend.unwrap_or(wgpu::BlendState::REPLACE).color;
|
||||
let additive = matches!(
|
||||
(blend.src_factor, blend.dst_factor),
|
||||
(
|
||||
wgpu::BlendFactor::One
|
||||
| wgpu::BlendFactor::Src
|
||||
| wgpu::BlendFactor::SrcAlpha
|
||||
| wgpu::BlendFactor::Zero,
|
||||
wgpu::BlendFactor::One
|
||||
)
|
||||
) && blend.operation == wgpu::BlendOperation::Add;
|
||||
wrap(&mut module, fragment.entry_point?, blend)?;
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: desc.label,
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
|
||||
});
|
||||
let over = wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::One,
|
||||
dst_factor: wgpu::BlendFactor::OneMinusSrc,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
};
|
||||
let targets = [
|
||||
Some(primary),
|
||||
Some(wgpu::ColorTargetState {
|
||||
format: FORMAT,
|
||||
blend: Some(wgpu::BlendState {
|
||||
color: over,
|
||||
alpha: over,
|
||||
}),
|
||||
write_mask: if additive {
|
||||
wgpu::ColorWrites::empty()
|
||||
} else {
|
||||
wgpu::ColorWrites::RED
|
||||
},
|
||||
}),
|
||||
];
|
||||
fragment.module = &shader;
|
||||
fragment.targets = &targets;
|
||||
Some(device.create_render_pipeline(&desc))
|
||||
}
|
||||
|
||||
fn wrap(module: &mut naga::Module, name: &str, blend: wgpu::BlendComponent) -> Option<()> {
|
||||
use naga::{Expression as E, Statement as S, TypeInner as T};
|
||||
use wgpu::BlendFactor as B;
|
||||
let span = naga::Span::UNDEFINED;
|
||||
let entry = module.entry_points.iter_mut().find(|e| e.name == name)?;
|
||||
// Checked before anything is taken apart: a resource pack's shader may have no colour.
|
||||
let result = entry.function.result.clone()?;
|
||||
if let T::Struct { members, .. } = &module.types[result.ty].inner {
|
||||
members
|
||||
.iter()
|
||||
.find(|m| matches!(m.binding, Some(naga::Binding::Location { location: 0, .. })))?;
|
||||
} else if !matches!(
|
||||
result.binding,
|
||||
Some(naga::Binding::Location { location: 0, .. })
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
let mut original = std::mem::take(&mut entry.function);
|
||||
entry.function.arguments = original.arguments.clone();
|
||||
for arg in &mut original.arguments {
|
||||
arg.binding = None;
|
||||
}
|
||||
original.result.as_mut().unwrap().binding = None;
|
||||
original.name = Some("hud_color".into());
|
||||
let function = module.functions.append(original, span);
|
||||
let f = &mut entry.function;
|
||||
let args = (0..f.arguments.len())
|
||||
.map(|i| f.expressions.append(E::FunctionArgument(i as u32), span))
|
||||
.collect();
|
||||
let call = f.expressions.append(E::CallResult(function), span);
|
||||
f.body.push(
|
||||
S::Call {
|
||||
function,
|
||||
arguments: args,
|
||||
result: Some(call),
|
||||
},
|
||||
span,
|
||||
);
|
||||
let one = f
|
||||
.expressions
|
||||
.append(E::Literal(naga::Literal::F32(1.0)), span);
|
||||
let start = f.expressions.len();
|
||||
let (mut members, mut values, color, offset) = match &module.types[result.ty].inner {
|
||||
T::Struct {
|
||||
members,
|
||||
span: size,
|
||||
} => {
|
||||
// HUD pipelines have one target, so a pack's other colour outputs went nowhere;
|
||||
// left in, one at location 1 would collide with the coverage.
|
||||
let (members, values): (Vec<_>, Vec<_>) = members
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, m)| {
|
||||
!matches!(m.binding, Some(naga::Binding::Location { location, .. }) if location != 0)
|
||||
})
|
||||
.map(|(i, m)| {
|
||||
let value = f.expressions.append(
|
||||
E::AccessIndex {
|
||||
base: call,
|
||||
index: i as u32,
|
||||
},
|
||||
span,
|
||||
);
|
||||
(m.clone(), value)
|
||||
})
|
||||
.unzip();
|
||||
let color = members
|
||||
.iter()
|
||||
.position(|m| {
|
||||
matches!(m.binding, Some(naga::Binding::Location { location: 0, .. }))
|
||||
})
|
||||
.unwrap();
|
||||
let color = values[color];
|
||||
(members, values, color, *size)
|
||||
}
|
||||
_ => (
|
||||
vec![naga::StructMember {
|
||||
name: Some("color".into()),
|
||||
ty: result.ty,
|
||||
binding: result.binding,
|
||||
offset: 0,
|
||||
}],
|
||||
vec![call],
|
||||
call,
|
||||
16,
|
||||
),
|
||||
};
|
||||
let mut component = |index| {
|
||||
f.expressions
|
||||
.append(E::AccessIndex { base: color, index }, span)
|
||||
};
|
||||
let [r, g, b, a] = [component(0), component(1), component(2), component(3)];
|
||||
let mut math = |fun, arg, arg1| {
|
||||
f.expressions.append(
|
||||
E::Math {
|
||||
fun,
|
||||
arg,
|
||||
arg1: Some(arg1),
|
||||
arg2: None,
|
||||
arg3: None,
|
||||
},
|
||||
span,
|
||||
)
|
||||
};
|
||||
let low = math(naga::MathFunction::Min, r, g);
|
||||
let low = math(naga::MathFunction::Min, low, b);
|
||||
let high = math(naga::MathFunction::Max, r, g);
|
||||
let high = math(naga::MathFunction::Max, high, b);
|
||||
// A scalar cannot reproduce RGB-dependent replacement or inversion: surviving pixels
|
||||
// cover fully, so the later composite cannot leak world light through them.
|
||||
let c = if blend.operation != wgpu::BlendOperation::Add {
|
||||
one
|
||||
} else {
|
||||
match (blend.src_factor, blend.dst_factor) {
|
||||
(B::SrcAlpha | B::One, B::OneMinusSrcAlpha) => a,
|
||||
(B::Zero, B::OneMinusSrc) => high,
|
||||
// Multiplying retains src.rgb (or 1 - alpha + src.rgb) of the backdrop.
|
||||
// Use the greatest channel darkening, as for the credits vignette above.
|
||||
(B::Zero, B::Src)
|
||||
| (B::Dst, B::Zero)
|
||||
| (B::Dst, B::OneMinusSrcAlpha)
|
||||
| (B::Dst, B::Src) => {
|
||||
let top = if blend.dst_factor == B::OneMinusSrcAlpha {
|
||||
a
|
||||
} else {
|
||||
one
|
||||
};
|
||||
let factor = if blend.dst_factor == B::Src && blend.src_factor == B::Dst {
|
||||
f.expressions.append(
|
||||
E::Binary {
|
||||
op: naga::BinaryOperator::Add,
|
||||
left: low,
|
||||
right: low,
|
||||
},
|
||||
span,
|
||||
)
|
||||
} else {
|
||||
low
|
||||
};
|
||||
f.expressions.append(
|
||||
E::Binary {
|
||||
op: naga::BinaryOperator::Subtract,
|
||||
left: top,
|
||||
right: factor,
|
||||
},
|
||||
span,
|
||||
)
|
||||
}
|
||||
_ => one,
|
||||
}
|
||||
};
|
||||
let c = f.expressions.append(
|
||||
E::Math {
|
||||
fun: naga::MathFunction::Saturate,
|
||||
arg: c,
|
||||
arg1: None,
|
||||
arg2: None,
|
||||
arg3: None,
|
||||
},
|
||||
span,
|
||||
);
|
||||
let scalar = module.types.insert(
|
||||
naga::Type {
|
||||
name: None,
|
||||
inner: T::Scalar(naga::Scalar::F32),
|
||||
},
|
||||
span,
|
||||
);
|
||||
members.push(naga::StructMember {
|
||||
name: Some("coverage".into()),
|
||||
ty: scalar,
|
||||
binding: Some(naga::Binding::Location {
|
||||
location: 1,
|
||||
interpolation: None,
|
||||
sampling: None,
|
||||
blend_src: None,
|
||||
per_primitive: false,
|
||||
}),
|
||||
offset,
|
||||
});
|
||||
let ty = module.types.insert(
|
||||
naga::Type {
|
||||
name: Some("HudCoverage".into()),
|
||||
inner: T::Struct {
|
||||
members,
|
||||
span: (offset + 4).next_multiple_of(16),
|
||||
},
|
||||
},
|
||||
span,
|
||||
);
|
||||
values.push(c);
|
||||
let value = f.expressions.append(
|
||||
E::Compose {
|
||||
ty,
|
||||
components: values,
|
||||
},
|
||||
span,
|
||||
);
|
||||
f.result = Some(naga::FunctionResult { ty, binding: None });
|
||||
f.body.push(S::Emit(f.expressions.range_from(start)), span);
|
||||
f.body.push(S::Return { value: Some(value) }, span);
|
||||
Some(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn coverage_requires_the_no_pack_float_hdr_lane_and_independent_r8_blending() {
|
||||
let flags = wgpu::DownlevelFlags::INDEPENDENT_BLEND;
|
||||
let format = wgpu::TextureFormatFeatures {
|
||||
allowed_usages: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
flags: wgpu::TextureFormatFeatureFlags::BLENDABLE,
|
||||
};
|
||||
let lane = (false, true, true);
|
||||
assert!(supported(lane, true, 2, flags, format));
|
||||
// Switch unset (including legacy 16-bit HDR and 0.1 packs), HDR off, a 0.2
|
||||
// pack's float frame, and unsupported devices never allocate coverage.
|
||||
for other in [
|
||||
(false, false, true),
|
||||
(true, false, true),
|
||||
(true, true, true),
|
||||
(false, true, false),
|
||||
] {
|
||||
assert!(!supported(other, true, 2, flags, format));
|
||||
}
|
||||
assert!(!supported(lane, false, 2, flags, format));
|
||||
assert!(!supported(lane, true, 1, flags, format));
|
||||
assert!(!supported(
|
||||
lane,
|
||||
true,
|
||||
2,
|
||||
wgpu::DownlevelFlags::empty(),
|
||||
format
|
||||
));
|
||||
assert!(!supported(
|
||||
lane,
|
||||
true,
|
||||
2,
|
||||
flags,
|
||||
wgpu::TextureFormatFeatures {
|
||||
flags: wgpu::TextureFormatFeatureFlags::empty(),
|
||||
..format
|
||||
}
|
||||
));
|
||||
assert!(!supported(
|
||||
lane,
|
||||
true,
|
||||
2,
|
||||
flags,
|
||||
wgpu::TextureFormatFeatures {
|
||||
allowed_usages: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
..format
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_coverage_builds_only_the_original_pipeline() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let Ok((device, _)) = pollster::block_on(adapter.request_device(&Default::default()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Wgsl(
|
||||
"@vertex fn vs() -> @builtin(position) vec4<f32> { return vec4(0.0); }".into(),
|
||||
),
|
||||
});
|
||||
let desc = wgpu::RenderPipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::Always),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: None,
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
};
|
||||
// The coverage source must not even be parsed outside the lane.
|
||||
let pipeline = Pipeline::new(
|
||||
&device,
|
||||
&desc,
|
||||
wgpu::ShaderSource::Wgsl("invalid".into()),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(pipeline.coverage.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coverage_wraps_the_debug_crosshair_without_changing_world_entry_points() {
|
||||
let mut module = naga::front::wgsl::parse_str(include_str!("../terrain.wgsl")).unwrap();
|
||||
wrap(
|
||||
&mut module,
|
||||
"fs_debug_line",
|
||||
wgpu::BlendState::ALPHA_BLENDING.color,
|
||||
)
|
||||
.unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coverage_replaces_a_colour_output_the_hud_has_no_target_for() {
|
||||
let mut module = naga::front::wgsl::parse_str(
|
||||
"struct Out { @location(0) color: vec4<f32>, @location(1) unused: vec4<f32> }
|
||||
@fragment fn fs() -> Out { return Out(vec4(0.5), vec4(1.0)); }",
|
||||
)
|
||||
.unwrap();
|
||||
wrap(&mut module, "fs", wgpu::BlendState::ALPHA_BLENDING.color).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap();
|
||||
let result = module.entry_points[0].function.result.as_ref().unwrap();
|
||||
let naga::TypeInner::Struct { members, .. } = &module.types[result.ty].inner else {
|
||||
panic!("the wrapped entry returns a struct");
|
||||
};
|
||||
let names: Vec<_> = members.iter().map(|m| m.name.as_deref().unwrap()).collect();
|
||||
assert_eq!(names, ["color", "coverage"]);
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,27 @@ impl NativePack {
|
||||
report: Report,
|
||||
constants: &[(String, f64)],
|
||||
lights: super::block_light::LightTable,
|
||||
) -> Result<(Self, Vec<String>), String> {
|
||||
// A backend may refuse a layout or pipeline the pack asks for (a stage's bindings
|
||||
// past its sampled-texture limit, say, as OpenGL's 16 was before #10095): the pack
|
||||
// is refused with that error instead of a validation panic in the renderer.
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let built = Self::build(device, queue, terrain, report, constants, lights);
|
||||
let refused = pollster::block_on(scope.pop());
|
||||
match (built, refused) {
|
||||
(Ok(built), None) => Ok(built),
|
||||
(Ok(_), Some(e)) => Err(e.to_string()),
|
||||
(Err(e), _) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn build(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
terrain: &TerrainShape<'_>,
|
||||
report: Report,
|
||||
constants: &[(String, f64)],
|
||||
lights: super::block_light::LightTable,
|
||||
) -> Result<(Self, Vec<String>), String> {
|
||||
let (globals, format) = (terrain.globals, terrain.format);
|
||||
let textures = super::native_post::pack_textures(device, queue, &report);
|
||||
@@ -278,9 +299,90 @@ impl NativePack {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::chosen_shadow;
|
||||
use super::{NativePack, TerrainShape, chosen_shadow};
|
||||
use horizon_shader_api::manifest::{OptionKind, PackOption, Shadow};
|
||||
|
||||
/// A device with OpenGL's minimum 16 sampled textures per stage refuses a pack whose
|
||||
/// sky adds the terrain's 10 to the frame's 9: that is the pack's error to report, not
|
||||
/// a validation panic (#10095).
|
||||
#[test]
|
||||
fn a_pack_the_backend_refuses_is_an_error_not_a_panic() {
|
||||
use horizon_shader_api::{MANIFEST, Pack, check};
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let mut limits = adapter.limits();
|
||||
limits.max_sampled_textures_per_shader_stage = 16;
|
||||
let Ok((device, queue)) =
|
||||
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
required_limits: limits,
|
||||
..Default::default()
|
||||
}))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// The terrain's group 0: its uniform and the 10 sampled textures (_terrain.wgsl_'s
|
||||
// 8 sprite arrays, the lightmap and the emission thresholds).
|
||||
let entry = |binding, ty| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty,
|
||||
count: None,
|
||||
};
|
||||
let mut entries = vec![entry(
|
||||
0,
|
||||
wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
)];
|
||||
entries.extend((1..=10).map(|binding| {
|
||||
entry(
|
||||
binding,
|
||||
wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
)
|
||||
}));
|
||||
let globals = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: None,
|
||||
entries: &entries,
|
||||
});
|
||||
let empty = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: None,
|
||||
entries: &[],
|
||||
});
|
||||
let terrain = TerrainShape {
|
||||
globals: &globals,
|
||||
flat: None,
|
||||
sprites: [1.0, 2048.0],
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
sprite_tags: &[],
|
||||
sprite_materials: &[],
|
||||
entities: &empty,
|
||||
weather_groups: [&empty, &empty],
|
||||
pbr: None,
|
||||
};
|
||||
let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.1\"\n\
|
||||
[sky]\nfile = \"sky.wgsl\"\n";
|
||||
let files = [
|
||||
(MANIFEST, manifest),
|
||||
(
|
||||
"sky.wgsl",
|
||||
"fn sky(input: SkyInput) -> vec4<f32> { return input.color; }\n",
|
||||
),
|
||||
]
|
||||
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
|
||||
.into();
|
||||
let report = check(&Pack::from_files(files).unwrap()).unwrap();
|
||||
let built = NativePack::new(&device, &queue, &terrain, report, &[], Default::default());
|
||||
assert!(built.is_err(), "the pack should be refused");
|
||||
}
|
||||
|
||||
fn option(id: &str, kind: OptionKind) -> PackOption {
|
||||
PackOption {
|
||||
id: id.into(),
|
||||
|
||||
@@ -30,7 +30,7 @@ pub fn buffers() -> [Option<wgpu::VertexBufferLayout<'static>>; 1] {
|
||||
/// What `gbuffers_entities` and `gbuffers_block` read from an entity vertex: camera-relative
|
||||
/// position, the shared atlas's UV, colour, light (block, sky in 16ths) and the world-oriented
|
||||
/// normal. The quad's corners, read from the same vertices at set 1, give the face normal where
|
||||
/// the vertex has none (block entities) and `at_tangent` along the texture's u, as for terrain.
|
||||
/// the vertex has none (custom geometry) and `at_tangent` along the texture's u, as for terrain.
|
||||
pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
let words = std::mem::size_of::<Vertex>() / 4;
|
||||
let mut declarations = "layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in vec2 hz_v_uv;\n\
|
||||
|
||||
@@ -55,6 +55,13 @@ pub struct Post {
|
||||
sampler: wgpu::Sampler,
|
||||
copy: wgpu::RenderPipeline,
|
||||
fxaa: wgpu::RenderPipeline,
|
||||
/// Contrast-adaptive sharpening of TAA's upscaled picture: the jittered frames are
|
||||
/// spread over more output pixels, and the blend softens edges.
|
||||
sharpen: wgpu::RenderPipeline,
|
||||
/// The strength `sharpen` was built with (`set_sharpness`), and what rebuilding it takes.
|
||||
sharpness: f32,
|
||||
shader: wgpu::ShaderModule,
|
||||
pipeline_layout: wgpu::PipelineLayout,
|
||||
format: wgpu::TextureFormat,
|
||||
/// World resolution relative to the window (0.5..=2.0).
|
||||
pub scale: f32,
|
||||
@@ -232,8 +239,12 @@ impl Post {
|
||||
..Default::default()
|
||||
});
|
||||
Self {
|
||||
shader: shader.clone(),
|
||||
pipeline_layout: pipeline_layout.clone(),
|
||||
copy: pipeline("fs_copy", &pipeline_layout, format),
|
||||
fxaa: pipeline("fs_fxaa", &pipeline_layout, format),
|
||||
sharpen: sharpen_pipeline(device, &shader, &pipeline_layout, format, 0.85),
|
||||
sharpness: 0.85,
|
||||
layout,
|
||||
sampler,
|
||||
format,
|
||||
@@ -474,6 +485,21 @@ impl Post {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Upscaling Sharpness setting, 0..=1: rebuilds the sharpen pipeline when it changed.
|
||||
pub fn set_sharpness(&mut self, device: &wgpu::Device, sharpness: f32) {
|
||||
let sharpness = sharpness.clamp(0.0, 1.0);
|
||||
if sharpness != self.sharpness {
|
||||
self.sharpen = sharpen_pipeline(
|
||||
device,
|
||||
&self.shader,
|
||||
&self.pipeline_layout,
|
||||
self.format,
|
||||
sharpness,
|
||||
);
|
||||
self.sharpness = sharpness;
|
||||
}
|
||||
}
|
||||
|
||||
/// The size of the world's own target, when it has one.
|
||||
pub fn size(&self) -> Option<[u32; 2]> {
|
||||
self.target.as_ref().map(|t| t.size)
|
||||
@@ -535,7 +561,12 @@ impl Post {
|
||||
pass.set_bind_group(0, &taa.resolve[i], &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
drop(pass);
|
||||
self.finish_with(encoder, screen, &taa.show[i], timing);
|
||||
let pipeline = if self.upscale > 1.0 {
|
||||
&self.sharpen
|
||||
} else {
|
||||
&self.copy
|
||||
};
|
||||
self.draw_onto(encoder, screen, &taa.show[i], pipeline, timing);
|
||||
}
|
||||
|
||||
/// Draw what `group` binds (`bind`) onto `screen`.
|
||||
@@ -545,6 +576,23 @@ impl Post {
|
||||
screen: &wgpu::TextureView,
|
||||
group: &wgpu::BindGroup,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
let pipeline = if self.antialias {
|
||||
&self.fxaa
|
||||
} else {
|
||||
&self.copy
|
||||
};
|
||||
self.draw_onto(encoder, screen, group, pipeline, timing);
|
||||
}
|
||||
|
||||
/// Draw what `group` binds onto `screen` through `pipeline`.
|
||||
fn draw_onto(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
screen: &wgpu::TextureView,
|
||||
group: &wgpu::BindGroup,
|
||||
pipeline: &wgpu::RenderPipeline,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: screen,
|
||||
@@ -561,16 +609,51 @@ impl Post {
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(if self.antialias {
|
||||
&self.fxaa
|
||||
} else {
|
||||
&self.copy
|
||||
});
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
}
|
||||
|
||||
/// `fs_sharpen` at `sharpness` (its override), drawing onto `format`.
|
||||
fn sharpen_pipeline(
|
||||
device: &wgpu::Device,
|
||||
shader: &wgpu::ShaderModule,
|
||||
layout: &wgpu::PipelineLayout,
|
||||
format: wgpu::TextureFormat,
|
||||
sharpness: f32,
|
||||
) -> wgpu::RenderPipeline {
|
||||
let constants = [("sharpness", f64::from(sharpness))];
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Horizon sharpen"),
|
||||
layout: Some(layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: shader,
|
||||
entry_point: Some("vs_post"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: shader,
|
||||
entry_point: Some("fs_sharpen"),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &constants,
|
||||
..Default::default()
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// This frame's clip space to the previous frame's, for camera-relative projections:
|
||||
/// a point `camera - previous_camera` further from the previous camera than from this one.
|
||||
pub(super) fn reprojection(
|
||||
|
||||
@@ -725,6 +725,8 @@ pub struct Graphics {
|
||||
pub taa: bool,
|
||||
/// TAA upscaling; anything but `Native` comes with `taa` on.
|
||||
pub upscaling: Upscaling,
|
||||
/// How much the upscaled picture is sharpened, 0..=100 (percent of the most).
|
||||
pub upscaling_sharpness: u32,
|
||||
/// F2 screenshots at this many times the game's resolution: 1, 2, 4 or 8.
|
||||
pub screenshot_quality: u32,
|
||||
/// Frame generation (Horizon setting), off by default.
|
||||
@@ -813,6 +815,7 @@ impl Default for Graphics {
|
||||
fxaa: false,
|
||||
taa: false,
|
||||
upscaling: Upscaling::Native,
|
||||
upscaling_sharpness: 85,
|
||||
screenshot_quality: 1,
|
||||
frame_generation: FrameGeneration::Off,
|
||||
hdr: false,
|
||||
@@ -935,6 +938,9 @@ impl Graphics {
|
||||
"fxaa" => g.fxaa = flag(g.fxaa),
|
||||
"taa" => g.taa = flag(g.taa),
|
||||
"upscaling" => g.upscaling = Upscaling::parse(value),
|
||||
"upscalingSharpness" => {
|
||||
g.upscaling_sharpness = number(g.upscaling_sharpness).min(100)
|
||||
}
|
||||
"screenshotQuality" => {
|
||||
g.screenshot_quality = Some(number(0))
|
||||
.filter(|q| SCREENSHOT_QUALITIES.contains(q))
|
||||
@@ -980,7 +986,7 @@ impl Graphics {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
let mut text = format!(
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nentityCullingPrecheck:{}\nparticleCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrLighting:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\ngpuCulling:{}\n",
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nentityCullingPrecheck:{}\nparticleCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nupscalingSharpness:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrLighting:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\ngpuCulling:{}\n",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
@@ -1019,6 +1025,7 @@ impl Graphics {
|
||||
bool_text(self.fxaa),
|
||||
bool_text(self.taa),
|
||||
self.upscaling.name(),
|
||||
self.upscaling_sharpness,
|
||||
self.screenshot_quality,
|
||||
self.frame_generation.name(),
|
||||
bool_text(self.hdr),
|
||||
@@ -1202,6 +1209,7 @@ mod tests {
|
||||
fxaa: true,
|
||||
taa: true,
|
||||
upscaling: Upscaling::Balanced,
|
||||
upscaling_sharpness: 40,
|
||||
screenshot_quality: 4,
|
||||
frame_generation: FrameGeneration::Fsr31,
|
||||
hdr: true,
|
||||
|
||||
@@ -97,6 +97,7 @@ pub enum H {
|
||||
ResolutionScale,
|
||||
Antialiasing,
|
||||
Upscaling,
|
||||
UpscalingSharpness,
|
||||
ScreenshotQuality,
|
||||
FrameGeneration,
|
||||
Hdr,
|
||||
@@ -372,6 +373,11 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
|
||||
High,
|
||||
"Renders the world smaller and rebuilds it with TAA. Faster, softer.",
|
||||
),
|
||||
h(
|
||||
H::UpscalingSharpness,
|
||||
Low,
|
||||
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.",
|
||||
),
|
||||
h(
|
||||
H::ScreenshotQuality,
|
||||
None,
|
||||
@@ -785,6 +791,7 @@ impl H {
|
||||
}
|
||||
H::Workers => Some(g.workers as f32 / max_workers() as f32),
|
||||
H::ResolutionScale => Some((g.resolution_scale as f32 - 50.0) / 150.0),
|
||||
H::UpscalingSharpness => Some(g.upscaling_sharpness as f32 / 100.0),
|
||||
// Auto, then 10..480 by 10.
|
||||
H::HdrPaperWhite => Some(g.hdr_paper_white as f32 / 480.0),
|
||||
// Auto, then 200..2000 by 50.
|
||||
@@ -804,6 +811,7 @@ impl H {
|
||||
H::UploadBudget => Some((UPLOAD_STEPS.len() - 1) as u32),
|
||||
H::Workers => Some(max_workers()),
|
||||
H::ResolutionScale => Some(30),
|
||||
H::UpscalingSharpness => Some(20),
|
||||
H::HdrPaperWhite => Some(41),
|
||||
H::HdrPeak => Some(37),
|
||||
H::HdrBlackLevel => Some(100),
|
||||
@@ -893,6 +901,9 @@ impl H {
|
||||
},
|
||||
),
|
||||
H::Upscaling => labelled("Upscaling", g.upscaling.label()),
|
||||
H::UpscalingSharpness => {
|
||||
labelled("Upscaling Sharpness", format!("{}%", g.upscaling_sharpness))
|
||||
}
|
||||
H::ScreenshotQuality => {
|
||||
labelled("Screenshot Quality", format!("{}x", g.screenshot_quality))
|
||||
}
|
||||
@@ -965,6 +976,7 @@ impl H {
|
||||
}
|
||||
H::Workers => g.workers = (v * max_workers() as f32).round() as u32,
|
||||
H::ResolutionScale => g.resolution_scale = (50.0 + (v * 30.0).round() * 5.0) as u32,
|
||||
H::UpscalingSharpness => g.upscaling_sharpness = (v * 20.0).round() as u32 * 5,
|
||||
H::HdrPaperWhite => g.hdr_paper_white = (v * 48.0).round() as u32 * 10,
|
||||
H::HdrPeak => {
|
||||
g.hdr_peak = match (v * 37.0).round() as u32 {
|
||||
@@ -3222,6 +3234,7 @@ mod tests {
|
||||
H::UploadBudget,
|
||||
H::Workers,
|
||||
H::ResolutionScale,
|
||||
H::UpscalingSharpness,
|
||||
H::HdrPaperWhite,
|
||||
H::HdrPeak,
|
||||
H::HdrBlackLevel,
|
||||
|
||||
@@ -531,6 +531,49 @@ fn cit_sprite(root: &Path, id: &str) -> Option<(PackedImage, Option<TextureFlags
|
||||
let flags = mcmeta.as_deref().and_then(TextureFlags::from_mcmeta);
|
||||
Some(((name.to_owned(), w, h, pixels), flags, animation.is_some()))
|
||||
}
|
||||
/// The texture of every `bitmap` font provider. A provider's `file` may name
|
||||
/// any texture, so a pack's glyph sheet need not live under `textures/font`.
|
||||
fn bitmap_font_textures(root: &Path) -> Vec<PathBuf> {
|
||||
fn jsons(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
for entry in fs::read_dir(dir).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
jsons(&path, out);
|
||||
} else if path.extension().is_some_and(|e| e == "json") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
let assets = root.join("assets");
|
||||
let mut defs = Vec::new();
|
||||
for ns in fs::read_dir(&assets).into_iter().flatten().flatten() {
|
||||
jsons(&ns.path().join("font"), &mut defs);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
for def in defs {
|
||||
let Ok(bytes) = fs::read(&def) else { continue };
|
||||
let bytes = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(&bytes);
|
||||
let Ok(json) = serde_json::from_slice::<serde_json::Value>(bytes) else {
|
||||
continue;
|
||||
};
|
||||
for p in json["providers"].as_array().into_iter().flatten() {
|
||||
if p["type"] != "bitmap" {
|
||||
continue;
|
||||
}
|
||||
let Some(file) = p["file"].as_str() else {
|
||||
continue;
|
||||
};
|
||||
let (ns, path) = file.split_once(':').unwrap_or(("minecraft", file));
|
||||
let path = assets.join(ns).join("textures").join(path);
|
||||
if path.is_file() && seen.insert(path.clone()) {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Folders of `assets/minecraft/textures/` that [`HudAtlas::pack`] walks for
|
||||
/// the full atlas. A pack may add others through `atlases/blocks.json`.
|
||||
const WALKED_TEXTURE_FOLDERS: &[&str] = &[
|
||||
@@ -1102,6 +1145,18 @@ impl HudAtlas {
|
||||
for folder in folders {
|
||||
walk(&textures.join(folder), &mut files);
|
||||
}
|
||||
// A bitmap provider's `file` may be outside the walked folders (HUD
|
||||
// packs keep their glyph sheets under their own namespace or path).
|
||||
// Pack those sheets too, so Font reads them from the atlas instead of
|
||||
// copying every glyph into the small run-time region.
|
||||
if folders.contains(&"font") {
|
||||
let mut seen: HashSet<PathBuf> = files.iter().cloned().collect();
|
||||
files.extend(
|
||||
bitmap_font_textures(root)
|
||||
.into_iter()
|
||||
.filter(|p| seen.insert(p.clone())),
|
||||
);
|
||||
}
|
||||
// Content mods' entity textures: their equipment assets name them as
|
||||
// `<ns>:<path>`, and native mods draw their block entities with them
|
||||
// (`native_draw_quad`: `<ns>:entity/...`); and their mob effects'
|
||||
@@ -1465,8 +1520,9 @@ impl HudAtlas {
|
||||
let white = sprites["horizon/white"];
|
||||
let glint = Self::glint_texture(root, defaults, "enchanted_glint_item");
|
||||
let glint_armor = Self::glint_texture(root, defaults, "enchanted_glint_armor");
|
||||
let font = crate::font::Font::load(
|
||||
let font = crate::font::Font::load_over(
|
||||
root,
|
||||
defaults,
|
||||
&sprites,
|
||||
&pixels,
|
||||
(width, height),
|
||||
@@ -2379,17 +2435,19 @@ impl HudUniform {
|
||||
}
|
||||
}
|
||||
}
|
||||
use crate::gfx::hud_coverage::{self, Pipeline};
|
||||
|
||||
pub struct HudRenderer {
|
||||
pub sprite_animations: Vec<(Rect, crate::sprites::SpriteAnimation, (usize, usize, u32))>,
|
||||
/// The shared atlas texture, for run-time glyph uploads.
|
||||
pub atlas_texture: Option<wgpu::Texture>,
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
pipeline: Pipeline,
|
||||
/// `RenderPipelines.CROSSHAIR`.
|
||||
invert_pipeline: wgpu::RenderPipeline,
|
||||
additive_pipeline: wgpu::RenderPipeline,
|
||||
depth_pipeline: wgpu::RenderPipeline,
|
||||
vignette_pipeline: wgpu::RenderPipeline,
|
||||
end_portal_pipeline: wgpu::RenderPipeline,
|
||||
invert_pipeline: Pipeline,
|
||||
additive_pipeline: Pipeline,
|
||||
depth_pipeline: Pipeline,
|
||||
vignette_pipeline: Pipeline,
|
||||
end_portal_pipeline: Pipeline,
|
||||
/// The layout of `group`, which the CIT glint pipelines share.
|
||||
layout: wgpu::BindGroupLayout,
|
||||
group: wgpu::BindGroup,
|
||||
@@ -2399,6 +2457,7 @@ pub struct HudRenderer {
|
||||
buffer: wgpu::Buffer,
|
||||
capacity: u64,
|
||||
format: wgpu::TextureFormat,
|
||||
coverage: bool,
|
||||
atlas_view: wgpu::TextureView,
|
||||
/// The resource pack's text shader, when it has one that works.
|
||||
pack_text: Option<pack_text::PackText>,
|
||||
@@ -2413,6 +2472,7 @@ impl HudRenderer {
|
||||
view: &wgpu::TextureView,
|
||||
glint_view: &wgpu::TextureView,
|
||||
glint_sampler: &wgpu::Sampler,
|
||||
coverage: bool,
|
||||
) -> Result<Self> {
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon HUD"),
|
||||
@@ -2525,7 +2585,7 @@ impl HudRenderer {
|
||||
let attributes =
|
||||
wgpu::vertex_attr_array![0=>Float32x2,1=>Float32x2,2=>Unorm8x4,3=>Float32,4=>Float32x2];
|
||||
let create = |label, blend, fragment_entry, depth: bool| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
let desc = wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
@@ -2559,7 +2619,9 @@ impl HudRenderer {
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let source = wgpu::ShaderSource::Wgsl(include_str!("hud.wgsl").into());
|
||||
Pipeline::new(device, &desc, source, coverage).expect("the HUD's coverage")
|
||||
};
|
||||
let pipeline = create("HUD", wgpu::BlendState::ALPHA_BLENDING, "fs_main", false);
|
||||
let depth_pipeline = create(
|
||||
@@ -2650,6 +2712,7 @@ impl HudRenderer {
|
||||
buffer,
|
||||
capacity,
|
||||
format,
|
||||
coverage,
|
||||
atlas_view: view.clone(),
|
||||
pack_text: None,
|
||||
zoom: 1.0,
|
||||
@@ -2664,7 +2727,7 @@ impl HudRenderer {
|
||||
textures: Option<&cit_glint::GlintTextures>,
|
||||
) {
|
||||
self.cit_glint = textures.and_then(|t| {
|
||||
cit_glint::CitGlintPass::new(device, queue, self.format, &self.layout, t)
|
||||
cit_glint::CitGlintPass::new(device, queue, self.format, &self.layout, t, self.coverage)
|
||||
});
|
||||
}
|
||||
/// Draw the GUI's text with `shader` (`HudAtlas::text_shader`), or natively.
|
||||
@@ -2674,7 +2737,7 @@ impl HudRenderer {
|
||||
shader: Option<&horizon_shaders::CoreProgram>,
|
||||
) {
|
||||
self.pack_text = shader.and_then(|shader| {
|
||||
pack_text::PackText::new(device, self.format, &self.atlas_view, shader)
|
||||
pack_text::PackText::new(device, self.format, &self.atlas_view, shader, self.coverage)
|
||||
});
|
||||
}
|
||||
pub fn animate_sprites(
|
||||
@@ -2823,6 +2886,7 @@ impl HudRenderer {
|
||||
self.draw_range(
|
||||
encoder,
|
||||
target,
|
||||
None,
|
||||
depth,
|
||||
batch,
|
||||
size,
|
||||
@@ -2836,6 +2900,7 @@ impl HudRenderer {
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
target: &wgpu::TextureView,
|
||||
coverage: Option<&wgpu::TextureView>,
|
||||
depth: &wgpu::TextureView,
|
||||
batch: &HudBatch,
|
||||
size: [u32; 2],
|
||||
@@ -2845,7 +2910,7 @@ impl HudRenderer {
|
||||
if vertices.is_empty() || size.contains(&0) {
|
||||
return;
|
||||
}
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
let mut attachments = vec![Some(wgpu::RenderPassColorAttachment {
|
||||
view: target,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
@@ -2854,6 +2919,9 @@ impl HudRenderer {
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
if let Some(view) = coverage {
|
||||
attachments.push(Some(hud_coverage::attachment(view)));
|
||||
}
|
||||
let runs = batch.runs_in(vertices);
|
||||
let mut at = 0;
|
||||
while at < runs.len() {
|
||||
@@ -2905,16 +2973,17 @@ impl HudRenderer {
|
||||
Blend::EndPortal => &self.end_portal_pipeline,
|
||||
Blend::CitGlint(blend) => {
|
||||
if let Some(cit) = &self.cit_glint {
|
||||
cit.draw(&mut pass, *blend, range.clone());
|
||||
cit.draw(&mut pass, *blend, range.clone(), coverage.is_some());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let pipeline = pipeline.get(coverage.is_some());
|
||||
match &self.pack_text {
|
||||
Some(pack_text) if *blend == Blend::Alpha => {
|
||||
for (part, text) in pack_text::text_runs(&batch.vertices, range.clone()) {
|
||||
if text {
|
||||
pack_text.draw(&mut pass, part);
|
||||
pack_text.draw(&mut pass, part, coverage.is_some());
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
} else {
|
||||
pass.set_pipeline(pipeline);
|
||||
@@ -2933,6 +3002,10 @@ impl HudRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "hud/coverage_tests.rs"]
|
||||
mod coverage_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -3891,6 +3964,30 @@ texture=wide",
|
||||
assert_eq!(((r.w, r.h), r.texels(size)), (gui, texels), "{name}");
|
||||
}
|
||||
}
|
||||
/// A bitmap font's `file` may be any texture, not only one under
|
||||
/// `textures/font`; the atlas packs it so the font can read its glyphs
|
||||
/// from there instead of copying them into the run-time region.
|
||||
#[test]
|
||||
fn a_bitmap_font_sheet_outside_font_is_packed() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-font-pack-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
write_png(&root, "hud/sheet", 16, 8);
|
||||
let def = root.join("assets/minecraft/font/default.json");
|
||||
fs::create_dir_all(def.parent().unwrap()).unwrap();
|
||||
fs::write(
|
||||
&def,
|
||||
r#"{"providers":[{"type":"bitmap","file":"minecraft:hud/sheet.png","ascent":7,"chars":["ab"]}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let rect = atlas
|
||||
.sprites
|
||||
.get("hud/sheet")
|
||||
.expect("the font sheet is packed");
|
||||
assert_eq!(rect.texels((atlas.width, atlas.height)), (16, 8));
|
||||
}
|
||||
|
||||
/// A content mod's GUI textures join the atlas under its namespace;
|
||||
/// its other textures and vanilla's names stay as they were.
|
||||
#[test]
|
||||
@@ -4010,6 +4107,52 @@ texture=wide",
|
||||
.unwrap();
|
||||
root
|
||||
}
|
||||
/// A pack layer that adds a font provider over the game's own resources:
|
||||
/// its `font/default.json` names `minecraft:font/test.png`, which only the
|
||||
/// game's root holds (`HudAtlas::build_over`'s `defaults`).
|
||||
fn pack_font_over_defaults(name: &str) -> (PathBuf, PathBuf) {
|
||||
let base = std::env::temp_dir().join(format!(
|
||||
"horizon-font-fallback-{name}-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let pack = base.join("pack");
|
||||
let vanilla = base.join("vanilla");
|
||||
fs::create_dir_all(pack.join("assets/minecraft/font")).unwrap();
|
||||
fs::create_dir_all(vanilla.join("assets/minecraft/textures/font")).unwrap();
|
||||
fs::write(
|
||||
pack.join("assets/minecraft/font/default.json"),
|
||||
r#"{"providers":[{"type":"bitmap","file":"minecraft:font/test.png","ascent":7,"chars":["ab"]}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut pixels = vec![0u8; 16 * 8 * 4];
|
||||
for y in 0..8 {
|
||||
for x in (0..3).chain(8..13) {
|
||||
pixels[(y * 16 + x) * 4 + 3] = 255;
|
||||
}
|
||||
}
|
||||
let mut encoder = png::Encoder::new(
|
||||
fs::File::create(vanilla.join("assets/minecraft/textures/font/test.png")).unwrap(),
|
||||
16,
|
||||
8,
|
||||
);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&pixels)
|
||||
.unwrap();
|
||||
(pack, vanilla)
|
||||
}
|
||||
#[test]
|
||||
fn a_pack_overlay_reads_font_sheets_from_the_game_below_it() {
|
||||
let (pack, vanilla) = pack_font_over_defaults("sheets");
|
||||
let atlas = HudAtlas::build_over(&pack, &vanilla).unwrap();
|
||||
let glyph = atlas.font.glyph('a');
|
||||
let _ = fs::remove_dir_all(pack.parent().unwrap());
|
||||
assert!(glyph.rect.is_some(), "text draws, not the missing glyph");
|
||||
assert!(glyph.advance > 0.0);
|
||||
}
|
||||
#[test]
|
||||
fn the_fonts_only_atlas_measures_text_like_the_full_one() {
|
||||
let root = two_glyph_font("atlas");
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! vanilla's glint's is. Without enchantment rules nothing here is made.
|
||||
|
||||
use super::HudVertex;
|
||||
use crate::gfx::hud_coverage::Pipeline;
|
||||
use horizon_optifine::cit::{Blend, Factor, Kind, Rule};
|
||||
use std::path::Path;
|
||||
|
||||
@@ -230,7 +231,7 @@ pub fn blend_state(blend: Option<Blend>) -> wgpu::BlendState {
|
||||
/// pipeline per blend.
|
||||
pub(super) struct CitGlintPass {
|
||||
group: wgpu::BindGroup,
|
||||
pipelines: Vec<wgpu::RenderPipeline>,
|
||||
pipelines: Vec<Pipeline>,
|
||||
}
|
||||
|
||||
impl CitGlintPass {
|
||||
@@ -241,6 +242,7 @@ impl CitGlintPass {
|
||||
format: wgpu::TextureFormat,
|
||||
hud: &wgpu::BindGroupLayout,
|
||||
textures: &GlintTextures,
|
||||
coverage: bool,
|
||||
) -> Option<Self> {
|
||||
use wgpu::util::DeviceExt;
|
||||
if textures.is_empty() {
|
||||
@@ -381,7 +383,7 @@ impl CitGlintPass {
|
||||
.blends
|
||||
.iter()
|
||||
.map(|&blend| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
let desc = wgpu::RenderPipelineDescriptor {
|
||||
label: Some("CIT glint"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
@@ -409,7 +411,9 @@ impl CitGlintPass {
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let source = wgpu::ShaderSource::Wgsl(include_str!("../hud.wgsl").into());
|
||||
Pipeline::new(device, &desc, source, coverage).expect("the CIT glint's coverage")
|
||||
})
|
||||
.collect();
|
||||
Some(Self { group, pipelines })
|
||||
@@ -421,9 +425,10 @@ impl CitGlintPass {
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
blend: u8,
|
||||
vertices: std::ops::Range<u32>,
|
||||
coverage: bool,
|
||||
) {
|
||||
if let Some(pipeline) = self.pipelines.get(usize::from(blend)) {
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_pipeline(pipeline.get(coverage));
|
||||
pass.set_bind_group(1, &self.group, &[]);
|
||||
pass.draw(vertices, 0..1);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
use super::*;
|
||||
use crate::gfx::hud_coverage;
|
||||
|
||||
struct Gpu {
|
||||
device: wgpu::Device,
|
||||
queue: wgpu::Queue,
|
||||
hud: HudRenderer,
|
||||
}
|
||||
|
||||
impl Gpu {
|
||||
fn new() -> Option<Self> {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
if !hud_coverage::supported(
|
||||
(false, true, true),
|
||||
true,
|
||||
adapter.limits().max_color_attachments,
|
||||
adapter.get_downlevel_capabilities().flags,
|
||||
adapter.get_texture_format_features(hud_coverage::FORMAT),
|
||||
) {
|
||||
eprintln!("skipping HUD coverage: adapter lacks independent blending or R8 rendering");
|
||||
return None;
|
||||
}
|
||||
let (device, queue) =
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()?;
|
||||
let atlas = device.create_texture_with_data(
|
||||
&queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 2,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
&[255, 255, 255, 255, 255, 255, 255, 0],
|
||||
);
|
||||
let view = atlas.create_view(&Default::default());
|
||||
let hud = HudRenderer::new(
|
||||
&device,
|
||||
wgpu::TextureFormat::Rgba16Float,
|
||||
&view,
|
||||
&view,
|
||||
&device.create_sampler(&Default::default()),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
Some(Self { device, queue, hud })
|
||||
}
|
||||
|
||||
fn texture(&self, format: wgpu::TextureFormat) -> wgpu::Texture {
|
||||
self.device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 4,
|
||||
height: 2,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
})
|
||||
}
|
||||
|
||||
fn draw(&self, batch: &HudBatch, initial: f64, coverage: bool) -> (Vec<u8>, Vec<u8>) {
|
||||
let frame = self.texture(wgpu::TextureFormat::Rgba16Float);
|
||||
let mask = self.texture(hud_coverage::FORMAT);
|
||||
let depth = self.texture(wgpu::TextureFormat::Depth32Float);
|
||||
let [frame_view, mask_view, depth_view] =
|
||||
[&frame, &mask, &depth].map(|t| t.create_view(&Default::default()));
|
||||
self.queue
|
||||
.write_buffer(&self.hud.buffer, 0, bytemuck::cast_slice(&batch.vertices));
|
||||
self.queue.write_buffer(
|
||||
&self.hud.uniform,
|
||||
0,
|
||||
bytemuck::bytes_of(&HudUniform::new([4.0, 2.0, 1.0, 0.75], batch.end_portal, 0)),
|
||||
);
|
||||
let mut encoder = self.device.create_command_encoder(&Default::default());
|
||||
for (view, value) in [(&frame_view, 0.3), (&mask_view, initial)] {
|
||||
let mut attachment = hud_coverage::attachment(view);
|
||||
attachment.ops.load = wgpu::LoadOp::Clear(wgpu::Color {
|
||||
r: value,
|
||||
g: value,
|
||||
b: value,
|
||||
a: value,
|
||||
});
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
color_attachments: &[Some(attachment)],
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
self.hud.draw_range(
|
||||
&mut encoder,
|
||||
&frame_view,
|
||||
coverage.then_some(&mask_view),
|
||||
&depth_view,
|
||||
batch,
|
||||
[4, 2],
|
||||
0..batch.vertices.len() as u32,
|
||||
None,
|
||||
);
|
||||
let buffers = [&frame, &mask].map(|t| {
|
||||
let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 512,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_texture_to_buffer(
|
||||
t.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &buffer,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(256),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
t.size(),
|
||||
);
|
||||
buffer
|
||||
});
|
||||
self.queue.submit([encoder.finish()]);
|
||||
let [color, mask] = buffers.map(|buffer| {
|
||||
buffer.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
|
||||
self.device
|
||||
.poll(wgpu::PollType::wait_indefinitely())
|
||||
.unwrap();
|
||||
buffer.get_mapped_range(..).unwrap().to_vec()
|
||||
});
|
||||
(
|
||||
color
|
||||
.chunks(256)
|
||||
.flat_map(|r| r[..32].iter().copied())
|
||||
.collect(),
|
||||
mask.chunks(256)
|
||||
.flat_map(|r| r[..4].iter().copied())
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn check(&self, batch: &HudBatch, initial: f64, expected: [u8; 4]) {
|
||||
let (plain, _) = self.draw(batch, initial, false);
|
||||
let (color, mask) = self.draw(batch, initial, true);
|
||||
assert_eq!(color, plain, "coverage changed the primary colour or alpha");
|
||||
for (got, want) in mask.iter().zip(expected.repeat(2)) {
|
||||
assert!(
|
||||
got.abs_diff(want) <= 1,
|
||||
"coverage {mask:?}, expected {expected:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn quad(batch: &mut HudBatch, blend: Blend, color: u32, x: f32, uv: f32) {
|
||||
let start = batch.vertices.len();
|
||||
batch.quad(
|
||||
[[x, 0.0], [x + 2.0, 0.0], [x + 2.0, 2.0], [x, 2.0]],
|
||||
[[uv, 0.5]; 4],
|
||||
color,
|
||||
);
|
||||
batch.blend_since(start, blend);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coverage_follows_hud_blends_discard_scissor_and_depth_without_changing_colour() {
|
||||
let Some(gpu) = Gpu::new() else {
|
||||
return;
|
||||
};
|
||||
for (blend, color, initial, count, expected) in [
|
||||
(Blend::Alpha, rgba(255, 255, 255, 128), 0.0, 2, 192),
|
||||
(Blend::Additive, rgba(255, 255, 255, 128), 0.3, 1, 77),
|
||||
(Blend::Invert, rgba(255, 255, 255, 32), 0.0, 1, 255),
|
||||
(Blend::EndPortal, rgba(255, 255, 255, 255), 0.0, 1, 255),
|
||||
(Blend::Vignette, rgba(102, 102, 102, 255), 0.0, 1, 102),
|
||||
] {
|
||||
let mut batch = HudBatch::new([4, 2]);
|
||||
for _ in 0..count {
|
||||
quad(&mut batch, blend, color, 0.0, 0.25);
|
||||
}
|
||||
// Crosshair fragments with zero texture alpha discard; EndPortal has no discard.
|
||||
if blend == Blend::Invert {
|
||||
quad(&mut batch, blend, color, 2.0, 0.75);
|
||||
}
|
||||
gpu.check(
|
||||
&batch,
|
||||
initial,
|
||||
[
|
||||
expected,
|
||||
expected,
|
||||
(initial * 255.0).round() as u8,
|
||||
(initial * 255.0).round() as u8,
|
||||
],
|
||||
);
|
||||
}
|
||||
let mut batch = HudBatch::new([4, 2]);
|
||||
for depth in [0.25, 0.75] {
|
||||
quad(
|
||||
&mut batch,
|
||||
Blend::Alpha,
|
||||
rgba(255, 255, 255, 128),
|
||||
0.0,
|
||||
0.25,
|
||||
);
|
||||
let start = batch.vertices.len() - 6;
|
||||
for vertex in &mut batch.vertices[start..] {
|
||||
vertex.depth = depth;
|
||||
}
|
||||
}
|
||||
batch.blends.push((0..12, Blend::Depth([1, 0, 2, 2])));
|
||||
gpu.check(&batch, 0.0, [0, 128, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coverage_preserves_each_cit_blend_and_its_destination_alpha() {
|
||||
let Some(mut gpu) = Gpu::new() else {
|
||||
return;
|
||||
};
|
||||
use horizon_optifine::cit::parse_blend;
|
||||
for (name, expected) in [
|
||||
("alpha", 128),
|
||||
("add", 0),
|
||||
("dodge", 0),
|
||||
("glint", 0),
|
||||
("burn", 102),
|
||||
("multiply", 26),
|
||||
("overlay", 51),
|
||||
("screen", 255),
|
||||
("subtract", 255),
|
||||
("replace", 255),
|
||||
("0 768", 153),
|
||||
("774 0", 153),
|
||||
("770 771 772 773", 128),
|
||||
] {
|
||||
let textures = cit_glint::GlintTextures {
|
||||
width: 1,
|
||||
height: 1,
|
||||
pixels: vec![102, 102, 102, 128],
|
||||
slots: vec![cit_glint::Slot {
|
||||
rgba: [1.0; 4],
|
||||
blur: false,
|
||||
blend: 0,
|
||||
}],
|
||||
blends: vec![parse_blend(name).unwrap()],
|
||||
..Default::default()
|
||||
};
|
||||
gpu.hud
|
||||
.set_cit_glints(&gpu.device, &gpu.queue, Some(&textures));
|
||||
let mut batch = HudBatch::new([4, 2]);
|
||||
quad(
|
||||
&mut batch,
|
||||
Blend::CitGlint(0),
|
||||
rgba(0, 0, 0, 255),
|
||||
0.0,
|
||||
0.25,
|
||||
);
|
||||
quad(
|
||||
&mut batch,
|
||||
Blend::CitGlint(0),
|
||||
rgba(0, 0, 0, 255),
|
||||
2.0,
|
||||
0.75,
|
||||
);
|
||||
gpu.check(&batch, 0.0, [expected, expected, 0, 0]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coverage_uses_pack_texts_final_alpha_and_discard() {
|
||||
let Some(mut gpu) = Gpu::new() else {
|
||||
return;
|
||||
};
|
||||
// A translated-style struct result and early return; no dependency on game resources.
|
||||
let program = horizon_shaders::CoreProgram {
|
||||
vertex: naga::front::wgsl::parse_str("@vertex fn main(@location(0) p: vec2<f32>) -> @builtin(position) vec4<f32> { return vec4(p.x / 2.0 - 1.0, 1.0 - p.y, 0.0, 1.0); }").unwrap(),
|
||||
fragment: naga::front::wgsl::parse_str("struct Result { @location(0) color: vec4<f32> } @fragment fn main(@builtin(position) p: vec4<f32>) -> Result { if p.x > 2.0 { discard; } return Result(vec4(0.4, 0.2, 0.1, 0.5)); }").unwrap(),
|
||||
};
|
||||
gpu.hud.set_text_shader(&gpu.device, Some(&program));
|
||||
assert!(gpu.hud.pack_text.is_some());
|
||||
let mut batch = HudBatch::new([4, 2]);
|
||||
for x in [0.0, 2.0, 0.0, 2.0] {
|
||||
quad(&mut batch, Blend::Alpha, rgba(255, 255, 255, 255), x, 0.25);
|
||||
}
|
||||
for v in &mut batch.vertices {
|
||||
v.text = 1;
|
||||
}
|
||||
gpu.check(&batch, 0.0, [191, 191, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pack_text_shader_without_a_colour_output_is_dropped_not_wrapped() {
|
||||
let Some(mut gpu) = Gpu::new() else {
|
||||
return;
|
||||
};
|
||||
let program = horizon_shaders::CoreProgram {
|
||||
vertex: naga::front::wgsl::parse_str("@vertex fn main(@location(0) p: vec2<f32>) -> @builtin(position) vec4<f32> { return vec4(p, 0.0, 1.0); }").unwrap(),
|
||||
fragment: naga::front::wgsl::parse_str("@fragment fn main() -> @location(1) vec4<f32> { return vec4(1.0); }").unwrap(),
|
||||
};
|
||||
gpu.hud.set_text_shader(&gpu.device, Some(&program));
|
||||
assert!(gpu.hud.pack_text.is_none());
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use std::sync::Arc;
|
||||
use horizon_shaders::{CoreProgram, Glue};
|
||||
|
||||
use super::{HudBatch, HudVertex};
|
||||
use crate::gfx::hud_coverage::Pipeline;
|
||||
|
||||
/// GUI text's vertex format (`POSITION_TEX_COLOR`) from the HUD's vertices. A glyph is two
|
||||
/// triangles here, its corners top left, top right, bottom right, top left, bottom right, bottom
|
||||
@@ -81,7 +82,7 @@ const BLOCK_SIZES: [u64; 4] = [48, 64, 160, 48];
|
||||
|
||||
/// The pipeline and its resources.
|
||||
pub(super) struct PackText {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
pipeline: Pipeline,
|
||||
group: wgpu::BindGroup,
|
||||
uniform: wgpu::Buffer,
|
||||
}
|
||||
@@ -92,6 +93,7 @@ impl PackText {
|
||||
format: wgpu::TextureFormat,
|
||||
atlas: &wgpu::TextureView,
|
||||
program: &CoreProgram,
|
||||
coverage: bool,
|
||||
) -> Option<Self> {
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let module = |label, module: &naga::Module| {
|
||||
@@ -179,7 +181,7 @@ impl PackText {
|
||||
..Default::default()
|
||||
});
|
||||
let attributes = wgpu::vertex_attr_array![0=>Float32x2,1=>Float32x2,2=>Unorm8x4];
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
let desc = wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Resource pack text"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
@@ -208,11 +210,17 @@ impl PackText {
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
};
|
||||
let source = wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(program.fragment.clone()));
|
||||
let pipeline = Pipeline::new(device, &desc, source, coverage);
|
||||
if let Some(e) = pollster::block_on(scope.pop()) {
|
||||
log::warn!("the resource pack's text shader is not used: {e}");
|
||||
return None;
|
||||
}
|
||||
let Some(pipeline) = pipeline else {
|
||||
log::warn!("the resource pack's text shader is not used: it has no colour output");
|
||||
return None;
|
||||
};
|
||||
Some(Self {
|
||||
pipeline,
|
||||
group,
|
||||
@@ -234,8 +242,13 @@ impl PackText {
|
||||
|
||||
/// Draw `range` (whole glyph quads) with the pack's shader; the caller sets its own
|
||||
/// pipeline and bind group again after.
|
||||
pub(super) fn draw(&self, pass: &mut wgpu::RenderPass<'_>, range: std::ops::Range<u32>) {
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pub(super) fn draw(
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
range: std::ops::Range<u32>,
|
||||
coverage: bool,
|
||||
) {
|
||||
pass.set_pipeline(self.pipeline.get(coverage));
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pass.draw(range.clone(), range.start..range.start + 1);
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@ pub mod preedit;
|
||||
pub mod providers;
|
||||
pub mod pvp;
|
||||
pub mod pvp_hud;
|
||||
pub mod random_source;
|
||||
pub mod screen_draw;
|
||||
pub mod screen_effects;
|
||||
pub mod sculk;
|
||||
|
||||
@@ -20,6 +20,14 @@ use crate::{
|
||||
fn tr(key: &str) -> Text {
|
||||
Text::translate(key, &[])
|
||||
}
|
||||
|
||||
/// False only in a game built without the `horizon-extras` feature (docs/forking.md), which says
|
||||
/// so once at startup: the Options screen then has vanilla's Telemetry Data slot instead of
|
||||
/// Horizon Settings and Mods, and Credits & Attribution names the official build.
|
||||
pub static HORIZON_EXTRAS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
|
||||
fn horizon_extras() -> bool {
|
||||
HORIZON_EXTRAS.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
fn generic(caption: &str, value: Text) -> Text {
|
||||
Text::translate("options.generic_value", &[tr(caption), value])
|
||||
}
|
||||
@@ -3113,6 +3121,22 @@ impl OptionsUi {
|
||||
("horizon.pages.open.mods", Some(Page::Mods)),
|
||||
("options.credits_and_attribution", Some(Page::Credits)),
|
||||
];
|
||||
// horizon-extras: without it, vanilla's last row instead of Horizon Settings and
|
||||
// Mods: Telemetry Data (greyed: nothing is sent) and Credits & Attribution.
|
||||
let plain;
|
||||
let entries: &[_] = if horizon_extras() {
|
||||
&entries
|
||||
} else {
|
||||
plain = [
|
||||
&entries[..entries.len() - 3],
|
||||
&[
|
||||
("options.telemetry", None),
|
||||
("options.credits_and_attribution", Some(Page::Credits)),
|
||||
],
|
||||
]
|
||||
.concat();
|
||||
&plain
|
||||
};
|
||||
let rows = entries.len().div_ceil(2) as f32;
|
||||
let gy = (61.0 + 30.0f32).min(h - 33.0 - rows * 24.0);
|
||||
for (i, (key, page)) in entries.iter().enumerate() {
|
||||
@@ -3162,6 +3186,22 @@ impl OptionsUi {
|
||||
let y = y + 28.0 * (i + 1) as f32;
|
||||
out.push(inactive(button(x, y, 210.0, tr(key), Act::None)));
|
||||
}
|
||||
// horizon-extras: a build without them says where the official one is.
|
||||
if !horizon_extras() {
|
||||
out.push(Widget {
|
||||
x: 0.0,
|
||||
y: (y + 92.0).min(h - 42.0),
|
||||
w,
|
||||
h: 9.0,
|
||||
kind: WKind::Text {
|
||||
text: tr("horizon.plain.official"),
|
||||
color: 0xFFA0A0A0,
|
||||
centered: true,
|
||||
},
|
||||
list: false,
|
||||
row: 0,
|
||||
});
|
||||
}
|
||||
out.push(done(h - 27.0));
|
||||
top = 0.0;
|
||||
bottom = 0.0;
|
||||
|
||||
@@ -462,7 +462,7 @@ fn read_meta(source: &Source) -> Option<serde_json::Value> {
|
||||
/// The first JSON value of a `pack.mcmeta`: what follows it is ignored, as some packs sign the
|
||||
/// file with a comment after the closing brace. Gson reads past a UTF-8 byte order mark;
|
||||
/// serde_json rejects it.
|
||||
fn parse_meta(bytes: &[u8]) -> Option<serde_json::Value> {
|
||||
pub(crate) fn parse_meta(bytes: &[u8]) -> Option<serde_json::Value> {
|
||||
serde_json::Deserializer::from_slice(bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes))
|
||||
.into_iter()
|
||||
.next()?
|
||||
@@ -837,24 +837,55 @@ fn build_overlay_uncached(
|
||||
dependencies,
|
||||
&mut extract,
|
||||
)?;
|
||||
// What the zips may unpack to, all threads together: a server's pack is a download the
|
||||
// player only accepts, and a gigabyte of zeros deflates to a megabyte, so without it a pack
|
||||
// at the download limit wrote hundreds of gigabytes here. Vanilla never unpacks a pack.
|
||||
let budget = std::sync::atomic::AtomicU64::new(unpack_budget(packs));
|
||||
// Inflating and writing a big pack's files one by one was most of a cold build; its
|
||||
// folders all exist by now.
|
||||
crate::par::par_map(&extract, |(owner, inner, target)| {
|
||||
let extracted: io::Result<()> = crate::par::par_map(&extract, |(owner, inner, target)| {
|
||||
let Some(Some(z)) = zips.get(*owner) else {
|
||||
return Ok(());
|
||||
};
|
||||
// One unreadable entry costs that file, not the pack: an error here
|
||||
// failed the build and the reload dropped every server pack in the stack.
|
||||
match z.read(inner) {
|
||||
Ok(bytes) => fs::write(target, bytes),
|
||||
let copied = fs::File::create(target).and_then(|file| {
|
||||
let mut file = io::BufWriter::new(file);
|
||||
z.copy_to(inner, &mut file, &budget)?;
|
||||
io::Write::flush(&mut file)
|
||||
});
|
||||
match copied {
|
||||
Ok(()) => Ok(()),
|
||||
// Past the budget the build fails, and the reload drops the server packs.
|
||||
Err(e) if e.kind() == io::ErrorKind::FileTooLarge => Err(e),
|
||||
// One unreadable entry costs that file, not the pack: an error here
|
||||
// failed the build and the reload dropped every server pack in the stack.
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: skipping unreadable pack entry {inner}: {e}");
|
||||
let _ = fs::remove_file(target);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
})
|
||||
.into_iter()
|
||||
.collect()
|
||||
.collect();
|
||||
if extracted.is_err() {
|
||||
// What was unpacked before the budget ran out goes too, not only the marker.
|
||||
let _ = fs::remove_dir_all(out);
|
||||
}
|
||||
extracted
|
||||
}
|
||||
/// The most the zip packs in `packs` may unpack to in one overlay: 16 times their size on disk
|
||||
/// (real packs are mostly PNGs and JSON, a few times smaller zipped at most), at least 64 MiB
|
||||
/// for a small pack of text, and never past 8 GiB.
|
||||
fn unpack_budget(packs: &[Pack]) -> u64 {
|
||||
packs
|
||||
.iter()
|
||||
.filter_map(|p| match &p.source {
|
||||
Source::Zip(z) => fs::metadata(z).ok().map(|m| m.len()),
|
||||
Source::Dir(_) => None,
|
||||
})
|
||||
.sum::<u64>()
|
||||
.saturating_mul(16)
|
||||
.clamp(64 << 20, 8 << 30)
|
||||
}
|
||||
/// Files vanilla loads from every pack in turn (`getResourceStack`) rather
|
||||
/// than from the top pack only.
|
||||
@@ -1000,7 +1031,10 @@ pub fn resource_root(vanilla: &Path, packs: &[Pack], cache: &Path) -> io::Result
|
||||
(h ^ u64::from(b)).wrapping_mul(0x100_0000_01b3)
|
||||
});
|
||||
let out = cache.join(format!("{hash:016x}"));
|
||||
cache::hold(&out)?;
|
||||
build_overlay(vanilla, packs, &out)?;
|
||||
// Old selections' overlays would otherwise stay for good, each a whole unpacked copy.
|
||||
cache::prune(cache);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -1736,6 +1770,77 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
#[test]
|
||||
fn a_pack_that_unpacks_past_its_budget_fails_and_leaves_nothing() {
|
||||
use std::io::Write as _;
|
||||
let base = std::env::temp_dir().join(format!("horizon-pack-bomb-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&base);
|
||||
let vanilla = base.join("vanilla");
|
||||
fs::create_dir_all(vanilla.join("assets/minecraft/textures/block")).unwrap();
|
||||
fs::write(
|
||||
vanilla.join("assets/minecraft/textures/block/stone.png"),
|
||||
b"vanilla",
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir_all(base.join("packs")).unwrap();
|
||||
let pack = |name: &str, files: &[(&str, usize)]| {
|
||||
let path = base.join("packs").join(name);
|
||||
let mut zip = zip::ZipWriter::new(fs::File::create(&path).unwrap());
|
||||
let deflated = zip::write::SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated);
|
||||
zip.start_file("pack.mcmeta", deflated).unwrap();
|
||||
zip.write_all(br#"{"pack":{"description":"d","pack_format":97}}"#)
|
||||
.unwrap();
|
||||
let zeros = vec![0u8; 1 << 20];
|
||||
for (file, mib) in files {
|
||||
zip.start_file(*file, deflated).unwrap();
|
||||
for _ in 0..*mib {
|
||||
zip.write_all(&zeros).unwrap();
|
||||
}
|
||||
}
|
||||
zip.finish().unwrap();
|
||||
fs::metadata(&path).unwrap().len()
|
||||
};
|
||||
// Four 24 MiB files of zeros: well under the per-file limit, 96 MiB in all from a zip of
|
||||
// a few hundred KiB, past the 64 MiB a small pack may unpack to.
|
||||
let size = pack(
|
||||
"bomb.zip",
|
||||
&[
|
||||
("assets/minecraft/textures/block/a.png", 24),
|
||||
("assets/minecraft/textures/block/b.png", 24),
|
||||
("assets/minecraft/textures/block/c.png", 24),
|
||||
("assets/minecraft/textures/block/d.png", 24),
|
||||
],
|
||||
);
|
||||
assert!(size < 1 << 20, "the bomb is small: {size} bytes");
|
||||
let packs = discover(&vanilla, &base.join("packs"));
|
||||
assert_eq!(packs.len(), 1);
|
||||
let out = base.join("overlay");
|
||||
let error = build_overlay(&vanilla, &packs, &out).unwrap_err();
|
||||
assert_eq!(error.kind(), io::ErrorKind::FileTooLarge, "{error}");
|
||||
assert!(
|
||||
!out.exists(),
|
||||
"what was unpacked before the budget ran out stays"
|
||||
);
|
||||
// The same files within the budget unpack whole.
|
||||
fs::remove_file(base.join("packs/bomb.zip")).unwrap();
|
||||
pack(
|
||||
"fine.zip",
|
||||
&[
|
||||
("assets/minecraft/textures/block/a.png", 24),
|
||||
("assets/minecraft/textures/block/b.png", 24),
|
||||
],
|
||||
);
|
||||
let packs = discover(&vanilla, &base.join("packs"));
|
||||
build_overlay(&vanilla, &packs, &out).unwrap();
|
||||
assert_eq!(
|
||||
fs::metadata(out.join("assets/minecraft/textures/block/b.png"))
|
||||
.unwrap()
|
||||
.len(),
|
||||
24 << 20
|
||||
);
|
||||
let _ = fs::remove_dir_all(&base);
|
||||
}
|
||||
#[test]
|
||||
fn zip_entries_never_leave_the_overlay() {
|
||||
use std::io::Write as _;
|
||||
let base = std::env::temp_dir().join(format!("horizon-pack-escape-{}", std::process::id()));
|
||||
|
||||
@@ -297,6 +297,107 @@ fn lock_path(out: &Path) -> PathBuf {
|
||||
marker_path(out).with_extension("lock")
|
||||
}
|
||||
|
||||
/// The overlays [`prune`] keeps in a cache folder, newest use first. Each holds a whole unpacked
|
||||
/// copy of its zip packs, and every server pack, and every new version of one, makes another.
|
||||
pub(super) const KEEP: usize = 8;
|
||||
|
||||
/// The file whose shared lock says a process uses `out`, and whose time is its last use.
|
||||
fn use_path(out: &Path) -> PathBuf {
|
||||
marker_path(out).with_extension("use")
|
||||
}
|
||||
|
||||
/// Marks `out` as used by this process until it exits ([`prune`] never deletes it), and as used
|
||||
/// now. The lock is taken before the overlay is built or read, so a prune in another process
|
||||
/// that took the folder first finishes deleting it before this one rebuilds it.
|
||||
pub(super) fn hold(out: &Path) -> io::Result<()> {
|
||||
use std::sync::{Mutex, PoisonError};
|
||||
static HELD: Mutex<Vec<(PathBuf, fs::File)>> = Mutex::new(Vec::new());
|
||||
if let Some(parent) = out.parent().filter(|p| !p.as_os_str().is_empty()) {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut held = HELD.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let at = match held.iter().position(|(path, _)| path == out) {
|
||||
Some(at) => at,
|
||||
None => {
|
||||
let file = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(use_path(out))?;
|
||||
file.lock_shared()?;
|
||||
held.push((out.into(), file));
|
||||
held.len() - 1
|
||||
}
|
||||
};
|
||||
// Only the order of the prune depends on it: a filesystem without the time loses nothing.
|
||||
let _ = held[at].1.set_modified(std::time::SystemTime::now());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Deletes the overlays in `cache` past the [`KEEP`] most recently used, skipping any that a
|
||||
/// process holds ([`hold`]) or builds. Only `<16 hex digits>` folders are overlays.
|
||||
pub(super) fn prune(cache: &Path) {
|
||||
let Ok(entries) = fs::read_dir(cache) else {
|
||||
return;
|
||||
};
|
||||
let mut overlays: Vec<(std::time::SystemTime, PathBuf)> = entries
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| {
|
||||
p.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.is_some_and(|n| n.len() == 16 && n.bytes().all(|b| b.is_ascii_hexdigit()))
|
||||
&& p.is_dir()
|
||||
})
|
||||
.map(|p| {
|
||||
// An overlay built before use files existed goes by its marker or its folder.
|
||||
let used = [use_path(&p), marker_path(&p), p.clone()]
|
||||
.iter()
|
||||
.find_map(|f| fs::metadata(f).and_then(|m| m.modified()).ok())
|
||||
.unwrap_or(std::time::UNIX_EPOCH);
|
||||
(used, p)
|
||||
})
|
||||
.collect();
|
||||
overlays.sort_by_key(|(used, _)| std::cmp::Reverse(*used));
|
||||
for (_, old) in overlays.into_iter().skip(KEEP) {
|
||||
if let Err(e) = remove_unused(&old) {
|
||||
log::warn!(
|
||||
"Horizon: couldn't remove the old pack overlay {}: {e}",
|
||||
old.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes the overlay `out` unless a process builds or uses it. The lock files stay: deleting
|
||||
/// them would let two processes lock different files of the same name.
|
||||
fn remove_unused(out: &Path) -> io::Result<()> {
|
||||
let open = |path: PathBuf| {
|
||||
fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(path)
|
||||
};
|
||||
let building = open(lock_path(out))?;
|
||||
let using = open(use_path(out))?;
|
||||
for lock in [&building, &using] {
|
||||
match lock.try_lock() {
|
||||
Ok(()) => {}
|
||||
Err(fs::TryLockError::WouldBlock) => return Ok(()),
|
||||
Err(fs::TryLockError::Error(e)) => return Err(e),
|
||||
}
|
||||
}
|
||||
// The marker first: a folder half deleted is never taken for a built one.
|
||||
match fs::remove_file(marker_path(out)) {
|
||||
Err(e) if e.kind() != io::ErrorKind::NotFound => return Err(e),
|
||||
_ => {}
|
||||
}
|
||||
fs::remove_dir_all(out)
|
||||
}
|
||||
|
||||
pub(super) fn build(vanilla: &Path, packs: &[Pack], out: &Path) -> io::Result<()> {
|
||||
if let Some(parent) = out.parent().filter(|p| !p.as_os_str().is_empty()) {
|
||||
fs::create_dir_all(parent)?;
|
||||
@@ -368,6 +469,77 @@ mod tests {
|
||||
use crate::{packs, text::Text};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[test]
|
||||
fn prune_keeps_the_most_recently_used_overlays_and_those_in_use() {
|
||||
let f = Fixture::new();
|
||||
let cache = f.0.join("cache");
|
||||
let now = std::time::SystemTime::now();
|
||||
// KEEP + 4 overlays, overlay 0 used last; one more not an overlay.
|
||||
let overlays: Vec<PathBuf> = (0..KEEP + 4)
|
||||
.map(|i| {
|
||||
let out = cache.join(format!("{i:016x}"));
|
||||
f.write(&format!("cache/{i:016x}/assets/a.png"), b"x");
|
||||
f.write(&format!("cache/{i:016x}.overlay-cache.json"), b"{}");
|
||||
let used = fs::File::create(use_path(&out)).unwrap();
|
||||
used.set_modified(now - std::time::Duration::from_secs(60 * i as u64))
|
||||
.unwrap();
|
||||
out
|
||||
})
|
||||
.collect();
|
||||
f.write("cache/notes/keep.txt", b"x");
|
||||
// Another process uses the oldest, and builds the second oldest.
|
||||
let using = fs::File::open(use_path(&overlays[KEEP + 3])).unwrap();
|
||||
using.lock_shared().unwrap();
|
||||
let building = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(lock_path(&overlays[KEEP + 2]))
|
||||
.unwrap();
|
||||
building.lock().unwrap();
|
||||
prune(&cache);
|
||||
let kept: Vec<bool> = overlays.iter().map(|o| o.is_dir()).collect();
|
||||
let mut expected = vec![true; KEEP];
|
||||
expected.extend([false, false, true, true]);
|
||||
assert_eq!(kept, expected);
|
||||
assert!(
|
||||
!marker_path(&overlays[KEEP]).exists(),
|
||||
"never reused half deleted"
|
||||
);
|
||||
assert!(cache.join("notes/keep.txt").exists());
|
||||
// Released, they go at the next prune.
|
||||
drop((using, building));
|
||||
prune(&cache);
|
||||
assert_eq!(
|
||||
overlays.iter().filter(|o| o.is_dir()).count(),
|
||||
KEEP,
|
||||
"only the most recent stay"
|
||||
);
|
||||
assert!(overlays[..KEEP].iter().all(|o| o.is_dir()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_overlay_held_by_this_process_is_never_pruned() {
|
||||
let f = Fixture::new();
|
||||
let cache = f.0.join("cache");
|
||||
let held = cache.join("00000000000000ff");
|
||||
f.write("cache/00000000000000ff/assets/a.png", b"x");
|
||||
hold(&held).unwrap();
|
||||
// Older than KEEP others, and still in use.
|
||||
let old = std::time::SystemTime::now() - std::time::Duration::from_secs(3600);
|
||||
fs::File::options()
|
||||
.write(true)
|
||||
.open(use_path(&held))
|
||||
.unwrap()
|
||||
.set_modified(old)
|
||||
.unwrap();
|
||||
for i in 0..KEEP {
|
||||
f.write(&format!("cache/{i:016x}/assets/a.png"), b"x");
|
||||
}
|
||||
prune(&cache);
|
||||
assert!(held.is_dir());
|
||||
}
|
||||
|
||||
struct Fixture(PathBuf);
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
|
||||
@@ -58,6 +58,8 @@ pub struct ParticleOptions {
|
||||
/// A native mod's own particle type, by its namespaced name (`mymod:spark`):
|
||||
/// `SimpleParticleType` plus the provider `particles/<name>.json` names.
|
||||
pub mod_particle: Option<String>,
|
||||
/// Pre-1.20.5 entity-effect providers use spawn velocity as RGB; true is the ambient provider.
|
||||
pub legacy_entity_effect: Option<bool>,
|
||||
}
|
||||
impl ParticleOptions {
|
||||
pub fn simple(ty: ParticleType) -> Self {
|
||||
@@ -72,6 +74,7 @@ impl ParticleOptions {
|
||||
geyser: None,
|
||||
vibration: None,
|
||||
mod_particle: None,
|
||||
legacy_entity_effect: None,
|
||||
}
|
||||
}
|
||||
/// A native mod's `SimpleParticleType`: the sprite set of
|
||||
@@ -3167,8 +3170,16 @@ impl ParticleEngine {
|
||||
q.set_color(b, 0.0, b);
|
||||
}
|
||||
T::EntityEffect => {
|
||||
q.set_color(o.color[0], o.color[1], o.color[2]);
|
||||
q.rgba[3] = o.color[3];
|
||||
if let Some(ambient) = o.legacy_entity_effect {
|
||||
q.set_color(v[0] as f32, v[1] as f32, v[2] as f32);
|
||||
// AmbientMobProvider overrides constructor alpha; MobProvider only sets RGB.
|
||||
if ambient {
|
||||
q.rgba[3] = 0.15;
|
||||
}
|
||||
} else {
|
||||
q.set_color(o.color[0], o.color[1], o.color[2]);
|
||||
q.rgba[3] = o.color[3];
|
||||
}
|
||||
}
|
||||
T::Effect | T::InstantEffect => {
|
||||
q.set_color(o.color[0], o.color[1], o.color[2]);
|
||||
@@ -3177,7 +3188,12 @@ impl ParticleEngine {
|
||||
_ => {}
|
||||
}
|
||||
q.behaviour = Behaviour::Spell {
|
||||
original_alpha: q.rgba[3],
|
||||
// The old SpellParticle.tick always eased back to 1, including ambient effects.
|
||||
original_alpha: if o.legacy_entity_effect.is_some() {
|
||||
1.0
|
||||
} else {
|
||||
q.rgba[3]
|
||||
},
|
||||
};
|
||||
q.set = set;
|
||||
q
|
||||
@@ -3469,7 +3485,13 @@ impl ParticleEngine {
|
||||
scaled(o.color[2], jb),
|
||||
);
|
||||
q.size = 0.26;
|
||||
q.lifetime = duration;
|
||||
// The older-server adapter marks pre-1.21.4 TargetColorParticleOption with -1.
|
||||
// Its TrailParticle.Provider chose nextInt(40) + 10 after constructing the particle.
|
||||
q.lifetime = if duration == -1 {
|
||||
self.level_random.next_int_bound(40).unwrap_or(0) + 10
|
||||
} else {
|
||||
duration
|
||||
};
|
||||
q.has_physics = false;
|
||||
q.free_move = true;
|
||||
q.light = Light::FullBright;
|
||||
@@ -4941,6 +4963,45 @@ const CREEPER: [[f64; 2]; 12] = [
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn legacy_spell_providers_use_velocity_rgb_and_their_original_alpha_rules() {
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
let velocity = [0.25, 0.5, 0.75];
|
||||
for scoped in [false, true] {
|
||||
engine.set_scope_eye(scoped.then_some([0.0; 3]));
|
||||
for ambient in [false, true] {
|
||||
let mut options =
|
||||
ParticleOptions::colored(ParticleType::EntityEffect, [1.0, 0.0, 0.0, 0.8]);
|
||||
options.legacy_entity_effect = Some(ambient);
|
||||
let particle = engine.make(&options, [0.0; 3], velocity, 7).unwrap();
|
||||
let alpha = if ambient {
|
||||
0.15
|
||||
} else if scoped {
|
||||
0.0
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
assert_eq!(particle.rgba, [0.25, 0.5, 0.75, alpha]);
|
||||
assert_eq!(
|
||||
particle.behaviour,
|
||||
Behaviour::Spell {
|
||||
original_alpha: 1.0
|
||||
}
|
||||
);
|
||||
}
|
||||
let options =
|
||||
ParticleOptions::colored(ParticleType::EntityEffect, [1.0, 0.0, 0.0, 0.8]);
|
||||
let current = engine.make(&options, [0.0; 3], velocity, 7).unwrap();
|
||||
assert_eq!(current.rgba, options.color);
|
||||
assert_eq!(
|
||||
current.behaviour,
|
||||
Behaviour::Spell {
|
||||
original_alpha: 0.8
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn particle_light_cache_reuses_exact_blocks_and_fails_open_on_collisions() {
|
||||
let reads = std::cell::Cell::new(0);
|
||||
@@ -5700,6 +5761,33 @@ mod tests {
|
||||
assert!((0.15 * 0.4 - 1e-6..=0.45 * 0.4 + 1e-6).contains(&s));
|
||||
}
|
||||
#[test]
|
||||
fn legacy_trails_choose_a_lifetime_for_each_particle_from_the_level_random() {
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
engine.seed_randoms(1, 42, 2);
|
||||
for _ in 0..5 {
|
||||
engine.create(
|
||||
&ParticleOptions::trail([1.0, 2.0, 3.0], 0x7f3f1f, -1),
|
||||
[0.0; 3],
|
||||
[0.0; 3],
|
||||
);
|
||||
}
|
||||
// java.util.Random(42).nextInt(40) + 10, as vanilla 1.21.3 TrailParticle.Provider does.
|
||||
assert_eq!(
|
||||
engine
|
||||
.pending
|
||||
.iter()
|
||||
.map(|p| p.lifetime)
|
||||
.collect::<Vec<_>>(),
|
||||
[20, 13, 18, 14, 20]
|
||||
);
|
||||
engine.create(
|
||||
&ParticleOptions::trail([1.0, 2.0, 3.0], 0x7f3f1f, 37),
|
||||
[0.0; 3],
|
||||
[0.0; 3],
|
||||
);
|
||||
assert_eq!(engine.pending.last().unwrap().lifetime, 37);
|
||||
}
|
||||
#[test]
|
||||
fn sizes_follow_vanilla_curves() {
|
||||
let mut r = LegacyRandom::new(2);
|
||||
let mut p = Particle::at(&mut r, ParticleType::Flame, [0.0; 3]);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
//! The randoms vanilla creates unseeded: `RandomSource.create()` (sounds,
|
||||
//! screens, the torch flicker, a raid's random) and an entity's own random
|
||||
//! (`Entity.random`). Each is seeded from one shared counter and the clock, as
|
||||
//! `RandomSupport.generateUniqueSeed` does. Kept out of `horizon_core::random`,
|
||||
//! whose streams are only ever seeded explicitly.
|
||||
use horizon_core::random::LegacyRandom;
|
||||
|
||||
/// `RandomSource.create()`.
|
||||
pub fn create() -> LegacyRandom {
|
||||
LegacyRandom::new(unique_seed())
|
||||
}
|
||||
|
||||
/// An entity's own random (`Entity.random`). Vanilla's is
|
||||
/// `RandomSource.createThreadLocalInstance`, seeded from Java's
|
||||
/// `ThreadLocalRandom`; Horizon seeds it as `create` does. Draws from an
|
||||
/// entity's own random never touch the level's.
|
||||
pub fn entity() -> LegacyRandom {
|
||||
create()
|
||||
}
|
||||
|
||||
/// `RandomSupport.generateUniqueSeed`. Under `HORIZON_STILL` (screenshots that
|
||||
/// come out the same every run) the clock is left out, so the seeds repeat run
|
||||
/// to run, in the order they are taken.
|
||||
pub fn unique_seed() -> i64 {
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
static UNIQUIFIER: AtomicI64 = AtomicI64::new(8_682_522_807_148_012);
|
||||
let mut current = UNIQUIFIER.load(Ordering::Relaxed);
|
||||
let next = loop {
|
||||
let next = current.wrapping_mul(1_181_783_497_276_652_981);
|
||||
match UNIQUIFIER.compare_exchange(current, next, Ordering::Relaxed, Ordering::Relaxed) {
|
||||
Ok(_) => break next,
|
||||
Err(v) => current = v,
|
||||
}
|
||||
};
|
||||
if still() {
|
||||
return next;
|
||||
}
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_nanos() as i64);
|
||||
next ^ nanos
|
||||
}
|
||||
|
||||
/// `HORIZON_STILL=1`, read once, as the clock's `still_requested` reads it.
|
||||
fn still() -> bool {
|
||||
static STILL: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*STILL.get_or_init(|| std::env::var("HORIZON_STILL").is_ok_and(|v| v == "1"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_core::random::RandomSource;
|
||||
|
||||
/// One shared counter: seeds taken back to back never repeat, even when
|
||||
/// the clock hasn't moved between them.
|
||||
#[test]
|
||||
fn each_seed_and_each_random_is_new() {
|
||||
let seeds: std::collections::HashSet<i64> = (0..1000).map(|_| unique_seed()).collect();
|
||||
assert_eq!(seeds.len(), 1000);
|
||||
assert_ne!(create().next_long(), create().next_long());
|
||||
}
|
||||
}
|
||||
@@ -1580,27 +1580,6 @@ impl SoundEngine {
|
||||
pub fn active_instances(&self) -> Vec<InstanceId> {
|
||||
self.instance_to_channel.iter().map(|(i, _)| *i).collect()
|
||||
}
|
||||
/// A fresh unseeded random (`RandomSource.create()`).
|
||||
pub fn unseeded() -> LegacyRandom {
|
||||
LegacyRandom::new(unique_seed())
|
||||
}
|
||||
}
|
||||
/// `RandomSupport.generateUniqueSeed`.
|
||||
pub fn unique_seed() -> i64 {
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
static UNIQUIFIER: AtomicI64 = AtomicI64::new(8_682_522_807_148_012);
|
||||
let mut current = UNIQUIFIER.load(Ordering::Relaxed);
|
||||
let next = loop {
|
||||
let next = current.wrapping_mul(1_181_783_497_276_652_981);
|
||||
match UNIQUIFIER.compare_exchange(current, next, Ordering::Relaxed, Ordering::Relaxed) {
|
||||
Ok(_) => break next,
|
||||
Err(v) => current = v,
|
||||
}
|
||||
};
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_nanos() as i64);
|
||||
next ^ nanos
|
||||
}
|
||||
/// The `SoundManager` calls game code makes.
|
||||
pub trait Sounds {
|
||||
@@ -1774,7 +1753,7 @@ mod tests {
|
||||
silent: false,
|
||||
underwater: false,
|
||||
};
|
||||
let mut s = SoundInstance::riding_happy_ghast(&ghast, SoundEngine::unseeded());
|
||||
let mut s = SoundInstance::riding_happy_ghast(&ghast, crate::random_source::create());
|
||||
assert_eq!(s.volume, 0.0);
|
||||
let mut ctx = TickContext {
|
||||
ridden: Some(ghast),
|
||||
@@ -1821,7 +1800,7 @@ mod tests {
|
||||
attack_scale: 0.0,
|
||||
sniffer_digging: true,
|
||||
};
|
||||
let mut s = SoundInstance::sniffer(3, SoundEngine::unseeded());
|
||||
let mut s = SoundInstance::sniffer(3, crate::random_source::create());
|
||||
assert_eq!(s.position, [0.0; 3]);
|
||||
assert!(!s.looping);
|
||||
let mut ctx = TickContext {
|
||||
@@ -1836,7 +1815,7 @@ mod tests {
|
||||
s.tick(&ctx);
|
||||
assert!(s.stopped);
|
||||
// Removed (missing): stop.
|
||||
let mut s = SoundInstance::sniffer(3, SoundEngine::unseeded());
|
||||
let mut s = SoundInstance::sniffer(3, crate::random_source::create());
|
||||
ctx.mobs.clear();
|
||||
s.tick(&ctx);
|
||||
assert!(s.stopped);
|
||||
@@ -1850,7 +1829,7 @@ mod tests {
|
||||
silent: false,
|
||||
underwater: true,
|
||||
};
|
||||
let mut s = SoundInstance::riding_nautilus(&nautilus, SoundEngine::unseeded());
|
||||
let mut s = SoundInstance::riding_nautilus(&nautilus, crate::random_source::create());
|
||||
let mut ctx = TickContext {
|
||||
ridden: Some(nautilus),
|
||||
..TickContext::default()
|
||||
@@ -1953,7 +1932,7 @@ mod tests {
|
||||
position: [10.0, 64.0, 5.0],
|
||||
speed: 0.0,
|
||||
};
|
||||
let mut s = SoundInstance::minecart(&cart, SoundEngine::unseeded());
|
||||
let mut s = SoundInstance::minecart(&cart, crate::random_source::create());
|
||||
assert!(s.can_start_silent);
|
||||
assert_eq!(s.volume, 0.0);
|
||||
|
||||
@@ -1993,8 +1972,10 @@ mod tests {
|
||||
position: [0.0, 60.0, 0.0],
|
||||
speed: 0.4,
|
||||
};
|
||||
let mut surface = SoundInstance::riding_minecart(&cart, false, SoundEngine::unseeded());
|
||||
let mut underwater = SoundInstance::riding_minecart(&cart, true, SoundEngine::unseeded());
|
||||
let mut surface =
|
||||
SoundInstance::riding_minecart(&cart, false, crate::random_source::create());
|
||||
let mut underwater =
|
||||
SoundInstance::riding_minecart(&cart, true, crate::random_source::create());
|
||||
|
||||
let mut ctx = TickContext {
|
||||
vehicle: Some(99),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! `SoundPreviewHandler`: the sound a category volume slider plays while it
|
||||
//! moves on the title screen's options (`Options.createSoundSliderOptionInstance`
|
||||
//! calls `preview` when there is no level).
|
||||
use crate::random_source;
|
||||
use crate::settings::SOUND_SOURCES;
|
||||
use crate::sound_engine::{InstanceId, SoundEngine, SoundInstance, Sounds};
|
||||
use crate::sound_engine::{InstanceId, SoundInstance, Sounds};
|
||||
|
||||
/// The sound `SoundPreviewHandler.preview` plays for a `SoundSource` index,
|
||||
/// or `None` (`SoundEvents.EMPTY`) for master, music and voice.
|
||||
@@ -44,7 +45,7 @@ impl SoundPreview {
|
||||
if let Some(old) = self.active.take() {
|
||||
sounds.release(old);
|
||||
}
|
||||
let instance = SoundInstance::for_ui(event, 1.0, volume, SoundEngine::unseeded());
|
||||
let instance = SoundInstance::for_ui(event, 1.0, volume, random_source::create());
|
||||
self.active = Some(sounds.play_instance(instance, true).0);
|
||||
}
|
||||
|
||||
@@ -72,7 +73,7 @@ impl SoundPreview {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::sound_engine::{Bound, TickContext, UI, test_backend::Null};
|
||||
use crate::sound_engine::{Bound, SoundEngine, TickContext, UI, test_backend::Null};
|
||||
use crate::sound_events::SoundRegistry;
|
||||
|
||||
fn engine() -> SoundEngine {
|
||||
|
||||
@@ -70,6 +70,12 @@ pub type Proto = Box<dyn std::any::Any + Send>;
|
||||
pub trait ChunkGenerator: Send + Sync + 'static {
|
||||
type Worker: Send + 'static;
|
||||
fn worker(&self) -> Self::Worker;
|
||||
/// The persistent wrapper supplies starts from their owning chunk, in every dimension.
|
||||
fn set_saved_starts(
|
||||
&self,
|
||||
_source: Arc<horizon_world::worldgen::generator::SavedStructureStarts>,
|
||||
) {
|
||||
}
|
||||
fn lod_sampler(self: Arc<Self>) -> Box<dyn LodSampler>
|
||||
where
|
||||
Self: Sized,
|
||||
|
||||
@@ -9,6 +9,102 @@ use horizon_assets::palette::apply_palette;
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fs, path::Path};
|
||||
|
||||
/// Runtime trim slots and the completed ordered requests for their current atlas.
|
||||
/// A frame with more requests than slots keeps the same final residents as the
|
||||
/// sequential LRU pass, without decoding and evicting them again every frame.
|
||||
#[derive(Default)]
|
||||
pub struct TrimSlots {
|
||||
slots: Vec<(String, usize)>,
|
||||
atlas_revision: u64,
|
||||
prepared: Option<Vec<TrimTexture>>,
|
||||
}
|
||||
|
||||
impl TrimSlots {
|
||||
/// The renderer was rebuilt: runtime pixels must be uploaded again.
|
||||
pub fn forget(&mut self) {
|
||||
self.atlas_revision = 0;
|
||||
self.prepared = None;
|
||||
}
|
||||
|
||||
pub fn prepare(
|
||||
&mut self,
|
||||
wanted: Vec<TrimTexture>,
|
||||
root: &Path,
|
||||
atlas: &mut crate::hud::HudAtlas,
|
||||
patches: &mut Vec<crate::font::Patch>,
|
||||
) {
|
||||
if self.atlas_revision != atlas.revision {
|
||||
for (name, _) in self.slots.drain(..) {
|
||||
atlas.sprites.remove(&name);
|
||||
}
|
||||
self.atlas_revision = atlas.revision;
|
||||
self.prepared = None;
|
||||
}
|
||||
let before = self.slots.len();
|
||||
self.slots
|
||||
.retain(|(name, _)| atlas.sprites.contains_key(name));
|
||||
if before != self.slots.len() {
|
||||
self.prepared = None;
|
||||
}
|
||||
if self.prepared.as_ref() == Some(&wanted) {
|
||||
return;
|
||||
}
|
||||
let mut complete = true;
|
||||
for t in &wanted {
|
||||
let name = t.sprite();
|
||||
if let Some(i) = self.slots.iter().position(|e| e.0 == name) {
|
||||
let e = self.slots.remove(i);
|
||||
self.slots.push(e);
|
||||
continue;
|
||||
}
|
||||
let Some((w, h, rgba)) = t.generate(root) else {
|
||||
complete = false;
|
||||
continue;
|
||||
};
|
||||
let slot =
|
||||
match (0..crate::hud::TRIM_SLOTS).find(|i| !self.slots.iter().any(|e| e.1 == *i)) {
|
||||
Some(free) => free,
|
||||
None => {
|
||||
let (old, slot) = self.slots.remove(0);
|
||||
atlas.sprites.remove(&old);
|
||||
slot
|
||||
}
|
||||
};
|
||||
let Some(area) = atlas.sprites.get(&format!("horizon/trim{slot}")).copied() else {
|
||||
complete = false;
|
||||
continue;
|
||||
};
|
||||
if w > area.w || h > area.h {
|
||||
complete = false;
|
||||
continue;
|
||||
}
|
||||
let (aw, ah) = (atlas.width as f32, atlas.height as f32);
|
||||
let (x, y) = ((area.u0 * aw).round(), (area.v0 * ah).round());
|
||||
patches.push(crate::font::Patch {
|
||||
x: x as u32,
|
||||
y: y as u32,
|
||||
width: w,
|
||||
height: h,
|
||||
rgba,
|
||||
});
|
||||
atlas.sprites.insert(
|
||||
name.clone(),
|
||||
crate::hud::Rect {
|
||||
u0: area.u0,
|
||||
v0: area.v0,
|
||||
u1: (x + w as f32) / aw,
|
||||
v1: (y + h as f32) / ah,
|
||||
w,
|
||||
h,
|
||||
},
|
||||
);
|
||||
self.slots.push((name, slot));
|
||||
}
|
||||
// Failed or not-yet-available textures retain their per-frame retries.
|
||||
self.prepared = complete.then_some(wanted);
|
||||
}
|
||||
}
|
||||
|
||||
/// An RGBA image: width, height, pixels.
|
||||
pub type Image = (u32, u32, Vec<u8>);
|
||||
|
||||
@@ -249,6 +345,9 @@ fn png_size(path: &Path) -> Option<(u32, u32)> {
|
||||
(&head[12..16] == b"IHDR").then(|| (be(16), be(20)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod slots_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
use super::*;
|
||||
use crate::hud::{HudAtlas, Rect, TRIM_SLOTS};
|
||||
use std::path::PathBuf;
|
||||
|
||||
struct Fixture(PathBuf);
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"horizon-trim-slots-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
));
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
Self(root)
|
||||
}
|
||||
|
||||
fn texture(&self, id: u8) -> TrimTexture {
|
||||
TrimTexture {
|
||||
texture: format!("test:trims/entity/humanoid/{id}"),
|
||||
palette: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn write(&self, id: u8, width: u32) {
|
||||
let path = asset_path(&self.0, "textures", &self.texture(id).texture, ".png");
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let mut encoder = png::Encoder::new(fs::File::create(path).unwrap(), width, 1);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&[id, 255 - id, 42, 255].repeat(width as usize))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn atlas(&self) -> HudAtlas {
|
||||
let mut atlas = HudAtlas::fonts(&self.0).unwrap();
|
||||
atlas.width = TRIM_SLOTS as u32 * 2;
|
||||
atlas.height = 1;
|
||||
atlas.pixels = vec![0; atlas.width as usize * 4];
|
||||
for i in 0..TRIM_SLOTS {
|
||||
atlas.sprites.insert(
|
||||
format!("horizon/trim{i}"),
|
||||
Rect {
|
||||
u0: i as f32 / TRIM_SLOTS as f32,
|
||||
u1: (i + 1) as f32 / TRIM_SLOTS as f32,
|
||||
v0: 0.0,
|
||||
v1: 1.0,
|
||||
w: 2,
|
||||
h: 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
atlas
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn upload(atlas: &mut HudAtlas, patches: &mut Vec<crate::font::Patch>) {
|
||||
for patch in patches.drain(..) {
|
||||
for y in 0..patch.height as usize {
|
||||
let offset = ((patch.y as usize + y) * atlas.width as usize + patch.x as usize) * 4;
|
||||
let width = patch.width as usize * 4;
|
||||
atlas.pixels[offset..offset + width]
|
||||
.copy_from_slice(&patch.rgba[y * width..(y + 1) * width]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn color(atlas: &HudAtlas, texture: &TrimTexture) -> Option<[u8; 4]> {
|
||||
let rect = atlas.sprites.get(&texture.sprite())?;
|
||||
let x = (rect.u0 * atlas.width as f32).round() as usize;
|
||||
Some(atlas.pixels[x * 4..x * 4 + 4].try_into().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflow_keeps_the_rendered_pixels_without_reuploading_each_frame() {
|
||||
let fixture = Fixture::new();
|
||||
let wanted: Vec<_> = (0..33).map(|id| fixture.texture(id)).collect();
|
||||
for id in 0..33 {
|
||||
fixture.write(id, 2);
|
||||
}
|
||||
let mut atlas = fixture.atlas();
|
||||
let mut slots = TrimSlots::default();
|
||||
let mut patches = Vec::new();
|
||||
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
|
||||
upload(&mut atlas, &mut patches);
|
||||
// The original sequential 32-slot pass finishes with requests 1..=32.
|
||||
assert_eq!(color(&atlas, &wanted[0]), None);
|
||||
for (id, texture) in wanted.iter().enumerate().skip(1) {
|
||||
assert_eq!(
|
||||
color(&atlas, texture),
|
||||
Some([id as u8, 255 - id as u8, 42, 255])
|
||||
);
|
||||
}
|
||||
for _ in 0..3 {
|
||||
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
|
||||
assert!(
|
||||
patches.is_empty(),
|
||||
"unchanged overflow must not upload again"
|
||||
);
|
||||
}
|
||||
wanted
|
||||
.iter()
|
||||
.skip(1)
|
||||
.for_each(|t| assert!(color(&atlas, t).is_some()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_request_order_or_equipment_changes_the_resident_images() {
|
||||
let fixture = Fixture::new();
|
||||
for id in 0..34 {
|
||||
fixture.write(id, 2);
|
||||
}
|
||||
let mut wanted: Vec<_> = (0..33).map(|id| fixture.texture(id)).collect();
|
||||
let mut atlas = fixture.atlas();
|
||||
let mut slots = TrimSlots::default();
|
||||
let mut patches = Vec::new();
|
||||
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
|
||||
upload(&mut atlas, &mut patches);
|
||||
wanted.rotate_left(1);
|
||||
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
|
||||
upload(&mut atlas, &mut patches);
|
||||
assert_eq!(color(&atlas, &fixture.texture(1)), None);
|
||||
assert_eq!(color(&atlas, &fixture.texture(0)), Some([0, 255, 42, 255]));
|
||||
wanted = vec![fixture.texture(33), fixture.texture(2)];
|
||||
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
|
||||
upload(&mut atlas, &mut patches);
|
||||
assert_eq!(color(&atlas, &wanted[0]), Some([33, 222, 42, 255]));
|
||||
assert_eq!(color(&atlas, &wanted[1]), Some([2, 253, 42, 255]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_atlas_rebuild_uploads_the_same_requests_again() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.write(7, 2);
|
||||
let wanted = vec![fixture.texture(7)];
|
||||
let mut slots = TrimSlots::default();
|
||||
let mut patches = Vec::new();
|
||||
let mut atlas = fixture.atlas();
|
||||
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
|
||||
upload(&mut atlas, &mut patches);
|
||||
atlas = fixture.atlas();
|
||||
fixture.write(7, 1);
|
||||
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
|
||||
assert_eq!(patches.len(), 1);
|
||||
assert_eq!(patches[0].width, 1, "reload must decode the new image");
|
||||
upload(&mut atlas, &mut patches);
|
||||
assert_eq!(color(&atlas, &wanted[0]), Some([7, 248, 42, 255]));
|
||||
atlas.pixels.fill(0);
|
||||
slots.forget();
|
||||
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
|
||||
assert_eq!(
|
||||
patches.len(),
|
||||
1,
|
||||
"a rebuilt renderer needs the pixels again"
|
||||
);
|
||||
upload(&mut atlas, &mut patches);
|
||||
assert_eq!(color(&atlas, &wanted[0]), Some([7, 248, 42, 255]));
|
||||
atlas.sprites.remove(&wanted[0].sprite());
|
||||
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
|
||||
assert_eq!(patches.len(), 1, "an erased sprite must be restored too");
|
||||
upload(&mut atlas, &mut patches);
|
||||
assert_eq!(color(&atlas, &wanted[0]), Some([7, 248, 42, 255]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_or_oversized_images_are_retried_when_they_become_available() {
|
||||
let fixture = Fixture::new();
|
||||
let wanted = vec![fixture.texture(9)];
|
||||
let mut atlas = fixture.atlas();
|
||||
let mut slots = TrimSlots::default();
|
||||
let mut patches = Vec::new();
|
||||
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
|
||||
assert_eq!(color(&atlas, &wanted[0]), None);
|
||||
fixture.write(9, 3);
|
||||
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
|
||||
assert!(patches.is_empty());
|
||||
fixture.write(9, 2);
|
||||
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
|
||||
assert_eq!(patches.len(), 1);
|
||||
upload(&mut atlas, &mut patches);
|
||||
assert_eq!(color(&atlas, &wanted[0]), Some([9, 246, 42, 255]));
|
||||
}
|
||||
@@ -56,6 +56,32 @@ fn luma(c: vec3<f32>) -> f32 {
|
||||
return vec4(b, centre.a);
|
||||
}
|
||||
|
||||
// Contrast-adaptive sharpening (after AMD's CAS) of TAA's upscaled picture: each
|
||||
// pixel pushed away from its four neighbours, less where they already differ a lot,
|
||||
// so edges get their crispness back without halos or lifted grain.
|
||||
// The Upscaling Sharpness setting, 0..1.
|
||||
override sharpness: f32 = 0.85;
|
||||
|
||||
@fragment fn fs_sharpen(in: PostOut) -> @location(0) vec4<f32> {
|
||||
let size = vec2<i32>(textureDimensions(world));
|
||||
let p = clamp(vec2<i32>(in.uv * vec2<f32>(size)), vec2(0), size - 1);
|
||||
let centre = textureLoad(world, p, 0);
|
||||
let c = centre.rgb;
|
||||
let n = textureLoad(world, clamp(p + vec2(0, -1), vec2(0), size - 1), 0).rgb;
|
||||
let s = textureLoad(world, clamp(p + vec2(0, 1), vec2(0), size - 1), 0).rgb;
|
||||
let w = textureLoad(world, clamp(p + vec2(-1, 0), vec2(0), size - 1), 0).rgb;
|
||||
let e = textureLoad(world, clamp(p + vec2(1, 0), vec2(0), size - 1), 0).rgb;
|
||||
let lo = min(c, min(min(n, s), min(w, e)));
|
||||
let hi = max(c, max(max(n, s), max(w, e)));
|
||||
// How much room there is before clipping, per channel: little at strong edges.
|
||||
let room = clamp(min(lo, 2.0 - hi) / max(hi, vec3(0.0001)), vec3(0.0), vec3(1.0));
|
||||
// Up to -1/5, the most CAS allows (the Upscaling Sharpness setting's 100%).
|
||||
let lobe = -sqrt(room) * 0.2 * sharpness;
|
||||
let sharp = (c + lobe * (n + s + w + e)) / (1.0 + 4.0 * lobe);
|
||||
// The alpha carries the emissive mark through to HDR output (hdr.wgsl).
|
||||
return vec4(clamp(sharp, lo, hi), centre.a);
|
||||
}
|
||||
|
||||
// TAA (`world_post.rs`): blend this frame into the history, reprojected from the
|
||||
// depth buffer through the camera's motion and clamped to this frame's 3x3
|
||||
// neighbourhood so that what moved or appeared does not ghost.
|
||||
@@ -95,6 +121,28 @@ fn sample_history(uv: vec2<f32>) -> vec3<f32> {
|
||||
return max(c / weight, vec3(0.0));
|
||||
}
|
||||
|
||||
fn to_ycocg(c: vec3<f32>) -> vec3<f32> {
|
||||
return vec3(0.25 * (c.r + 2.0 * c.g + c.b), 0.5 * (c.r - c.b), 0.25 * (2.0 * c.g - c.r - c.b));
|
||||
}
|
||||
|
||||
fn from_ycocg(c: vec3<f32>) -> vec3<f32> {
|
||||
return max(vec3(c.x + c.y - c.z, c.x + c.z, c.x - c.y - c.z), vec3(0.0));
|
||||
}
|
||||
|
||||
// Moves `c` toward the box's centre until it lies inside (clipping keeps the
|
||||
// colour's hue where a per-channel clamp would shift it).
|
||||
fn clip_box(c: vec3<f32>, lo: vec3<f32>, hi: vec3<f32>) -> vec3<f32> {
|
||||
let mid = 0.5 * (hi + lo);
|
||||
let half_size = max(0.5 * (hi - lo), vec3(0.0001));
|
||||
let d = c - mid;
|
||||
let unit = abs(d / half_size);
|
||||
let far = max(unit.x, max(unit.y, unit.z));
|
||||
if far > 1.0 {
|
||||
return mid + d / far;
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
// It runs at the history's size, which upscaling makes larger than the world's.
|
||||
// The history is unjittered: each output pixel takes the world pixel whose
|
||||
// jittered sample lands nearest its centre, weighted by how near it lands.
|
||||
@@ -106,17 +154,24 @@ fn sample_history(uv: vec2<f32>) -> vec3<f32> {
|
||||
let last = vec2<i32>(size) - 1;
|
||||
let p = clamp(vec2<i32>(floor(drawn)), vec2(0), last);
|
||||
let centre = textureLoad(world, p, 0);
|
||||
var lo = centre.rgb;
|
||||
var hi = centre.rgb;
|
||||
var lo = to_ycocg(centre.rgb);
|
||||
var hi = lo;
|
||||
// Mean and spread of the 3x3 (YCoCg): the history is clipped to a box around
|
||||
// the mean a few deviations wide, tighter than min..max, so that what changed
|
||||
// does not linger as a smear.
|
||||
var m1 = vec3(0.0);
|
||||
var m2 = vec3(0.0);
|
||||
// Reversed depth: the nearest of the 3x3 is the largest, and its motion keeps
|
||||
// the edges of what is in front from trailing.
|
||||
var near = 0.0;
|
||||
for (var y = -1; y <= 1; y++) {
|
||||
for (var x = -1; x <= 1; x++) {
|
||||
let q = clamp(p + vec2(x, y), vec2(0), last);
|
||||
let c = textureLoad(world, q, 0).rgb;
|
||||
let c = to_ycocg(textureLoad(world, q, 0).rgb);
|
||||
lo = min(lo, c);
|
||||
hi = max(hi, c);
|
||||
m1 += c;
|
||||
m2 += c * c;
|
||||
near = max(near, textureLoad(depth, q, 0).r);
|
||||
}
|
||||
}
|
||||
@@ -126,12 +181,19 @@ fn sample_history(uv: vec2<f32>) -> vec3<f32> {
|
||||
if taa.jitter.z > 0.0 || any(uv != clamp(uv, vec2(0.0), vec2(1.0))) {
|
||||
return vec4(centre.rgb, centre.a);
|
||||
}
|
||||
let past = clamp(sample_history(uv), lo, hi);
|
||||
// How far this pixel moved since the last frame, in output pixels.
|
||||
let speed = length((uv - in.uv) * vec2<f32>(textureDimensions(history)));
|
||||
let moving = clamp(speed / 6.0, 0.0, 1.0);
|
||||
let mean = m1 / 9.0;
|
||||
let sigma = sqrt(max(m2 / 9.0 - mean * mean, vec3(0.0)));
|
||||
let wide = mix(1.25, 0.75, moving) * sigma;
|
||||
let past = from_ycocg(clip_box(to_ycocg(sample_history(uv)), max(lo, mean - wide), min(hi, mean + wide)));
|
||||
// How far the sample landed from this pixel's centre, in output pixels: a
|
||||
// Gaussian of it scales the new frame's share, so that frames whose sample
|
||||
// fell far away (most of them when upscaling) barely move the pixel.
|
||||
// fell far away (most of them when upscaling) barely move the pixel. Under
|
||||
// motion the history is resampled every frame and blurs, so new frames weigh more.
|
||||
let off = (drawn - (vec2<f32>(p) + 0.5)) * vec2<f32>(textureDimensions(history)) / size;
|
||||
let share = 0.1 * exp(-2.0 * dot(off, off));
|
||||
let share = mix(0.1, 0.3, moving) * exp(-2.0 * dot(off, off));
|
||||
// The alpha carries the emissive mark through to HDR output (hdr.wgsl).
|
||||
return vec4(mix(past, centre.rgb, share), centre.a);
|
||||
}
|
||||
@@ -7,7 +7,10 @@ use std::{
|
||||
fs,
|
||||
io::{self, Read, Seek, SeekFrom},
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
},
|
||||
};
|
||||
use zip::{CompressionMethod, ZipArchive};
|
||||
|
||||
@@ -169,30 +172,12 @@ impl Zip {
|
||||
self.index(name).is_some()
|
||||
}
|
||||
pub fn read(&self, name: &str) -> io::Result<Vec<u8>> {
|
||||
let index = self
|
||||
.index(name)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, name.to_owned()))?;
|
||||
// A clone shares the reader's data and the central directory: reading
|
||||
// needs `&mut`.
|
||||
let mut archive = self.archive.clone();
|
||||
let mut entry = archive.by_index_raw(index).map_err(invalid)?;
|
||||
let method = entry.compression();
|
||||
let mut raw = Vec::with_capacity((entry.compressed_size() as usize).min(self.len));
|
||||
entry.read_to_end(&mut raw)?;
|
||||
let (method, raw) = self.raw(name)?;
|
||||
match method {
|
||||
CompressionMethod::Stored => Ok(raw),
|
||||
CompressionMethod::Deflated => {
|
||||
// Java's ZipFile trusts neither size an entry declares: it
|
||||
// inflates the stream to its end and keeps what comes out.
|
||||
// Packs in the wild (a Nexo-generated one served through
|
||||
// Dropbox, for one) carry central-directory sizes that point
|
||||
// at other files, and miniz_oxide, which refuses input past
|
||||
// the stream's end, failed whole entries with "corrupt
|
||||
// deflate stream" and with them the whole pack. Only the
|
||||
// size limit stands between the archive and a zip bomb.
|
||||
let decoder = flate2::read::DeflateDecoder::new(raw.as_slice());
|
||||
let mut decoded = Vec::with_capacity(raw.len().min(MAX_ENTRY));
|
||||
decoder
|
||||
inflate(&raw)
|
||||
.take(MAX_ENTRY as u64 + 1)
|
||||
.read_to_end(&mut decoded)
|
||||
.map_err(|_| invalid("corrupt deflate stream"))?;
|
||||
@@ -204,6 +189,81 @@ impl Zip {
|
||||
_ => Err(invalid("unsupported zip compression")),
|
||||
}
|
||||
}
|
||||
/// What [`Self::read`] reads, written to `out` a piece at a time as it inflates, each piece
|
||||
/// taken from `budget`: the bytes left for everything sharing it. Past the budget it stops
|
||||
/// with [`io::ErrorKind::FileTooLarge`], `out` holding part of the entry; the entry's own
|
||||
/// limit ([`MAX_ENTRY`]) still applies. Memory stays at the compressed entry and one piece,
|
||||
/// whatever the entry inflates to. The bytes written.
|
||||
pub fn copy_to(
|
||||
&self,
|
||||
name: &str,
|
||||
out: &mut impl io::Write,
|
||||
budget: &AtomicU64,
|
||||
) -> io::Result<u64> {
|
||||
let (method, raw) = self.raw(name)?;
|
||||
let mut from: Box<dyn Read + '_> = match method {
|
||||
CompressionMethod::Stored => Box::new(raw.as_slice()),
|
||||
CompressionMethod::Deflated => Box::new(inflate(&raw)),
|
||||
_ => return Err(invalid("unsupported zip compression")),
|
||||
};
|
||||
let mut piece = vec![0; 64 << 10];
|
||||
let mut total = 0u64;
|
||||
loop {
|
||||
let n = match from.read(&mut piece) {
|
||||
Ok(0) => return Ok(total),
|
||||
Ok(n) => n,
|
||||
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
|
||||
Err(_) => return Err(invalid("corrupt deflate stream")),
|
||||
};
|
||||
total += n as u64;
|
||||
if total > MAX_ENTRY as u64 {
|
||||
return Err(invalid("entry inflates past the size limit"));
|
||||
}
|
||||
take(budget, n as u64)?;
|
||||
out.write_all(&piece[..n])?;
|
||||
}
|
||||
}
|
||||
/// The entry `name` as stored: its compression and its bytes.
|
||||
fn raw(&self, name: &str) -> io::Result<(CompressionMethod, Vec<u8>)> {
|
||||
let index = self
|
||||
.index(name)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, name.to_owned()))?;
|
||||
// A clone shares the reader's data and the central directory: reading
|
||||
// needs `&mut`.
|
||||
let mut archive = self.archive.clone();
|
||||
let mut entry = archive.by_index_raw(index).map_err(invalid)?;
|
||||
let method = entry.compression();
|
||||
let mut raw = Vec::with_capacity((entry.compressed_size() as usize).min(self.len));
|
||||
entry.read_to_end(&mut raw)?;
|
||||
Ok((method, raw))
|
||||
}
|
||||
}
|
||||
|
||||
/// `n` bytes taken from `budget`, or `FileTooLarge` (taking nothing) when it has fewer left.
|
||||
fn take(budget: &AtomicU64, n: u64) -> io::Result<()> {
|
||||
let mut left = budget.load(Ordering::Relaxed);
|
||||
loop {
|
||||
let rest = left.checked_sub(n).ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::FileTooLarge,
|
||||
"the packs unpack to more than their size allows",
|
||||
)
|
||||
})?;
|
||||
match budget.compare_exchange_weak(left, rest, Ordering::Relaxed, Ordering::Relaxed) {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(now) => left = now,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A deflated entry's bytes as they inflate. Java's ZipFile trusts neither size an entry
|
||||
/// declares: it inflates the stream to its end and keeps what comes out. Packs in the wild (a
|
||||
/// Nexo-generated one served through Dropbox, for one) carry central-directory sizes that point
|
||||
/// at other files, and miniz_oxide, which refuses input past the stream's end, failed whole
|
||||
/// entries with "corrupt deflate stream" and with them the whole pack. Only the size limits
|
||||
/// stand between the archive and a zip bomb.
|
||||
fn inflate(raw: &[u8]) -> flate2::read::DeflateDecoder<&[u8]> {
|
||||
flate2::read::DeflateDecoder::new(raw)
|
||||
}
|
||||
|
||||
/// The 22 fixed bytes of the end-of-central-directory record.
|
||||
|
||||
@@ -109,12 +109,19 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
|
||||
|
||||
[features]
|
||||
default = ["audio", "controllers", "discord", "friends", "heartbeat"]
|
||||
default = ["audio", "controllers", "horizon-extras"]
|
||||
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
|
||||
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
|
||||
audio = ["horizon-client-render/audio", "horizon-client-lib/audio"]
|
||||
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
|
||||
controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
|
||||
# Horizon's own extras over the vanilla game, for a fork that wants them out (docs/forking.md):
|
||||
# cosmetics and emotes, the "Horizon player" presence, the official and partner servers added to
|
||||
# the server list, the Horizon Settings and Mods pages, Discord, Play with Friends and the
|
||||
# heartbeat. Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
|
||||
# every place it gates. Without it (`--no-default-features --features audio,controllers`) the
|
||||
# client is best effort: CI builds only the default.
|
||||
horizon-extras = ["discord", "friends", "heartbeat"]
|
||||
# Online extras a server or a port can leave out; the server build has none of them. "Playing Horizon"
|
||||
# on Discord (game/discord.rs).
|
||||
discord = ["horizon-client-lib/discord"]
|
||||
|
||||
@@ -47,6 +47,21 @@
|
||||
"horizon.studio.replay.seek.invalid": "Ungültige Wiedergabeposition",
|
||||
"horizon.studio.replay.status": "Studio-Wiedergabe: %s / %s ms (%s%%), Geschwindigkeit %s×, pausiert %s, beendet %s",
|
||||
"horizon.studio.replay.closed": "Studio-Wiedergabe geschlossen",
|
||||
"horizon.studio.freecam.enabled": "Studio-Freikamera aktiviert. WASD bewegt sie, Leertaste und Umschalt heben oder senken sie, Strg beschleunigt.",
|
||||
"horizon.studio.freecam.disabled": "Studio-Freikamera deaktiviert",
|
||||
"horizon.studio.freecam.unavailable": "Die Studio-Freikamera konnte keine Spielerkamera finden",
|
||||
"horizon.studio.freecam.replay_required": "Die Studio-Freikamera ist während der Wiedergabe verfügbar",
|
||||
"horizon.studio.freecam.usage": "Verwendung: /studio freecam <on|off>",
|
||||
"horizon.studio.keyframe.added": "Kameraschlüsselbild bei %s ms hinzugefügt. Der Pfad wird beim Schließen der Wiedergabe gespeichert.",
|
||||
"horizon.studio.keyframe.cleared": "Kamerapfad geleert. Die Änderung wird beim Schließen der Wiedergabe gespeichert.",
|
||||
"horizon.studio.keyframe.removed": "Kameraschlüsselbild %s entfernt. Die Änderung wird beim Schließen der Wiedergabe gespeichert.",
|
||||
"horizon.studio.keyframe.save_error": "Kamerapfad konnte nicht gespeichert werden: %s",
|
||||
"horizon.studio.keyframe.remove_usage": "Verwendung: /studio keyframe remove <INDEX>",
|
||||
"horizon.studio.keyframe.index_invalid": "Kameraschlüsselbildindex liegt außerhalb des Bereichs",
|
||||
"horizon.studio.keyframe.usage": "Verwendung: /studio keyframe <add|remove INDEX|clear>",
|
||||
"horizon.studio.path.locked": "Studio-Kamerapfadwiedergabe aktiviert",
|
||||
"horizon.studio.path.unlocked": "Studio-Kamerapfadwiedergabe gestoppt",
|
||||
"horizon.studio.path.usage": "Verwendung: /studio path <lock|unlock> (zuerst mindestens ein Schlüsselbild hinzufügen)",
|
||||
"horizon.studio.saved_recordings": "Gespeicherte Aufzeichnungen",
|
||||
"horizon.studio.empty": "Keine Aufzeichnungen im Aufnahmeordner gefunden.",
|
||||
"horizon.studio.empty_hint": "Nutze /studio record start <name> in einer Welt oder lege .mcpr-Dateien dort ab.",
|
||||
@@ -113,6 +128,7 @@
|
||||
"Resolution Scale": "Auflösungsskalierung",
|
||||
"Anti-aliasing": "Kantenglättung",
|
||||
"Upscaling": "Upscaling",
|
||||
"Upscaling Sharpness": "Upscaling-Schärfe",
|
||||
"Paper White": "Papierweiß",
|
||||
"Peak Brightness": "Spitzenhelligkeit",
|
||||
"Black Level": "Schwarzwert",
|
||||
@@ -208,6 +224,7 @@
|
||||
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Weltpixel pro Bildschirmpixel. Unter 100 % ist es auf der GPU schneller.",
|
||||
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Glättet Kanten. FXAA: weicher. TAA: am glattesten, evtl. Schlieren.",
|
||||
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Rendert die Welt kleiner und baut sie mit TAA wieder auf. Schneller, weicher.",
|
||||
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Schärft das hochskalierte Bild. Höher: schärfere Kanten, mehr Flimmern.",
|
||||
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR-Ausgabe auf HDR-Bildschirm: Lava, Feuer, Sonne heller.",
|
||||
"How frames reach the screen while VSync is off.": "Wie Bilder auf den Bildschirm gelangen, wenn VSync aus ist.",
|
||||
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Sparsam schläft bis zum nächsten Bild; Präzise wartet aktiv, wie in Vanilla.",
|
||||
@@ -226,6 +243,7 @@
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Pro Bild an die GPU gesendete Mesh-Daten. Weniger ist flüssiger beim Laden.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Bildrate und Bildzeiten über dem Spiel, mit Diagramm.",
|
||||
"horizon.multiplayer.finding_friend": "Die Welt deines Freundes wird gesucht …",
|
||||
"horizon.plain.official": "Offizielles Horizon mit Launcher, Mods und Updates: playhorizon.dev",
|
||||
"horizon.title.not_affiliated": "Nicht mit Mojang oder Microsoft verbunden",
|
||||
"horizon.extras.header.lighting": "Beleuchtung",
|
||||
"horizon.extras.header.third_person": "Dritte Person",
|
||||
|
||||
@@ -104,6 +104,21 @@
|
||||
"horizon.studio.replay.seek.invalid": "Invalid replay position",
|
||||
"horizon.studio.replay.status": "Studio replay: %s / %s ms (%s%%), speed %s×, paused %s, finished %s",
|
||||
"horizon.studio.replay.closed": "Studio replay closed",
|
||||
"horizon.studio.freecam.enabled": "Studio free camera enabled. Use WASD to move, Space and Shift to rise or descend, and Control to move faster.",
|
||||
"horizon.studio.freecam.disabled": "Studio free camera disabled",
|
||||
"horizon.studio.freecam.unavailable": "Studio free camera could not find a player camera",
|
||||
"horizon.studio.freecam.replay_required": "Studio free camera is available during replay playback",
|
||||
"horizon.studio.freecam.usage": "Usage: /studio freecam <on|off>",
|
||||
"horizon.studio.keyframe.added": "Camera keyframe added at %s ms. The path is saved when you close the replay.",
|
||||
"horizon.studio.keyframe.cleared": "Camera path cleared. The change is saved when you close the replay.",
|
||||
"horizon.studio.keyframe.removed": "Removed camera keyframe %s. The change is saved when you close the replay.",
|
||||
"horizon.studio.keyframe.save_error": "Could not save camera path: %s",
|
||||
"horizon.studio.keyframe.remove_usage": "Usage: /studio keyframe remove <INDEX>",
|
||||
"horizon.studio.keyframe.index_invalid": "Camera keyframe index is out of range",
|
||||
"horizon.studio.keyframe.usage": "Usage: /studio keyframe <add|remove INDEX|clear>",
|
||||
"horizon.studio.path.locked": "Studio camera path playback enabled",
|
||||
"horizon.studio.path.unlocked": "Studio camera path playback stopped",
|
||||
"horizon.studio.path.usage": "Usage: /studio path <lock|unlock> (add at least one keyframe first)",
|
||||
"horizon.pages.mods": "Mods",
|
||||
"horizon.pages.shader_packs": "Shader Packs",
|
||||
"horizon.pages.shader_options": "Shader Options",
|
||||
@@ -216,6 +231,7 @@
|
||||
"Resolution Scale": "Resolution Scale",
|
||||
"Anti-aliasing": "Anti-aliasing",
|
||||
"Upscaling": "Upscaling",
|
||||
"Upscaling Sharpness": "Upscaling Sharpness",
|
||||
"Paper White": "Paper White",
|
||||
"Peak Brightness": "Peak Brightness",
|
||||
"Black Level": "Black Level",
|
||||
@@ -317,6 +333,7 @@
|
||||
"World pixels per screen pixel. Below 100% is faster on the GPU.": "World pixels per screen pixel. Below 100% is faster on the GPU.",
|
||||
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.",
|
||||
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Renders the world smaller and rebuilds it with TAA. Faster, softer.",
|
||||
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Sharpens the upscaled picture. Higher: crisper edges, more shimmer.",
|
||||
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR output on an HDR display: lava, fire and sunlight shine brighter.",
|
||||
"How frames reach the screen while VSync is off.": "How frames reach the screen while VSync is off.",
|
||||
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Efficient sleeps until the next frame; Precise spins, like vanilla.",
|
||||
@@ -366,6 +383,7 @@
|
||||
"Brighter-than-white output on an HDR display.": "Brighter-than-white output on an HDR display.",
|
||||
"Results for \"%s\"": "Results for \"%s\"",
|
||||
"No settings match.": "No settings match.",
|
||||
"horizon.plain.official": "Official Horizon with launcher, mods and updates: playhorizon.dev",
|
||||
"horizon.title.not_affiliated": "Not affiliated with Mojang or Microsoft",
|
||||
"horizon.extras.header.lighting": "Lighting",
|
||||
"horizon.extras.header.third_person": "Third person",
|
||||
|
||||
@@ -295,6 +295,7 @@
|
||||
"Brighter-than-white output on an HDR display.": "Salida más brillante que el blanco en una pantalla HDR.",
|
||||
"Results for \"%s\"": "Resultados de \"%s\"",
|
||||
"No settings match.": "Ningún ajuste coincide.",
|
||||
"horizon.plain.official": "Horizon oficial con launcher, mods y actualizaciones: playhorizon.dev",
|
||||
"horizon.title.not_affiliated": "No está afiliado a Mojang ni a Microsoft",
|
||||
"horizon.extras.header.lighting": "Iluminación",
|
||||
"horizon.extras.header.third_person": "Tercera persona",
|
||||
|
||||
@@ -188,6 +188,7 @@
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Données de maillage vers le GPU par image. Moins : plus fluide.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Fréquence et temps d'image par-dessus le jeu, avec un graphique.",
|
||||
"horizon.multiplayer.finding_friend": "Recherche du monde de votre ami...",
|
||||
"horizon.plain.official": "Horizon officiel avec launcher, mods et mises à jour : playhorizon.dev",
|
||||
"horizon.title.not_affiliated": "Non affilié à Mojang ou à Microsoft",
|
||||
"horizon.extras.header.lighting": "Éclairage",
|
||||
"horizon.extras.header.third_person": "Troisième personne",
|
||||
|
||||
@@ -231,6 +231,7 @@
|
||||
"Brighter-than-white output on an HDR display.": "Uscita più luminosa del bianco su un display HDR.",
|
||||
"Results for \"%s\"": "Risultati per \"%s\"",
|
||||
"No settings match.": "Nessuna impostazione corrisponde.",
|
||||
"horizon.plain.official": "Horizon ufficiale con launcher, mod e aggiornamenti: playhorizon.dev",
|
||||
"horizon.title.not_affiliated": "Non affiliato con Mojang o Microsoft",
|
||||
"horizon.extras.header.lighting": "Illuminazione",
|
||||
"horizon.extras.header.third_person": "Terza persona",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"horizon.pages.extras": "Horizon エクストラ",
|
||||
"horizon.pages.settings": "Horizon 設定",
|
||||
"horizon.pages.agent": "AIエージェント",
|
||||
"horizon.plain.official": "ランチャー、Mod、アップデート付きの公式 Horizon: playhorizon.dev",
|
||||
"horizon.split.leave": "休暇を取る",
|
||||
"horizon.pages.split_settings": "分割画面設定",
|
||||
"horizon.pages.split_profiles": "誰が遊んでるの?",
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"horizon.pages.extras": "Horizon 추가 기능",
|
||||
"horizon.pages.settings": "Horizon 설정",
|
||||
"horizon.pages.agent": "AI 에이전트",
|
||||
"horizon.plain.official": "런처, 모드, 업데이트가 포함된 공식 Horizon: playhorizon.dev",
|
||||
"horizon.split.leave": "나가기",
|
||||
"horizon.pages.split_settings": "화면 분할 설정",
|
||||
"horizon.pages.split_profiles": "플레이어 선택",
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"Brighter-than-white output on an HDR display.": "Helderder dan witte uitvoer op een HDR-scherm.",
|
||||
"Results for \"%s\"": "Resultaten voor \"%s\"",
|
||||
"No settings match.": "Er komen geen instellingen overeen.",
|
||||
"horizon.plain.official": "Officiële Horizon met launcher, mods en updates: playhorizon.dev",
|
||||
"horizon.title.not_affiliated": "Niet aangesloten bij Mojang of Microsoft",
|
||||
"horizon.extras.header.lighting": "Verlichting",
|
||||
"horizon.extras.header.third_person": "Derde persoon",
|
||||
|
||||
@@ -222,6 +222,7 @@
|
||||
"Brighter-than-white output on an HDR display.": "Obraz jaśniejszy niż biel na ekranie HDR.",
|
||||
"Results for \"%s\"": "Wyniki dla \"%s\"",
|
||||
"No settings match.": "Żadne ustawienie nie pasuje.",
|
||||
"horizon.plain.official": "Oficjalny Horizon z launcherem, modami i aktualizacjami: playhorizon.dev",
|
||||
"horizon.title.not_affiliated": "Niepowiązane z Mojang ani Microsoft",
|
||||
"horizon.extras.header.lighting": "Oświetlenie",
|
||||
"horizon.extras.header.third_person": "Widok z trzeciej osoby",
|
||||
|
||||
@@ -233,6 +233,7 @@
|
||||
"Brighter-than-white output on an HDR display.": "Saída mais brilhante que o branco em uma tela HDR.",
|
||||
"Results for \"%s\"": "Resultados para \"%s\"",
|
||||
"No settings match.": "Nenhuma configuração corresponde.",
|
||||
"horizon.plain.official": "Horizon oficial com launcher, mods e atualizações: playhorizon.dev",
|
||||
"horizon.title.not_affiliated": "Não afiliado à Mojang ou Microsoft",
|
||||
"horizon.extras.header.lighting": "Iluminação",
|
||||
"horizon.extras.header.third_person": "Terceira pessoa",
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"Resolution Scale": "Масштаб разрешения",
|
||||
"Anti-aliasing": "Сглаживание",
|
||||
"Upscaling": "Апскейл",
|
||||
"Upscaling Sharpness": "Резкость апскейла",
|
||||
"Paper White": "Белая точка",
|
||||
"Peak Brightness": "Пиковая яркость",
|
||||
"Black Level": "Уровень чёрного",
|
||||
@@ -167,6 +168,7 @@
|
||||
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Пикселей мира на пиксель экрана. Ниже 100% — быстрее для видеокарты.",
|
||||
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Сглаживает рваные края. FXAA: мягче. TAA: глаже всего, но движущееся может шлейфить.",
|
||||
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Рисует мир мельче и восстанавливает его через TAA. Быстрее, но мягче.",
|
||||
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Повышает резкость увеличенной картинки. Выше: чётче края, больше мерцания.",
|
||||
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR-вывод на HDR-экране: лава, огонь и солнце светятся ярче.",
|
||||
"How frames reach the screen while VSync is off.": "Как кадры попадают на экран, когда вертикальная синхронизация выключена.",
|
||||
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "«Экономный» спит до следующего кадра; «Точный» крутится, как в ванили.",
|
||||
@@ -185,6 +187,7 @@
|
||||
"Frame rate and frame times over the game, with a graph.": "Частота кадров и их время поверх игры, с графиком.",
|
||||
"horizon.multiplayer.finding_friend": "Ищем мир друга…",
|
||||
"horizon.multiplayer.not_signed_in": "Этот сервер работает в онлайн-режиме. Войдите с помощью аккаунта Microsoft в лаунчере Horizon, чтобы присоединиться.",
|
||||
"horizon.plain.official": "Официальный Horizon с лаунчером, модами и обновлениями: playhorizon.dev",
|
||||
"horizon.realms.not_signed_in": "Войдите в лаунчере Horizon с учётной записью Microsoft, чтобы играть в Realms.",
|
||||
"horizon.multiplayer.invalid_address": "Недействительный адрес сервера: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Установлено прямое соединение с другом…",
|
||||
|
||||
@@ -187,6 +187,7 @@
|
||||
"horizon.pages.shader_options": "Gölgelendirici Seçenekleri",
|
||||
"horizon.pages.split_profiles": "Kim oynuyor?",
|
||||
"horizon.pages.split_profiles.tooltip": "Kim oynuyor: bu oyun klasöründe tutulan bir profil.",
|
||||
"horizon.plain.official": "Başlatıcı, modlar ve güncellemelerle resmi Horizon: playhorizon.dev",
|
||||
"horizon.shader_options.profile": "Profil",
|
||||
"Particle Culling": "Particle Culling",
|
||||
"Draws every particle.": "Draws every particle.",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"horizon.mods.dev": " (розробка)",
|
||||
"horizon.mods.settings": "Налаштування",
|
||||
"horizon.mods.open_folder": "Відкрити теку з модами",
|
||||
"horizon.plain.official": "Офіційний Horizon із лаунчером, модами та оновленнями: playhorizon.dev",
|
||||
"horizon.shader_options.profile": "Профіль",
|
||||
"horizon.agent.copy_details": "Копіювати дані з’єднання",
|
||||
"horizon.multiplayer.join": "Приєднатися",
|
||||
@@ -471,5 +472,450 @@
|
||||
"Pack settings": "Налаштування пакета",
|
||||
"Built-in settings": "Вбудовані налаштування",
|
||||
"%s settings": "Налаштування %s",
|
||||
"Settings made by the pack's author: they change only this pack.": "Налаштування, створені автором пакета: вони змінюють лише цей пакет."
|
||||
"Settings made by the pack's author: they change only this pack.": "Налаштування, створені автором пакета: вони змінюють лише цей пакет.",
|
||||
"horizon.extras.header.world": "Світ",
|
||||
"horizon.extras.pregen": "Попередня генерація чанків",
|
||||
"horizon.extras.pregen.tooltip": "Вмикає /pregen у ваших одиночних і LAN-світах. З увімкненими читами введіть /pregen start, щоб згенерувати область, перш ніж її досліджувати. На серверах, до яких ви приєднуєтесь, команда не вмикається.",
|
||||
"horizon.options.three_d_skin_layers": "3D-шари скіна",
|
||||
"horizon.options.play_in_background": "Гра у фоні",
|
||||
"horizon.options.trigger_threshold": "Поріг тригера",
|
||||
"horizon.studio.record.usage": "Використання: /studio record <start [назва]|stop|status>",
|
||||
"horizon.studio.record.started": "Запис Studio розпочато: %s",
|
||||
"horizon.studio.record.saved": "Запис Studio збережено: %s",
|
||||
"horizon.studio.record.error": "Помилка запису Studio: %s",
|
||||
"horizon.studio.record.status.active": "Запис Studio триває — %s с, %s пакетів, %s КБ (%s)",
|
||||
"horizon.studio.record.status.inactive": "Запис Studio не ведеться",
|
||||
"horizon.studio.replay.usage": "Використання: /studio replay <open ШЛЯХ|pause|resume|speed МНОЖНИК|seek МС|status|close>",
|
||||
"horizon.studio.replay.open.usage": "Використання: /studio replay open <ШЛЯХ>",
|
||||
"horizon.studio.replay.opened": "Повтор Studio відкрито: %s (%s с, %s пакетів)",
|
||||
"horizon.studio.replay.open_error": "Не вдалося відкрити повтор: %s",
|
||||
"horizon.studio.replay.paused": "Повтор Studio призупинено",
|
||||
"horizon.studio.replay.resumed": "Повтор Studio продовжено",
|
||||
"horizon.studio.replay.inactive": "Немає активного повтору Studio",
|
||||
"horizon.studio.replay.speed.usage": "Використання: /studio replay speed <МНОЖНИК>",
|
||||
"horizon.studio.replay.speed.set": "Швидкість повтору Studio: %s×",
|
||||
"horizon.studio.replay.speed.invalid": "Неправильна швидкість повтору",
|
||||
"horizon.studio.replay.seek.usage": "Використання: /studio replay seek <МІЛІСЕКУНДИ>",
|
||||
"horizon.studio.replay.seeked": "Повтор Studio перемотано на %s мс (%s с)",
|
||||
"horizon.studio.replay.seek.error": "Не вдалося перемотати повтор: %s",
|
||||
"horizon.studio.replay.seek.backward_requires_standalone": "Перемотування назад доступне лише в окремому повторі.",
|
||||
"horizon.studio.replay.seek.invalid": "Неправильна позиція повтору",
|
||||
"horizon.studio.replay.status": "Повтор Studio: %s / %s мс (%s%%), швидкість %s×, пауза: %s, завершено: %s",
|
||||
"horizon.studio.replay.closed": "Повтор Studio закрито",
|
||||
"horizon.pages.studio": "Студія повторів",
|
||||
"horizon.pages.music": "Музика",
|
||||
"horizon.pages.music.menu": "Меню",
|
||||
"horizon.pages.music.overworld": "Верхній світ",
|
||||
"horizon.pages.music.creative": "Творчий режим",
|
||||
"horizon.pages.music.underwater": "Під водою",
|
||||
"horizon.pages.music.end": "Енд",
|
||||
"horizon.pages.music.boss": "Бос",
|
||||
"horizon.pages.music.credits": "Титри",
|
||||
"horizon.pages.music.overworld_biomes": "Біоми Верхнього світу",
|
||||
"horizon.pages.music.nether_biomes": "Біоми Незеру",
|
||||
"horizon.pages.music.end_biomes": "Біоми Енду",
|
||||
"horizon.pages.music.other": "Інша музика",
|
||||
"horizon.pages.music.empty": "Немає музичних треків: звуки гри не завантажено.",
|
||||
"horizon.pages.music.tooltip": "Вбудовані треки за ситуаціями: увімкніть будь-який зараз або вимкніть, щоб гра більше його не обирала.",
|
||||
"horizon.pages.studio.tooltip": "Перегляд збережених записів і їхніх метаданих.",
|
||||
"horizon.pages.open.studio": "Студія повторів...",
|
||||
"horizon.studio.saved_recordings": "Збережені записи",
|
||||
"horizon.studio.empty": "У теці записів немає жодного запису.",
|
||||
"horizon.studio.empty_hint": "Введіть у світі /studio record start <назва> або покладіть туди файли .mcpr.",
|
||||
"horizon.studio.open_folder": "Відкрити записи",
|
||||
"horizon.studio.open_replay": "Відтворити повтор",
|
||||
"horizon.studio.refresh": "Оновити",
|
||||
"horizon.studio.unknown_server": "Невідомо",
|
||||
"horizon.pages.open.music": "Музика...",
|
||||
"horizon.mods.in_world": " (вийдіть зі світу, щоб змінити)",
|
||||
"horizon.mods.browse": "Огляд модів...",
|
||||
"horizon.mods.browse.title": "Огляд модів",
|
||||
"horizon.mods.browse.search": "Пошук модів...",
|
||||
"horizon.mods.browse.project": "Мод",
|
||||
"horizon.mods.browse.loading": "Читання реєстру модів Horizon...",
|
||||
"horizon.mods.browse.failed": "Не вдалося прочитати реєстр модів: %s",
|
||||
"horizon.mods.browse.none": "Модів не знайдено",
|
||||
"horizon.mods.browse.has_update": " (оновлення)",
|
||||
"horizon.mods.browse.is_installed": " (встановлено)",
|
||||
"horizon.mods.browse.by": "автор: %s",
|
||||
"horizon.mods.browse.installing": "Встановлення...",
|
||||
"horizon.mods.browse.install": "Встановити",
|
||||
"horizon.mods.browse.update": "Оновити",
|
||||
"horizon.mods.browse.installed": "Встановлено",
|
||||
"horizon.mods.browse.open_page": "Відкрити сторінку",
|
||||
"horizon.mods.browse.enabled": "Мод",
|
||||
"horizon.mods.browse.changelog": "Список змін",
|
||||
"horizon.mods.browse.done": "%s %s встановлено й увімкнено.",
|
||||
"horizon.mods.browse.install_failed": "Не вдалося встановити: %s",
|
||||
"horizon.mods.switch.worldgen": "змінює генерацію світу",
|
||||
"horizon.mods.switch.registries": "додає блоки, предмети чи інші записи реєстрів",
|
||||
"horizon.mods.switch.restart": "Перезапустіть гру, щоб застосувати %s: вони додають блоки, предмети або генерацію світу.",
|
||||
"GPU Culling": "Відсікання на GPU",
|
||||
"HDR Lighting": "HDR-освітлення",
|
||||
"Light sources, the sun and sunlight shine past white. Off: just as SDR.": "Джерела світла, сонце й сонячне світло сяють яскравіше за білий. Вимкнено: як у SDR.",
|
||||
"Smooth Gradients": "Плавні градієнти",
|
||||
"16-bit frame: dark skies and shadows don't band. A bit more VRAM. Next start.": "16-бітний кадр: темне небо й тіні без смуг. Трохи більше відеопам’яті. З наступного запуску.",
|
||||
"Not with software rendering, which runs it on the CPU.": "Не з програмним рендерингом, який виконує це на CPU.",
|
||||
"Out of video memory: off until the next start.": "Забракло відеопам’яті: вимкнено до наступного запуску.",
|
||||
"The graphics device was lost: off until the next start.": "Графічний пристрій втрачено: вимкнено до наступного запуску.",
|
||||
"Not on DirectX 12 yet: try the Vulkan graphics API.": "Поки не працює на DirectX 12: спробуйте графічний API Vulkan.",
|
||||
"Culls nearby terrain on the GPU: less CPU at high render distance, more GPU.": "Відсікає ближній рельєф на GPU: за великої дальності промальовування менше навантажує CPU, більше — GPU.",
|
||||
"horizon.realms.not_signed_in": "Увійдіть за допомогою акаунта Microsoft у лаунчері Horizon, щоб грати на Realms.",
|
||||
"horizon.extras.music_page": "Сторінка «Музика»",
|
||||
"horizon.extras.zoom": "Наближення",
|
||||
"horizon.extras.zoom_mod_install": "Наближення: встановіть у модах",
|
||||
"horizon.extras.wheel_cycles_list_settings.tooltip": "Коліщатко миші над кнопкою в списку налаштувань змінює її значення. Вимкнено, як у ванільній грі: коліщатко прокручує список.",
|
||||
"horizon.extras.pack_browser": "Огляд наборів ресурсів",
|
||||
"horizon.extras.music_page.tooltip": "У «Музика і звуки» з’являється сторінка «Музика...»: увімкніть будь-який вбудований трек зараз або вимкніть, щоб гра більше його не обирала. Нічого не змінюється, доки ви не вимкнете трек.",
|
||||
"horizon.friends.relay_stopped": "Забагато неправильних коротких кодів, тому ретранслятор вимкнено, доки ви знову не поділитеся світом. Друзі все ще можуть приєднатися за повним кодом.",
|
||||
"horizon.extras.pack_browser.tooltip": "У «Вибрати набори ресурсів» з’являється кнопка «Додати», яка шукає в Modrinth і встановлює набір у вашу теку наборів. Вимкнено: гра ніколи не звертається до Modrinth.",
|
||||
"horizon.splitscreen.online_only": "Цей сервер працює в онлайн-режимі: офлайн-профілі не можуть приєднатися, кожному гравцеві потрібен власний акаунт Minecraft.",
|
||||
"horizon.splitscreen.own_account": "Цей сервер вимагає від кожного гравця власного акаунта Minecraft",
|
||||
"horizon.splitscreen.local_only": "Інші гравці можуть приєднатися до цього сервера лише з власним акаунтом Minecraft: офлайн-профілі грають у вашому світі, через LAN або на сервері у вашій локальній мережі.",
|
||||
"Shaders": "Шейдери",
|
||||
"Shader packs, Horizon's and shaders/ folder ones, and their options.": "Пакети шейдерів — від Horizon і з теки shaders/ — та їхні налаштування.",
|
||||
"Shader packs": "Пакети шейдерів",
|
||||
"Vanilla's own preset: sets only vanilla's options, as Minecraft does.": "Власний пресет ванільної гри: задає лише ванільні налаштування, як це робить Minecraft.",
|
||||
"Fullscreen takes the display over, at the mode below.": "Повноекранний режим займає весь дисплей у режимі, вказаному нижче.",
|
||||
"The display mode exclusive fullscreen uses. Applies when you leave.": "Режим дисплея для ексклюзивного повноекранного режиму. Застосовується, коли ви виходите.",
|
||||
"Blurs the world behind menus.": "Розмиває світ за меню.",
|
||||
"How textures are sampled: sharper at a slant with Anisotropic.": "Як вибираються текстури: з анізотропною фільтрацією чіткіші під кутом.",
|
||||
"How far Anisotropic filtering goes. Higher is sharper at a slant.": "Рівень анізотропної фільтрації. Вищий — чіткіше під кутом.",
|
||||
"Only with Anisotropic texture filtering.": "Лише з анізотропною фільтрацією текстур.",
|
||||
"Picks a shader pack, Horizon's or a shaders/ folder one, and sets its options.": "Обирає пакет шейдерів — від Horizon чи з теки shaders/ — і задає його налаштування.",
|
||||
"Hidden Faces": "Приховані грані",
|
||||
"Drops leaf faces behind two leaf layers: faster, same crowns.": "Прибирає грані листя за двома шарами листя: швидше, а крони ті самі.",
|
||||
"Entity Culling Precheck": "Попередня перевірка відсікання сутностей",
|
||||
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Запускає попередню перевірку AABB мобів, коли завантажено стільки мобів. Нуль вимикає її.",
|
||||
"%s mobs": "%s мобів",
|
||||
"horizon.splitscreen.profiles.offline": " (Офлайн)",
|
||||
"horizon.splitscreen.profiles.microsoft": " (Microsoft)",
|
||||
"horizon.splitscreen.profiles.sign_in": "Увійдіть знову в лаунчері",
|
||||
"horizon.splitscreen.profiles.online": "Microsoft онлайн",
|
||||
"horizon.splitscreen.account_unavailable": "%s офлайн. Увійдіть знову в лаунчері, потім перезапустіть гру.",
|
||||
"horizon.splitscreen.account_expired": "Вхід Microsoft для %s закінчився. Увійдіть знову в лаунчері, потім перезапустіть гру.",
|
||||
"horizon.pack.add": "+",
|
||||
"horizon.pack.addInfo": "Додати набір ресурсів із Modrinth",
|
||||
"horizon.pack.addWorking": "Запит до Modrinth...",
|
||||
"horizon.pack.addNone": "Наборів ресурсів не знайдено",
|
||||
"horizon.pack.addTitle": "Додати набір ресурсів",
|
||||
"horizon.pack.install": "Встановити",
|
||||
"horizon.pack.installing": "Встановлення",
|
||||
"horizon.pack.installed": "Встановлено",
|
||||
"horizon.pack.search": "Пошук",
|
||||
"horizon.pack.searchHint": "Пошук у Modrinth...",
|
||||
"horizon.pack.filters": "Фільтри",
|
||||
"horizon.pack.filter.realistic": "Реалістичні",
|
||||
"horizon.pack.filter.simplistic": "Спрощені",
|
||||
"horizon.pack.filter.vanillaLike": "Як ванільні",
|
||||
"horizon.pack.filter.x16": "16x",
|
||||
"horizon.pack.filter.x32": "32x",
|
||||
"horizon.pack.filter.x64": "64x",
|
||||
"horizon.pack.addFailed": "Не вдалося додати набір: %s",
|
||||
"horizon.extras.header.cosmetics": "Косметика",
|
||||
"horizon.extras.cosmetics_mine": "Моя косметика",
|
||||
"horizon.extras.cosmetics_mine.tooltip": "Приховано: усі гравці Horizon бачать вас без косметики, і ви теж. Той самий перемикач, що й у Гардеробі лаунчера.",
|
||||
"key.emote": "Колесо емоцій",
|
||||
"horizon.emote_wheel.title": "Емоції",
|
||||
"horizon.emote_wheel.empty": "Емоцій поки немає",
|
||||
"horizon.emote_wheel.help": "Відпустіть або клацніть, щоб відтворити · 1–8 · Esc закриває",
|
||||
"horizon.extras.cosmetics_others": "Косметика інших гравців",
|
||||
"horizon.extras.cosmetics_others.tooltip": "Що носять інші гравці Horizon: усе, лише стилі ніків або нічого. Приховано, якщо у вас немає косметики, доки ви не оберете. Коли приховано, гра ніколи не питає, що вони носять.",
|
||||
"horizon.extras.cosmetics_hide_fx": "Приховати сліди й аури інших гравців",
|
||||
"horizon.extras.cosmetics_hide_fx.tooltip": "Сліди й аури інших гравців приховано; решту їхньої косметики видно. Типово вимкнено.",
|
||||
"horizon.extras.cosmetics.shown": "Показано",
|
||||
"horizon.extras.cosmetics.hidden": "Приховано",
|
||||
"horizon.extras.cosmetics.name_tags": "Лише ніки",
|
||||
"horizon.moderation.duration.minute": "%s хв",
|
||||
"horizon.moderation.duration.minutes": "%s хв",
|
||||
"horizon.moderation.duration.hour": "%s год",
|
||||
"horizon.moderation.duration.hours": "%s год",
|
||||
"horizon.moderation.duration.day": "%s дн.",
|
||||
"horizon.moderation.duration.days": "%s дн.",
|
||||
"horizon.moderation.reason": "%1$s: %2$s",
|
||||
"horizon.moderation.mutelist.empty": "Заглушених гравців немає",
|
||||
"horizon.moderation.mutelist.count": "Заглушених гравців: %s:",
|
||||
"horizon.moderation.chat_filter": "фільтр чату",
|
||||
"horizon.moderation.until_unmuted": "до зняття заглушення",
|
||||
"horizon.moderation.remaining": "ще на %s",
|
||||
"horizon.moderation.mutelist.entry": "%1$s заглушено гравцем %2$s %3$s",
|
||||
"horizon.moderation.invalid_duration": "%s — не тривалість: 10m, 2h, 1d",
|
||||
"horizon.moderation.usage.mute": "/mute <гравець> [тривалість] [причина]",
|
||||
"horizon.moderation.usage.tempmute": "/tempmute <гравець> <тривалість> [причина]",
|
||||
"horizon.moderation.usage.unmute": "/unmute <гравець>",
|
||||
"horizon.moderation.usage.mutelist": "/mutelist",
|
||||
"horizon.moderation.for_duration": "на %s",
|
||||
"horizon.moderation.muted": "Заглушено %1$s %2$s",
|
||||
"horizon.moderation.unmuted": "Знято заглушення з %s",
|
||||
"horizon.moderation.not_muted": "%s не заглушено",
|
||||
"horizon.tempban.usage": "/tempban <гравець> <тривалість> [причина]",
|
||||
"horizon.tempban.invalid_duration": "%s — не тривалість: 30m, 12h, 7d",
|
||||
"horizon.tempban.dedicated_required": "Тимчасові бани потребують виділеного сервера",
|
||||
"horizon.tempban.success": "%1$s на %2$s, до %3$s",
|
||||
"horizon.tempban.list.empty": "Тимчасових банів немає",
|
||||
"horizon.tempban.list.count": "Тимчасових банів: %s:",
|
||||
"horizon.tempban.list.entry": "%1$s забанено гравцем %2$s %3$s (до %4$s)",
|
||||
"horizon.options.pointer_speed": "Швидкість курсора в меню",
|
||||
"horizon.options.scroll_speed": "Швидкість прокрутки в меню",
|
||||
"horizon.options.repeat_delay": "Затримка повтору навігації",
|
||||
"horizon.options.repeat_interval": "Інтервал повтору навігації",
|
||||
"horizon.options.milliseconds": "%s мс",
|
||||
"horizon.extras.cosmetics_solo": "В одиночній грі",
|
||||
"horizon.extras.cosmetics_solo.tooltip": "Показувати вашу косметику й значок вашого ніка також в одиночних світах (сам нік — із «Власним ніком»). Вимкнено: лише на серверах.",
|
||||
"horizon.extras.horizon_marks": "Логотипи Horizon",
|
||||
"horizon.extras.horizon_marks.tooltip": "Показувати логотип Horizon біля гравців Horizon: маленький логотип перед іменами інших гравців, які грають у Horizon, над їхніми головами та в списку гравців. Його бачать лише гравці Horizon: сервери нічого не отримують.",
|
||||
"horizon.extras.horizon_me": "Показувати мене як гравця Horizon",
|
||||
"horizon.extras.horizon_me.tooltip": "Показувати мене як гравця Horizon: інші гравці Horizon бачать логотип біля вашого імені. Коли ввімкнено, сервіс відповідає кожному, хто про вас питає, що ви грали в Horizon за останні 3 години; коли вимкнено — ніколи про вас не повідомляє.",
|
||||
"horizon.pages.pvp": "PvP та HUD",
|
||||
"horizon.pages.pvp.tooltip": "Налаштування прицілу, HUD і PvP, усі типово вимкнені. Тут немає нічого, що показує більше, ніж ванільна гра.",
|
||||
"horizon.pages.open.pvp": "PvP та HUD...",
|
||||
"horizon.pvp.header.crosshair": "Приціл",
|
||||
"horizon.pvp.crosshair": "Власний приціл",
|
||||
"horizon.pvp.crosshair_shape": "Форма",
|
||||
"horizon.pvp.crosshair_size": "Розмір",
|
||||
"horizon.pvp.crosshair_gap": "Проміжок",
|
||||
"horizon.pvp.crosshair_thickness": "Товщина",
|
||||
"horizon.pvp.crosshair_colour": "Колір",
|
||||
"horizon.pvp.crosshair_outline": "Обведення",
|
||||
"horizon.pvp.crosshair_dot": "Точка в центрі",
|
||||
"horizon.pvp.crosshair_hover": "Колір на сутностях",
|
||||
"horizon.pvp.crosshair.tooltip": "Малює ваш власний приціл замість ванільного. Початкові налаштування виглядають як ванільний.",
|
||||
"horizon.pvp.crosshair_shape.tooltip": "Хрест, T, X, коло, квадрат або лише точка.",
|
||||
"horizon.pvp.crosshair_size.tooltip": "Довжина кожного променя в пікселях інтерфейсу.",
|
||||
"horizon.pvp.crosshair_gap.tooltip": "Порожнє місце посередині.",
|
||||
"horizon.pvp.crosshair_thickness.tooltip": "Ширина кожної лінії.",
|
||||
"horizon.pvp.crosshair_colour.tooltip": "«Інвертований» показує колір під прицілом інвертованим, як ванільний приціл.",
|
||||
"horizon.pvp.crosshair_outline.tooltip": "Тонкий чорний край, щоб приціл було видно на будь-якому фоні.",
|
||||
"horizon.pvp.crosshair_dot.tooltip": "Точка посередині прицілу.",
|
||||
"horizon.pvp.crosshair_hover.tooltip": "Інший колір, коли приціл наведено на моба чи гравця, якого можна вдарити, — як це визначає ванільний індикатор атаки.",
|
||||
"horizon.pvp.colour.inverted": "Інвертований",
|
||||
"horizon.pvp.colour.white": "Білий",
|
||||
"horizon.pvp.colour.red": "Червоний",
|
||||
"horizon.pvp.colour.orange": "Помаранчевий",
|
||||
"horizon.pvp.colour.yellow": "Жовтий",
|
||||
"horizon.pvp.colour.green": "Зелений",
|
||||
"horizon.pvp.colour.cyan": "Бірюзовий",
|
||||
"horizon.pvp.colour.blue": "Синій",
|
||||
"horizon.pvp.colour.magenta": "Пурпуровий",
|
||||
"horizon.pvp.colour.black": "Чорний",
|
||||
"horizon.pvp.shape.cross": "Хрест",
|
||||
"horizon.pvp.shape.t": "T",
|
||||
"horizon.pvp.shape.x": "X",
|
||||
"horizon.pvp.shape.circle": "Коло",
|
||||
"horizon.pvp.shape.square": "Квадрат",
|
||||
"horizon.pvp.shape.dot": "Точка",
|
||||
"horizon.pvp.outline": "Власне обведення блока",
|
||||
"horizon.pvp.outline.tooltip": "Змінює обведення навколо блока, на який ви дивитеся.",
|
||||
"horizon.pvp.outline_colour": "Колір",
|
||||
"horizon.pvp.outline_colour.tooltip": "Колір обведення. Чорний на 40% — ванільний.",
|
||||
"horizon.pvp.outline_opacity": "Непрозорість",
|
||||
"horizon.pvp.outline_opacity.tooltip": "Наскільки щільне обведення.",
|
||||
"horizon.pvp.outline_thickness": "Товщина",
|
||||
"horizon.pvp.outline_thickness.tooltip": "Ширина обведення, у разах від ванільної.",
|
||||
"horizon.pvp.outline_fill": "Заливка",
|
||||
"horizon.pvp.outline_fill.tooltip": "Злегка підфарбовує грані блока, на який ви дивитеся.",
|
||||
"horizon.pvp.hit": "Власний колір удару",
|
||||
"horizon.pvp.hit.tooltip": "Змінює колір, яким спалахують моби й гравці, коли їм завдають шкоди. Червоний на 30% — ванільний.",
|
||||
"horizon.pvp.hit_colour": "Колір",
|
||||
"horizon.pvp.hit_colour.tooltip": "Колір, яким спалахують поранені моби й гравці.",
|
||||
"horizon.pvp.hit_opacity": "Непрозорість",
|
||||
"horizon.pvp.hit_opacity.tooltip": "Наскільки колір їх покриває.",
|
||||
"horizon.pvp.header.outline": "Обведення блока",
|
||||
"horizon.pvp.header.hit": "Колір удару",
|
||||
"horizon.pvp.hud_armour": "Броня",
|
||||
"horizon.pvp.hud_armour.tooltip": "Броня, яку ви носите, починаючи з голови, і скільки використань лишилося в кожного предмета.",
|
||||
"horizon.pvp.hud_armour_corner": "Кут броні",
|
||||
"horizon.pvp.hud_armour_corner.tooltip": "Де показується броня.",
|
||||
"horizon.pvp.hud_armour_percent": "Міцність у %",
|
||||
"horizon.pvp.hud_armour_percent.tooltip": "Залишок використань у відсотках від максимуму предмета замість числа.",
|
||||
"horizon.pvp.hud_effects": "Ефекти",
|
||||
"horizon.pvp.hud_effects.tooltip": "Ваші ефекти з назвами, рівнями й часом, що лишився, замість значків угорі праворуч.",
|
||||
"horizon.pvp.hud_effects_corner": "Кут ефектів",
|
||||
"horizon.pvp.hud_effects_corner.tooltip": "Де показуються ефекти.",
|
||||
"horizon.pvp.hud_held": "Кількість предмета в руці",
|
||||
"horizon.pvp.hud_held.tooltip": "Скільки предметів, як у вашій руці, є в інвентарі; з луком чи арбалетом — стріл.",
|
||||
"horizon.pvp.hud_held_corner": "Кут кількості",
|
||||
"horizon.pvp.hud_held_corner.tooltip": "Де показується кількість.",
|
||||
"horizon.pvp.hud_background": "Фон віджетів",
|
||||
"horizon.pvp.hud_background.tooltip": "Темна підкладка під кожним віджетом.",
|
||||
"horizon.pvp.hud_opacity": "Непрозорість фону",
|
||||
"horizon.pvp.hud_opacity.tooltip": "Наскільки темні підкладки під віджетами.",
|
||||
"horizon.pvp.hud_text_colour": "Колір тексту",
|
||||
"horizon.pvp.hud_text_colour.tooltip": "Колір тексту віджетів. Міцність лишається від зеленого до червоного.",
|
||||
"horizon.pvp.hud_shadow": "Тінь тексту",
|
||||
"horizon.pvp.hud_shadow.tooltip": "Тінь під текстом віджетів, як у тексту ванільного HUD.",
|
||||
"horizon.pvp.header.hud": "Віджети HUD",
|
||||
"horizon.pvp.corner.top_left": "Угорі ліворуч",
|
||||
"horizon.pvp.corner.top_right": "Угорі праворуч",
|
||||
"horizon.pvp.corner.bottom_left": "Унизу ліворуч",
|
||||
"horizon.pvp.corner.bottom_right": "Унизу праворуч",
|
||||
"horizon.pvp.hud_ping": "Пінг",
|
||||
"horizon.pvp.hud_ping.tooltip": "Затримка, яку сервер повідомляє для вас, у мілісекундах. Лише в мережевій грі.",
|
||||
"horizon.pvp.hud_ping_corner": "Кут пінгу",
|
||||
"horizon.pvp.hud_ping_corner.tooltip": "Де показується пінг.",
|
||||
"horizon.pvp.hud_coords": "Координати",
|
||||
"horizon.pvp.hud_coords.tooltip": "Ваша позиція в блоках і напрямок погляду, як їх показує F3. Приховано, коли світ чи сервер скорочує F3.",
|
||||
"horizon.pvp.hud_coords_corner": "Кут координат",
|
||||
"horizon.pvp.hud_coords_corner.tooltip": "Де показуються координати.",
|
||||
"horizon.pvp.hud_server": "Адреса сервера",
|
||||
"horizon.pvp.hud_server.tooltip": "Адреса сервера, на якому ви граєте. Лише в мережевій грі.",
|
||||
"horizon.pvp.hud_server_corner": "Кут сервера",
|
||||
"horizon.pvp.hud_server_corner.tooltip": "Де показується адреса.",
|
||||
"horizon.pvp.hud_combo": "Лічильник комбо",
|
||||
"horizon.pvp.hud_combo.tooltip": "Удари поспіль, підтверджені сервером, доки вас не поранять або не мине 3 секунди без удару.",
|
||||
"horizon.pvp.hud_combo_corner": "Кут комбо",
|
||||
"horizon.pvp.hud_combo_corner.tooltip": "Де показується комбо.",
|
||||
"horizon.pvp.hud.ping": "Пінг: %s мс",
|
||||
"horizon.pvp.hud.coords": "XYZ: %s %s %s",
|
||||
"horizon.pvp.hud.facing": "Напрямок: %s",
|
||||
"horizon.pvp.hud.combo": "Комбо: %s",
|
||||
"horizon.pvp.hud.direction.north": "Північ",
|
||||
"horizon.pvp.hud.direction.south": "Південь",
|
||||
"horizon.pvp.hud.direction.east": "Схід",
|
||||
"horizon.pvp.hud.direction.west": "Захід",
|
||||
"horizon.pvp.hud_fps": "FPS",
|
||||
"horizon.pvp.hud_fps.tooltip": "Кадрів за останню секунду.",
|
||||
"horizon.pvp.hud_fps_corner": "Кут FPS",
|
||||
"horizon.pvp.hud_fps_corner.tooltip": "Де показується FPS.",
|
||||
"horizon.pvp.hud_cps": "Кліки за секунду",
|
||||
"horizon.pvp.hud_cps.tooltip": "Ваші ліві й праві кліки за останню секунду.",
|
||||
"horizon.pvp.hud_cps_corner": "Кут CPS",
|
||||
"horizon.pvp.hud_cps_corner.tooltip": "Де показуються кліки за секунду.",
|
||||
"horizon.pvp.hud_keys": "Натискання клавіш",
|
||||
"horizon.pvp.hud_keys.tooltip": "Клавіші руху, кнопки миші та стрибок, які підсвічуються, поки ви їх тримаєте, і кліки за секунду.",
|
||||
"horizon.pvp.hud_keys_corner": "Кут натискань",
|
||||
"horizon.pvp.hud_keys_corner.tooltip": "Де показуються натискання клавіш.",
|
||||
"horizon.pvp.hud_clock": "Годинник",
|
||||
"horizon.pvp.hud_clock.tooltip": "Час на вашому комп’ютері.",
|
||||
"horizon.pvp.hud_clock_corner": "Кут годинника",
|
||||
"horizon.pvp.hud_clock_corner.tooltip": "Де показується годинник.",
|
||||
"horizon.pvp.hud_reach": "Дальність",
|
||||
"horizon.pvp.hud_reach.tooltip": "На якій відстані від ваших очей влучив останній удар, протягом 3 секунд. Це лише вимірювання: ваша дальність лишається ванільною.",
|
||||
"horizon.pvp.hud_reach_corner": "Кут дальності",
|
||||
"horizon.pvp.hud_reach_corner.tooltip": "Де показується дальність.",
|
||||
"horizon.pvp.hud_speed": "Швидкість",
|
||||
"horizon.pvp.hud_speed.tooltip": "Як швидко ви рухаєтеся по землі, у блоках за секунду.",
|
||||
"horizon.pvp.hud_speed_corner": "Кут швидкості",
|
||||
"horizon.pvp.hud_speed_corner.tooltip": "Де показується швидкість.",
|
||||
"horizon.pvp.hud.fps": "%s FPS",
|
||||
"horizon.pvp.hud.cps": "CPS: %s | %s",
|
||||
"horizon.pvp.hud.reach": "Дальність: %s",
|
||||
"horizon.pvp.hud.speed": "Швидкість: %s б/с",
|
||||
"horizon.pvp.hud.keys.attack": "ЛКМ",
|
||||
"horizon.pvp.hud.keys.use": "ПКМ",
|
||||
"horizon.pvp.hud.keys.cps": "%s CPS",
|
||||
"horizon.pvp.numeric_enchants": "Рівні чар числами",
|
||||
"horizon.pvp.numeric_enchants.tooltip": "Підказки та чарувальний стіл показують «Гострота 5» замість «Гострота V».",
|
||||
"horizon.pvp.header.items": "Предмети",
|
||||
"horizon.pvp.fire_lower": "Нижчий вогонь",
|
||||
"horizon.pvp.fire_lower.tooltip": "Опускає полум’я, яке ви бачите, коли горите, щоб воно менше закривало екран.",
|
||||
"horizon.pvp.shield_size": "Розмір щита",
|
||||
"horizon.pvp.shield_size.tooltip": "Розмір щита у вашій руці від першої особи.",
|
||||
"horizon.pvp.totem_size": "Розмір анімації тотема",
|
||||
"horizon.pvp.totem_size.tooltip": "Розмір тотема, що заповнює екран, коли рятує вас.",
|
||||
"horizon.pvp.glint_colour": "Колір блиску чар",
|
||||
"horizon.pvp.glint_colour.tooltip": "Колір мерехтіння на зачарованих предметах і броні. Вимкнено — ванільний фіолетовий.",
|
||||
"horizon.pvp.clear_water": "Прозора вода",
|
||||
"horizon.pvp.clear_water.tooltip": "Під водою одразу бачите так далеко, як ванільна гра дозволяє лише після 30 секунд під водою.",
|
||||
"horizon.pvp.clear_lava": "Прозора лава",
|
||||
"horizon.pvp.clear_lava.tooltip": "У лаві бачите так далеко, як дозволяє ванільна Вогнестійкість, без цього ефекту.",
|
||||
"horizon.pvp.header.visual": "Візуальні зміни",
|
||||
"horizon.pvp.view_time": "Час доби",
|
||||
"horizon.pvp.view_time.tooltip": "Час доби, який ви бачите: лише небо та його світло. Час у світі йде далі, і на сервер нічого не надсилається.",
|
||||
"horizon.pvp.view_weather": "Погода",
|
||||
"horizon.pvp.view_weather.tooltip": "Погода, яку ви бачите: лише дощ, його світло й туман. Погода у світі не змінюється, і на сервер нічого не надсилається.",
|
||||
"horizon.pvp.header.view": "Що ви бачите",
|
||||
"horizon.pvp.view.world": "Як у світі",
|
||||
"horizon.pvp.time.morning": "Ранок",
|
||||
"horizon.pvp.time.noon": "Полудень",
|
||||
"horizon.pvp.time.sunset": "Захід сонця",
|
||||
"horizon.pvp.time.midnight": "Опівніч",
|
||||
"horizon.pvp.weather.clear": "Ясно",
|
||||
"horizon.pvp.weather.rain": "Дощ",
|
||||
"horizon.pvp.weather.thunder": "Гроза",
|
||||
"horizon.chat.timestamp": "[%s] ",
|
||||
"horizon.chat.repeats": " (x%s)",
|
||||
"horizon.pvp.header.chat": "Чат",
|
||||
"horizon.chat.copied": "Повідомлення скопійовано",
|
||||
"horizon.chat.search": "Пошук у чаті:",
|
||||
"horizon.pvp.chat_timestamps": "Позначки часу",
|
||||
"horizon.pvp.chat_timestamps.tooltip": "Перед кожним повідомленням — час, коли воно надійшло.",
|
||||
"horizon.pvp.chat_compact": "Компактний чат",
|
||||
"horizon.pvp.chat_compact.tooltip": "Повідомлення, повторене кілька разів поспіль, показується один раз із кількістю повторів: (x3).",
|
||||
"horizon.pvp.chat_copy": "Копіювання клацанням",
|
||||
"horizon.pvp.chat_copy.tooltip": "Клацання по повідомленню у відкритому чаті копіює його текст, якщо клацання не робить чогось іншого.",
|
||||
"horizon.pvp.chat_search": "Пошук у чаті",
|
||||
"horizon.pvp.chat_search.tooltip": "Ctrl+F у відкритому чаті: показуються лише повідомлення, що містять введений текст. Enter або повторне Ctrl+F завершує пошук.",
|
||||
"horizon.pvp.preset": "PvP-пресет",
|
||||
"horizon.pvp.preset.tooltip": "Вмикає власний приціл, віджети броні й ефектів, перемикання бігу та скрадання, нижчий вогонь, а також моди лічильника FPS і натискань клавіш, якщо їх встановлено. Ваші налаштування кожного з них зберігаються.",
|
||||
"horizon.pvp.hud_editor_key": "Клавіша редактора HUD",
|
||||
"horizon.pvp.hud_editor_key.tooltip": "Відкриває редактор HUD із гри. Клацніть, потім натисніть клавішу; Escape скасовує призначення.",
|
||||
"horizon.pvp.editor": "Редагувати HUD",
|
||||
"horizon.pvp.editor.tooltip": "Розставте віджети просто в грі: перетягуйте куди завгодно, змінюйте розмір коліщатком, клацніть правою кнопкою, щоб повернути віджет у його кут.",
|
||||
"horizon.pvp.editor.hint": "Перетягуйте віджети · Коліщатко: розмір · Права кнопка: назад у кут",
|
||||
"horizon.pvp.combat_feel": "Відчуття бою",
|
||||
"horizon.pvp.combat_feel.tooltip": "«Класичне» показує бої як у 1.8.9: предмет у руці, його змах, їжу, лук і піднятий меч, поки ви тримаєте «використати», без просідання після кожного удару та без індикатора атаки. Лише вигляд: те, що ви надсилаєте серверу, не змінюється.",
|
||||
"horizon.pvp.combat_indicator": "Індикатор атаки в «Класичному»",
|
||||
"horizon.pvp.combat_indicator.tooltip": "Залишає в «Класичному» індикатор атаки, якого в 1.8.9 не було.",
|
||||
"horizon.pvp.combat_feel.modern": "Сучасне",
|
||||
"horizon.pvp.combat_feel.legacy": "Класичне (1.8.9)",
|
||||
"horizon.pvp.header.combat": "Відчуття бою",
|
||||
"horizon.pvp.combat_sounds": "Звуки ударів 1.8",
|
||||
"horizon.pvp.combat_sounds.tooltip": "У «Класичному» прибирає звуки атаки, додані в 1.9 (сильний, слабкий, розмашистий, критичний удар...). Звуки поранення лишаються.",
|
||||
"horizon.pvp.combat_rules": "Бій 1.8 у ваших світах",
|
||||
"horizon.pvp.combat_rules.tooltip": "В одиночній грі та у світах, відкритих для LAN, бої йдуть за правилами 1.8.9: без перезарядки атаки й розмашистого удару, зі шкодою зброї, Силою, Гостротою і критичними ударами як у 1.8. Сервери, до яких ви приєднуєтесь, лишають свої правила.",
|
||||
"Bindings": "Призначення",
|
||||
"Play": "Гра",
|
||||
"Device": "Пристрій",
|
||||
"Look": "Огляд",
|
||||
"Movement": "Рух",
|
||||
"Menus": "Меню",
|
||||
"Actions": "Дії",
|
||||
"Reset": "Скинути",
|
||||
"Detected": "Виявлено",
|
||||
"Glyphs": "Значки кнопок",
|
||||
"Feedback": "Віддача",
|
||||
"Controller": "Геймпад",
|
||||
"How the sticks turn and move you, and how menus repeat and scroll.": "Як стіки повертають і рухають вас, а меню повторюють і прокручують.",
|
||||
"Which button does what. Each player has their own.": "Яка кнопка що робить. У кожного гравця — свої.",
|
||||
"The controller the game sees, its button glyphs and its feedback.": "Геймпад, який бачить гра, значки його кнопок і віддача.",
|
||||
"Press this row, then the button that should do it.": "Натисніть цей рядок, потім кнопку, яка має це робити.",
|
||||
"Off, the game ignores every controller for this player.": "Вимкнено: гра ігнорує всі геймпади для цього гравця.",
|
||||
"How fast the right stick turns the view.": "Як швидко правий стік повертає огляд.",
|
||||
"Turn the view the other way, left and right.": "Повертати огляд у протилежний бік ліворуч і праворуч.",
|
||||
"Turn the view the other way, up and down.": "Повертати огляд у протилежний бік угору і вниз.",
|
||||
"How far the right stick moves before the view turns. Raise it if the view drifts.": "Наскільки має відхилитися правий стік, перш ніж огляд почне повертатися. Збільште, якщо огляд сам зсувається.",
|
||||
"Hold the button to sneak, or press it once to start and again to stop.": "Тримайте кнопку, щоб скрадатися, або натисніть раз, щоб почати, і ще раз, щоб припинити.",
|
||||
"How far the left stick moves before you walk. Raise it if you drift.": "Наскільки має відхилитися лівий стік, перш ніж ви підете. Збільште, якщо вас самого зносить.",
|
||||
"How far a trigger is pulled before it counts as pressed.": "Наскільки треба натиснути тригер, щоб це вважалося натисканням.",
|
||||
"How fast the stick moves the crosshair in inventories.": "Як швидко стік рухає курсор в інвентарях.",
|
||||
"How fast the right stick scrolls a list.": "Як швидко правий стік прокручує список.",
|
||||
"How long a held direction waits before it repeats.": "Скільки утримуваний напрямок чекає перед першим повтором.",
|
||||
"How long a held direction takes between repeats.": "Скільки часу минає між повторами утримуваного напрямку.",
|
||||
"Which on-screen keyboard opens when a text field is pressed.": "Яка екранна клавіатура відкривається, коли натискаєте текстове поле.",
|
||||
"Every action back on its default button.": "Повернути всі дії на типові кнопки.",
|
||||
"These settings belong to this player's profile.": "Ці налаштування належать профілю цього гравця.",
|
||||
"The controller driving this player, or else the first one connected.": "Геймпад, яким керує цей гравець, або інакше перший підключений.",
|
||||
"The USB vendor and product the system reports. Useful when reporting a controller that misbehaves.": "Виробник і продукт USB, які повідомляє система. Знадобиться, коли повідомляєте про геймпад, що працює неправильно.",
|
||||
"The glyph family the game picked from the controller's maker.": "Набір значків, який гра обрала за виробником геймпада.",
|
||||
"Follow the controller, or always show one family's buttons.": "Як у підключеного геймпада або завжди кнопки одного набору.",
|
||||
"The buttons menus show right now.": "Кнопки, які меню показують зараз.",
|
||||
"Rumble when something happens to you.": "Вібрація, коли з вами щось стається.",
|
||||
"The controller keeps playing while this window isn't focused, and losing focus won't pause.": "Геймпад працює, навіть коли це вікно не у фокусі, а втрата фокусу не ставить гру на паузу.",
|
||||
"A DualSense's triggers push back on bows and tools.": "Тригери DualSense чинять опір, коли ви стріляєте з лука чи працюєте інструментами.",
|
||||
"What a DualSense's light shows, over USB or Bluetooth.": "Що показує підсвічування DualSense через USB або Bluetooth.",
|
||||
"Player profile": "Профіль гравця",
|
||||
"Vendor and product": "Виробник і продукт",
|
||||
"Detected glyphs": "Виявлені значки",
|
||||
"Glyphs in use": "Поточні значки",
|
||||
"Unknown": "Невідомо",
|
||||
"No controller": "Немає геймпада",
|
||||
"Editing: %s": "Редагування: %s",
|
||||
"Press the button for this. %s cancels.": "Натисніть кнопку для цього. %s — скасувати.",
|
||||
"This button also does: %s": "Ця кнопка також робить: %s",
|
||||
"horizon.pad.notice.connected.press": "Натисніть, щоб використовувати",
|
||||
"horizon.recipe_transfer.missing": "Бракує предметів",
|
||||
"horizon.recipe_transfer.no_room": "Інвентар заповнено",
|
||||
"horizon.recipe_transfer.unavailable": "Рецепт не підходить для цього меню",
|
||||
"horizon.recipe_transfer.unsupported": "Сервер вимагає відомого рецепта"
|
||||
}
|
||||
@@ -195,6 +195,7 @@
|
||||
"horizon.pages.shader_options": "光影选项",
|
||||
"horizon.pages.split_profiles": "谁在玩?",
|
||||
"horizon.pages.split_profiles.tooltip": "谁在玩:保存在此游戏文件夹中的一份档案。",
|
||||
"horizon.plain.official": "带启动器、模组和更新的官方 Horizon:playhorizon.dev",
|
||||
"horizon.shader_options.profile": "档案",
|
||||
"General": "常规",
|
||||
"Advanced": "高级",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Headless transport for the block-entity render parity gate: replays each
|
||||
//! case (block state, light, partial tick and ticked block-entity fields)
|
||||
//! through `BlockEntityRenderer` at the block origin and writes the quads
|
||||
//! (texture, sprite-local UVs, baked colour, light) and sign text calls.
|
||||
//! (texture, sprite-local UVs, tint, normal, light) and sign text calls.
|
||||
#![forbid(unsafe_code)]
|
||||
use glam::{DVec3, Mat4, Quat, Vec3};
|
||||
use horizon_client::{
|
||||
@@ -417,7 +417,8 @@ pub fn main() -> Result<()> {
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
json!({"texture": texture, "v": v, "color": q[0].color, "light": q[0].light})
|
||||
json!({"texture": texture, "v": v, "color": q[0].color, "light": q[0].light,
|
||||
"normal": q[0].normal})
|
||||
})
|
||||
.collect();
|
||||
let texts: Vec<Value> = texts
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Rust side of tools/test_enchant_durability_parity.py: `Enchantments` value
|
||||
//! effects for the `minecraft:item_damage` (Unbreaking) and
|
||||
//! `minecraft:repair_with_xp` (Mending) components on one stack per case.
|
||||
use horizon_client::{
|
||||
enchant::{EffectContext, Enchantments},
|
||||
item_data::StackData,
|
||||
items::{ItemStack, Items},
|
||||
};
|
||||
use horizon_core::random::LegacyRandom;
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use serde_json::Value;
|
||||
use std::{fs, io::Write, path::Path};
|
||||
|
||||
pub fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let input = fs::read_to_string(args.get(1).ok_or("missing INPUT")?)?;
|
||||
let root = input
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("#resources "))
|
||||
.map(Path::new)
|
||||
.ok_or("no #resources line")?;
|
||||
let enchantments = Enchantments::load(root);
|
||||
let items = Items::catalog(root, &PackOverlay::NONE)?;
|
||||
let out = fs::File::create(args.get(2).ok_or("missing OUTPUT")?)?;
|
||||
let mut out = std::io::BufWriter::new(out);
|
||||
for line in input.lines() {
|
||||
if line.starts_with("#resources ") || line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let case: Value = serde_json::from_str(line)?;
|
||||
let item = case["item"].as_str().unwrap();
|
||||
let level = case["level"].as_u64().unwrap() as u32;
|
||||
let component = if case["mode"] == "unbreaking" {
|
||||
"minecraft:item_damage"
|
||||
} else {
|
||||
"minecraft:repair_with_xp"
|
||||
};
|
||||
let stack = ItemStack {
|
||||
item: item.into(),
|
||||
count: 1,
|
||||
damage: case["damage"].as_u64().unwrap() as u32,
|
||||
extra: Some(Box::new(StackData {
|
||||
enchantments: vec![(
|
||||
component_enchantment(case["mode"].as_str().unwrap()).into(),
|
||||
level,
|
||||
)],
|
||||
..Default::default()
|
||||
})),
|
||||
};
|
||||
let ctx = EffectContext {
|
||||
tool: Some(&stack.item),
|
||||
items: Some(&items),
|
||||
..Default::default()
|
||||
};
|
||||
let mut random = LegacyRandom::new(case["seed"].as_i64().unwrap());
|
||||
let value = enchantments.modify_value(
|
||||
Some(&stack),
|
||||
component,
|
||||
&ctx,
|
||||
&mut random,
|
||||
case["amount"].as_i64().unwrap() as f32,
|
||||
);
|
||||
let value = if case["mode"] == "mending" {
|
||||
value.max(0.0)
|
||||
} else {
|
||||
value
|
||||
};
|
||||
writeln!(out, "{}", value as i64)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn component_enchantment(mode: &str) -> &'static str {
|
||||
if mode == "unbreaking" {
|
||||
"minecraft:unbreaking"
|
||||
} else {
|
||||
"minecraft:mending"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Environment attribute entries for tools/test_environment_attribute_modifier_parity.py: each
|
||||
//! case parses an entry for an attribute and applies it to a subject value.
|
||||
use horizon_world::environment_attributes::{ATTRIBUTES, Attribute, Entry, Kind, Value, attribute};
|
||||
use std::{error::Error, fmt::Write};
|
||||
|
||||
fn bits(f: f32) -> String {
|
||||
format!("{:08x}", f.to_bits())
|
||||
}
|
||||
|
||||
fn format(value: &Value) -> String {
|
||||
match value {
|
||||
Value::Bool(b) => b.to_string(),
|
||||
Value::Float(f) => bits(*f),
|
||||
Value::Rgb(v) => v.map(bits).join(" "),
|
||||
Value::Argb(v) => v.map(bits).join(" "),
|
||||
Value::Other(json) => json.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn run(attribute: &Attribute, subject: &str, entry: &str) -> Result<String, Box<dyn Error>> {
|
||||
let subject = match Entry::parse(attribute, &serde_json::from_str(subject)?)? {
|
||||
Entry::Override(value) => value,
|
||||
_ => return Err("the subject is not a plain value".into()),
|
||||
};
|
||||
let Ok(entry) = Entry::parse(attribute, &serde_json::from_str(entry)?) else {
|
||||
return Ok("error".into());
|
||||
};
|
||||
let value = entry.apply(&subject);
|
||||
Ok(format!(
|
||||
"{}\t{}",
|
||||
format(&value),
|
||||
format(&attribute.sanitize(value.clone()))
|
||||
))
|
||||
}
|
||||
|
||||
/// Every attribute in registration order: id, type, default, and how a float range clamps.
|
||||
fn registry(output: &mut String) -> std::fmt::Result {
|
||||
for a in &ATTRIBUTES {
|
||||
let kind = match a.kind {
|
||||
Kind::Boolean => "minecraft:boolean",
|
||||
Kind::Float => "minecraft:float",
|
||||
Kind::AngleDegrees => "minecraft:angle_degrees",
|
||||
Kind::RgbColor => "minecraft:rgb_color",
|
||||
Kind::ArgbColor => "minecraft:argb_color",
|
||||
Kind::Other => "other",
|
||||
};
|
||||
let default = a.default_value();
|
||||
let range = match default {
|
||||
Some(Value::Float(_)) => format!(
|
||||
"{} {}",
|
||||
format(&a.sanitize(Value::Float(f32::NEG_INFINITY))),
|
||||
format(&a.sanitize(Value::Float(f32::INFINITY)))
|
||||
),
|
||||
_ => "-".into(),
|
||||
};
|
||||
let default = default.map_or("unported".into(), |v| format(&v));
|
||||
writeln!(output, "minecraft:{}\t{kind}\t{default}\t{range}", a.id)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn main() -> Result<(), Box<dyn Error>> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let mut output = String::new();
|
||||
for line in std::fs::read_to_string(&args[1])?.lines() {
|
||||
if line == "registry" {
|
||||
registry(&mut output)?;
|
||||
continue;
|
||||
}
|
||||
let fields: Vec<&str> = line.split('\t').collect();
|
||||
let attribute = attribute(fields[0]).ok_or("unknown attribute")?;
|
||||
writeln!(output, "{}", run(attribute, fields[1], fields[2])?)?;
|
||||
}
|
||||
std::fs::write(&args[2], output)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Rust side of tools/test_falling_block_parity.py: replays the landing branch
|
||||
//! of `FallingBlockEntity.tick` over the case's block map through
|
||||
//! `horizon_world::level::Behaviours::falling_block_landed` and prints the same
|
||||
//! decision, changed blocks and numeric level events as the Java oracle.
|
||||
use horizon_core::{position::BlockPos, random::LegacyRandom};
|
||||
use horizon_world::{
|
||||
fluid::FluidKind,
|
||||
level::{Behaviours, Dimension, Landing, LevelAccess, LevelEvent, Ticks, data::load_registry},
|
||||
registry::BlockStateId,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
io::{BufRead, Write},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
struct Cells {
|
||||
cells: HashMap<BlockPos, BlockStateId>,
|
||||
air: BlockStateId,
|
||||
random: LegacyRandom,
|
||||
events: Vec<String>,
|
||||
}
|
||||
impl LevelAccess for Cells {
|
||||
fn block_state(&self, pos: BlockPos) -> BlockStateId {
|
||||
self.cells.get(&pos).copied().unwrap_or(self.air)
|
||||
}
|
||||
fn set_raw(&mut self, pos: BlockPos, state: BlockStateId) -> Option<BlockStateId> {
|
||||
let old = self.block_state(pos);
|
||||
if state == self.air {
|
||||
self.cells.remove(&pos);
|
||||
} else {
|
||||
self.cells.insert(pos, state);
|
||||
}
|
||||
Some(old)
|
||||
}
|
||||
fn is_loaded(&self, _pos: BlockPos) -> bool {
|
||||
true
|
||||
}
|
||||
fn min_y(&self) -> i32 {
|
||||
-64
|
||||
}
|
||||
fn max_y(&self) -> i32 {
|
||||
320
|
||||
}
|
||||
fn random(&mut self) -> &mut LegacyRandom {
|
||||
&mut self.random
|
||||
}
|
||||
fn game_time(&self) -> i64 {
|
||||
0
|
||||
}
|
||||
fn dimension(&self) -> Dimension {
|
||||
Dimension::Overworld
|
||||
}
|
||||
fn emit(&mut self, event: LevelEvent) {
|
||||
if let LevelEvent::LevelEvent { id, pos, data } = event
|
||||
&& id == 1031
|
||||
{
|
||||
self.events
|
||||
.push(format!("L {} {},{},{} {}", id, pos.x, pos.y, pos.z, data));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pos(v: &Value) -> BlockPos {
|
||||
BlockPos::new(
|
||||
v[0].as_i64().unwrap() as i32,
|
||||
v[1].as_i64().unwrap() as i32,
|
||||
v[2].as_i64().unwrap() as i32,
|
||||
)
|
||||
}
|
||||
|
||||
fn main_landing(case: &Value, b: &Behaviours, air: BlockStateId, out: &mut Vec<String>) {
|
||||
let mut level = Cells {
|
||||
cells: HashMap::new(),
|
||||
air,
|
||||
random: LegacyRandom::new(case["seed"].as_i64().unwrap()),
|
||||
events: Vec::new(),
|
||||
};
|
||||
for c in case["cells"].as_array().unwrap() {
|
||||
let state = BlockStateId(c[3].as_i64().unwrap() as u32);
|
||||
if state != air {
|
||||
level.cells.insert(pos(c), state);
|
||||
}
|
||||
}
|
||||
let at = pos(&case["pos"]);
|
||||
let before = level.cells.clone();
|
||||
let original = BlockStateId(case["state"].as_i64().unwrap() as u32);
|
||||
// `FallingBlockEntity.fall` drops the WATERLOGGED property before the entity
|
||||
// carries the state and puts the source's legacy fluid block at the cell;
|
||||
// `falling_block_fall` does the same for the caller. The case's destination
|
||||
// state is then restored for the landing probe.
|
||||
let state = b
|
||||
.table
|
||||
.with_bool(original, b.props.waterlogged, false)
|
||||
.unwrap_or(original);
|
||||
let destination = level.cells.get(&at).copied();
|
||||
let legacy = b.legacy_block(b.fluid(original));
|
||||
if legacy == air {
|
||||
level.cells.remove(&at);
|
||||
} else {
|
||||
level.cells.insert(at, legacy);
|
||||
}
|
||||
if let Some(destination) = destination {
|
||||
level.cells.insert(at, destination);
|
||||
}
|
||||
let stuck = matches!(
|
||||
b.fluid(level.block_state(at)).kind(),
|
||||
FluidKind::Water | FluidKind::FlowingWater
|
||||
);
|
||||
let mut ticks = Ticks::new(0);
|
||||
let landing = b.falling_block_landed(&mut level, &mut ticks, at, state, stuck);
|
||||
match landing {
|
||||
Landing::Placed(placed) => {
|
||||
out.push(format!("decision placed {}", placed.0));
|
||||
}
|
||||
Landing::Broken => out.push("decision broken".into()),
|
||||
Landing::Continue => out.push("decision continue".into()),
|
||||
}
|
||||
let mut changed: Vec<BlockPos> = before
|
||||
.keys()
|
||||
.copied()
|
||||
.chain(level.cells.keys().copied())
|
||||
.collect();
|
||||
changed.sort_by_key(|p| (p.x, p.y, p.z));
|
||||
changed.dedup();
|
||||
for p in changed {
|
||||
let now = level.block_state(p);
|
||||
if before.get(&p).copied().unwrap_or(air) != now {
|
||||
out.push(format!("set {},{},{} {}", p.x, p.y, p.z, now.0));
|
||||
}
|
||||
}
|
||||
out.extend(level.events);
|
||||
}
|
||||
|
||||
pub fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut args = crate::args().skip(1);
|
||||
let resources = args.next().expect("resource directory");
|
||||
let cases = args.next().expect("case file");
|
||||
let output = args.next().expect("output file");
|
||||
let resources = Path::new(&resources);
|
||||
let registry = load_registry(resources)?;
|
||||
let behaviours = Behaviours::load(®istry, resources)?;
|
||||
let air = registry.default_state("minecraft:air").unwrap();
|
||||
let mut out = std::io::BufWriter::new(std::fs::File::create(output)?);
|
||||
for (index, line) in std::io::BufReader::new(std::fs::File::open(cases)?)
|
||||
.lines()
|
||||
.enumerate()
|
||||
{
|
||||
let case: Value = serde_json::from_str(&line?)?;
|
||||
let mut lines = Vec::new();
|
||||
main_landing(&case, &behaviours, air, &mut lines);
|
||||
writeln!(out, "== {index}")?;
|
||||
for line in lines {
|
||||
writeln!(out, "{line}")?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//! Furnace parity (tools/test_furnace_parity.py): reads "furnace KIND TICKS
|
||||
//! INPUT FUEL OUTPUT TIMER TOTAL LIT LITTOTAL SPEED CHANGE_AT NEW_INPUT"
|
||||
//! lines, runs the furnace model and prints the slots, timers, LIT state,
|
||||
//! RecipesUsed counts and each used recipe's experience.
|
||||
//!
|
||||
//! The first input line is `#resources RESOURCES`; usage: furnace_fixture INPUT OUTPUT
|
||||
use horizon_client::{
|
||||
crafting::Recipes,
|
||||
furnace::FurnaceCooking,
|
||||
item_data::{canon_stack, parse_stack},
|
||||
items::Items,
|
||||
};
|
||||
use horizon_client_render::{
|
||||
chunk::block_entity::{Furnace, FurnaceKind},
|
||||
providers::Providers,
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use std::{collections::HashMap, fmt::Write as _, fs};
|
||||
|
||||
pub fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let input = fs::read_to_string(args.get(1).ok_or("missing INPUT")?)?;
|
||||
let root = input
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("#resources "))
|
||||
.map(std::path::PathBuf::from)
|
||||
.ok_or("no #resources line")?;
|
||||
let items = Items::catalog(&root, &PackOverlay::NONE)?;
|
||||
let recipes = Recipes::load(&root, &items, &PackOverlay::NONE)?;
|
||||
let providers = Providers::load(&root);
|
||||
let mut out = String::new();
|
||||
for (n, line) in input
|
||||
.lines()
|
||||
.filter(|line| !line.starts_with('#'))
|
||||
.enumerate()
|
||||
{
|
||||
let f: Vec<&str> = line.split(' ').collect();
|
||||
let kind = match f[1] {
|
||||
"furnace" => FurnaceKind::Furnace,
|
||||
"blast_furnace" => FurnaceKind::BlastFurnace,
|
||||
"smoker" => FurnaceKind::Smoker,
|
||||
_ => return Err("unknown furnace kind".into()),
|
||||
};
|
||||
let ticks: u32 = f[2].parse()?;
|
||||
let mut items3 = [None, None, None];
|
||||
for i in 0..3 {
|
||||
items3[i] = parse_stack(f[3 + i])?;
|
||||
}
|
||||
let mut furnace = Furnace {
|
||||
kind,
|
||||
items: items3,
|
||||
lit_remaining: f[8].parse()?,
|
||||
lit_total: f[9].parse()?,
|
||||
cooking_timer: f[6].parse()?,
|
||||
cooking_total: f[7].parse()?,
|
||||
speed: f[10].parse()?,
|
||||
recipes_used: HashMap::new(),
|
||||
};
|
||||
let change_at: i32 = f[11].parse()?;
|
||||
let new_input = parse_stack(f[12])?;
|
||||
let mut lit_state = false;
|
||||
let mut drops = Vec::new();
|
||||
for t in 0..ticks {
|
||||
if t as i32 == change_at {
|
||||
furnace.set_input(&recipes, new_input.clone());
|
||||
}
|
||||
if let Some(lit) = furnace.tick(&recipes, &items, &providers, &mut drops) {
|
||||
lit_state = lit;
|
||||
}
|
||||
}
|
||||
if change_at == ticks as i32 {
|
||||
furnace.set_input(&recipes, new_input.clone());
|
||||
}
|
||||
write!(out, "{n} kind={} ", f[1])?;
|
||||
for i in 0..3 {
|
||||
write!(out, "s{i}={} ", canon_stack(furnace.items[i].as_ref()))?;
|
||||
}
|
||||
write!(
|
||||
out,
|
||||
"timer={} total={} lit={} littotal={} speed={:.3} state={lit_state} used=",
|
||||
furnace.cooking_timer,
|
||||
furnace.cooking_total,
|
||||
furnace.lit_remaining,
|
||||
furnace.lit_total,
|
||||
furnace.speed
|
||||
)?;
|
||||
let mut ids: Vec<&String> = furnace.recipes_used.keys().collect();
|
||||
ids.sort();
|
||||
for (i, id) in ids.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
write!(out, "{id}={}", furnace.recipes_used[*id])?;
|
||||
}
|
||||
out.push_str(" xp=");
|
||||
for (i, id) in ids.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
write!(out, "{id}={}", recipes.cooking_experience(id))?;
|
||||
}
|
||||
writeln!(out)?;
|
||||
}
|
||||
fs::write(args.get(2).ok_or("missing OUTPUT")?, out)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -39,18 +39,22 @@ mod creative_mining_probe;
|
||||
mod creative_search_fixture;
|
||||
mod cushion_placement_probe;
|
||||
mod emitter_particles_probe;
|
||||
mod enchant_durability_fixture;
|
||||
mod enchanting_fixture;
|
||||
mod entity_anim_fixture;
|
||||
mod entity_components_probe;
|
||||
mod entity_death_loot_fixture;
|
||||
mod entity_particles_probe;
|
||||
mod entity_sound_packet_fixture;
|
||||
mod environment_attribute_modifier_fixture;
|
||||
mod environment_fixture;
|
||||
mod falling_block_fixture;
|
||||
mod finalize_spawn_fixture;
|
||||
mod fire_starter_sound_probe;
|
||||
mod firework_tooltip_fixture;
|
||||
mod first_person_probe;
|
||||
mod frame_limiter_probe;
|
||||
mod furnace_fixture;
|
||||
mod game_loop_probe;
|
||||
mod ghost_recipe_fixture;
|
||||
mod held_drop_fixture;
|
||||
@@ -101,6 +105,7 @@ mod spawn_rules_fixture;
|
||||
mod sprint_probe;
|
||||
mod squid_client_probe;
|
||||
mod stack_foil_fixture;
|
||||
mod stack_mining_probe;
|
||||
mod storage_menu_fixture;
|
||||
mod subtitle_fixture;
|
||||
mod tab_list_fixture;
|
||||
@@ -109,6 +114,7 @@ mod text_display_fixture;
|
||||
mod text_display_render_fixture;
|
||||
mod toggle_keys_probe;
|
||||
mod tooltip_display_fixture;
|
||||
mod trade_components_probe;
|
||||
mod trading_fixture;
|
||||
mod translucency_fixture;
|
||||
mod trident_probe;
|
||||
@@ -128,12 +134,14 @@ probes! {
|
||||
block_entity_tickers_fixture,
|
||||
block_particles_fixture,
|
||||
block_transform_fixture,
|
||||
furnace_fixture,
|
||||
brewing_fixture,
|
||||
breaking_fixture,
|
||||
block_tint_probe,
|
||||
camera_probe,
|
||||
squid_client_probe,
|
||||
creative_mining_probe,
|
||||
stack_mining_probe,
|
||||
cushion_placement_probe,
|
||||
cauldron_fixture,
|
||||
chat_screen_fixture,
|
||||
@@ -146,11 +154,14 @@ probes! {
|
||||
conversion_tags_fixture,
|
||||
creative_search_fixture,
|
||||
emitter_particles_probe,
|
||||
enchant_durability_fixture,
|
||||
enchanting_fixture,
|
||||
falling_block_fixture,
|
||||
entity_anim_fixture,
|
||||
entity_components_probe,
|
||||
entity_death_loot_fixture,
|
||||
entity_particles_probe,
|
||||
environment_attribute_modifier_fixture,
|
||||
environment_fixture,
|
||||
finalize_spawn_fixture,
|
||||
fire_starter_sound_probe,
|
||||
@@ -217,6 +228,7 @@ probes! {
|
||||
text_display_fixture,
|
||||
text_display_render_fixture,
|
||||
trading_fixture,
|
||||
trade_components_probe,
|
||||
trident_probe,
|
||||
translucency_fixture,
|
||||
stack_foil_fixture,
|
||||
|
||||
@@ -204,6 +204,7 @@ fn goal_name(m: &Mob, name: &str) -> String {
|
||||
"Float" => "FloatGoal",
|
||||
"RandomFloatAround" => "RandomFloatAroundGoal",
|
||||
"GhastLook" => "GhastLookGoal",
|
||||
"Peek" => "ShulkerPeekGoal",
|
||||
"GhastShoot" => "GhastShootFireballGoal",
|
||||
"Stroll" => "WaterAvoidingRandomStrollGoal",
|
||||
"ParrotWander" => "ParrotWanderGoal",
|
||||
@@ -642,7 +643,7 @@ fn player_hurt(player: Option<&PlayerView>, e: &MobEvent) -> bool {
|
||||
..
|
||||
} => true,
|
||||
// ServerExplosion.hurtEntities: within twice the radius every hit does damage.
|
||||
MobEvent::Explode(at, radius, _) => {
|
||||
MobEvent::Explode(at, radius, _, _) => {
|
||||
let d = Vec3::new(at[0], at[1], at[2])
|
||||
.distance_squared(p.position)
|
||||
.sqrt();
|
||||
@@ -1068,6 +1069,13 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
// Set once the brain exists (below).
|
||||
x if x.starts_with("cooldown=") => {}
|
||||
"dead" => m.health = 0.0,
|
||||
// effect=<name>:<amplifier>, never running out.
|
||||
x if x.starts_with("effect=") => {
|
||||
let (name, amp) = x[7..].split_once(':').unwrap();
|
||||
let name: &'static str =
|
||||
Box::leak(format!("minecraft:{name}").into_boxed_str());
|
||||
m.add_effect(name, -1, amp.parse().unwrap());
|
||||
}
|
||||
"leashed" => m.extra.leash = Some(horizon_client::mobs::leash::Holder::Player),
|
||||
x if x.starts_with("leashto=") => {
|
||||
let n: u32 = x[8..].parse().unwrap();
|
||||
@@ -1208,7 +1216,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
day_time: world.day_time,
|
||||
raining: s.rain,
|
||||
thundering: s.thunder,
|
||||
nether: false,
|
||||
level: horizon_core::LevelKey::OVERWORLD,
|
||||
block_tags: &items.block_tags,
|
||||
landing: &motion.landing,
|
||||
biomes: &biomes,
|
||||
@@ -1217,7 +1225,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
mob_drops: true,
|
||||
max_entity_cramming: 24,
|
||||
difficulty: LocalDifficulty::new(s.difficulty, world.day_time, 0, 0.0),
|
||||
end: false,
|
||||
dimension_type: horizon_world::dimension_type::DimensionFacts::overworld(),
|
||||
world_seed: 0,
|
||||
// FlatLevelSource.getSeaLevel.
|
||||
sea_level: -63,
|
||||
|
||||
@@ -30,7 +30,7 @@ use horizon_client::{
|
||||
mobs::{
|
||||
MobTypes, Species,
|
||||
data::MobData,
|
||||
spawn_rules::{Dimension, SpawnLevel},
|
||||
spawn_rules::SpawnLevel,
|
||||
spawn_selection::{
|
||||
self, Category, Counted, Event, GenEvent, Heightmap, NaturalLevel, SpawnCost,
|
||||
SpawnPlayer, SpawnRequest, SpawnState, Spawned, StructureAt, StructureSpawns,
|
||||
@@ -49,12 +49,13 @@ use horizon_client::{
|
||||
vault::{Vault, VaultEvent, VaultLevel, VaultPlayer, VaultState},
|
||||
};
|
||||
use horizon_core::{
|
||||
LevelKey,
|
||||
geometry::Aabb,
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
use horizon_world::{
|
||||
block_collision::BlockCollisionRegistry, chunk::BlockState, loot::PackOverlay, nbt::Tag,
|
||||
registry::BlockStateId,
|
||||
block_collision::BlockCollisionRegistry, chunk::BlockState, dimension_type::DimensionFacts,
|
||||
loot::PackOverlay, nbt::Tag, registry::BlockStateId,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
@@ -98,6 +99,7 @@ struct Shared<'a> {
|
||||
entity_types: &'a HashSet<String>,
|
||||
}
|
||||
struct World<'a> {
|
||||
border: horizon_core::geometry::border::BorderGeometry,
|
||||
s: &'a Shared<'a>,
|
||||
blocks: Vec<(Bx, BlockStateId)>,
|
||||
lights: Vec<(Bx, i32, i32)>,
|
||||
@@ -110,7 +112,7 @@ struct World<'a> {
|
||||
sea: i32,
|
||||
seed: i64,
|
||||
peaceful: bool,
|
||||
dimension: Dimension,
|
||||
dimension: &'static DimensionFacts,
|
||||
dimension_id: String,
|
||||
slime: f32,
|
||||
starts: Vec<Start>,
|
||||
@@ -158,6 +160,10 @@ impl World<'_> {
|
||||
}
|
||||
}
|
||||
impl SpawnLevel for World<'_> {
|
||||
fn within_world_border(&self, x: i32, z: i32) -> bool {
|
||||
self.border
|
||||
.is_within_bounds(f64::from(x), f64::from(z), 0.0)
|
||||
}
|
||||
fn state(&self, x: i32, y: i32, z: i32) -> BlockStateId {
|
||||
if let Some((_, s)) = self
|
||||
.placed
|
||||
@@ -214,7 +220,7 @@ impl SpawnLevel for World<'_> {
|
||||
fn peaceful(&self) -> bool {
|
||||
self.peaceful
|
||||
}
|
||||
fn dimension(&self) -> Dimension {
|
||||
fn dimension_type(&self) -> &DimensionFacts {
|
||||
self.dimension
|
||||
}
|
||||
fn surface_slime_chance(&self, _: i32, _: i32, _: i32) -> f32 {
|
||||
@@ -232,28 +238,24 @@ impl SpawnLevel for World<'_> {
|
||||
}
|
||||
impl NaturalLevel for World<'_> {
|
||||
fn min_y(&self) -> i32 {
|
||||
if self.dimension == Dimension::Overworld {
|
||||
if self.dimension.id == "minecraft:overworld" {
|
||||
-64
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
fn max_y(&self) -> i32 {
|
||||
if self.dimension == Dimension::Overworld {
|
||||
if self.dimension.id == "minecraft:overworld" {
|
||||
319
|
||||
} else {
|
||||
255
|
||||
}
|
||||
}
|
||||
fn ambient_light(&self) -> f32 {
|
||||
match self.dimension {
|
||||
Dimension::Overworld => 0.0,
|
||||
Dimension::Nether => 0.1,
|
||||
Dimension::End => 0.25,
|
||||
}
|
||||
self.dimension.ambient_light
|
||||
}
|
||||
fn has_ceiling(&self) -> bool {
|
||||
self.dimension == Dimension::Nether
|
||||
self.dimension.id == "minecraft:the_nether"
|
||||
}
|
||||
fn mob_type(&self, entity: &str) -> Option<(Species, f32, f32, Category)> {
|
||||
let t = self.s.types.types.get(entity)?;
|
||||
@@ -316,6 +318,8 @@ impl NaturalLevel for World<'_> {
|
||||
}
|
||||
fn can_spawn_in_chunk(&self, cx: i32, cz: i32) -> bool {
|
||||
!self.unloaded.contains(&(cx, cz))
|
||||
&& self.within_world_border(cx << 4, cz << 4)
|
||||
&& self.within_world_border((cx << 4).wrapping_add(15), (cz << 4).wrapping_add(15))
|
||||
}
|
||||
fn no_collision(&self, bounds: Aabb) -> bool {
|
||||
let lookup = |x: i32, y: i32, z: i32| self.state(x, y, z);
|
||||
@@ -664,11 +668,8 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let dimension_id = get("dim", "minecraft:overworld");
|
||||
let dimension = match dimension_id.as_str() {
|
||||
"minecraft:the_nether" => Dimension::Nether,
|
||||
"minecraft:the_end" => Dimension::End,
|
||||
_ => Dimension::Overworld,
|
||||
};
|
||||
let dimension =
|
||||
DimensionFacts::of_builtin_level(LevelKey::builtin_or_overworld(&dimension_id));
|
||||
let respawn = {
|
||||
let r = get("respawn", "0,-1000,0/minecraft:overworld");
|
||||
let (p, d) = r.split_once('/').ok_or("bad respawn")?;
|
||||
@@ -709,6 +710,11 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
));
|
||||
}
|
||||
let world = World {
|
||||
border: horizon_core::geometry::border::BorderGeometry::new(
|
||||
[get("borderx", "0").parse()?, get("borderz", "0").parse()?],
|
||||
get("bordersize", "59999968").parse()?,
|
||||
29_999_984,
|
||||
)?,
|
||||
s: &shared,
|
||||
blocks,
|
||||
lights,
|
||||
@@ -885,15 +891,17 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
let categories = state
|
||||
.filtered_categories(get("enemies", "1") == "1", get("persistent", "1") == "1");
|
||||
trace(Event::Categories(categories.clone()));
|
||||
spawn_selection::spawn_for_chunk(
|
||||
&world,
|
||||
&mut random,
|
||||
&mut state,
|
||||
&categories,
|
||||
chunk,
|
||||
&mut spawn,
|
||||
&mut trace,
|
||||
);
|
||||
if mode != "tick_chunk" || world.can_spawn_in_chunk(chunk.0, chunk.1) {
|
||||
spawn_selection::spawn_for_chunk(
|
||||
&world,
|
||||
&mut random,
|
||||
&mut state,
|
||||
&categories,
|
||||
chunk,
|
||||
&mut spawn,
|
||||
&mut trace,
|
||||
);
|
||||
}
|
||||
}
|
||||
log.push(format!("next {}", random.next_int_bound(1000).unwrap_or(0)));
|
||||
}
|
||||
|
||||
@@ -11,14 +11,16 @@ use horizon_client::{
|
||||
mobs::{
|
||||
Species,
|
||||
data::MobData,
|
||||
spawn_rules::{self, Dimension, SpawnLevel},
|
||||
spawn_rules::{self, SpawnLevel},
|
||||
spawn_selection::{self, Heightmap},
|
||||
spawning::LocalDifficulty,
|
||||
},
|
||||
patrol::{self, GameplayAttributes, Member, PatrolLevel, PatrolSpawner},
|
||||
raid::{self, Action, BarName, RaidLevel, RaidPlayer, RaiderInfo, Raids},
|
||||
};
|
||||
use horizon_core::LevelKey;
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use horizon_world::dimension_type::DimensionFacts;
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use horizon_world::{chunk::BlockState, nbt::Tag, registry::BlockStateId};
|
||||
use std::{
|
||||
@@ -74,7 +76,7 @@ struct World<'a> {
|
||||
light: i32,
|
||||
darken: i32,
|
||||
difficulty: i32,
|
||||
dimension: Dimension,
|
||||
dimension: &'static DimensionFacts,
|
||||
dimension_id: String,
|
||||
unloaded: HashSet<(i32, i32)>,
|
||||
no_ticking: HashSet<(i32, i32)>,
|
||||
@@ -96,14 +98,14 @@ impl World<'_> {
|
||||
.map_or((self.sky, self.light), |(_, s, b)| (*s, *b))
|
||||
}
|
||||
fn min_y(&self) -> i32 {
|
||||
if self.dimension == Dimension::Overworld {
|
||||
if self.dimension.id == "minecraft:overworld" {
|
||||
-64
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
fn max_y(&self) -> i32 {
|
||||
if self.dimension == Dimension::Overworld {
|
||||
if self.dimension.id == "minecraft:overworld" {
|
||||
319
|
||||
} else {
|
||||
255
|
||||
@@ -175,7 +177,7 @@ impl SpawnLevel for World<'_> {
|
||||
fn peaceful(&self) -> bool {
|
||||
self.difficulty == 0
|
||||
}
|
||||
fn dimension(&self) -> Dimension {
|
||||
fn dimension_type(&self) -> &DimensionFacts {
|
||||
self.dimension
|
||||
}
|
||||
fn surface_slime_chance(&self, _: i32, _: i32, _: i32) -> f32 {
|
||||
@@ -193,7 +195,7 @@ impl PatrolLevel for World<'_> {
|
||||
self.patrols
|
||||
}
|
||||
fn bright_outside(&self) -> bool {
|
||||
self.dimension == Dimension::Overworld && self.darken < 4
|
||||
self.dimension.id == "minecraft:overworld" && self.darken < 4
|
||||
}
|
||||
fn patrol_players(&self) -> Vec<([i32; 3], bool)> {
|
||||
self.player
|
||||
@@ -548,11 +550,8 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let dimension_id = get("dim", "minecraft:overworld");
|
||||
let dimension = match dimension_id.as_str() {
|
||||
"minecraft:the_nether" => Dimension::Nether,
|
||||
"minecraft:the_end" => Dimension::End,
|
||||
_ => Dimension::Overworld,
|
||||
};
|
||||
let dimension =
|
||||
DimensionFacts::of_builtin_level(LevelKey::builtin_or_overworld(&dimension_id));
|
||||
let player = c.get("p0").map(|p| {
|
||||
let mut parts = p.split('/');
|
||||
let v: Vec<f64> = parts
|
||||
|
||||
@@ -75,6 +75,18 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
.collect::<Vec<_>>()
|
||||
.join(";"),
|
||||
},
|
||||
"smith_transform" => {
|
||||
let files = vec![(
|
||||
"recipe/test:transform".to_owned(),
|
||||
vec![f[1].as_bytes().to_vec()],
|
||||
)];
|
||||
let stations = StationRecipes::load(root, &items, &PackOverlay { files: &files });
|
||||
let (t, b, a) = (stack(2)?, stack(3)?, stack(4)?);
|
||||
match stations.smithing_result(&enchantments, t.as_ref(), b.as_ref(), a.as_ref()) {
|
||||
None => "none".to_owned(),
|
||||
Some(r) => canon_stack(Some(&r)),
|
||||
}
|
||||
}
|
||||
"smith" => {
|
||||
let (t, b, a) = (stack(1)?, stack(2)?, stack(3)?);
|
||||
match stations.smithing_result(&enchantments, t.as_ref(), b.as_ref(), a.as_ref()) {
|
||||
|
||||
@@ -10,14 +10,16 @@ use horizon_client::{
|
||||
mobs::{
|
||||
MobTypes,
|
||||
data::MobData,
|
||||
spawn_rules::{self, Dimension, Reason, SpawnLevel},
|
||||
spawn_rules::{self, Reason, SpawnLevel},
|
||||
spawn_selection::{
|
||||
self, Heightmap, MobType as SelectionMobType, NaturalLevel, SpawnCost, StructureAt,
|
||||
StructureSpawns, spawn_aabb,
|
||||
},
|
||||
},
|
||||
};
|
||||
use horizon_core::LevelKey;
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use horizon_world::dimension_type::DimensionFacts;
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use horizon_world::{chunk::BlockState, registry::BlockStateId};
|
||||
use std::{
|
||||
@@ -28,6 +30,7 @@ use std::{
|
||||
};
|
||||
|
||||
struct Column<'a> {
|
||||
border: horizon_core::geometry::border::BorderGeometry,
|
||||
facts: &'a BlockFacts,
|
||||
data: &'a MobData,
|
||||
block_tags: &'a HashMap<String, HashSet<String>>,
|
||||
@@ -43,7 +46,7 @@ struct Column<'a> {
|
||||
surface: i32,
|
||||
seed: i64,
|
||||
peaceful: bool,
|
||||
dimension: Dimension,
|
||||
dimension: &'static DimensionFacts,
|
||||
biome: String,
|
||||
slime: f32,
|
||||
/// A player position and whether it is survival or adventure.
|
||||
@@ -53,6 +56,10 @@ struct Column<'a> {
|
||||
structure_spawns: StructureSpawns,
|
||||
}
|
||||
impl SpawnLevel for Column<'_> {
|
||||
fn within_world_border(&self, x: i32, z: i32) -> bool {
|
||||
self.border
|
||||
.is_within_bounds(f64::from(x), f64::from(z), 0.0)
|
||||
}
|
||||
fn state(&self, x: i32, y: i32, z: i32) -> BlockStateId {
|
||||
if x != self.x0 || z != self.z0 {
|
||||
return self.air;
|
||||
@@ -95,7 +102,7 @@ impl SpawnLevel for Column<'_> {
|
||||
fn peaceful(&self) -> bool {
|
||||
self.peaceful
|
||||
}
|
||||
fn dimension(&self) -> Dimension {
|
||||
fn dimension_type(&self) -> &DimensionFacts {
|
||||
self.dimension
|
||||
}
|
||||
fn surface_slime_chance(&self, _: i32, _: i32, _: i32) -> f32 {
|
||||
@@ -264,6 +271,11 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
)
|
||||
});
|
||||
let level = Column {
|
||||
border: horizon_core::geometry::border::BorderGeometry::new(
|
||||
[get("borderx", "0").parse()?, get("borderz", "0").parse()?],
|
||||
get("bordersize", "59999968").parse()?,
|
||||
29_999_984,
|
||||
)?,
|
||||
facts: &facts,
|
||||
data: &data,
|
||||
block_tags: &items.block_tags,
|
||||
@@ -279,11 +291,10 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
surface: get("surface", "0").parse()?,
|
||||
seed: get("wseed", "0").parse()?,
|
||||
peaceful: get("difficulty", "normal") == "peaceful",
|
||||
dimension: match get("dim", "minecraft:overworld").as_str() {
|
||||
"minecraft:the_nether" => Dimension::Nether,
|
||||
"minecraft:the_end" => Dimension::End,
|
||||
_ => Dimension::Overworld,
|
||||
},
|
||||
dimension: DimensionFacts::of_builtin_level(LevelKey::builtin_or_overworld(&get(
|
||||
"dim",
|
||||
"minecraft:overworld",
|
||||
))),
|
||||
biome: get("biome", "minecraft:plains"),
|
||||
slime: get("slime", "0.0").parse()?,
|
||||
player,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
use horizon_client::{
|
||||
Result, item_data,
|
||||
items::{ItemStack, Items},
|
||||
};
|
||||
use horizon_items::item_nbt;
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
fn query(items: &Items, stack: &ItemStack, block: &str) -> String {
|
||||
format!(
|
||||
"{} {} {} {}",
|
||||
items.mining_speed(Some(stack), block).to_bits(),
|
||||
items.correct_for_drops(Some(stack), block),
|
||||
items
|
||||
.mining_damage(stack)
|
||||
.map_or_else(|| "none".into(), |n| n.to_string()),
|
||||
items.mining_damage_capacity(stack)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn main() -> Result<()> {
|
||||
let args: Vec<_> = crate::args().collect();
|
||||
let root = Path::new(&args[3]);
|
||||
let items = Items::catalog(root, &PackOverlay::NONE)?;
|
||||
let mut out = BufWriter::new(File::create(&args[2])?);
|
||||
for line in BufReader::new(File::open(&args[1])?).lines() {
|
||||
let row: serde_json::Value = serde_json::from_str(&line?)?;
|
||||
let input = row["item"].as_str().unwrap();
|
||||
let (id, components) = input
|
||||
.find('[')
|
||||
.map_or((input, None), |i| (&input[..i], Some(&input[i..])));
|
||||
let mut stack = ItemStack::new(id, 1);
|
||||
stack.count = row["count"].as_u64().unwrap_or(1) as u32;
|
||||
if let Some(components) = components {
|
||||
let tag = item_data::command_components(components)?;
|
||||
(stack.damage, stack.extra) = item_data::decode_components(Some(&tag));
|
||||
}
|
||||
let block = row["block"].as_str().unwrap();
|
||||
let restored = if stack.count == 0 || id == "minecraft:air" {
|
||||
stack.clone()
|
||||
} else {
|
||||
item_nbt::item_from_nbt(&item_nbt::item_to_nbt(&stack)).unwrap()
|
||||
};
|
||||
writeln!(
|
||||
out,
|
||||
"{} | {}",
|
||||
query(&items, &stack, block),
|
||||
query(&items, &restored, block)
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//! Headless trade costs/modifiers: no game, window or server is started.
|
||||
use horizon_client::{
|
||||
enchant::Enchantments,
|
||||
items::{ItemStack, Items},
|
||||
merchant::{TradeContext, Trades},
|
||||
};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use serde_json::{Value, json};
|
||||
use std::{collections::HashMap, fs, path::Path};
|
||||
|
||||
pub fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<_> = crate::args().collect();
|
||||
let root = Path::new(&args[1]);
|
||||
let items = Items::catalog(root, &PackOverlay::NONE)?;
|
||||
let enchantments = Enchantments::load(root);
|
||||
let trades = Trades {
|
||||
trades: HashMap::new(),
|
||||
tags: HashMap::new(),
|
||||
sets: HashMap::new(),
|
||||
potion_tags: HashMap::new(),
|
||||
};
|
||||
let ctx = TradeContext {
|
||||
items: &items,
|
||||
enchantments: &enchantments,
|
||||
variant: "minecraft:plains",
|
||||
origin: [0.0; 3],
|
||||
maps: None,
|
||||
};
|
||||
let mut output = String::new();
|
||||
for line in fs::read_to_string(&args[2])?.lines() {
|
||||
let v: Value = serde_json::from_str(line)?;
|
||||
let mut random = LegacyRandom::new(v["seed"].as_i64().unwrap());
|
||||
let o = trades.offer_from(&ctx, &v["trade"], &mut random);
|
||||
let result = if let Some(o) = o {
|
||||
let mut levels = o.result.crafting_enchantments().to_vec();
|
||||
levels.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
let payments: Vec<_> = ["plain", "renamed", "wrong", "extra_potion"]
|
||||
.iter()
|
||||
.map(|kind| {
|
||||
let mut p =
|
||||
ItemStack::new("minecraft:potion", 2).with_potion(if *kind == "wrong" {
|
||||
"minecraft:awkward"
|
||||
} else {
|
||||
"minecraft:water"
|
||||
});
|
||||
if *kind == "renamed" {
|
||||
p.data_mut()
|
||||
.set_loot_component("minecraft:custom_name", &json!("Bottle"));
|
||||
}
|
||||
if *kind == "extra_potion" {
|
||||
p.data_mut()
|
||||
.potion_extra
|
||||
.push(("custom_color".into(), horizon_world::nbt::Tag::Int(123)));
|
||||
}
|
||||
let mut a = Some(p);
|
||||
let accepted = o.take(&items, &mut a, &mut None);
|
||||
json!([accepted, a.map_or(0, |s| s.count)])
|
||||
})
|
||||
.collect();
|
||||
json!({"item": &*o.result.item, "count": o.result.count, "levels": levels.iter().map(|(id,l)| json!([&**id,l])).collect::<Vec<_>>(), "a": o.base_cost_a.stack.count, "b": o.cost_b.as_ref().map(|b| b.stack.count), "payments": payments})
|
||||
} else {
|
||||
Value::Null
|
||||
};
|
||||
output.push_str(&json!({"result": result, "next":random.next_long()}).to_string());
|
||||
output.push('\n');
|
||||
}
|
||||
fs::write(&args[3], output)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -638,6 +638,9 @@ pub struct ItemView {
|
||||
pub enchantments: Vec<(String, u32)>,
|
||||
pub potion: Option<String>,
|
||||
pub custom_name: bool,
|
||||
/// Typed snapshot for supported mob equipment views. `None` leaves this
|
||||
/// family unprojected and unsupported; an empty compound is actual absence.
|
||||
pub custom_data: Option<horizon_world::nbt::Tag>,
|
||||
}
|
||||
/// An entity as predicates see it.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -689,13 +692,17 @@ pub struct EntityView {
|
||||
/// real holder without scores. Named predicates snapshot current values.
|
||||
pub scores: Option<BTreeMap<String, i32>>,
|
||||
}
|
||||
/// What `PlayerPredicate` reads of a player beyond its entity. Each part is
|
||||
/// filled only when a criterion may ask for it (`Tree::player_facts_asked`);
|
||||
/// a predicate that asks for a part left `None` fails.
|
||||
/// What `PlayerPredicate` reads of a player beyond its entity. Each record
|
||||
/// is filled only when a criterion may ask for it (`Tree::player_facts_asked`),
|
||||
/// the food always (two numbers); a predicate that asks for a part left
|
||||
/// `None` fails.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct PlayerFacts {
|
||||
/// `Player.experienceLevel`.
|
||||
pub level: Option<i32>,
|
||||
/// `Player.getFoodData`: `FoodData.getFoodLevel` and
|
||||
/// `FoodData.getSaturationLevel`.
|
||||
pub food: Option<(i32, f32)>,
|
||||
/// `ServerStatsCounter`: stat type -> stat id -> value, both namespaced.
|
||||
pub stats: Option<BTreeMap<String, BTreeMap<String, i32>>>,
|
||||
/// `PlayerAdvancements`: the loaded advancements and the player's
|
||||
@@ -708,6 +715,7 @@ impl std::fmt::Debug for PlayerFacts {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("PlayerFacts")
|
||||
.field("level", &self.level)
|
||||
.field("food", &self.food)
|
||||
.field("stats", &self.stats)
|
||||
.field(
|
||||
"advancements",
|
||||
@@ -847,6 +855,20 @@ pub fn item(v: &Value, it: &ItemView, w: &dyn World) -> bool {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// `CustomDataPredicate.matches`: typed subset matching of a projected
|
||||
// stack snapshot. Unprojected consumers keep their unsupported behavior.
|
||||
if let Some(pattern) = keyed(preds, "minecraft:custom_data").and_then(Value::as_str)
|
||||
&& let Some(actual) = it.custom_data.as_ref()
|
||||
{
|
||||
let Ok(pattern @ horizon_world::nbt::Tag::Compound(_)) =
|
||||
crate::command::snbt::parse_fully(pattern)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if !crate::nbt_path::compare_nbt(&pattern, Some(actual)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `LockCode.canUnlock`: a spectator opens any container; otherwise the
|
||||
@@ -1093,15 +1115,29 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
|
||||
true
|
||||
}
|
||||
/// The rest of `PlayerPredicate.matches` (expected from it): the experience
|
||||
/// level in the `level` bounds; each `stats` entry's value (0 when never
|
||||
/// counted) in its bounds; each listed recipe known or not as asked; each
|
||||
/// listed advancement loaded, and done or not, or each named criterion of it
|
||||
/// granted or not. A field asked of facts that weren't filled fails.
|
||||
/// level in the `level` bounds; the food (`FoodPredicate.matches`); each
|
||||
/// `stats` entry's value (0 when never counted) in its bounds; each listed
|
||||
/// recipe known or not as asked; each listed advancement loaded, and done or
|
||||
/// not, or each named criterion of it granted or not. A field asked of facts
|
||||
/// that weren't filled fails.
|
||||
fn player_facts(p: &Value, f: &PlayerFacts) -> bool {
|
||||
let level = &p["level"];
|
||||
if !level.is_null() && !f.level.is_some_and(|l| range_i(level, i64::from(l))) {
|
||||
return false;
|
||||
}
|
||||
// `FoodPredicate.matches`: the food level in `level`, then the
|
||||
// saturation (a float, compared as a double) in `saturation`.
|
||||
let food = &p["food"];
|
||||
if !food.is_null() {
|
||||
let Some((level, saturation)) = f.food else {
|
||||
return false;
|
||||
};
|
||||
if !range_i(&food["level"], i64::from(level))
|
||||
|| !range_f(&food["saturation"], f64::from(saturation))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let stats = &p["stats"];
|
||||
if !stats.is_null() {
|
||||
let Some(list) = stats.as_array() else {
|
||||
@@ -2811,6 +2847,59 @@ mod tests {
|
||||
assert!(player_matches(json!({}), &PlayerFacts::default()));
|
||||
}
|
||||
#[test]
|
||||
fn player_food_tests_level_and_saturation_in_their_bounds() {
|
||||
let fed = PlayerFacts {
|
||||
food: Some((17, 3.0)),
|
||||
..PlayerFacts::default()
|
||||
};
|
||||
for (food, expected) in [
|
||||
(json!({}), true),
|
||||
(json!({"level": {"min": 17}}), true),
|
||||
(json!({"level": {"min": 18}}), false),
|
||||
(json!({"level": {"max": 16}}), false),
|
||||
(json!({"level": 17}), true),
|
||||
(json!({"saturation": {"min": 3.0}}), true),
|
||||
(json!({"saturation": {"min": 3.5}}), false),
|
||||
(json!({"saturation": {"max": 2.9}}), false),
|
||||
(
|
||||
json!({"level": {"min": 17}, "saturation": {"max": 3.0}}),
|
||||
true,
|
||||
),
|
||||
(
|
||||
json!({"level": {"min": 17}, "saturation": {"min": 3.5}}),
|
||||
false,
|
||||
),
|
||||
(
|
||||
json!({"level": {"min": 18}, "saturation": {"min": 3.0}}),
|
||||
false,
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
player_matches(json!({"food": food}), &fed),
|
||||
expected,
|
||||
"{food}"
|
||||
);
|
||||
}
|
||||
// The saturation float widens to a double: 1.2f is 1.2000000476837158.
|
||||
let widened = PlayerFacts {
|
||||
food: Some((20, 1.2)),
|
||||
..PlayerFacts::default()
|
||||
};
|
||||
assert!(player_matches(
|
||||
json!({"food": {"saturation": {"min": 1.2}}}),
|
||||
&widened
|
||||
));
|
||||
assert!(!player_matches(
|
||||
json!({"food": {"saturation": {"max": 1.2}}}),
|
||||
&widened
|
||||
));
|
||||
// No food known: food asked fails, even unbounded.
|
||||
assert!(!player_matches(
|
||||
json!({"food": {}}),
|
||||
&PlayerFacts::default()
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn player_stats_match_each_counter_by_type_and_id() {
|
||||
let mut counts: BTreeMap<String, BTreeMap<String, i32>> = BTreeMap::new();
|
||||
(counts.entry("minecraft:custom".into()).or_default())
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
//! `minecraft:audio/ambient_sounds` attribute, `UnderwaterAmbientSoundHandler`
|
||||
//! and `LocalPlayer.updateIsUnderwater` (enter/exit sounds and the
|
||||
//! `UnderLiquidAmbientSoundInstance` loop), played through the sound engine.
|
||||
use crate::sound_engine::{AMBIENT, InstanceId, PLAYERS, SoundEngine, SoundInstance, Sounds};
|
||||
use crate::random_source;
|
||||
use crate::sound_engine::{AMBIENT, InstanceId, PLAYERS, SoundInstance, Sounds};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, path::Path};
|
||||
@@ -160,7 +161,7 @@ pub struct Ambience {
|
||||
}
|
||||
impl Default for Ambience {
|
||||
fn default() -> Self {
|
||||
Self::new(crate::sound_engine::unique_seed())
|
||||
Self::new(random_source::unique_seed())
|
||||
}
|
||||
}
|
||||
impl Ambience {
|
||||
@@ -225,7 +226,7 @@ impl Ambience {
|
||||
);
|
||||
let instance = SoundInstance::under_liquid_loop(
|
||||
"minecraft:ambient.underwater.loop",
|
||||
SoundEngine::unseeded(),
|
||||
random_source::create(),
|
||||
);
|
||||
sounds.play_instance(instance, false);
|
||||
}
|
||||
@@ -276,7 +277,7 @@ impl Ambience {
|
||||
};
|
||||
if let Some(sound) = sound {
|
||||
self.underwater_delay = 0;
|
||||
let instance = SoundInstance::under_liquid_sub(sound, SoundEngine::unseeded());
|
||||
let instance = SoundInstance::under_liquid_sub(sound, random_source::create());
|
||||
sounds.play_instance(instance, false);
|
||||
}
|
||||
}
|
||||
@@ -308,7 +309,7 @@ impl Ambience {
|
||||
let id = match self.loops.iter().find(|(e, _)| *e == event) {
|
||||
Some((_, id)) => *id,
|
||||
None => {
|
||||
let instance = SoundInstance::biome_loop(&event, SoundEngine::unseeded());
|
||||
let instance = SoundInstance::biome_loop(&event, random_source::create());
|
||||
let id = sounds.engine().add(instance, true);
|
||||
sounds.play_id(id);
|
||||
self.loops.push((event, id));
|
||||
@@ -323,7 +324,7 @@ impl Ambience {
|
||||
for (sound, chance) in &ctx.sounds.additions {
|
||||
if self.random.next_double() < *chance {
|
||||
let event = crate::sound_events::normalize(sound);
|
||||
let instance = SoundInstance::for_ambient_addition(&event, SoundEngine::unseeded());
|
||||
let instance = SoundInstance::for_ambient_addition(&event, random_source::create());
|
||||
sounds.play_instance(instance, false);
|
||||
}
|
||||
}
|
||||
@@ -376,7 +377,7 @@ impl Ambience {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::sound_engine::{Bound, TickContext, test_backend::Null};
|
||||
use crate::sound_engine::{Bound, SoundEngine, TickContext, test_backend::Null};
|
||||
use crate::sound_events::SoundRegistry;
|
||||
|
||||
fn engine() -> SoundEngine {
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
//! Existing pure bed geometry shared by gameplay and the reusable Mob layer.
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
/// The part of a bed a sleeper lies on: `AbstractBedBlock.getSleepHeight`
|
||||
/// answers with the shape of the block it is given, and `StrawBedBlock`
|
||||
/// first walks a head over to its foot, so a straw bed is slept on at its
|
||||
/// foot's shape while the head's own (the pillow's) is taller.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SleepOn {
|
||||
/// The head itself: `BedBlock` and every other bed.
|
||||
Head,
|
||||
/// The foot the straw bed's head lies against.
|
||||
Foot,
|
||||
}
|
||||
|
||||
/// `StrawBedBlock.getSleepHeight` then `AbstractBedBlock.getSleepHeight`: the
|
||||
/// part whose shape the sleeper lies on, or no part at all. A straw bed's
|
||||
/// head takes its foot's block, and only a foot of the same block will do:
|
||||
/// with the foot broken, or replaced by something else, the height is empty
|
||||
/// (`OptionalDouble.empty`) and `LivingEntity.startSleeping` returns false,
|
||||
/// so nobody lies in the half that is left.
|
||||
pub fn slept_on(
|
||||
bed: &str,
|
||||
part: Option<&str>,
|
||||
foot: Option<(&str, Option<&str>)>,
|
||||
) -> Option<SleepOn> {
|
||||
if bed != "minecraft:straw_bed" || part != Some("head") {
|
||||
return Some(SleepOn::Head);
|
||||
}
|
||||
match foot {
|
||||
Some(("minecraft:straw_bed", Some("foot"))) => Some(SleepOn::Foot),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `VoxelShape.max(Axis.Y)` of the boxes the pack gives a block, in the
|
||||
/// block's own coordinates: the top of the bed, `1.0` for a full cube, and
|
||||
/// `None` for a shape with nothing in it (`VoxelShape.isEmpty`).
|
||||
pub fn shape_top(boxes: &[[f64; 6]], base_y: f64) -> Option<f64> {
|
||||
boxes
|
||||
.iter()
|
||||
.map(|b| b[4] - base_y)
|
||||
.fold(None, |top: Option<f64>, y| {
|
||||
Some(top.map_or(y, |top| top.max(y)))
|
||||
})
|
||||
}
|
||||
|
||||
/// `LivingEntity.setPosToBed`: the middle of the sleeper's block, `0.125`
|
||||
/// above the top of the shape, which `getSleepHeight` measured from that
|
||||
/// block's own y.
|
||||
pub fn pos_to_bed(head: [i32; 3], height: f64) -> Vec3 {
|
||||
Vec3::new(
|
||||
f64::from(head[0]) + 0.5,
|
||||
f64::from(head[1]) + height + 0.125,
|
||||
f64::from(head[2]) + 0.5,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{SleepOn, pos_to_bed, shape_top, slept_on};
|
||||
|
||||
/// `StrawBedBlock.getSleepHeight`: a straw bed's head is slept on at its
|
||||
/// foot, and one whose foot is gone - or is not a straw bed's foot any
|
||||
/// more - has no height, so `startSleeping` gives up on it.
|
||||
#[test]
|
||||
fn a_straw_beds_head_is_slept_on_at_its_foot() {
|
||||
let straw = "minecraft:straw_bed";
|
||||
assert_eq!(slept_on(straw, Some("head"), None), None, "missing foot");
|
||||
assert_eq!(
|
||||
slept_on(straw, Some("head"), Some((straw, None))),
|
||||
None,
|
||||
"missing foot part"
|
||||
);
|
||||
assert_eq!(
|
||||
slept_on(straw, Some("head"), Some((straw, Some("foot")))),
|
||||
Some(SleepOn::Foot)
|
||||
);
|
||||
assert_eq!(
|
||||
slept_on(straw, Some("head"), Some((straw, Some("head")))),
|
||||
None,
|
||||
"a head facing another head"
|
||||
);
|
||||
assert_eq!(
|
||||
slept_on(straw, Some("head"), Some(("minecraft:oak_planks", None))),
|
||||
None,
|
||||
"the foot was replaced"
|
||||
);
|
||||
assert_eq!(
|
||||
slept_on(
|
||||
straw,
|
||||
Some("head"),
|
||||
Some(("minecraft:red_bed", Some("foot")))
|
||||
),
|
||||
None,
|
||||
"the foot is another bed"
|
||||
);
|
||||
assert_eq!(
|
||||
slept_on(straw, Some("foot"), None),
|
||||
Some(SleepOn::Head),
|
||||
"a straw bed's foot sleeps on its own shape"
|
||||
);
|
||||
assert_eq!(
|
||||
slept_on("minecraft:red_bed", Some("head"), None),
|
||||
Some(SleepOn::Head),
|
||||
"an ordinary bed's head is its own shape"
|
||||
);
|
||||
}
|
||||
|
||||
/// `AbstractBedBlock.getSleepHeight`: the tallest box of the shape, from
|
||||
/// the block's own y; a shape with nothing in it has no height.
|
||||
#[test]
|
||||
fn the_height_is_the_top_of_the_shape() {
|
||||
// The boxes are the pack's, in world space, as `outline_boxes` gives them.
|
||||
let bed = [[10.0, 70.0, 5.0, 11.0, 70.5625, 6.0]];
|
||||
assert_eq!(shape_top(&bed, 70.0), Some(0.5625));
|
||||
let pillow = [[10.0, 70.0, 5.0, 11.0, 70.6875, 6.0]];
|
||||
assert_eq!(shape_top(&pillow, 70.0), Some(0.6875));
|
||||
let two = [
|
||||
[10.0, 70.0, 5.0, 11.0, 70.5625, 6.0],
|
||||
[10.0, 70.0, 5.0, 10.5, 70.25, 5.5],
|
||||
];
|
||||
assert_eq!(shape_top(&two, 70.0), Some(0.5625), "the tallest box");
|
||||
assert_eq!(shape_top(&[], 70.0), None, "a bed with no shape");
|
||||
}
|
||||
|
||||
/// `setPosToBed`: `0.125` above the height, in the middle of the block.
|
||||
/// A straw bed's head slept on a foot `7/16` tall at y 70 puts the player
|
||||
/// at 70.5625, where the head's own `9/16` (or the flat 0.5625 every bed
|
||||
/// was given) had them at 70.6875.
|
||||
#[test]
|
||||
fn the_sleeper_lies_an_eighth_above_the_shape() {
|
||||
let at = pos_to_bed([10, 70, 5], 0.4375);
|
||||
assert_eq!((at.x, at.y, at.z), (10.5, 70.5625, 5.5));
|
||||
assert_eq!(pos_to_bed([10, 70, 5], 0.5625).y, 70.6875);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ pub mod advancements;
|
||||
pub mod ambience;
|
||||
pub mod animate_tick;
|
||||
pub mod arrows;
|
||||
pub mod bed_geometry;
|
||||
pub mod block_contact;
|
||||
pub mod powder_snow;
|
||||
pub use horizon_client_render::{
|
||||
@@ -19,8 +20,8 @@ pub use horizon_client_render::{
|
||||
level_loading, light, lod, logging, menu_blur, mesher, minimap, mipmap, mod_browser, mod_list,
|
||||
model_library, models, multiline_edit, native_emissive, options_file, options_fixes,
|
||||
options_ui, overlay, pack_browser, pack_screen, packs, pad_cursor, pad_glyph, par, particles,
|
||||
poi_sections, post_chain, potent_sulfur, preedit, providers, pvp, pvp_hud, screen_draw,
|
||||
screen_effects, sculk, section_mask, server_terrain, settings, shader_options,
|
||||
poi_sections, post_chain, potent_sulfur, preedit, providers, pvp, pvp_hud, random_source,
|
||||
screen_draw, screen_effects, sculk, section_mask, server_terrain, settings, shader_options,
|
||||
shader_pack_list, signs, skins, sound_engine, sound_events, sound_preview, spans, split_layout,
|
||||
split_map, split_profiles, sprites, streaming, structure_block, tab_list, telemetry,
|
||||
terrain_model, test_block, test_instance_block, testgen, title_edition, trims, ui, vault,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Non-player entities simulated on the client world. Dropped items follow
|
||||
//! `ItemEntity.tick`: gravity 0.04, 0.98 drag (block friction on the ground),
|
||||
//! `ItemEntity.tick`: gravity 0.04, 0.98F drag (block friction on the ground),
|
||||
//! a ground bounce of -0.5, slime landing restitution (0.8, including a moving
|
||||
//! piston that is carrying slime), water buoyancy, pickup delay, 6000-tick despawn,
|
||||
//! `HoneyBlock.entityInside` when falling against a honey block's side, and
|
||||
@@ -524,9 +524,25 @@ impl ItemEntity {
|
||||
spilled.extend(crate::item_data::destroyed_container(&mut self.stack));
|
||||
Some(spilled)
|
||||
}
|
||||
/// The ItemEntity snapshot used by both its move and its noCollision check.
|
||||
fn collision_context(&self) -> horizon_world::collision_context::CollisionContext<'static> {
|
||||
use horizon_world::collision_context::{
|
||||
CollisionContext, CollisionEntity, EntityContextKind,
|
||||
};
|
||||
CollisionContext::for_entity(
|
||||
CollisionEntity {
|
||||
identity: horizon_world::entity_obstruction::EntityIdentity(u64::from(self.id)),
|
||||
kind: EntityContextKind::Item,
|
||||
y: self.position.y,
|
||||
descending: false,
|
||||
main_hand: None,
|
||||
},
|
||||
false,
|
||||
)
|
||||
}
|
||||
/// `ItemEntity.tick` on a server: an item whose box is inside a block
|
||||
/// (one placed where it lay) has no physics this tick
|
||||
/// (`level.noCollision(box.deflate(1.0E-7))`).
|
||||
/// (`level.noCollision(this, box.deflate(1.0E-7))`).
|
||||
pub fn inside_block(&self, world: &World, collision: &BlockCollisionRegistry<'_>) -> bool {
|
||||
let mut scene = SceneCollisions {
|
||||
world,
|
||||
@@ -534,7 +550,7 @@ impl ItemEntity {
|
||||
subject: None,
|
||||
};
|
||||
let inner = self.bounds().deflate(Vec3::new(1.0e-7, 1.0e-7, 1.0e-7));
|
||||
!crate::collisions::no_collision_empty(&mut scene, inner)
|
||||
!crate::collisions::no_collision_with_context(&mut scene, inner, self.collision_context())
|
||||
}
|
||||
/// `Entity.updateFluidInteraction`: entering water after the first tick
|
||||
/// runs `doWaterSplashEffect`.
|
||||
@@ -575,6 +591,22 @@ impl ItemEntity {
|
||||
speed,
|
||||
);
|
||||
}
|
||||
/// `ItemEntity.setUnderwaterMovement` / `setUnderLavaMovement`: horizontal
|
||||
/// drag is a float (`0.99F` / `0.95F`), and a slow item is lifted by
|
||||
/// `5.0E-4F` while slower than `0.06F`.
|
||||
fn set_fluid_movement(&mut self, drag: f32) {
|
||||
let drag = f64::from(drag);
|
||||
let lift = if self.velocity.y < f64::from(0.06_f32) {
|
||||
f64::from(5.0e-4_f32)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.velocity = Vec3::new(
|
||||
self.velocity.x * drag,
|
||||
self.velocity.y + lift,
|
||||
self.velocity.z * drag,
|
||||
);
|
||||
}
|
||||
/// `ItemEntity.tick` up to the blocks' push. The game has just run the
|
||||
/// fluid push (`Entity::push_by_fluid`) and passes the heights it found;
|
||||
/// it pushes by a bubble column next (`Entity::push_by_bubble_column`) and
|
||||
@@ -624,18 +656,11 @@ impl ItemEntity {
|
||||
let old_velocity = self.velocity;
|
||||
let in_water = water_height > f64::from(0.1f32);
|
||||
if in_water {
|
||||
// ItemEntity.setUnderwaterMovement
|
||||
self.velocity = Vec3::new(
|
||||
self.velocity.x * 0.99,
|
||||
self.velocity.y + if self.velocity.y < 0.06 { 5.0e-4 } else { 0.0 },
|
||||
self.velocity.z * 0.99,
|
||||
);
|
||||
// ItemEntity.setUnderwaterMovement: 0.99F, 5.0E-4F, 0.06F.
|
||||
self.set_fluid_movement(0.99_f32);
|
||||
} else if lava_height > f64::from(0.1f32) {
|
||||
self.velocity = Vec3::new(
|
||||
self.velocity.x * 0.95,
|
||||
self.velocity.y + if self.velocity.y < 0.06 { 5.0e-4 } else { 0.0 },
|
||||
self.velocity.z * 0.95,
|
||||
);
|
||||
// ItemEntity.setUnderLavaMovement: 0.95F, 5.0E-4F, 0.06F.
|
||||
self.set_fluid_movement(0.95_f32);
|
||||
} else {
|
||||
self.velocity.y -= self.get_gravity();
|
||||
}
|
||||
@@ -673,7 +698,13 @@ impl ItemEntity {
|
||||
entities: entity_boxes,
|
||||
border: border.map(|b| (b, [self.position.x, self.position.z])),
|
||||
}
|
||||
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
|
||||
.resolve_with_context(
|
||||
self.bounds(),
|
||||
wanted,
|
||||
0.0,
|
||||
self.on_ground,
|
||||
self.collision_context(),
|
||||
)
|
||||
.unwrap_or(Vec3::ZERO)
|
||||
};
|
||||
if move_applies(wanted, moved) {
|
||||
@@ -804,18 +835,22 @@ impl ItemEntity {
|
||||
if !no_physics {
|
||||
self.slime_step_on(world, facts);
|
||||
}
|
||||
let mut friction = 0.98;
|
||||
// `float friction = getAirDrag()` (0.98F); on the ground, times
|
||||
// the block's friction as a float, then widened for the scale.
|
||||
let mut friction = 0.98_f32;
|
||||
if self.on_ground {
|
||||
let below = world.block(
|
||||
self.position.x.floor() as i32,
|
||||
(self.position.y - 0.999_999).floor() as i32,
|
||||
self.position.z.floor() as i32,
|
||||
);
|
||||
friction = f64::from(facts.get(below).friction) * 0.98;
|
||||
friction *= facts.get(below).friction;
|
||||
}
|
||||
let friction = f64::from(friction);
|
||||
let air = f64::from(0.98_f32);
|
||||
self.velocity = Vec3::new(
|
||||
self.velocity.x * friction,
|
||||
self.velocity.y * 0.98,
|
||||
self.velocity.y * air,
|
||||
self.velocity.z * friction,
|
||||
);
|
||||
if self.on_ground && self.velocity.y < 0.0 {
|
||||
@@ -2371,6 +2406,7 @@ impl ArmorStand {
|
||||
let context = horizon_world::collision_context::CollisionContext::for_entity(
|
||||
horizon_world::collision_context::CollisionEntity {
|
||||
identity: horizon_world::entity_obstruction::EntityIdentity(u64::from(self.id)),
|
||||
kind: horizon_world::collision_context::EntityContextKind::Living,
|
||||
y: self.position.y,
|
||||
descending: false,
|
||||
main_hand: Some(horizon_world::collision_context::CollisionItem::EMPTY),
|
||||
@@ -3612,12 +3648,53 @@ mod slime_bounce_tests {
|
||||
|
||||
#[test]
|
||||
fn a_slime_landing_stays_upward_after_item_friction() {
|
||||
// ItemEntity then multiplies Y by 0.98 and applies -0.5 only if still
|
||||
// ItemEntity then multiplies Y by 0.98F and applies -0.5 only if still
|
||||
// negative on the ground. A slime bounce must remain positive.
|
||||
let bounced = vertical_collision_velocity(-1.0, -0.5, ITEM_GRAVITY, true, true) * 0.98;
|
||||
let bounced =
|
||||
vertical_collision_velocity(-1.0, -0.5, ITEM_GRAVITY, true, true) * f64::from(0.98_f32);
|
||||
assert!(bounced > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_air_drag_widens_get_air_drag() {
|
||||
// ItemEntity.tick: getAirDrag 0.98F, not the f64 0.98 (#6814).
|
||||
let mut item = super::honey_slide_tests::falling_item(0.5, 64.0, 0.5, 0.1, 0.2, -0.1);
|
||||
let world = World::new(horizon_world::registry::BlockStateId(0), 0);
|
||||
let facts = BlockFacts {
|
||||
states: Vec::new(),
|
||||
air: horizon_world::registry::BlockStateId(0),
|
||||
woods: Default::default(),
|
||||
};
|
||||
item.tick_settle(
|
||||
&world,
|
||||
&facts,
|
||||
true,
|
||||
ItemStep {
|
||||
old_velocity: item.velocity,
|
||||
movement: Some(item.velocity),
|
||||
clipped_vertical: false,
|
||||
},
|
||||
);
|
||||
assert_eq!(item.velocity.x.to_bits(), 0x3fb9_1687_3333_3334);
|
||||
assert_eq!(item.velocity.y.to_bits(), 0x3fc9_1687_3333_3334);
|
||||
assert_eq!(item.velocity.z.to_bits(), 0xbfb9_1687_3333_3334);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_fluid_drags_widen_float_constants() {
|
||||
// setUnderwaterMovement 0.99F / 5.0E-4F; setUnderLavaMovement 0.95F.
|
||||
let mut water = super::honey_slide_tests::falling_item(0.5, 64.0, 0.5, 0.1, 0.01, -0.2);
|
||||
water.set_fluid_movement(0.99_f32);
|
||||
assert_eq!(water.velocity.x.to_bits(), 0x3fb9_5810_6666_6667);
|
||||
assert_eq!(water.velocity.y.to_bits(), 0x3f85_8106_25ae_147b);
|
||||
assert_eq!(water.velocity.z.to_bits(), 0xbfc9_5810_6666_6667);
|
||||
let mut lava = super::honey_slide_tests::falling_item(0.5, 64.0, 0.5, 0.1, 0.01, -0.2);
|
||||
lava.set_fluid_movement(0.95_f32);
|
||||
assert_eq!(lava.velocity.x.to_bits(), 0x3fb8_51eb_8000_0000);
|
||||
assert_eq!(lava.velocity.y.to_bits(), 0x3f85_8106_25ae_147b);
|
||||
assert_eq!(lava.velocity.z.to_bits(), 0xbfc8_51eb_8000_0000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slime_bounce_marks_the_item_for_immediate_sync() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
|
||||
@@ -685,9 +685,13 @@ pub fn mob_to_nbt(
|
||||
} else {
|
||||
map.remove(&horizon_world::nbt::JavaString::from("TicksFrozen"));
|
||||
}
|
||||
// LivingEntity.addAdditionalSaveData: `putBoolean(FallFlying, isFallFlying())`.
|
||||
map.insert(
|
||||
"FallFlying".into(),
|
||||
Tag::Byte(i8::from(m.extra.fall_flying)),
|
||||
);
|
||||
for (key, value) in [
|
||||
("AbsorptionAmount", Tag::Float(0)),
|
||||
("FallFlying", Tag::Byte(0)),
|
||||
("current_impulse_context_reset_grace_time", Tag::Int(0)),
|
||||
(
|
||||
"Brain",
|
||||
@@ -909,7 +913,7 @@ pub fn mob_to_nbt(
|
||||
| "minecraft:llama"
|
||||
| "minecraft:trader_llama" => {
|
||||
map.insert("Tame".into(), Tag::Byte(i8::from(m.extra.tame)));
|
||||
map.insert("Temper".into(), Tag::Int(i32::from(m.extra.variant3)));
|
||||
map.insert("Temper".into(), Tag::Int(m.extra.equine_temper));
|
||||
map.insert("Bred".into(), Tag::Byte(i8::from(m.extra.bred)));
|
||||
// AbstractHorse.addAdditionalSaveData; Horizon does not model the
|
||||
// eating haystack yet.
|
||||
@@ -1149,6 +1153,11 @@ pub fn mob_to_nbt(
|
||||
);
|
||||
}
|
||||
"minecraft:camel" | "minecraft:camel_husk" => {
|
||||
// AbstractHorse.addAdditionalSaveData, including Camel.isTamed.
|
||||
map.insert("Tame".into(), Tag::Byte(i8::from(m.extra.tame)));
|
||||
map.insert("Temper".into(), Tag::Int(m.extra.equine_temper));
|
||||
map.insert("Bred".into(), Tag::Byte(i8::from(m.extra.bred)));
|
||||
map.insert("EatingHaystack".into(), Tag::Byte(i8::from(m.extra.flag)));
|
||||
// LastPoseTick: the pose change time, negative while sitting. The
|
||||
// game time is not known here; the world's save overwrites it
|
||||
// with the real one (`camel::synced_pose_tick`).
|
||||
@@ -1190,6 +1199,7 @@ pub fn mob_to_nbt(
|
||||
map.insert("listener".into(), st.vibration.to_nbt());
|
||||
map.insert("anger".into(), warden_anger_to_nbt(&st.anger, &mut uuid_of));
|
||||
}
|
||||
write_warden_owned_memories(m, map);
|
||||
}
|
||||
// Armadillo.addAdditionalSaveData: the time left to the next scute, and
|
||||
// the state it is in, which is always written (`idle` when it is not
|
||||
@@ -1250,9 +1260,19 @@ pub fn mob_to_nbt(
|
||||
Tag::Int(crate::mobs::aquatic::dark_ticks(m)),
|
||||
);
|
||||
}
|
||||
// Phantom.addAdditionalSaveData.
|
||||
// Phantom.addAdditionalSaveData: the size, and the circling anchor.
|
||||
// `storeNullable` writes nothing for a phantom that has none, so its
|
||||
// save carries no `anchor_pos` at all.
|
||||
"minecraft:phantom" => {
|
||||
map.insert("size".into(), Tag::Int(m.extra.size));
|
||||
match m.extra.home {
|
||||
Some(anchor) => {
|
||||
map.insert("anchor_pos".into(), Tag::IntArray(anchor.to_vec()));
|
||||
}
|
||||
None => {
|
||||
map.remove(&"anchor_pos".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
"minecraft:slime" | "minecraft:magma_cube" => {
|
||||
map.insert("Size".into(), Tag::Int(m.extra.size.max(1) - 1));
|
||||
@@ -1697,6 +1717,8 @@ pub fn mob_from_nbt(
|
||||
let mut m = Mob::new(id, kind, position, yaw);
|
||||
m.velocity = motion(tag);
|
||||
m.head_pitch = pitch;
|
||||
// LivingEntity.readAdditionalSaveData: `setSharedFlag(7, getBooleanOr(FallFlying, false))`.
|
||||
m.extra.fall_flying = flag(tag, "FallFlying");
|
||||
read_mob_home(&mut m, tag);
|
||||
m.on_ground = flag(tag, "OnGround");
|
||||
m.fall_distance = float(tag.get("fall_distance")).unwrap_or(0.0);
|
||||
@@ -1996,7 +2018,8 @@ pub fn mob_from_nbt(
|
||||
}
|
||||
if crate::mobs::equine::is_equine(m.species()) {
|
||||
m.extra.tame = flag(tag, "Tame");
|
||||
m.extra.variant3 = number(tag.get("Temper")).unwrap_or(0).clamp(0, 100) as u8;
|
||||
// AbstractHorse.readAdditionalSaveData: the saved Int as is, camels included.
|
||||
m.extra.equine_temper = tag.get("Temper").and_then(int_value).unwrap_or(0);
|
||||
m.extra.bred = flag(tag, "Bred");
|
||||
m.extra.owner = crate::save::uuid_from_tag(tag.get("Owner"));
|
||||
let v = number(tag.get("Variant")).unwrap_or(0) as i32;
|
||||
@@ -2226,6 +2249,9 @@ pub fn mob_from_nbt(
|
||||
crate::mobs::frog::set_pregnant(&mut m, true);
|
||||
}
|
||||
if crate::mobs::camel::is_camel(m.species()) {
|
||||
// AbstractHorse.readAdditionalSaveData: the eating state (Temper is
|
||||
// read with the other equines' above).
|
||||
m.extra.flag = flag(tag, "EatingHaystack");
|
||||
// The tick is a game time: `camel::finish_load` puts it on the mob's
|
||||
// clock once the world's time is known.
|
||||
let tick = match tag.get("LastPoseTick") {
|
||||
@@ -2303,6 +2329,7 @@ pub fn mob_from_nbt(
|
||||
crate::mobs::warden::AngerManagement::from_saved(warden_anger_from_nbt(anger));
|
||||
}
|
||||
}
|
||||
restore_warden_owned_memories(&mut m, tag);
|
||||
}
|
||||
// Armadillo.readAdditionalSaveData: the saved state through `switchToState`
|
||||
// (`onSyncedDataUpdated` then restarts the time in that state), idle for a
|
||||
@@ -2395,8 +2422,11 @@ pub fn mob_from_nbt(
|
||||
m.extra.flag2 = flag(tag, "wasOnGround");
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Phantom {
|
||||
// Phantom.readAdditionalSaveData: setPhantomSize(size).
|
||||
// Phantom.readAdditionalSaveData: setPhantomSize(size), and the circling
|
||||
// anchor, absent or unreadable when the save has no anchor at all
|
||||
// (`input.read("anchor_pos", BlockPos.CODEC).orElse(null)`).
|
||||
crate::mobs::phantom::set_size(&mut m, number(tag.get("size")).unwrap_or(0) as i32);
|
||||
m.extra.home = home_block_pos(tag.get("anchor_pos"));
|
||||
}
|
||||
// Zombie, Piglin and Zoglin.readAdditionalSaveData: IsBaby (the baby
|
||||
// zombies of trial spawners).
|
||||
@@ -3396,7 +3426,9 @@ pub fn falling_block_to_nbt(
|
||||
/// (block state tag, position, motion, time, fall distance)
|
||||
pub fn falling_block_from_nbt(tag: &Tag) -> Option<(Tag, Vec3, Vec3, i32, f64)> {
|
||||
Some((
|
||||
tag.get("BlockState")?.clone(),
|
||||
tag.get("BlockState")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| string("minecraft:sand")),
|
||||
position(tag)?,
|
||||
motion(tag),
|
||||
number(tag.get("Time")).unwrap_or(0) as i32,
|
||||
@@ -3404,6 +3436,21 @@ pub fn falling_block_from_nbt(tag: &Tag) -> Option<(Tag, Vec3, Vec3, i32, f64)>
|
||||
))
|
||||
}
|
||||
|
||||
/// FallingBlockEntity.readAdditionalSaveData uses the modern state codec.
|
||||
pub fn falling_state_from_nbt(registry: &BlockRegistry, tag: &Tag) -> Option<BlockStateId> {
|
||||
BlockState::from_nbt(tag)
|
||||
.ok()
|
||||
.and_then(|mut state| {
|
||||
state.name = horizon_world::identifier::Identifier::parse(&state.name)
|
||||
.ok()?
|
||||
.to_string();
|
||||
registry
|
||||
.id(&state)
|
||||
.or_else(|| registry.default_state(&state.name))
|
||||
})
|
||||
.or_else(|| registry.default_state("minecraft:sand"))
|
||||
}
|
||||
|
||||
/// `NoGravity` and `DropItem` (true when absent) of a saved falling block.
|
||||
pub fn falling_block_flags(tag: &Tag) -> (bool, bool) {
|
||||
(
|
||||
@@ -4487,7 +4534,7 @@ mod tests {
|
||||
events
|
||||
.iter()
|
||||
.find_map(|e| match e {
|
||||
crate::mobs::MobEvent::Explode(_, radius, _) => Some(*radius),
|
||||
crate::mobs::MobEvent::Explode(_, radius, ..) => Some(*radius),
|
||||
_ => None,
|
||||
})
|
||||
.expect("the creeper exploded")
|
||||
@@ -4766,6 +4813,94 @@ mod tests {
|
||||
assert!(!armor_stand_from_nbt(&cleared, 7).unwrap().no_gravity);
|
||||
}
|
||||
|
||||
fn falling_decode_registry() -> BlockRegistry {
|
||||
BlockRegistry::from_nbt(&Tag::compound([
|
||||
("DataVersion", Tag::Int(horizon_world::DATA_VERSION)),
|
||||
(
|
||||
"states",
|
||||
Tag::List(
|
||||
["minecraft:air", "minecraft:sand", "minecraft:gravel"]
|
||||
.into_iter()
|
||||
.map(string)
|
||||
.collect(),
|
||||
),
|
||||
),
|
||||
(
|
||||
"defaults",
|
||||
Tag::compound([
|
||||
("minecraft:air", Tag::Int(0)),
|
||||
("minecraft:sand", Tag::Int(1)),
|
||||
("minecraft:gravel", Tag::Int(2)),
|
||||
]),
|
||||
),
|
||||
]))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn falling_decode_input() -> Tag {
|
||||
horizon_world::nbt::parse_snbt(
|
||||
"{Pos:[3.5d,200d,4.5d],Motion:[0d,0d,0d],Time:10,DropItem:0b,NoGravity:1b}",
|
||||
)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
|
||||
fn falling_decode_save(registry: &BlockRegistry, input: &Tag) -> Tag {
|
||||
let (state, pos, motion, time, distance) =
|
||||
falling_block_from_nbt(input).expect("absent BlockState is valid input");
|
||||
let actual = falling_state_from_nbt(registry, &state).unwrap();
|
||||
assert_eq!(falling_block_flags(input), (true, false));
|
||||
// Save only the decoded concrete model; no original source compound.
|
||||
let saved = falling_block_to_nbt(
|
||||
state_to_nbt(registry, actual),
|
||||
pos,
|
||||
motion,
|
||||
time,
|
||||
false,
|
||||
crate::falling_block::Damage::default(),
|
||||
distance,
|
||||
[0.0, 0.0],
|
||||
[96, 0, 0, 1],
|
||||
);
|
||||
assert_eq!(saved.get("Pos"), input.get("Pos"));
|
||||
assert_eq!(saved.get("Motion"), input.get("Motion"));
|
||||
assert_eq!(saved.get("Time"), Some(&Tag::Int(10)));
|
||||
assert_eq!(saved.get("DropItem"), Some(&Tag::Byte(0)));
|
||||
saved
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falling_decode_absent_state_saves_actual_sand_without_raw() {
|
||||
let registry = falling_decode_registry();
|
||||
let saved = falling_decode_save(®istry, &falling_decode_input());
|
||||
assert_eq!(saved.get("BlockState"), Some(&string("minecraft:sand")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falling_decode_modern_boundary_keeps_shared_legacy_reader() {
|
||||
let registry = falling_decode_registry();
|
||||
let legacy = Tag::compound([("Name", string("minecraft:gravel"))]);
|
||||
assert_eq!(
|
||||
state_from_nbt(®istry, &legacy),
|
||||
registry.default_state("minecraft:gravel")
|
||||
);
|
||||
for (state, expected) in [
|
||||
(string("minecraft:sand"), "minecraft:sand"),
|
||||
(
|
||||
Tag::compound([("id", string("minecraft:gravel"))]),
|
||||
"minecraft:gravel",
|
||||
),
|
||||
(string("minecraft:air"), "minecraft:air"),
|
||||
(legacy, "minecraft:sand"),
|
||||
(Tag::Compound(Default::default()), "minecraft:sand"),
|
||||
] {
|
||||
let mut input = falling_decode_input();
|
||||
put(&mut input, "BlockState", state);
|
||||
let saved = falling_decode_save(®istry, &input);
|
||||
assert_eq!(saved.get("BlockState"), Some(&string(expected)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falling_block_round_trip_keeps_impact_distance_and_settings() {
|
||||
let state = Tag::compound([("Name", string("minecraft:damaged_anvil"))]);
|
||||
@@ -5585,6 +5720,60 @@ fn write_guarded_piglin_anger(m: &Mob, map: &mut Compound) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Brain`'s two measured Warden Unit memories, using actual existing slots.
|
||||
fn write_warden_owned_memories(m: &Mob, map: &mut Compound) {
|
||||
let brain = map
|
||||
.entry("Brain".into())
|
||||
.or_insert_with(|| Tag::Compound(Compound::new()));
|
||||
let Tag::Compound(brain) = brain else { return };
|
||||
let memories = brain
|
||||
.entry("memories".into())
|
||||
.or_insert_with(|| Tag::Compound(Compound::new()));
|
||||
let Tag::Compound(memories) = memories else {
|
||||
return;
|
||||
};
|
||||
let mem = m.extra.warden.as_ref().map(|st| &st.brain.mem);
|
||||
for (key, slot) in [
|
||||
("minecraft:is_emerging", mem.map(|mem| &mem.is_emerging)),
|
||||
("minecraft:dig_cooldown", mem.map(|mem| &mem.dig_cooldown)),
|
||||
] {
|
||||
if let Some(slot) = slot.filter(|slot| slot.present()) {
|
||||
let mut entry = Tag::compound([("value", Tag::Compound(Compound::new()))]);
|
||||
if slot.ttl() != i64::MAX {
|
||||
put(&mut entry, "ttl", Tag::Long(slot.ttl()));
|
||||
}
|
||||
memories.insert(key.into(), entry);
|
||||
} else {
|
||||
memories.remove(&key.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `Brain` / `ExpirableValue`: admitted Unit values with absent or Long TTL.
|
||||
fn restore_warden_owned_memories(m: &mut Mob, tag: &Tag) {
|
||||
let memories = tag.get("Brain").and_then(|brain| brain.get("memories"));
|
||||
let Some(st) = m.extra.warden.as_mut() else {
|
||||
return;
|
||||
};
|
||||
for (key, slot) in [
|
||||
("minecraft:is_emerging", &mut st.brain.mem.is_emerging),
|
||||
("minecraft:dig_cooldown", &mut st.brain.mem.dig_cooldown),
|
||||
] {
|
||||
let Some(entry) = memories.and_then(|memories| memories.get(key)) else {
|
||||
// Fresh Java read/load: absent entries do not retain finalize's slot.
|
||||
slot.clear();
|
||||
continue;
|
||||
};
|
||||
if !matches!(entry.get("value"), Some(Tag::Compound(value)) if value.is_empty()) {
|
||||
continue; // Unmeasured malformed shapes retain existing initialization.
|
||||
}
|
||||
match entry.get("ttl") {
|
||||
None => slot.set(()),
|
||||
Some(Tag::Long(ttl)) => slot.set_ttl((), *ttl),
|
||||
_ => {} // No new numeric/malformed TTL policy.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_axolotl_memories(m: &Mob, map: &mut Compound) {
|
||||
let brain = map
|
||||
.entry("Brain".into())
|
||||
@@ -9168,3 +9357,257 @@ mod carrier_inventory_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod camel_inherited_saved_tests {
|
||||
use super::*;
|
||||
use crate::mobs::Species;
|
||||
|
||||
fn types(species: Species, id: &str) -> HashMap<String, Arc<MobType>> {
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
let ty = Arc::make_mut(&mut kind);
|
||||
ty.id = id.into();
|
||||
ty.species = species;
|
||||
HashMap::from([(id.to_owned(), kind)])
|
||||
}
|
||||
|
||||
fn input(id: &str, temper: Option<i32>) -> Tag {
|
||||
let mut raw = Tag::compound([
|
||||
("id", string(id)),
|
||||
("Pos", doubles(&[1.5, 200.0, 4.5])),
|
||||
("Health", Tag::Float(10.0_f32.to_bits())),
|
||||
("NoAI", Tag::Byte(1)),
|
||||
("NoGravity", Tag::Byte(1)),
|
||||
("Silent", Tag::Byte(1)),
|
||||
("Invulnerable", Tag::Byte(1)),
|
||||
("LastPoseTick", Tag::Long(1)),
|
||||
("CustomName", string("kept")),
|
||||
("Tags", Tag::List(vec![string("kept")])),
|
||||
("audit:kept", Tag::Int(17)),
|
||||
]);
|
||||
if let Some(temper) = temper {
|
||||
put(&mut raw, "Tame", Tag::Byte(0));
|
||||
put(&mut raw, "Temper", Tag::Int(temper));
|
||||
put(&mut raw, "Bred", Tag::Byte(1));
|
||||
put(&mut raw, "EatingHaystack", Tag::Byte(1));
|
||||
}
|
||||
raw
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camel_inherited_fields_decode_and_save_without_raw_or_with_stale_raw() {
|
||||
for (species, id) in [
|
||||
(Species::Camel, "minecraft:camel"),
|
||||
(Species::CamelHusk, "minecraft:camel_husk"),
|
||||
] {
|
||||
let types = types(species, id);
|
||||
for temper in [None, Some(0), Some(50), Some(-1), Some(256), Some(i32::MAX)] {
|
||||
let raw = input(id, temper);
|
||||
let mob = mob_from_nbt(&raw, &types, 92, None).unwrap();
|
||||
assert!(
|
||||
mob.extra.tame,
|
||||
"Camel.isTamed remains true even with Tame:0b"
|
||||
);
|
||||
assert_eq!(mob.extra.bred, temper.is_some());
|
||||
assert_eq!(
|
||||
mob.extra.flag,
|
||||
temper.is_some(),
|
||||
"actual loaded eating state"
|
||||
);
|
||||
assert!(mob.no_ai && mob.no_gravity && mob.silent && mob.invulnerable_flag);
|
||||
assert!(!mob.extra.camel_sitting && !mob.extra.flag2);
|
||||
assert_eq!(mob.extra.pose_change_tick, 1);
|
||||
let mut stale = raw.clone();
|
||||
for (field, value) in [
|
||||
("Tame", Tag::Byte(0)),
|
||||
("Temper", Tag::Long(9999)),
|
||||
("Bred", Tag::Byte(i8::from(temper.is_none()))),
|
||||
("EatingHaystack", Tag::Byte(i8::from(temper.is_none()))),
|
||||
] {
|
||||
put(&mut stale, field, value);
|
||||
}
|
||||
for source in [None, Some(&raw), Some(&stale)] {
|
||||
let saved = mob_to_nbt(&mob, [92, 0, 0, 1], source, None, |_| None);
|
||||
assert_eq!(saved.get("Tame"), Some(&Tag::Byte(1)), "{id}, {temper:?}");
|
||||
assert_eq!(saved.get("Temper"), Some(&Tag::Int(temper.unwrap_or(0))));
|
||||
for field in ["Bred", "EatingHaystack"] {
|
||||
assert_eq!(
|
||||
saved.get(field),
|
||||
Some(&Tag::Byte(i8::from(temper.is_some())))
|
||||
);
|
||||
}
|
||||
for field in ["NoAI", "NoGravity", "Silent", "Invulnerable"] {
|
||||
assert_eq!(saved.get(field), Some(&Tag::Byte(1)));
|
||||
}
|
||||
assert_eq!(saved.get("UUID"), Some(&Tag::IntArray(vec![92, 0, 0, 1])));
|
||||
if source.is_some() {
|
||||
for field in ["CustomName", "Tags", "audit:kept"] {
|
||||
assert_eq!(saved.get(field), raw.get(field));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn camel_signed_state_and_mutations_override_contradictory_raw() {
|
||||
for (species, id) in [
|
||||
(Species::Camel, "minecraft:camel"),
|
||||
(Species::CamelHusk, "minecraft:camel_husk"),
|
||||
] {
|
||||
let types = types(species, id);
|
||||
for (tag, value) in [
|
||||
(Tag::Int(-1), -1),
|
||||
(Tag::Int(256), 256),
|
||||
(Tag::Int(i32::MAX), i32::MAX),
|
||||
(Tag::Float((-1.5_f32).to_bits()), -2),
|
||||
(Tag::Long(4_294_967_297), 1),
|
||||
(string("bad"), 0),
|
||||
] {
|
||||
let mut raw = input(id, Some(50));
|
||||
put(&mut raw, "Temper", tag);
|
||||
let mob = mob_from_nbt(&raw, &types, 92, None).unwrap();
|
||||
assert_eq!(mob.extra.equine_temper, value, "actual signed model state");
|
||||
assert!(mob.extra.tame && mob.extra.bred && mob.extra.flag);
|
||||
assert_eq!(
|
||||
mob_to_nbt(&mob, [92, 0, 0, 1], None, None, |_| None).get("Temper"),
|
||||
Some(&Tag::Int(value))
|
||||
);
|
||||
}
|
||||
let raw = input(id, Some(50));
|
||||
let mut mob = mob_from_nbt(&raw, &types, 92, None).unwrap();
|
||||
crate::mobs::camel::finish_load(&mut mob, 1024);
|
||||
let unchanged = (
|
||||
mob.id,
|
||||
mob.position,
|
||||
mob.velocity,
|
||||
mob.health,
|
||||
mob.extra.variant3,
|
||||
mob.extra.pose_change_tick,
|
||||
mob.extra.egg_time,
|
||||
mob.extra.camel_sitting,
|
||||
mob.extra.flag2,
|
||||
);
|
||||
mob.extra.equine_temper = -77;
|
||||
mob.extra.bred = false;
|
||||
mob.extra.flag = false;
|
||||
for source in [None, Some(&raw)] {
|
||||
let saved = mob_to_nbt(&mob, [92, 0, 0, 1], source, None, |_| None);
|
||||
assert_eq!(saved.get("Temper"), Some(&Tag::Int(-77)));
|
||||
assert_eq!(saved.get("Tame"), Some(&Tag::Byte(1)));
|
||||
assert_eq!(saved.get("Bred"), Some(&Tag::Byte(0)));
|
||||
assert_eq!(saved.get("EatingHaystack"), Some(&Tag::Byte(0)));
|
||||
}
|
||||
assert_eq!(
|
||||
(
|
||||
mob.id,
|
||||
mob.position,
|
||||
mob.velocity,
|
||||
mob.health,
|
||||
mob.extra.variant3,
|
||||
mob.extra.pose_change_tick,
|
||||
mob.extra.egg_time,
|
||||
mob.extra.camel_sitting,
|
||||
mob.extra.flag2
|
||||
),
|
||||
unchanged
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camel_saved_carry_leaves_the_seven_equine_paths_unchanged() {
|
||||
for (species, id) in [
|
||||
(Species::Horse, "minecraft:horse"),
|
||||
(Species::Donkey, "minecraft:donkey"),
|
||||
(Species::Mule, "minecraft:mule"),
|
||||
(Species::SkeletonHorse, "minecraft:skeleton_horse"),
|
||||
(Species::ZombieHorse, "minecraft:zombie_horse"),
|
||||
(Species::Llama, "minecraft:llama"),
|
||||
(Species::TraderLlama, "minecraft:trader_llama"),
|
||||
] {
|
||||
let mut raw = input(id, Some(12));
|
||||
put(&mut raw, "Tame", Tag::Byte(1));
|
||||
let mob = mob_from_nbt(&raw, &types(species, id), 92, None).unwrap();
|
||||
assert_eq!(mob.extra.equine_temper, 12);
|
||||
assert!(!mob.extra.flag, "the other species' load path is unchanged");
|
||||
for source in [None, Some(&raw)] {
|
||||
let saved = mob_to_nbt(&mob, [92, 0, 0, 1], source, None, |_| None);
|
||||
assert_eq!(saved.get("Temper"), Some(&Tag::Int(12)));
|
||||
assert_eq!(saved.get("Tame"), Some(&Tag::Byte(1)));
|
||||
assert_eq!(saved.get("Bred"), Some(&Tag::Byte(1)));
|
||||
// Preserve the existing raw/default behavior outside Camel;
|
||||
// this control does not claim the other species' eating codec.
|
||||
assert_eq!(
|
||||
saved.get("EatingHaystack"),
|
||||
Some(&Tag::Byte(i8::from(source.is_some())))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod equine_temper_preservation_tests {
|
||||
use super::*;
|
||||
use crate::mobs::Species;
|
||||
|
||||
#[test]
|
||||
fn temper_preservation_decodes_and_saves_without_raw_input() {
|
||||
for (name, species) in [
|
||||
("llama", Species::Llama),
|
||||
("trader_llama", Species::TraderLlama),
|
||||
("horse", Species::Horse),
|
||||
] {
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
let def = Arc::make_mut(&mut kind);
|
||||
def.id = format!("minecraft:{name}");
|
||||
def.species = species;
|
||||
let types = [(def.id.clone(), kind.clone())].into();
|
||||
for value in [50, 0, 30, 100, -7, 1000, i32::MIN, i32::MAX] {
|
||||
let raw = Tag::compound([
|
||||
("id", string(&format!("minecraft:{name}"))),
|
||||
("Pos", doubles(&[1.5, 200.0, 1.5])),
|
||||
("Temper", Tag::Int(value)),
|
||||
("Tame", Tag::Byte(1)),
|
||||
("NoAI", Tag::Byte(1)),
|
||||
("Strength", Tag::Int(3)),
|
||||
("ChestedHorse", Tag::Byte(1)),
|
||||
]);
|
||||
let m = mob_from_nbt(&raw, &types, 7, None).unwrap();
|
||||
assert_eq!(m.species(), species);
|
||||
assert!(m.extra.tame && m.no_ai);
|
||||
let stale =
|
||||
Tag::compound([("Temper", Tag::Int(17)), ("audit:unrelated", Tag::Int(9))]);
|
||||
for base in [None, Some(&raw), Some(&stale)] {
|
||||
let saved = mob_to_nbt(&m, [93, 0, 0, 1], base, None, |_| None);
|
||||
assert_eq!(
|
||||
saved.get("Temper"),
|
||||
Some(&Tag::Int(value)),
|
||||
"{name}: loaded temper is not a gameplay limit"
|
||||
);
|
||||
assert_eq!(saved.get("Tame"), Some(&Tag::Byte(1)));
|
||||
if species != Species::Horse {
|
||||
assert_eq!(saved.get("Strength"), Some(&Tag::Int(3)));
|
||||
assert_eq!(saved.get("ChestedHorse"), Some(&Tag::Byte(1)));
|
||||
}
|
||||
if base == Some(&stale) {
|
||||
assert_eq!(saved.get("audit:unrelated"), Some(&Tag::Int(9)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let fresh = Mob::new(7, kind, Vec3::ZERO, 0.0);
|
||||
let saved = mob_to_nbt(&fresh, [93, 0, 0, 1], None, None, |_| None);
|
||||
assert_eq!(saved.get("Temper"), Some(&Tag::Int(0)));
|
||||
let absent = Tag::compound([
|
||||
("id", string(&format!("minecraft:{name}"))),
|
||||
("Pos", doubles(&[1.5, 200.0, 1.5])),
|
||||
]);
|
||||
let decoded = mob_from_nbt(&absent, &types, 7, None).unwrap();
|
||||
assert_eq!(
|
||||
mob_to_nbt(&decoded, [0; 4], None, None, |_| None).get("Temper"),
|
||||
Some(&Tag::Int(0))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,16 @@
|
||||
//! WHY: each kind used to check its own `NoGravity` flag and pick its own
|
||||
//! number, so a kind that forgot the check ignored the flag (#6481).
|
||||
//! REFS: vanilla `Entity.isNoGravity`, `getDefaultGravity`, `getGravity`,
|
||||
//! `applyGravity`, `isPushedByFluid`; issue #6509, slice #6544.
|
||||
//! `applyGravity`, `isPushedByFluid`, `getAirDrag`; issue #6509, slice #6544.
|
||||
//! LOGS: none. READS-BY: the kinds' ticks in this crate and in `game/`.
|
||||
//! ENDS-WHEN: kinds share one stored `Entity` state and this folds into it.
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
/// `Entity.scale(getAirDrag())`: 0.98F, widened before it scales double motion.
|
||||
pub fn scale_air_drag(velocity: Vec3) -> Vec3 {
|
||||
let drag = f64::from(0.98_f32);
|
||||
Vec3::new(velocity.x * drag, velocity.y * drag, velocity.z * drag)
|
||||
}
|
||||
|
||||
/// The rules vanilla's `Entity` answers for every kind. A kind overrides one
|
||||
/// only where its vanilla class does; the defaults are `Entity`'s.
|
||||
@@ -31,3 +38,20 @@ pub trait EntityRules {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn four_ticks_of_air_drag_match_vanilla_0_98f() {
|
||||
// Issue #6814: 0.1 × 0.98F⁴ is 0.09223682318…, not 0.1 × 0.98⁴ as f64.
|
||||
let mut v = Vec3::new(0.1, 0.0, 0.0);
|
||||
for _ in 0..4 {
|
||||
v = scale_air_drag(v);
|
||||
}
|
||||
assert_eq!(v.x.to_bits(), 0x3fb7_9cd5_1b0c_538e);
|
||||
assert_eq!(v.y.to_bits(), 0);
|
||||
assert_eq!(v.z.to_bits(), 0);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,11 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
log::warn!("Trait mods already installed; ignoring the second list");
|
||||
}
|
||||
horizon_threads::configure_allocator();
|
||||
// horizon-extras (docs/forking.md): without the feature, no Horizon Settings or Mods page.
|
||||
if !cfg!(feature = "horizon-extras") {
|
||||
horizon_client::options_ui::HORIZON_EXTRAS
|
||||
.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
horizon_client::telemetry::mark_start();
|
||||
horizon_client::spans::startup::init(std::time::Instant::now());
|
||||
horizon_client::spans::trace::start_from_env();
|
||||
|
||||
+695
-7095
File diff suppressed because it is too large.
Load diff
@@ -298,6 +298,7 @@ pub(super) fn item_view(s: &ItemStack) -> ItemView {
|
||||
.collect(),
|
||||
potion: s.potion().map(str::to_owned),
|
||||
custom_name: s.custom_name().is_some(),
|
||||
custom_data: None,
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
@@ -530,6 +531,8 @@ impl Game<'_> {
|
||||
let stats = (host.play.get(&conn).map(|p| &p.stats)).filter(|s| s.loaded);
|
||||
adv::PlayerFacts {
|
||||
level: asked.level.then_some(g.xp_level),
|
||||
// The guest's food as last written back from its body.
|
||||
food: Some((g.food, g.saturation)),
|
||||
stats: stats.filter(|_| asked.stats).map(|s| s.values.clone()),
|
||||
advancements: asked
|
||||
.advancements
|
||||
@@ -561,13 +564,17 @@ impl Game<'_> {
|
||||
me
|
||||
}
|
||||
/// The player's level, statistics, advancements and recipes that
|
||||
/// `asked` names, for `PlayerPredicate`; records not loaded stay `None`.
|
||||
/// `asked` names, and its food, for `PlayerPredicate`; records not
|
||||
/// loaded stay `None`.
|
||||
fn player_facts(&self, asked: adv::FactsAsked) -> adv::PlayerFacts {
|
||||
if self.player.is_none() {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return adv::PlayerFacts::default();
|
||||
}
|
||||
};
|
||||
adv::PlayerFacts {
|
||||
level: asked.level.then_some(self.experience.level),
|
||||
// `ServerPlayer.getFoodData`, read by every player predicate:
|
||||
// two numbers, so always filled.
|
||||
food: Some((p.health.food.food, p.health.food.saturation)),
|
||||
stats: (asked.stats && self.stats.loaded).then(|| self.stats.values.clone()),
|
||||
advancements: (asked.advancements && self.adv.loaded)
|
||||
.then(|| (self.adv.tree.clone(), self.adv.progress.clone())),
|
||||
@@ -690,6 +697,24 @@ impl Game<'_> {
|
||||
armor.damage = m.extra.body_damage.max(0) as u32;
|
||||
item_view(&armor)
|
||||
});
|
||||
// Only the new snapshot is adopted; the existing view fields stay as
|
||||
// constructed above. Body and other view consumers stay unprojected.
|
||||
use horizon_client::mobs::finalize;
|
||||
for (view, slot) in equipment[..6].iter_mut().zip([
|
||||
finalize::MAINHAND,
|
||||
finalize::OFFHAND,
|
||||
finalize::HEAD,
|
||||
finalize::CHEST,
|
||||
finalize::LEGS,
|
||||
finalize::FEET,
|
||||
]) {
|
||||
if let Some(view) = view {
|
||||
let data = finalize::item_stack(m, slot)
|
||||
.and_then(|stack| stack.custom_data().cloned())
|
||||
.unwrap_or_default();
|
||||
view.custom_data = Some(horizon_world::nbt::Tag::Compound(data));
|
||||
}
|
||||
}
|
||||
let passengers: Vec<EntityView> = self
|
||||
.mobs
|
||||
.iter()
|
||||
@@ -2920,7 +2945,11 @@ mod structure_tag_tests {
|
||||
let game_tags = pack.join("data/minecraft/tags/worldgen/structure");
|
||||
std::fs::create_dir_all(&tags).unwrap();
|
||||
std::fs::create_dir_all(&game_tags).unwrap();
|
||||
std::fs::write(pack.join("pack.mcmeta"), "{}").unwrap();
|
||||
std::fs::write(
|
||||
pack.join("pack.mcmeta"),
|
||||
r#"{"pack":{"description":"fixture"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
tags.join("village.json"),
|
||||
r##"{"values": ["#minecraft:village", "minecraft:igloo"]}"##,
|
||||
@@ -2949,3 +2978,141 @@ mod structure_tag_tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod custom_data_equipment_tests {
|
||||
use super::*;
|
||||
use crate::game::native_machines::tests::server_game;
|
||||
use horizon_client::{mobs::finalize, save};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_world::nbt::{Tag, parse_snbt};
|
||||
|
||||
fn item_predicate(pattern: &str) -> Value {
|
||||
serde_json::json!({"items":"minecraft:stick","count":1,
|
||||
"predicates":{"minecraft:custom_data":pattern}})
|
||||
}
|
||||
fn condition(pattern: &str) -> Value {
|
||||
serde_json::json!({"type":"minecraft:entity_properties","entity":"this",
|
||||
"predicate":{"minecraft:entity_type":"minecraft:pig",
|
||||
"minecraft:equipment":{"mainhand":item_predicate(pattern)}}})
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
|
||||
fn actual_mob_equipment_projects_typed_custom_data_into_named_predicate_evaluation() {
|
||||
let mut game = server_game();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
game.block_ticks
|
||||
.add_empty_chunk(BlockPos::new(0, 0, 0).chunk())
|
||||
.unwrap();
|
||||
assert!(game.run_command("forceload add 0 0"));
|
||||
assert!(game.run_command("fill 0 199 0 15 199 15 stone"));
|
||||
let clock = game.world.game_time;
|
||||
let rich = r#"{h289:1b,nested:{k:7,extra:9},entries:[{k:1b,extra:2b},{k:2b}],wide:9007199254740993L,spare:"keep"}"#;
|
||||
let mut failures = Vec::new();
|
||||
for (i, data) in [(1, Some(rich)), (2, None)] {
|
||||
let uuid = [289, 1, 0, i];
|
||||
let actor = format!("00000121-0000-0001-0000-{i:012x}");
|
||||
let components = data.map_or(String::new(), |d| {
|
||||
format!(",components:{{\"minecraft:custom_data\":{d}}}")
|
||||
});
|
||||
let x = f64::from(i) + 0.5;
|
||||
assert!(game.run_command(&format!(
|
||||
"summon minecraft:pig {x} 200 4.5 {{UUID:[I;289,1,0,{i}],NoAI:1b,NoGravity:1b,Silent:1b,PersistenceRequired:1b,Health:10f,equipment:{{mainhand:{{id:\"minecraft:stick\",count:1{components}}}}}}}"
|
||||
)));
|
||||
assert!(game.world.loaded(i, 4));
|
||||
assert_eq!(
|
||||
&*game.res.facts().get(game.world.block(i, 199, 4)).name,
|
||||
"minecraft:stone"
|
||||
);
|
||||
assert!(game.run_command(&format!("execute as {actor} if entity @s[type=pig,nbt={{UUID:[I;289,1,0,{i}],NoAI:1b,Health:10f,Pos:[{x}d,200d,4.5d]}}]")));
|
||||
let id = *game
|
||||
.persist
|
||||
.uuids
|
||||
.iter()
|
||||
.find(|(_, value)| **value == uuid)
|
||||
.expect("actual admitted UUID")
|
||||
.0;
|
||||
let mob = game.mobs.iter().find(|m| m.id == id).unwrap();
|
||||
assert!(mob.no_ai);
|
||||
let stack = finalize::item_stack(mob, finalize::MAINHAND)
|
||||
.expect("actual loaded equipment stack");
|
||||
assert_eq!((&*stack.item, stack.count), ("minecraft:stick", 1));
|
||||
let saved = save::item_to_nbt(&stack);
|
||||
if let Some(data) = data {
|
||||
let (expected, used) = parse_snbt(data).unwrap();
|
||||
assert_eq!(used, data.len());
|
||||
assert_eq!(
|
||||
saved
|
||||
.get("components")
|
||||
.and_then(|t| t.get("minecraft:custom_data")),
|
||||
Some(&expected)
|
||||
);
|
||||
assert_eq!(
|
||||
stack.custom_data().unwrap().get(&"h289".into()),
|
||||
Some(&Tag::Byte(1))
|
||||
);
|
||||
assert_eq!(
|
||||
stack.custom_data().unwrap().get(&"wide".into()),
|
||||
Some(&Tag::Long(9_007_199_254_740_993))
|
||||
);
|
||||
// Contradictory retained raw equipment must not replace actual stack state.
|
||||
let (wrong, _) = parse_snbt(r#"{equipment:{mainhand:{id:"minecraft:stick",count:1,components:{"minecraft:custom_data":{h289:2b}}}}}"#).unwrap();
|
||||
let raw = game
|
||||
.persist
|
||||
.raw
|
||||
.entry(id)
|
||||
.or_insert_with(|| Tag::Compound(Default::default()));
|
||||
let Tag::Compound(raw) = raw else {
|
||||
panic!("actual raw compound")
|
||||
};
|
||||
raw.insert("equipment".into(), wrong.get("equipment").unwrap().clone());
|
||||
assert!(game.run_command(&format!("execute if data entity {actor} equipment.mainhand.components{{\"minecraft:custom_data\":{{h289:1b}}}}")));
|
||||
} else {
|
||||
assert!(stack.custom_data().is_none());
|
||||
assert!(game.run_command(&format!("execute unless data entity {actor} equipment.mainhand.components.\"minecraft:custom_data\"")));
|
||||
}
|
||||
let mob = game.mobs.iter().find(|m| m.id == id).unwrap();
|
||||
let view = game.mob_entity(mob);
|
||||
assert_eq!(
|
||||
(
|
||||
&*view.equipment[0].as_ref().unwrap().id,
|
||||
view.equipment[0].as_ref().unwrap().count
|
||||
),
|
||||
("minecraft:stick", 1)
|
||||
);
|
||||
let probes: Vec<(&str, bool)> = if data.is_some() {
|
||||
vec![
|
||||
("{h289:1b}", true),
|
||||
("{nested:{k:7}}", true),
|
||||
("{entries:[{k:2b}]}", true),
|
||||
("{entries:[{k:2b},{k:1b}]}", true),
|
||||
("{wide:9007199254740993L}", true),
|
||||
("{}", true),
|
||||
("{h289:2b}", false),
|
||||
("{h289:1}", false),
|
||||
("{missing:1b}", false),
|
||||
("{entries:[]}", false),
|
||||
("{wide:9007199254740992L}", false),
|
||||
]
|
||||
} else {
|
||||
vec![("{}", true), ("{h289:1b}", false)]
|
||||
};
|
||||
for (pattern, want) in probes {
|
||||
let actual =
|
||||
game.conditions_hold(&condition(pattern), Some(&view), [x, 200.0, 4.5]);
|
||||
if actual != want {
|
||||
failures.push((i, pattern, actual, want));
|
||||
}
|
||||
}
|
||||
// Existing non-equipment views are unprojected and retain unsupported legacy behavior.
|
||||
assert!(game.loot_item_filter(&item_predicate("{h289:99b}"), &stack));
|
||||
assert_eq!(game.world.game_time, clock, "zero observation ticks");
|
||||
}
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"actual mob-equipment custom_data mismatch: {failures:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -336,9 +336,8 @@ impl Game<'_> {
|
||||
/// `PalettedTextureManager.getOrPrepare` for the trims drawn this frame
|
||||
/// (the player's armour and the armour stands'): each generated texture
|
||||
/// goes into an atlas trim slot, a free one, else the least recently
|
||||
/// drawn.
|
||||
/// drawn. Reuse the completed preparation while the requests and atlas stay the same.
|
||||
pub(super) fn prepare_trims(&mut self) {
|
||||
use horizon_client::hud::TRIM_SLOTS;
|
||||
let assets = &self.armor_stand_assets;
|
||||
let items = self.items();
|
||||
let mut wanted: Vec<TrimTexture> = Vec::new();
|
||||
@@ -398,60 +397,12 @@ impl Game<'_> {
|
||||
wanted.push(t);
|
||||
}
|
||||
}
|
||||
for t in wanted {
|
||||
let name = t.sprite();
|
||||
// Entries the atlas no longer has (rebuilt by a resource reload) are free.
|
||||
let sprites = &self.gui.atlas.sprites;
|
||||
self.trim_slots.retain(|(n, _)| sprites.contains_key(n));
|
||||
if let Some(i) = self.trim_slots.iter().position(|e| e.0 == name) {
|
||||
let e = self.trim_slots.remove(i);
|
||||
self.trim_slots.push(e);
|
||||
continue;
|
||||
}
|
||||
let Some((w, h, rgba)) = t.generate(&self.armor_stand_assets.root) else {
|
||||
continue;
|
||||
};
|
||||
let used: Vec<usize> = self.trim_slots.iter().map(|e| e.1).collect();
|
||||
let slot = match (0..TRIM_SLOTS).find(|i| !used.contains(i)) {
|
||||
Some(free) => free,
|
||||
None => {
|
||||
let (old, slot) = self.trim_slots.remove(0);
|
||||
self.gui.atlas.sprites.remove(&old);
|
||||
slot
|
||||
}
|
||||
};
|
||||
let Some(area) = self
|
||||
.gui
|
||||
.atlas
|
||||
.sprites
|
||||
.get(&format!("horizon/trim{slot}"))
|
||||
.copied()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if w > area.w || h > area.h {
|
||||
continue;
|
||||
}
|
||||
let (aw, ah) = (self.gui.atlas.width as f32, self.gui.atlas.height as f32);
|
||||
let (x, y) = ((area.u0 * aw).round(), (area.v0 * ah).round());
|
||||
self.map_patches.push(horizon_client::font::Patch {
|
||||
x: x as u32,
|
||||
y: y as u32,
|
||||
width: w,
|
||||
height: h,
|
||||
rgba,
|
||||
});
|
||||
let rect = horizon_client::hud::Rect {
|
||||
u0: area.u0,
|
||||
v0: area.v0,
|
||||
u1: (x + w as f32) / aw,
|
||||
v1: (y + h as f32) / ah,
|
||||
w,
|
||||
h,
|
||||
};
|
||||
self.gui.atlas.sprites.insert(name.clone(), rect);
|
||||
self.trim_slots.push((name, slot));
|
||||
}
|
||||
self.trim_slots.prepare(
|
||||
wanted,
|
||||
&assets.root,
|
||||
&mut self.gui.atlas,
|
||||
&mut self.map_patches,
|
||||
);
|
||||
}
|
||||
/// `ArmorStandItem.useOn`.
|
||||
/// `ArmorStandItem.useOn` up to its `FAIL`s, which a client checks too:
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
|
||||
pub(super) struct AtlasSlots<K> {
|
||||
count: usize,
|
||||
/// (image, slot), most recently shown last.
|
||||
held: Vec<(K, usize)>,
|
||||
/// (image, slot, the frame it was last shown in), most recently shown last.
|
||||
held: Vec<(K, usize, u64)>,
|
||||
/// The frame being drawn (`next_frame`).
|
||||
frame: u64,
|
||||
}
|
||||
|
||||
impl<K: PartialEq> AtlasSlots<K> {
|
||||
@@ -14,30 +16,39 @@ impl<K: PartialEq> AtlasSlots<K> {
|
||||
Self {
|
||||
count,
|
||||
held: Vec::new(),
|
||||
frame: 0,
|
||||
}
|
||||
}
|
||||
/// A new frame starts: what was shown before it may give up its slot.
|
||||
pub(super) fn next_frame(&mut self) {
|
||||
self.frame += 1;
|
||||
}
|
||||
/// The slot holding `key`, marking it recently shown.
|
||||
pub(super) fn show<Q: PartialEq + ?Sized>(&mut self, key: &Q) -> Option<usize>
|
||||
where
|
||||
K: std::borrow::Borrow<Q>,
|
||||
{
|
||||
let i = self.held.iter().position(|e| e.0.borrow() == key)?;
|
||||
let e = self.held.remove(i);
|
||||
let mut e = self.held.remove(i);
|
||||
e.2 = self.frame;
|
||||
let slot = e.1;
|
||||
self.held.push(e);
|
||||
Some(slot)
|
||||
}
|
||||
/// A slot for `key`: a free one, else the least recently shown, returned
|
||||
/// with the image it held.
|
||||
pub(super) fn claim(&mut self, key: K) -> (usize, Option<K>) {
|
||||
/// with the image it held. `None` when every slot shows something this
|
||||
/// frame: taking one would put this image on what was already drawn.
|
||||
pub(super) fn claim(&mut self, key: K) -> Option<(usize, Option<K>)> {
|
||||
let (slot, evicted) = if self.held.len() < self.count {
|
||||
(self.held.len(), None)
|
||||
} else {
|
||||
let (old, slot) = self.held.remove(0);
|
||||
} else if self.held[0].2 != self.frame {
|
||||
let (old, slot, _) = self.held.remove(0);
|
||||
(slot, Some(old))
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
self.held.push((key, slot));
|
||||
(slot, evicted)
|
||||
self.held.push((key, slot, self.frame));
|
||||
Some((slot, evicted))
|
||||
}
|
||||
/// The atlas was replaced: every slot is blank.
|
||||
pub(super) fn forget(&mut self) {
|
||||
@@ -52,13 +63,28 @@ mod tests {
|
||||
#[test]
|
||||
fn the_least_recently_shown_image_gives_up_its_slot() {
|
||||
let mut slots = AtlasSlots::new(2);
|
||||
assert_eq!(slots.claim("a"), (0, None));
|
||||
assert_eq!(slots.claim("b"), (1, None));
|
||||
assert_eq!(slots.claim("a"), Some((0, None)));
|
||||
assert_eq!(slots.claim("b"), Some((1, None)));
|
||||
slots.next_frame();
|
||||
assert_eq!(slots.show(&"a"), Some(0));
|
||||
assert_eq!(slots.claim("c"), (1, Some("b")));
|
||||
assert_eq!(slots.claim("c"), Some((1, Some("b"))));
|
||||
assert_eq!(slots.show(&"b"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_shown_this_frame_keeps_its_slot() {
|
||||
// More maps in view than slots: the ones past the slots go undrawn
|
||||
// rather than taking a slot another map was drawn with, both showing
|
||||
// the later picture.
|
||||
let mut slots = AtlasSlots::new(2);
|
||||
assert_eq!(slots.claim("a"), Some((0, None)));
|
||||
assert_eq!(slots.claim("b"), Some((1, None)));
|
||||
assert_eq!(slots.claim("c"), None);
|
||||
slots.next_frame();
|
||||
assert_eq!(slots.show(&"b"), Some(1));
|
||||
assert_eq!(slots.claim("c"), Some((0, Some("a"))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_atlas_shows_no_stored_skin() {
|
||||
// #3385: a skin stored before a pack reload must upload again, not
|
||||
@@ -68,6 +94,6 @@ mod tests {
|
||||
assert_eq!(slots.show("skins/abc"), Some(0));
|
||||
slots.forget();
|
||||
assert_eq!(slots.show("skins/abc"), None);
|
||||
assert_eq!(slots.claim("skins/abc".to_string()), (0, None));
|
||||
assert_eq!(slots.claim("skins/abc".to_string()), Some((0, None)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! The world's save on a timer (`MinecraftServer.computeNextAutosaveInterval`,
|
||||
//! `onTickRateChanged`), the players' autosaves, and the world's metadata (level.dat).
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn save_game(&mut self) -> Result<()> {
|
||||
self.save_game_with(true)
|
||||
}
|
||||
/// `MinecraftServer.computeNextAutosaveInterval`: three hundred seconds
|
||||
/// of ticks at the rate the server runs at now - its measured rate while
|
||||
/// a `/tick sprint` runs - never fewer than a hundred.
|
||||
pub(super) fn next_autosave_interval(&self) -> i64 {
|
||||
let rate = if self.clock.sprinting() {
|
||||
1.0e9 / (self.clock.average_nanos() + 1) as f32
|
||||
} else {
|
||||
self.clock.rate()
|
||||
};
|
||||
autosave_interval(rate)
|
||||
}
|
||||
/// `MinecraftServer.onTickRateChanged`: a rate that asks for a shorter
|
||||
/// wait than the one already running shortens it.
|
||||
pub(super) fn on_tick_rate_changed(&mut self) {
|
||||
let interval = self.next_autosave_interval();
|
||||
if interval < self.ticks_until_autosave {
|
||||
self.ticks_until_autosave = interval;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn finish_player_autosaves(&mut self, wait: bool) -> Result<bool> {
|
||||
let saved = self.player_autosaves.completed(wait);
|
||||
if !saved.is_empty() {
|
||||
let directory = horizon_client::lan::playerdata::directory(&self.world_root);
|
||||
for player in saved {
|
||||
self.native_player_save_to_file(player.uuid, &player.name, &directory);
|
||||
}
|
||||
}
|
||||
self.player_autosaves.writes_finished()
|
||||
}
|
||||
pub(super) fn save_game_metadata(&mut self) -> Result<()> {
|
||||
self.save_maps();
|
||||
self.save_random_sequences();
|
||||
self.save_fight();
|
||||
self.save_wandering_trader();
|
||||
self.save_portal_arrivals();
|
||||
self.save_stats();
|
||||
self.save_advancements();
|
||||
self.save_scoreboard();
|
||||
self.save_bossbars();
|
||||
self.save_schedule();
|
||||
self.save_warden_tracker();
|
||||
self.sync_level();
|
||||
// Level::write can synchronously replace the integrated owner file.
|
||||
let _ = self.finish_player_autosaves(true);
|
||||
let stored_owner = self.store_owner();
|
||||
write_level_for_role(
|
||||
&mut self.level,
|
||||
&self.world_root,
|
||||
self.options.execution_role,
|
||||
)?;
|
||||
// PlayerEvent.SaveToFile for the owner, once level.dat is written.
|
||||
if let Some((uuid, name)) = stored_owner {
|
||||
let directory = horizon_client::lan::playerdata::directory(&self.world_root);
|
||||
self.native_player_save_to_file(uuid, &name, &directory);
|
||||
}
|
||||
// PlayerList.saveAll: every placed player saves with the server, to
|
||||
// their own file (`PlayerDataStorage.save`); the owner is also in the
|
||||
// file level.dat names, which is the one it loads from
|
||||
// (`playerdata::load_owner`).
|
||||
if self.options.dedicated.is_some()
|
||||
&& !self.options.execution_role.is_client()
|
||||
&& let Some(host) = self.lan.take()
|
||||
{
|
||||
// The vehicle tree goes into a player's file when they leave
|
||||
// (`take_parent_vehicle`); here it stays in the chunk saves.
|
||||
let mut host = host;
|
||||
for g in host.hub.guests.iter_mut().filter(|g| g.placed) {
|
||||
// Read-only fixture input, correlated with the real save receipt.
|
||||
// Keep this identical on the untouched/fixed selector builds.
|
||||
if std::env::var_os("HORIZON_DEV_ADVANCEMENT_SELECTOR_AUDIT").is_some() {
|
||||
eprintln!(
|
||||
"advancement selector model raw: {}",
|
||||
serde_json::json!({"conn":g.conn,"name":g.name,
|
||||
"uuid":horizon_protocol::crypto::uuid_string(g.uuid),
|
||||
"game_time":self.world.game_time,
|
||||
"progress":g.advancements.to_json(&self.adv.tree, horizon_world::DATA_VERSION)})
|
||||
);
|
||||
}
|
||||
self.guest_attachments_into(g);
|
||||
self.player_autosaves.enqueue(g, &self.world_root);
|
||||
}
|
||||
self.lan = Some(host);
|
||||
}
|
||||
self.save_world_state();
|
||||
self.message = Some(("Saved the game".into(), Instant::now()));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -287,15 +287,26 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let range = f64::from(b.levels * 10 + 10);
|
||||
let Some(pp) = self.player.as_ref().map(|p| p.position) else {
|
||||
let Some((pp, pl)) = self.player.as_ref().map(|p| (p.position, p.dimensions)) else {
|
||||
return;
|
||||
};
|
||||
let inside = pp.x >= f64::from(p[0]) - range
|
||||
&& pp.x <= f64::from(p[0] + 1) + range
|
||||
&& pp.z >= f64::from(p[2]) - range
|
||||
&& pp.z <= f64::from(p[2] + 1) + range
|
||||
&& pp.y >= f64::from(p[1]) - range;
|
||||
if !inside {
|
||||
let height = f64::from(self.world.top_y() - self.world.bottom_y());
|
||||
let bounds = pl.bounds(pp);
|
||||
if !effect_box_hits(
|
||||
p,
|
||||
range,
|
||||
height,
|
||||
[
|
||||
bounds.min_position().x,
|
||||
bounds.min_position().y,
|
||||
bounds.min_position().z,
|
||||
],
|
||||
[
|
||||
bounds.max_position().x,
|
||||
bounds.max_position().y,
|
||||
bounds.max_position().z,
|
||||
],
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let secondary = b.secondary.as_deref().and_then(effect_id);
|
||||
@@ -682,6 +693,17 @@ fn beam(e: &BlockEntity) -> Option<&[(u32, i32)]> {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// `BeaconBlockEntity.applyEffects` box: `AABB(pos).inflate(range).expandTowards(0, height, 0)`,
|
||||
/// tested with `AABB.intersects` (strict) against the player's bounding box.
|
||||
fn effect_box_hits(p: [i32; 3], range: f64, height: f64, lo: [f64; 3], hi: [f64; 3]) -> bool {
|
||||
let [x, y, z] = p.map(f64::from);
|
||||
lo[0] < x + 1.0 + range
|
||||
&& hi[0] > x - range
|
||||
&& lo[1] < y + 1.0 + range + height
|
||||
&& hi[1] > y - range
|
||||
&& lo[2] < z + 1.0 + range
|
||||
&& hi[2] > z - range
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -696,4 +718,24 @@ mod tests {
|
||||
Some(&[(0xFFFFFF, 10)][..])
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn effect_box_uses_player_overlap() {
|
||||
let hit = |x: f64, y: f64| {
|
||||
effect_box_hits(
|
||||
[0, 100, 0],
|
||||
50.0,
|
||||
384.0,
|
||||
[x - 0.3, y, -0.3],
|
||||
[x + 0.3, y + 1.8, 0.3],
|
||||
)
|
||||
};
|
||||
assert!(hit(-50.2, 100.0));
|
||||
assert!(!hit(-50.4, 100.0));
|
||||
assert!(hit(51.2, 100.0));
|
||||
assert!(!hit(51.4, 100.0));
|
||||
assert!(hit(0.0, 48.3));
|
||||
assert!(!hit(0.0, 48.1));
|
||||
assert!(hit(0.0, 534.9));
|
||||
assert!(!hit(0.0, 535.0));
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
//! a broken bed, or leaving the game.
|
||||
use super::{Dim, Game};
|
||||
use horizon_client::Result;
|
||||
use horizon_client::bed_geometry::{SleepOn, pos_to_bed, shape_top, slept_on};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
|
||||
@@ -73,8 +74,8 @@ impl Game<'_> {
|
||||
// has destroy_on_leave, so a straw bed breaks when its sleeper gets up.
|
||||
let bed = self.world.block(head[0], head[1], head[2]);
|
||||
let straw = &*self.res.facts().get(bed).name == "minecraft:straw_bed";
|
||||
if dim == Dim::Overworld
|
||||
&& straw
|
||||
if straw
|
||||
&& self.dimension_type_of(dim).straw_bed_rule.destroy_on_leave
|
||||
&& let Err(e) = self.destroy_straw_bed(head)
|
||||
{
|
||||
log::warn!("could not break the straw bed at {head:?}: {e}");
|
||||
@@ -108,57 +109,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The part of a bed a sleeper lies on: `AbstractBedBlock.getSleepHeight`
|
||||
/// answers with the shape of the block it is given, and `StrawBedBlock`
|
||||
/// first walks a head over to its foot, so a straw bed is slept on at its
|
||||
/// foot's shape while the head's own (the pillow's) is taller.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum SleepOn {
|
||||
/// The head itself: `BedBlock` and every other bed.
|
||||
Head,
|
||||
/// The foot the straw bed's head lies against.
|
||||
Foot,
|
||||
}
|
||||
|
||||
/// `StrawBedBlock.getSleepHeight` then `AbstractBedBlock.getSleepHeight`: the
|
||||
/// part whose shape the sleeper lies on, or no part at all. A straw bed's
|
||||
/// head takes its foot's block, and only a foot of the same block will do:
|
||||
/// with the foot broken, or replaced by something else, the height is empty
|
||||
/// (`OptionalDouble.empty`) and `LivingEntity.startSleeping` returns false,
|
||||
/// so nobody lies in the half that is left.
|
||||
fn slept_on(bed: &str, part: Option<&str>, foot: Option<(&str, Option<&str>)>) -> Option<SleepOn> {
|
||||
if bed != "minecraft:straw_bed" || part != Some("head") {
|
||||
return Some(SleepOn::Head);
|
||||
}
|
||||
match foot {
|
||||
Some(("minecraft:straw_bed", Some("foot"))) => Some(SleepOn::Foot),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `VoxelShape.max(Axis.Y)` of the boxes the pack gives a block, in the
|
||||
/// block's own coordinates: the top of the bed, `1.0` for a full cube, and
|
||||
/// `None` for a shape with nothing in it (`VoxelShape.isEmpty`).
|
||||
fn shape_top(boxes: &[[f64; 6]], base_y: f64) -> Option<f64> {
|
||||
boxes
|
||||
.iter()
|
||||
.map(|b| b[4] - base_y)
|
||||
.fold(None, |top: Option<f64>, y| {
|
||||
Some(top.map_or(y, |top| top.max(y)))
|
||||
})
|
||||
}
|
||||
|
||||
/// `LivingEntity.setPosToBed`: the middle of the sleeper's block, `0.125`
|
||||
/// above the top of the shape, which `getSleepHeight` measured from that
|
||||
/// block's own y.
|
||||
fn pos_to_bed(head: [i32; 3], height: f64) -> Vec3 {
|
||||
Vec3::new(
|
||||
f64::from(head[0]) + 0.5,
|
||||
f64::from(head[1]) + height + 0.125,
|
||||
f64::from(head[2]) + 0.5,
|
||||
)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The bed's `part`: head or foot.
|
||||
fn bed_part(&self, state: BlockStateId) -> Option<String> {
|
||||
@@ -198,77 +148,3 @@ impl Game<'_> {
|
||||
.map(|height| pos_to_bed(head, height))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{SleepOn, pos_to_bed, shape_top, slept_on};
|
||||
|
||||
/// `StrawBedBlock.getSleepHeight`: a straw bed's head is slept on at its
|
||||
/// foot, and one whose foot is gone - or is not a straw bed's foot any
|
||||
/// more - has no height, so `startSleeping` gives up on it.
|
||||
#[test]
|
||||
fn a_straw_beds_head_is_slept_on_at_its_foot() {
|
||||
let straw = "minecraft:straw_bed";
|
||||
assert_eq!(
|
||||
slept_on(straw, Some("head"), Some((straw, Some("foot")))),
|
||||
Some(SleepOn::Foot)
|
||||
);
|
||||
assert_eq!(
|
||||
slept_on(straw, Some("head"), Some((straw, Some("head")))),
|
||||
None,
|
||||
"a head facing another head"
|
||||
);
|
||||
assert_eq!(
|
||||
slept_on(straw, Some("head"), Some(("minecraft:oak_planks", None))),
|
||||
None,
|
||||
"the foot was replaced"
|
||||
);
|
||||
assert_eq!(
|
||||
slept_on(
|
||||
straw,
|
||||
Some("head"),
|
||||
Some(("minecraft:red_bed", Some("foot")))
|
||||
),
|
||||
None,
|
||||
"the foot is another bed"
|
||||
);
|
||||
assert_eq!(
|
||||
slept_on(straw, Some("foot"), None),
|
||||
Some(SleepOn::Head),
|
||||
"a straw bed's foot sleeps on its own shape"
|
||||
);
|
||||
assert_eq!(
|
||||
slept_on("minecraft:red_bed", Some("head"), None),
|
||||
Some(SleepOn::Head),
|
||||
"an ordinary bed's head is its own shape"
|
||||
);
|
||||
}
|
||||
|
||||
/// `AbstractBedBlock.getSleepHeight`: the tallest box of the shape, from
|
||||
/// the block's own y; a shape with nothing in it has no height.
|
||||
#[test]
|
||||
fn the_height_is_the_top_of_the_shape() {
|
||||
// The boxes are the pack's, in world space, as `outline_boxes` gives them.
|
||||
let bed = [[10.0, 70.0, 5.0, 11.0, 70.5625, 6.0]];
|
||||
assert_eq!(shape_top(&bed, 70.0), Some(0.5625));
|
||||
let pillow = [[10.0, 70.0, 5.0, 11.0, 70.6875, 6.0]];
|
||||
assert_eq!(shape_top(&pillow, 70.0), Some(0.6875));
|
||||
let two = [
|
||||
[10.0, 70.0, 5.0, 11.0, 70.5625, 6.0],
|
||||
[10.0, 70.0, 5.0, 10.5, 70.25, 5.5],
|
||||
];
|
||||
assert_eq!(shape_top(&two, 70.0), Some(0.5625), "the tallest box");
|
||||
assert_eq!(shape_top(&[], 70.0), None, "a bed with no shape");
|
||||
}
|
||||
|
||||
/// `setPosToBed`: `0.125` above the height, in the middle of the block.
|
||||
/// A straw bed's head slept on a foot `7/16` tall at y 70 puts the player
|
||||
/// at 70.5625, where the head's own `9/16` (or the flat 0.5625 every bed
|
||||
/// was given) had them at 70.6875.
|
||||
#[test]
|
||||
fn the_sleeper_lies_an_eighth_above_the_shape() {
|
||||
let at = pos_to_bed([10, 70, 5], 0.4375);
|
||||
assert_eq!((at.x, at.y, at.z), (10.5, 70.5625, 5.5));
|
||||
assert_eq!(pos_to_bed([10, 70, 5], 0.5625).y, 70.6875);
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,8 @@ impl Game<'_> {
|
||||
self.edit_height(h, x, z)
|
||||
})?;
|
||||
// (what the registry, the tags and the facts tell of a state, beside the world being changed)
|
||||
self.ensure_stateful_entity_table();
|
||||
let stateful = self.world.block_entities.stateful_entities();
|
||||
let registry = self.pack.assets.registry;
|
||||
// (the tags as `block_matches` reads them for commands)
|
||||
let world_items = match &self.world_tables {
|
||||
@@ -81,6 +83,7 @@ impl Game<'_> {
|
||||
let f = facts.get(BlockStateId(s));
|
||||
watched.get(s as usize).copied().unwrap_or(false)
|
||||
|| f.block_entity
|
||||
|| stateful.get(s as usize).copied().unwrap_or(false)
|
||||
|| BlockEntity::for_block(&f.name).is_some()
|
||||
},
|
||||
);
|
||||
@@ -88,7 +91,7 @@ impl Game<'_> {
|
||||
// The block entities the new blocks bring, as `native_set_block` makes them (a mod block's own, API 15,
|
||||
// included), in the order `replace_block_entities` takes them: the positions needing a fresh entity.
|
||||
let name = |s: u32| self.res.facts().get(BlockStateId(s)).name.clone();
|
||||
let wanted: Vec<Arc<str>> = out
|
||||
let wanted: Vec<BlockStateId> = out
|
||||
.tracked
|
||||
.iter()
|
||||
.filter(|&&((x, y, z), old, new)| {
|
||||
@@ -99,9 +102,11 @@ impl Game<'_> {
|
||||
.get(&(x, y, z))
|
||||
.is_some_and(|e| e.fits(&name(new)))
|
||||
})
|
||||
.map(|&(_, _, new)| name(new))
|
||||
.map(|&(_, _, new)| BlockStateId(new))
|
||||
.collect();
|
||||
let mut made = make_block_entities(&wanted, |n| self.new_block_entity(n)).into_iter();
|
||||
let mut made =
|
||||
make_block_entities(&wanted, |state| self.new_block_entity_for_state(*state))
|
||||
.into_iter();
|
||||
let facts = self.res.facts();
|
||||
let removed = replace_block_entities(
|
||||
&mut self.world,
|
||||
@@ -384,15 +389,15 @@ fn resolve_runs(
|
||||
/// A block entity for each new block, in order: made for each position, as vanilla calls
|
||||
/// `EntityBlock.newBlockEntity` for each (a mod's may number its instances). A block that makes none (stone) is
|
||||
/// asked once.
|
||||
fn make_block_entities(
|
||||
names: &[Arc<str>],
|
||||
mut make: impl FnMut(&str) -> Option<BlockEntity>,
|
||||
fn make_block_entities<S: Eq + std::hash::Hash>(
|
||||
states: &[S],
|
||||
mut make: impl FnMut(&S) -> Option<BlockEntity>,
|
||||
) -> Vec<Option<BlockEntity>> {
|
||||
let mut none: FxHashSet<&str> = FxHashSet::default();
|
||||
names
|
||||
let mut none: FxHashSet<&S> = FxHashSet::default();
|
||||
states
|
||||
.iter()
|
||||
.map(|n| {
|
||||
if none.contains(&**n) {
|
||||
if none.contains(n) {
|
||||
return None;
|
||||
}
|
||||
let entity = make(n);
|
||||
@@ -767,7 +772,7 @@ mod tests {
|
||||
.map(Arc::<str>::from),
|
||||
|n| {
|
||||
calls.push(n.to_string());
|
||||
(n == "machines:press").then(|| {
|
||||
(n.as_ref() == "machines:press").then(|| {
|
||||
BlockEntity::Mod(Box::new(
|
||||
horizon_client::containers::ModBlockEntityFields {
|
||||
id: format!("press-{}", calls.len()),
|
||||
@@ -799,4 +804,25 @@ mod tests {
|
||||
["machines:press", "minecraft:stone", "machines:press"]
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn null_factories_are_cached_per_state_not_per_block_name() {
|
||||
// One block can create no BE in one state and a BE in another.
|
||||
// A cached null for the first state must not suppress the second.
|
||||
let mut calls = Vec::new();
|
||||
let made = make_block_entities(&[7_u32, 8, 7, 8], |state| {
|
||||
calls.push(*state);
|
||||
(*state == 8).then(|| {
|
||||
BlockEntity::Mod(Box::new(
|
||||
horizon_client::containers::ModBlockEntityFields {
|
||||
id: "machines:heater".into(),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
))
|
||||
})
|
||||
});
|
||||
assert_eq!(calls, [7, 8, 8]);
|
||||
assert!(made[0].is_none() && made[2].is_none());
|
||||
assert!(made[1].is_some() && made[3].is_some());
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ impl Game<'_> {
|
||||
self.tick_craft_table();
|
||||
self.tick_container_reach();
|
||||
self.load_entity_types();
|
||||
self.ensure_stateful_entity_table();
|
||||
if !self.world.block_entities.has_states() {
|
||||
let states = self.ticking_states();
|
||||
self.world.block_entities.set_states(states);
|
||||
|
||||
@@ -206,7 +206,13 @@ impl Game<'_> {
|
||||
// DecoratedPotBlock.getCloneItemStack: createDecoratedPotInstance, the
|
||||
// sherds only; the pot's item comes with include_data.
|
||||
let pot = &*facts.name == "minecraft:decorated_pot";
|
||||
if (include_data || banner || pot)
|
||||
// CopperGolemStatueBlockEntity.getItem: collectComponents (the golem's
|
||||
// custom name), data or not.
|
||||
let statue = matches!(
|
||||
self.world.block_entities.get(&key),
|
||||
Some(BlockEntity::CopperGolemStatue(_))
|
||||
);
|
||||
if (include_data || banner || pot || statue)
|
||||
&& let Some(saved) = self.saved_block_entity(key)
|
||||
{
|
||||
let mut picked = block_entity_item::pick(&saved);
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Block particles: the sprite and tint of a block's faces and particle material, honey slides,
|
||||
//! and the burst a destroyed block leaves (`ClientLevel.addDestroyBlockEffect`).
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
/// The block's first up face (or any face) and its fixed tint, for block
|
||||
/// models drawn as entities (falling blocks, carried blocks).
|
||||
pub(super) fn block_face_sprite(&self, state: BlockStateId) -> Option<(String, u32)> {
|
||||
let pack = &self.pack;
|
||||
let models = pack
|
||||
.render
|
||||
.model_catalog
|
||||
.collect(state, horizon_core::position::BlockPos::new(0, 0, 0))
|
||||
.ok()?;
|
||||
let face = models
|
||||
.iter()
|
||||
.flat_map(|(_, m)| m.faces.iter())
|
||||
.find(|f| f.quad.facing == horizon_assets::model::Direction::Up)
|
||||
.or_else(|| models.iter().flat_map(|(_, m)| m.faces.iter()).next())?;
|
||||
let name = pack.layer_names.get(&face.layer)?.clone();
|
||||
let tint = if face.quad.tint_index >= 0 {
|
||||
let n = &self.res.facts().get(state).name;
|
||||
if n.contains("leaves") || n.contains("vine") {
|
||||
0x48B518
|
||||
} else {
|
||||
0x91BD59
|
||||
}
|
||||
} else {
|
||||
0xFFFFFF
|
||||
};
|
||||
Some((name, tint))
|
||||
}
|
||||
/// `TerrainParticle` at `pos`: the model's particle sprite and tint source
|
||||
/// 0's `colorAsTerrainParticle` (white when the block has none).
|
||||
/// `HoneyBlock.showSlideParticles` (entity event 53): five honey block
|
||||
/// particles at the entity.
|
||||
pub(super) fn honey_slide_particles(&mut self, at: [f64; 3]) {
|
||||
self.honey_entity_particles(at, 53);
|
||||
}
|
||||
/// `HoneyBlock.showParticles`: the crumb count of entity event 53 or 54.
|
||||
pub(super) fn honey_entity_particles(&mut self, at: [f64; 3], event: i8) {
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
let Some(count) = horizon_client::landing::honey_particle_count(event) else {
|
||||
return;
|
||||
};
|
||||
let Some(honey) = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:honey_block")
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let block = [
|
||||
at[0].floor() as i32,
|
||||
at[1].floor() as i32,
|
||||
at[2].floor() as i32,
|
||||
];
|
||||
if let Some((sprite, tint)) = self.particle_sprite(honey, block) {
|
||||
for _ in 0..count {
|
||||
self.particles.add(
|
||||
&ParticleOptions::block(ParticleType::Block, sprite.clone(), tint),
|
||||
at,
|
||||
[0.0; 3],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `BlockStateModelSet.getParticleMaterial`: the sprite of the model's
|
||||
/// `particle` texture, else its first face (entity-rendered blocks may
|
||||
/// have a particle texture without faces, and faces without one).
|
||||
pub(super) fn block_particle_sprite(
|
||||
pack: &PackResources,
|
||||
state: BlockStateId,
|
||||
) -> Option<String> {
|
||||
let catalog = &pack.render.model_catalog;
|
||||
let layer = catalog.particle(state).or_else(|| {
|
||||
let models = catalog
|
||||
.collect(state, horizon_core::position::BlockPos::new(0, 0, 0))
|
||||
.ok()?;
|
||||
models
|
||||
.iter()
|
||||
.flat_map(|(_, m)| m.faces.iter())
|
||||
.next()
|
||||
.map(|f| f.layer)
|
||||
})?;
|
||||
pack.layer_names.get(&layer).cloned()
|
||||
}
|
||||
pub(super) fn particle_shape(&self, pos: [i32; 3], state: BlockStateId) -> Vec<[f64; 6]> {
|
||||
let f = self.res.facts().get(state);
|
||||
let offset = if f.outline_offset {
|
||||
self.pack
|
||||
.context
|
||||
.tables
|
||||
.offset(state, pos[0], pos[2])
|
||||
.unwrap_or([0.0; 3])
|
||||
} else {
|
||||
[0.0; 3]
|
||||
};
|
||||
f.outline
|
||||
.iter()
|
||||
.map(|b| std::array::from_fn(|i| b[i] + offset[i % 3]))
|
||||
.collect()
|
||||
}
|
||||
/// ClientLevel.addDestroyBlockEffect: a burst per outline box.
|
||||
pub(super) fn destroy_particles(&mut self, pos: [i32; 3], state: BlockStateId) {
|
||||
if self.settings.extras.hide_block_particles {
|
||||
return;
|
||||
}
|
||||
let Some((sprite, tint)) = self.particle_sprite(state, pos) else {
|
||||
return;
|
||||
};
|
||||
let boxes = self.particle_shape(pos, state);
|
||||
horizon_client::particles::destroy_positions(pos, &boxes, |p, v| {
|
||||
let particle = Particle::terrain(self.particles.random(), p, v, sprite.clone(), tint);
|
||||
self.particles.push(particle);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -50,10 +50,11 @@ impl Game<'_> {
|
||||
format!("{}[{}]", s.name, props.join(","))
|
||||
}
|
||||
fn put_block(&mut self, pos: [i32; 3], state: BlockStateId) -> Result<()> {
|
||||
let name = self.res.facts().get(state).name.clone();
|
||||
self.world.block_entities.remove(&(pos[0], pos[1], pos[2]));
|
||||
self.level_set(pos, state)?;
|
||||
if let Some(entity) = self.new_block_entity(&name) {
|
||||
if !self.world.block_entities.stateful_entity(state.0)
|
||||
&& let Some(entity) = self.new_block_entity_for_state(state)
|
||||
{
|
||||
self.world
|
||||
.block_entities
|
||||
.insert((pos[0], pos[1], pos[2]), entity);
|
||||
|
||||
@@ -468,7 +468,9 @@ impl Game<'_> {
|
||||
if self.boat_can_attach(i, player) {
|
||||
let mut b = self.boats[i].clone();
|
||||
self.release_boat_leash(&mut b, true);
|
||||
// LeashData.setLeashHolder forgets a holder still waiting in the save.
|
||||
b.leash = Some(Holder::Player);
|
||||
b.leash_pending = None;
|
||||
b.leash_player = Some(self.acting_player_uuid());
|
||||
self.boats[i] = b;
|
||||
self.lead_sound("tied", at);
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Using an item on a cauldron (`CauldronInteraction`): what the click does, and doing it.
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
/// What the cauldron at `pos` answers to `held` on a client, which only
|
||||
/// asks (`CauldronInteractions` acts `if (!level.isClientSide())`). A
|
||||
/// sneak with something in hand skips `useItemOn`
|
||||
/// (`Player.isSecondaryUseActive`).
|
||||
pub(super) fn cauldron_outcome(
|
||||
&self,
|
||||
pos: [i32; 3],
|
||||
held: Option<&ItemStack>,
|
||||
) -> horizon_client::cauldron::Outcome {
|
||||
use horizon_client::cauldron::{self, Outcome};
|
||||
let Some(held) = held else {
|
||||
return Outcome::Pass;
|
||||
};
|
||||
if self.keys.held(Mapping::Sneak) {
|
||||
return Outcome::Pass;
|
||||
}
|
||||
let Some((state, under_water)) = self.cauldron_at(pos) else {
|
||||
return Outcome::Pass;
|
||||
};
|
||||
let removes_dye = cauldron::removes_dye(self.items(), &held.item);
|
||||
cauldron::outcome(&state, under_water, removes_dye, held)
|
||||
}
|
||||
/// `CauldronInteractions` for the held item aimed at a cauldron.
|
||||
pub(super) fn use_on_cauldron(&mut self, pos: [i32; 3]) -> Result<bool> {
|
||||
use horizon_client::cauldron::{self, Outcome};
|
||||
struct Hand<'g, 'a>(&'g mut Game<'a>);
|
||||
impl cauldron::Player for Hand<'_, '_> {
|
||||
fn creative(&self) -> bool {
|
||||
self.0.creative
|
||||
}
|
||||
fn hand(&mut self) -> &mut Option<ItemStack> {
|
||||
self.0.held_mut()
|
||||
}
|
||||
fn add(&mut self, stack: &mut ItemStack) -> bool {
|
||||
let before = stack.count;
|
||||
match self.0.give(stack.clone()) {
|
||||
None => stack.count = 0,
|
||||
Some(rest) => stack.count = rest.count,
|
||||
}
|
||||
stack.count < before
|
||||
}
|
||||
fn contains(&self, stack: &ItemStack) -> bool {
|
||||
self.0
|
||||
.inventory
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|have| have.same_item_same_components(stack))
|
||||
}
|
||||
fn drop_stack(&mut self, stack: ItemStack) {
|
||||
if let Some(eye) = self.0.eye_position() {
|
||||
// ItemUtils.createFilledResult: `player.drop(stack, false)`.
|
||||
self.0.throw_from_eyes(stack, eye, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
let registry = self.pack.assets.registry;
|
||||
let Some((state, under_water)) = self.cauldron_at(pos) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let Some(held) = self.held() else {
|
||||
return Ok(false);
|
||||
};
|
||||
// Player.isSecondaryUseActive with an item in hand skips useItemOn.
|
||||
if self.keys.held(Mapping::Sneak) {
|
||||
return Ok(false);
|
||||
}
|
||||
let removes_dye = cauldron::removes_dye(self.items(), &held.item);
|
||||
let result = cauldron::interact(&state, under_water, removes_dye, &mut Hand(self));
|
||||
if result.outcome == Outcome::Pass {
|
||||
return Ok(false);
|
||||
}
|
||||
if let Some(stat) = result.stat {
|
||||
self.award_custom_stat(stat, 1);
|
||||
}
|
||||
if let Some(block) = result.block
|
||||
&& let Ok(new) = registry.resolve(&block)
|
||||
{
|
||||
self.level_set(pos, new)?;
|
||||
if result.lower_fill_level {
|
||||
self.post_block_game_event("minecraft:block_change", pos, None, Some(new));
|
||||
}
|
||||
}
|
||||
if let Some(sound) = result.sound {
|
||||
self.audio.play(
|
||||
sound,
|
||||
Some([
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]) + 0.5,
|
||||
f64::from(pos[2]) + 0.5,
|
||||
]),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ use horizon_client::{
|
||||
Chat, ChatAbilities, ChatAction, ChatScreen, ChatSource, ChatVisibility, Commands,
|
||||
SYSTEM_INDICATOR, normalize_chat_message,
|
||||
},
|
||||
command::Registries,
|
||||
command::{ArgType, Dispatcher, MOD_COMMAND, Registries},
|
||||
gamerules::GameRules,
|
||||
hud::HudBatch,
|
||||
screen_draw::Measure,
|
||||
@@ -55,6 +55,91 @@ pub(super) fn with_native_commands(
|
||||
)
|
||||
}
|
||||
|
||||
/// Add Studio's local command grammar to the suggestion tree. Commands are
|
||||
/// still dispatched by `studio_command`; this tree supplies coloring,
|
||||
/// completion, and Tab acceptance in chat.
|
||||
fn with_studio_commands(base: &Dispatcher) -> Dispatcher {
|
||||
fn literal(d: &mut Dispatcher, parent: usize, name: &str) -> usize {
|
||||
let node = d.literal(parent, name);
|
||||
d.executes(node, MOD_COMMAND);
|
||||
node
|
||||
}
|
||||
fn argument(d: &mut Dispatcher, parent: usize, name: &str, ty: ArgType) -> usize {
|
||||
let node = d.argument(parent, name, ty);
|
||||
d.executes(node, MOD_COMMAND);
|
||||
node
|
||||
}
|
||||
|
||||
let mut d = base.clone();
|
||||
let root = d.find("studio").unwrap_or_else(|| d.literal(0, "studio"));
|
||||
d.executes(root, MOD_COMMAND);
|
||||
|
||||
let record = literal(&mut d, root, "record");
|
||||
let start = literal(&mut d, record, "start");
|
||||
argument(&mut d, start, "name", ArgType::Word);
|
||||
literal(&mut d, record, "stop");
|
||||
literal(&mut d, record, "status");
|
||||
|
||||
let replay = literal(&mut d, root, "replay");
|
||||
let open = literal(&mut d, replay, "open");
|
||||
argument(&mut d, open, "path", ArgType::Greedy);
|
||||
literal(&mut d, replay, "pause");
|
||||
literal(&mut d, replay, "resume");
|
||||
let speed = literal(&mut d, replay, "speed");
|
||||
argument(
|
||||
&mut d,
|
||||
speed,
|
||||
"multiplier",
|
||||
ArgType::Double {
|
||||
min: 0.01,
|
||||
max: 20.0,
|
||||
},
|
||||
);
|
||||
let seek = literal(&mut d, replay, "seek");
|
||||
argument(
|
||||
&mut d,
|
||||
seek,
|
||||
"milliseconds",
|
||||
ArgType::Integer {
|
||||
min: 0,
|
||||
max: i32::MAX,
|
||||
},
|
||||
);
|
||||
literal(&mut d, replay, "status");
|
||||
literal(&mut d, replay, "close");
|
||||
|
||||
let freecam = literal(&mut d, root, "freecam");
|
||||
argument(
|
||||
&mut d,
|
||||
freecam,
|
||||
"state",
|
||||
ArgType::StrEnum(&["on", "off"], "argument.enum.invalid"),
|
||||
);
|
||||
|
||||
let keyframe = literal(&mut d, root, "keyframe");
|
||||
literal(&mut d, keyframe, "add");
|
||||
let remove = literal(&mut d, keyframe, "remove");
|
||||
argument(
|
||||
&mut d,
|
||||
remove,
|
||||
"index",
|
||||
ArgType::Integer {
|
||||
min: 0,
|
||||
max: i32::MAX,
|
||||
},
|
||||
);
|
||||
literal(&mut d, keyframe, "clear");
|
||||
|
||||
let path = literal(&mut d, root, "path");
|
||||
argument(
|
||||
&mut d,
|
||||
path,
|
||||
"state",
|
||||
ArgType::StrEnum(&["lock", "unlock"], "argument.enum.invalid"),
|
||||
);
|
||||
d
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The tree the chat box colours, completes and parses with for the
|
||||
/// world's connection: `base`, the tree the server sent, or the game's own
|
||||
@@ -66,12 +151,11 @@ impl Game<'_> {
|
||||
) -> horizon_client::command::Dispatcher {
|
||||
let mut names = Vec::new();
|
||||
self.native_commands(&mut names);
|
||||
names.push("studio");
|
||||
let base = match base {
|
||||
Some(tree) => tree,
|
||||
None => horizon_client::command::vanilla::game_dispatcher(),
|
||||
};
|
||||
with_native_commands(base, &names)
|
||||
with_studio_commands(&with_native_commands(base, &names))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2144,6 +2228,43 @@ mod tests {
|
||||
assert_eq!(shared.nodes.len(), vanilla.nodes.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn studio_command_tree_suggests_subcommands_and_arguments() {
|
||||
let reg = Registries::default();
|
||||
let base = horizon_client::command::vanilla::game_dispatcher();
|
||||
let tree = with_studio_commands(base);
|
||||
let suggestions = |input: &str| {
|
||||
let parse = tree.parse(input, ®);
|
||||
tree.client_suggestions(&parse, input.len(), ®)
|
||||
.1
|
||||
.into_iter()
|
||||
.map(|suggestion| suggestion.text)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
suggestions("studio "),
|
||||
["freecam", "keyframe", "path", "record", "replay"]
|
||||
);
|
||||
assert_eq!(suggestions("studio record "), ["start", "status", "stop"]);
|
||||
assert_eq!(
|
||||
suggestions("studio replay "),
|
||||
[
|
||||
"close", "open", "pause", "resume", "seek", "speed", "status"
|
||||
]
|
||||
);
|
||||
assert_eq!(suggestions("studio freecam "), ["off", "on"]);
|
||||
assert_eq!(suggestions("studio keyframe "), ["add", "clear", "remove"]);
|
||||
assert_eq!(suggestions("studio path "), ["lock", "unlock"]);
|
||||
assert_eq!(
|
||||
tree.parse("studio replay open some-recording.mcpr", ®)
|
||||
.executable()
|
||||
.unwrap()
|
||||
.1,
|
||||
MOD_COMMAND
|
||||
);
|
||||
}
|
||||
|
||||
/// When `entity_types` is empty (no `entity-types.json`), fallback includes
|
||||
/// standard mobs, non-mob entities, and `wind_charge` / `breeze_wind_charge`.
|
||||
#[test]
|
||||
@@ -2399,7 +2520,11 @@ mod enchantment_snapshot_tests {
|
||||
let pack = dir.join("datapacks/model");
|
||||
let tags = pack.join("data/h024/tags/enchantment");
|
||||
std::fs::create_dir_all(&tags).unwrap();
|
||||
std::fs::write(pack.join("pack.mcmeta"), "{}").unwrap();
|
||||
std::fs::write(
|
||||
pack.join("pack.mcmeta"),
|
||||
r#"{"pack":{"description":"fixture"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(tags.join("empty.json"), r#"{"values":[]}"#).unwrap();
|
||||
std::fs::write(
|
||||
dir.join("datapacks.json"),
|
||||
|
||||
@@ -166,7 +166,7 @@ impl Game<'_> {
|
||||
// its hub record, which leave_guest would otherwise overwrite.
|
||||
self.in_owner_level(|g| {
|
||||
let mut host = g.lan.take().ok_or("command harness: no hub")?;
|
||||
g.start_server_trip(&mut host, conn, plan.dim, Some(plan));
|
||||
g.start_server_trip(&mut host, conn, plan.dim, Some(plan), false);
|
||||
g.lan = Some(host);
|
||||
Ok(())
|
||||
})
|
||||
@@ -271,9 +271,19 @@ impl Game<'_> {
|
||||
self.chat_capture = Some(Vec::new());
|
||||
self.cmds.results = Some(Vec::new());
|
||||
self.harness_owner(|g| {
|
||||
let previous = g.command_source;
|
||||
if op["source"].as_str() == Some("console") {
|
||||
// Actual server source; permission-only `as_console`
|
||||
// otherwise keeps the connected owner's entity.
|
||||
g.command_source = Some(super::commands::execute::Source {
|
||||
silent: false,
|
||||
..g.server_source()
|
||||
});
|
||||
}
|
||||
g.as_console(|g| {
|
||||
g.run_command(command.strip_prefix('/').unwrap_or(command))
|
||||
});
|
||||
g.command_source = previous;
|
||||
Ok(())
|
||||
})?;
|
||||
let messages = self.chat_capture.take().unwrap_or_default();
|
||||
|
||||
@@ -68,6 +68,8 @@ pub(super) enum Target {
|
||||
Dragon(u32),
|
||||
/// A primed TNT, by its entity id (`minecraft:tnt`).
|
||||
Tnt(u32),
|
||||
/// A cushion, by its entity id (`minecraft:cushion`).
|
||||
Cushion(u32),
|
||||
/// A falling block, by its entity id (`minecraft:falling_block`).
|
||||
Falling(u32),
|
||||
/// A firework rocket, by its entity id (`minecraft:firework_rocket`).
|
||||
@@ -164,6 +166,7 @@ fn non_player_entity_id(t: Target) -> Option<i32> {
|
||||
| Target::Crystal(id)
|
||||
| Target::Dragon(id)
|
||||
| Target::Tnt(id)
|
||||
| Target::Cushion(id)
|
||||
| Target::Falling(id)
|
||||
| Target::Rocket(id)
|
||||
| Target::Trident(id)
|
||||
@@ -565,6 +568,11 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.filter(|d| d.id == id)
|
||||
.map(|d| [d.position.x, d.position.y, d.position.z]),
|
||||
Target::Cushion(id) => self
|
||||
.cushions
|
||||
.iter()
|
||||
.find(|c| c.id == id)
|
||||
.map(|c| [c.position.x, c.position.y, c.position.z]),
|
||||
Target::Tnt(id) => self
|
||||
.tnts
|
||||
.iter()
|
||||
@@ -639,6 +647,7 @@ impl Game<'_> {
|
||||
Target::Crystal(_) => "minecraft:end_crystal".into(),
|
||||
Target::Dragon(_) => "minecraft:ender_dragon".into(),
|
||||
Target::Tnt(_) => "minecraft:tnt".into(),
|
||||
Target::Cushion(_) => "minecraft:cushion".into(),
|
||||
Target::Falling(_) => "minecraft:falling_block".into(),
|
||||
Target::Rocket(_) => "minecraft:firework_rocket".into(),
|
||||
Target::Trident(_) => "minecraft:trident".into(),
|
||||
@@ -915,6 +924,7 @@ impl Game<'_> {
|
||||
});
|
||||
candidates.extend(self.item_frames.iter().map(|f| Target::Frame(f.id)));
|
||||
candidates.extend(self.paintings.iter().map(|p| Target::Painting(p.id)));
|
||||
candidates.extend(self.cushions.iter().map(|c| Target::Cushion(c.id)));
|
||||
candidates.extend(self.others.iter().map(|o| Target::Other(o.id)));
|
||||
candidates.extend(self.fangs.list.iter().map(|f| Target::Fangs(f.id)));
|
||||
// The End crystals and the dragon (EndCrystal and EnderDragon
|
||||
@@ -963,6 +973,7 @@ impl Game<'_> {
|
||||
Target::Crystal(id),
|
||||
Target::Dragon(id),
|
||||
Target::Tnt(id),
|
||||
Target::Cushion(id),
|
||||
Target::Falling(id),
|
||||
Target::Rocket(id),
|
||||
Target::Trident(id),
|
||||
@@ -992,6 +1003,7 @@ impl Game<'_> {
|
||||
Target::Crystal(id) => self.crystals.iter().any(|c| c.id == id),
|
||||
Target::Dragon(id) => self.dragon.as_ref().is_some_and(|d| d.id == id),
|
||||
Target::Tnt(id) => self.tnts.iter().any(|t| t.id == id),
|
||||
Target::Cushion(id) => self.cushions.iter().any(|c| c.id == id),
|
||||
Target::Falling(id) => self.falling.iter().any(|f| f.id == id),
|
||||
Target::Rocket(id) => self.rockets.iter().any(|r| r.id == id),
|
||||
Target::Trident(id) => self.tridents.iter().any(|t| t.id == id),
|
||||
@@ -1724,6 +1736,16 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Target::Cushion(id) => {
|
||||
// BlockAttachedEntity.setPos: the block it rests on follows the position.
|
||||
if let Some(c) = self.cushions.iter_mut().find(|c| c.id == id) {
|
||||
c.position = v;
|
||||
c.pos = [v.x.floor() as i32, v.y.floor() as i32, v.z.floor() as i32];
|
||||
if let Some(r) = rotation {
|
||||
c.yaw = r[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
Target::Falling(id) => {
|
||||
if let Some(f) = self.falling.iter_mut().find(|f| f.id == id) {
|
||||
f.position = v;
|
||||
@@ -2061,12 +2083,9 @@ impl Game<'_> {
|
||||
// `time of <clock>` names the clock; otherwise the dimension's default clock.
|
||||
let clock = match ctx.get("clock") {
|
||||
Some(Arg::Resource(c)) => c.clone(),
|
||||
_ => match self.dim {
|
||||
Dim::Overworld => "minecraft:overworld".to_owned(),
|
||||
Dim::End => "minecraft:the_end".to_owned(),
|
||||
Dim::Nether => {
|
||||
return Err(err("commands.time.no_default_clock", &[&self.dim.id()]));
|
||||
}
|
||||
_ => match self.dimension_type().default_clock.clone() {
|
||||
Some(clock) => clock,
|
||||
None => return Err(err("commands.time.no_default_clock", &[&self.dim.id()])),
|
||||
},
|
||||
};
|
||||
let total = self.command_clock_time(&clock);
|
||||
@@ -2361,18 +2380,23 @@ impl Game<'_> {
|
||||
if let Some(i) = self.boats.iter().position(|b| b.id == id) {
|
||||
self.release_vehicle_container(id);
|
||||
let b = self.boats.remove(i);
|
||||
self.spill_killed_vehicle_contents(b.position, &b.items);
|
||||
self.boat_removed(&b);
|
||||
}
|
||||
}
|
||||
Target::Cart(id) => {
|
||||
self.release_vehicle_container(id);
|
||||
self.carts.retain(|c| c.id != id);
|
||||
if let Some(i) = self.carts.iter().position(|c| c.id == id) {
|
||||
let c = self.carts.remove(i);
|
||||
self.spill_killed_vehicle_contents(c.position, &c.items);
|
||||
}
|
||||
}
|
||||
Target::Other(id) => self.others.retain(|o| o.id != id),
|
||||
Target::Fangs(id) => self.fangs.list.retain(|f| f.id != id),
|
||||
Target::Crystal(id) => self.kill_crystal(id),
|
||||
Target::Dragon(id) => self.kill_dragon(id),
|
||||
Target::Tnt(id) => self.tnts.retain(|t| t.id != id),
|
||||
Target::Cushion(id) => self.kill_cushion(id),
|
||||
Target::Falling(id) => self.falling.retain(|f| f.id != id),
|
||||
Target::Rocket(id) => self.rockets.retain(|r| r.id != id),
|
||||
Target::Trident(id) => self.tridents.retain(|t| t.id != id),
|
||||
@@ -2386,6 +2410,30 @@ impl Game<'_> {
|
||||
}
|
||||
self.native_leaves_flush();
|
||||
}
|
||||
/// `AbstractChestBoat.remove` / `AbstractMinecartContainer.remove` for
|
||||
/// destroying removal (`Entity.kill`): `Containers.dropContents`, separate
|
||||
/// from hurt-time hull drops and after the live menu writes back its slots.
|
||||
fn spill_killed_vehicle_contents(&mut self, pos: Vec3, slots: &[Option<ItemStack>]) {
|
||||
for stack in slots {
|
||||
let count = stack.as_ref().map_or(0, |s| s.count);
|
||||
for piece in horizon_world::level::removal::drop_item_stack_at(
|
||||
&mut self.world.random,
|
||||
pos.components(),
|
||||
count,
|
||||
) {
|
||||
let Some(stack) = stack else { continue };
|
||||
self.spawn_item(
|
||||
ItemStack {
|
||||
count: piece.count,
|
||||
..stack.clone()
|
||||
},
|
||||
piece.pos,
|
||||
piece.motion,
|
||||
0,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn cmd_kill(&mut self, ctx: &Context) -> R<i32> {
|
||||
let targets = if ctx.get("targets").is_some() {
|
||||
self.entities(ctx, "targets", false)?
|
||||
@@ -2641,6 +2689,7 @@ impl Game<'_> {
|
||||
if previous == state {
|
||||
return Ok(false);
|
||||
}
|
||||
self.ensure_stateful_entity_table();
|
||||
// A different block replaces the block entity (Level.setBlock).
|
||||
let (old, new) = (
|
||||
self.res.facts().get(previous).name.clone(),
|
||||
@@ -2673,9 +2722,10 @@ impl Game<'_> {
|
||||
// LevelChunk.setBlockState creates it when the block, not the state, is still
|
||||
// there after onPlace: a powered hopper turns itself `enabled=false`.
|
||||
let landed = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if old != new
|
||||
let needs_entity = !self.world.block_entities.stateful_entity(landed.0) && old != new;
|
||||
if needs_entity
|
||||
&& self.res.facts().get(landed).name == new
|
||||
&& let Some(entity) = self.new_block_entity(&new)
|
||||
&& let Some(entity) = self.new_block_entity_for_state(landed)
|
||||
{
|
||||
self.world.block_entities.insert(key, entity);
|
||||
}
|
||||
@@ -2781,7 +2831,15 @@ impl Game<'_> {
|
||||
let lock_changed = self.world.block_lock(key) != tag.get("lock");
|
||||
// So is the custom name.
|
||||
let name_changed = self.load_block_name(key, &new, &tag);
|
||||
if after == before && !rolled && !lock_changed && !name_changed {
|
||||
let cooldown_changed = if name == "minecraft:hopper" {
|
||||
let state = super::machines::HopperState::load(&tag);
|
||||
let changed = self.hoppers.get(&key).map_or(-1, |s| s.cooldown) != state.cooldown;
|
||||
self.hoppers.insert(key, state);
|
||||
changed
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if after == before && !rolled && !lock_changed && !name_changed && !cooldown_changed {
|
||||
return false;
|
||||
}
|
||||
self.world.block_entities.insert(key, new);
|
||||
@@ -4413,6 +4471,7 @@ mod look_at_tests {
|
||||
Target::Crystal(7),
|
||||
Target::Dragon(7),
|
||||
Target::Tnt(7),
|
||||
Target::Cushion(7),
|
||||
Target::Falling(7),
|
||||
Target::Rocket(7),
|
||||
Target::Trident(7),
|
||||
@@ -4683,6 +4742,205 @@ mod selector_bounds_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod local_coordinate_command_tests {
|
||||
use super::super::native_machines::tests::server_game;
|
||||
use super::*;
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
fn pos_bits(tag: &Tag) -> [u64; 3] {
|
||||
let Some(Tag::List(pos)) = tag.get("Pos") else {
|
||||
panic!("actual saved Double position missing")
|
||||
};
|
||||
assert_eq!(pos.len(), 3);
|
||||
std::array::from_fn(|i| match &pos[i] {
|
||||
Tag::Double(bits) => *bits,
|
||||
other => panic!("actual Pos must be Double: {other:?}"),
|
||||
})
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources and scratch homes/cwd"]
|
||||
fn h149_actual_registered_local_commands_preserve_exact_saved_positions() {
|
||||
let mut game = server_game();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
game.block_ticks
|
||||
.add_empty_chunk(BlockPos::new(0, 0, 0).chunk())
|
||||
.unwrap();
|
||||
assert!(game.run_command("forceload add 0 0"));
|
||||
assert!(game.run_command("fill 0 199 0 15 199 15 stone"));
|
||||
assert!(game.run_command("scoreboard objectives add h149 dummy"));
|
||||
let clock = game.world.game_time;
|
||||
let mut failures = Vec::new();
|
||||
let cases = [
|
||||
(
|
||||
"south",
|
||||
"execute positioned 8.5 202.25 8.5 rotated 0.0 0.0 run tp 00000095-0000-0001-0000-000000000001 ^0.75 ^0.5 ^1.25",
|
||||
[
|
||||
0x4022800000000000u64,
|
||||
0x4069580000000000u64,
|
||||
0x4023800000000000u64,
|
||||
],
|
||||
false,
|
||||
),
|
||||
(
|
||||
"west",
|
||||
"execute positioned 8.5 202.25 8.5 rotated 90.0 0.0 run tp 00000095-0000-0001-0000-000000000002 ^0.75 ^0.5 ^1.25",
|
||||
[
|
||||
0x401d000000000000u64,
|
||||
0x4069580000000000u64,
|
||||
0x4022800000000000u64,
|
||||
],
|
||||
false,
|
||||
),
|
||||
(
|
||||
"north",
|
||||
"execute positioned 8.5 202.25 8.5 rotated 180.0 0.0 run tp 00000095-0000-0001-0000-000000000003 ^0.75 ^0.5 ^1.25",
|
||||
[
|
||||
0x401f000000000000u64,
|
||||
0x4069580000000000u64,
|
||||
0x401d000000000000u64,
|
||||
],
|
||||
false,
|
||||
),
|
||||
(
|
||||
"east",
|
||||
"execute positioned 8.5 202.25 8.5 rotated 270.0 0.0 run tp 00000095-0000-0001-0000-000000000004 ^0.75 ^0.5 ^1.25",
|
||||
[
|
||||
0x4023800000000000u64,
|
||||
0x4069580000000000u64,
|
||||
0x401f000000000000u64,
|
||||
],
|
||||
false,
|
||||
),
|
||||
(
|
||||
"oblique",
|
||||
"execute positioned 8.5 202.25 8.5 rotated 37.5 -22.25 run tp 00000095-0000-0001-0000-000000000005 ^0.1 ^0.1 ^0.1",
|
||||
[
|
||||
0x402117934a6314b9u64,
|
||||
0x40694c2c5ee98f58u64,
|
||||
0x40213562572e889au64,
|
||||
],
|
||||
false,
|
||||
),
|
||||
(
|
||||
"positioned_oblique",
|
||||
"execute positioned 8.5 202.25 8.5 rotated 123.75 47.125 positioned ^0.1 ^0.1 ^0.1 run tp 00000095-0000-0001-0000-000000000006 ~ ~ ~",
|
||||
[
|
||||
0x4020a7648c7aa86bu64,
|
||||
0x406947d514129374u64,
|
||||
0x4021025e8b180036u64,
|
||||
],
|
||||
false,
|
||||
),
|
||||
(
|
||||
"world",
|
||||
"execute positioned 8.5 202.25 8.5 rotated 37.5 -22.25 run tp 00000095-0000-0001-0000-000000000007 9.25 202.75 9.75",
|
||||
[
|
||||
0x4022800000000000u64,
|
||||
0x4069580000000000u64,
|
||||
0x4023800000000000u64,
|
||||
],
|
||||
true,
|
||||
),
|
||||
(
|
||||
"relative_eyes",
|
||||
"execute positioned 8.5 202.25 8.5 rotated 37.5 -22.25 anchored eyes run tp 00000095-0000-0001-0000-000000000008 ~0.75 ~0.5 ~1.25",
|
||||
[
|
||||
0x4022800000000000u64,
|
||||
0x4069580000000000u64,
|
||||
0x4023800000000000u64,
|
||||
],
|
||||
true,
|
||||
),
|
||||
(
|
||||
"feet",
|
||||
"execute as 00000095-0000-0001-0000-000000000009 at @s anchored feet rotated 37.5 -22.25 run tp @s ^ ^ ^",
|
||||
[
|
||||
0x4012000000000000u64,
|
||||
0x4069000000000000u64,
|
||||
0x4012000000000000u64,
|
||||
],
|
||||
true,
|
||||
),
|
||||
(
|
||||
"eyes",
|
||||
"execute as 00000095-0000-0001-0000-00000000000a at @s anchored eyes rotated 37.5 -22.25 run tp @s ^ ^ ^",
|
||||
[
|
||||
0x4012000000000000u64,
|
||||
0x4069187ae1400000u64,
|
||||
0x4012000000000000u64,
|
||||
],
|
||||
true,
|
||||
),
|
||||
];
|
||||
for (i, (name, command, want, control)) in cases.into_iter().enumerate() {
|
||||
let number = i as i32 + 1;
|
||||
let actor = format!("00000095-0000-0001-0000-{number:012x}");
|
||||
let uuid = [149, 1, 0, number];
|
||||
assert!(game.run_command(&format!(r#"summon pig 4.5 200 4.5 {{UUID:[I;149,1,0,{number}],Tags:["h149"],Health:10f,NoAI:1b,NoGravity:1b,Silent:1b,PersistenceRequired:1b,Motion:[0d,0d,0d],Rotation:[11f,7f]}}"#)));
|
||||
let id = *game
|
||||
.persist
|
||||
.uuids
|
||||
.iter()
|
||||
.find(|(_, u)| **u == uuid)
|
||||
.unwrap()
|
||||
.0;
|
||||
assert!(game.mobs.iter().any(|m| m.id == id));
|
||||
assert!(game.run_command(&format!("execute if entity @e[type=pig,nbt={{UUID:[I;149,1,0,{number}],Health:10f,NoAI:1b,NoGravity:1b,Pos:[4.5d,200d,4.5d],Motion:[0d,0d,0d],Rotation:[11f,7f]}}]")));
|
||||
assert!(game.world.loaded(4, 4));
|
||||
let before = game.entity_nbt(Target::Mob(id)).unwrap();
|
||||
assert_eq!(pos_bits(&before), [4.5f64, 200.0, 4.5].map(f64::to_bits));
|
||||
assert_eq!(before.get("UUID"), Some(&Tag::IntArray(uuid.to_vec())));
|
||||
assert_eq!(before.get("Health"), Some(&Tag::Float(10f32.to_bits())));
|
||||
// Discard retained raw input: only actual modeled position may prove this.
|
||||
game.persist.raw.remove(&id);
|
||||
assert!(game.run_command("scoreboard players set #r h149 91"));
|
||||
assert!(game.run_command("scoreboard players set #s h149 17"));
|
||||
assert!(
|
||||
game.run_command(&format!(
|
||||
"execute store result score #r h149 store success score #s h149 run {command}"
|
||||
)),
|
||||
"actual command accepted: {name}"
|
||||
);
|
||||
assert_eq!(game.score.board.score("#r", "h149").unwrap().value, 1);
|
||||
assert_eq!(game.score.board.score("#s", "h149").unwrap().value, 1);
|
||||
let model = game.mobs.iter().find(|m| m.id == id).unwrap();
|
||||
let model_bits =
|
||||
[model.position.x, model.position.y, model.position.z].map(f64::to_bits);
|
||||
let saved = game.entity_nbt(Target::Mob(id)).unwrap();
|
||||
let got = pos_bits(&saved);
|
||||
assert_eq!(
|
||||
model_bits, got,
|
||||
"actual model and typed canonical save agree: {name}"
|
||||
);
|
||||
assert_eq!(saved.get("UUID"), before.get("UUID"));
|
||||
for key in ["Health", "NoAI", "NoGravity", "Rotation", "Motion"] {
|
||||
assert_eq!(saved.get(key), before.get(key), "unchanged {key}: {name}");
|
||||
}
|
||||
assert!(game.run_command(&format!("execute as {actor} at @s if block ~ 199 ~ stone")));
|
||||
assert!(game.world.loaded(
|
||||
f64::from_bits(got[0]).floor() as i32,
|
||||
f64::from_bits(got[2]).floor() as i32,
|
||||
));
|
||||
if control {
|
||||
assert_eq!(
|
||||
got, want,
|
||||
"independent world/relative/anchor control {name}"
|
||||
);
|
||||
} else if got != want {
|
||||
failures.push((name, got, want));
|
||||
}
|
||||
assert_eq!(game.world.game_time, clock, "zero observation ticks");
|
||||
}
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"actual registered local-coordinate bit mismatches: {failures:#?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod query_feedback_tests {
|
||||
#[test]
|
||||
@@ -4836,3 +5094,122 @@ mod forced_respawn_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod vehicle_kill_tests {
|
||||
use super::*;
|
||||
use crate::game::BlockEntity;
|
||||
use crate::game::{Audio, Options, Progress, Settings, load_resources};
|
||||
use horizon_world::nbt::parse_snbt;
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources and scratch homes/cwd"]
|
||||
fn kill_spills_explicit_vehicle_contents_after_menu_writeback() {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
let clock = game.world.game_time;
|
||||
let mut differences = Vec::new();
|
||||
for (index, kind) in [
|
||||
"oak_chest_boat",
|
||||
"bamboo_chest_raft",
|
||||
"chest_minecart",
|
||||
"hopper_minecart",
|
||||
"oak_boat",
|
||||
]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
let uuid = [51, 0, 0, index as i32 + 1];
|
||||
let source = r#"{Pos:[5.25d,200d,4.75d],NoGravity:1b,Items:[{Slot:0b,id:"minecraft:diamond",count:3,components:{"minecraft:custom_data":{h051:1}}},{Slot:1b,id:"minecraft:dirt",count:5},{Slot:2b,id:"minecraft:stick",count:7}]}"#.to_string();
|
||||
let (tag, _) = parse_snbt(&source).unwrap();
|
||||
let boat = kind.contains("boat") || kind.contains("raft");
|
||||
let (id, mut slots) = if boat {
|
||||
game.load_boat(tag, Some(uuid), &format!("minecraft:{kind}"));
|
||||
let b = game.boats.last().unwrap();
|
||||
assert_eq!(b.position, Vec3::new(5.25, 200.0, 4.75));
|
||||
(b.id, b.items.clone())
|
||||
} else {
|
||||
game.load_cart(tag, Some(uuid), &format!("minecraft:{kind}"));
|
||||
let c = game.carts.last().unwrap();
|
||||
(c.id, c.items.clone())
|
||||
};
|
||||
let container = index < 4;
|
||||
assert_eq!(
|
||||
slots.iter().flatten().map(|s| s.count).sum::<u32>(),
|
||||
if container { 15 } else { 0 }
|
||||
);
|
||||
let target = if boat {
|
||||
Target::Boat(id)
|
||||
} else {
|
||||
Target::Cart(id)
|
||||
};
|
||||
assert_eq!(
|
||||
game.entity_nbt(target).unwrap().get("UUID"),
|
||||
Some(&horizon_world::nbt::Tag::IntArray(uuid.to_vec()))
|
||||
);
|
||||
// The active menu's actual edited slot must win over the older model.
|
||||
let key = (i32::MAX - 15, crate::game::minecarts::CART_KEY_Y, id as i32);
|
||||
if container {
|
||||
slots[0].as_mut().unwrap().count = 4;
|
||||
game.world
|
||||
.block_entities
|
||||
.insert(key, BlockEntity::Chest(slots.clone()));
|
||||
game.container = Some(key);
|
||||
}
|
||||
let old_len = game.dropped.len();
|
||||
let actor = format!("00000033-0000-0000-0000-{:012x}", index + 1);
|
||||
assert!(
|
||||
game.run_command(&format!("kill {actor}")),
|
||||
"{kind}: actual command admitted"
|
||||
);
|
||||
assert!(!game.boats.iter().any(|b| b.id == id));
|
||||
assert!(!game.carts.iter().any(|c| c.id == id));
|
||||
assert!(!game.world.block_entities.contains_key(&key));
|
||||
assert_ne!(game.container, Some(key));
|
||||
let dropped = &game.dropped[old_len..];
|
||||
let expected: Vec<_> = slots.into_iter().flatten().collect();
|
||||
if dropped.len() != expected.len()
|
||||
|| expected
|
||||
.iter()
|
||||
.any(|s| !dropped.iter().any(|d| &d.stack == s))
|
||||
{
|
||||
differences.push(format!(
|
||||
"{kind}: expected {expected:?}, actual {:?}",
|
||||
dropped.iter().map(|d| &d.stack).collect::<Vec<_>>()
|
||||
));
|
||||
}
|
||||
}
|
||||
assert_eq!(game.world.game_time, clock);
|
||||
assert!(
|
||||
differences.is_empty(),
|
||||
"kill must spill authoritative explicit contents: {differences:#?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -231,6 +231,11 @@ impl Game<'_> {
|
||||
let i = self.tnts.iter().position(|t| t.id == id)?;
|
||||
self.tnt_nbt(i)
|
||||
}
|
||||
Target::Cushion(id) => {
|
||||
let uuid = self.uuid_for(id);
|
||||
let c = self.cushions.iter().find(|c| c.id == id)?;
|
||||
entity_nbt::cushion_to_nbt(c, uuid, self.persist.raw.get(&id))
|
||||
}
|
||||
Target::Falling(id) => {
|
||||
let f = self.falling.iter().find(|f| f.id == id)?.clone();
|
||||
let (uuid, raw) = match &f.saved {
|
||||
@@ -376,6 +381,13 @@ impl Game<'_> {
|
||||
// cooldown and previous-hit amount are not read from saved NBT.
|
||||
slot.invulnerable = old.invulnerable;
|
||||
slot.last_hurt = old.last_hurt;
|
||||
// Entity.load updates saved data on the existing entity;
|
||||
// lastTradedPlayer is transient and survives a live NoAI edit.
|
||||
if slot.species() == horizon_client::mobs::Species::Villager {
|
||||
horizon_client::mobs::villager::ext_mut(slot).last_traded_player =
|
||||
horizon_client::mobs::villager::ext(&old)
|
||||
.and_then(|ext| ext.last_traded_player);
|
||||
}
|
||||
slot.ai = old.ai;
|
||||
// Mob.readAdditionalSaveData updates persistent home fields,
|
||||
// while retaining the rest of the live AI/runtime state.
|
||||
@@ -462,6 +474,42 @@ impl Game<'_> {
|
||||
}
|
||||
set_key(&mut tag, "UUID", Tag::IntArray(uuid.to_vec()));
|
||||
}
|
||||
Target::Falling(id) => {
|
||||
// Use load_falling's existing converters, but mutate the resident
|
||||
// instance: loading/spawning would allocate a new id and join.
|
||||
let (state, position, velocity, age, fall_distance) =
|
||||
entity_nbt::falling_block_from_nbt(&tag).ok_or_else(stale)?;
|
||||
let state = self.state_from_nbt(&state).ok_or_else(stale)?;
|
||||
let (no_gravity, drop_item) = entity_nbt::falling_block_flags(&tag);
|
||||
let damage = horizon_client::falling_block::Damage::from_nbt(
|
||||
&tag,
|
||||
self.res.facts().get(state).name.ends_with("anvil"),
|
||||
);
|
||||
let uuid = self.uuid_for(id);
|
||||
let falling = self
|
||||
.falling
|
||||
.iter_mut()
|
||||
.find(|f| f.id == id)
|
||||
.ok_or_else(stale)?;
|
||||
falling.state = state;
|
||||
falling.position = position;
|
||||
falling.previous = position;
|
||||
falling.velocity = velocity;
|
||||
falling.rotation = entity_nbt::rotation(&tag);
|
||||
falling.age = age;
|
||||
falling.no_gravity = no_gravity;
|
||||
falling.drop_item = drop_item;
|
||||
falling.fall_distance = fall_distance;
|
||||
falling.damage = damage;
|
||||
// Keep id/join, start_pos, portal state and stuck multiplier.
|
||||
set_key(&mut tag, "UUID", Tag::IntArray(uuid.to_vec()));
|
||||
set_key(
|
||||
&mut tag,
|
||||
"id",
|
||||
Tag::String("minecraft:falling_block".into()),
|
||||
);
|
||||
falling.saved = Some(Box::new((uuid, tag.clone())));
|
||||
}
|
||||
Target::Tnt(id) => {
|
||||
// Entity.load and PrimedTnt.readAdditionalSaveData mutate the
|
||||
// resident instance; load_tnt would allocate another id/join.
|
||||
@@ -534,6 +582,7 @@ impl Game<'_> {
|
||||
| Target::Boat(id)
|
||||
| Target::Cart(id)
|
||||
| Target::Tnt(id)
|
||||
| Target::Cushion(id)
|
||||
| Target::Other(id)
|
||||
| Target::Fangs(id) = t
|
||||
{
|
||||
@@ -570,6 +619,7 @@ impl Game<'_> {
|
||||
// `tags_of` reads a primed TNT's compound; without this arm
|
||||
// `/tag` wrote nothing and still reported success.
|
||||
| Target::Tnt(id)
|
||||
| Target::Cushion(id)
|
||||
| Target::Orb(id)
|
||||
| Target::Projectile(id)
|
||||
| Target::Crystal(id)
|
||||
@@ -617,6 +667,25 @@ impl Game<'_> {
|
||||
.comparator_outputs
|
||||
.get(&pos)
|
||||
.copied()
|
||||
.or_else(|| {
|
||||
let saved = self
|
||||
.world
|
||||
.chunks
|
||||
.get(&(p[0] >> 4, p[2] >> 4))?
|
||||
.saved
|
||||
.as_deref()?;
|
||||
let tag = saved.block_entities.iter().find(|t| {
|
||||
[t.get("x"), t.get("y"), t.get("z")]
|
||||
.map(|v| save::number(v).unwrap_or(0) as i32)
|
||||
== p
|
||||
&& save::text(t.get("id")).as_deref() == Some("minecraft:comparator")
|
||||
})?;
|
||||
let tag = horizon_client::block_entity_nbt::canonical(tag)?;
|
||||
match tag.get("OutputSignal") {
|
||||
Some(Tag::Int(v)) => Some(*v),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.unwrap_or(0);
|
||||
Some(Tag::compound([
|
||||
("id", Tag::String("minecraft:comparator".into())),
|
||||
@@ -722,6 +791,13 @@ impl Game<'_> {
|
||||
let name = self.data_block_name(p);
|
||||
let e = self.world.block_entities.get(&key)?;
|
||||
let mut tag = save::block_entity_data(key, e, &name, self.world.block_lock(key));
|
||||
if name == "minecraft:hopper" {
|
||||
set_key(
|
||||
&mut tag,
|
||||
"TransferCooldown",
|
||||
Tag::Int(self.hopper_cooldown(key)),
|
||||
);
|
||||
}
|
||||
if let (Some(custom), Tag::Compound(map)) = (self.world.block_names.get(&key), &mut tag) {
|
||||
map.entry("CustomName".into())
|
||||
.or_insert_with(|| Tag::String(custom.as_str().into()));
|
||||
@@ -762,6 +838,10 @@ impl Game<'_> {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some((_, mut new)) = save::decode_block_entity(&tag) {
|
||||
if self.data_block_name(*p) == "minecraft:hopper" {
|
||||
self.hoppers
|
||||
.insert(key, super::super::machines::HopperState::load(&tag));
|
||||
}
|
||||
self.take_loot_table(key, &tag, &mut new);
|
||||
self.load_block_name(key, &new, &tag);
|
||||
if let (Some(BlockEntity::Mod(current)), BlockEntity::Mod(loaded)) =
|
||||
@@ -1220,6 +1300,8 @@ impl Game<'_> {
|
||||
Some(Target::Other(id))
|
||||
} else if self.tnts.iter().any(|t| t.id == id) {
|
||||
Some(Target::Tnt(id))
|
||||
} else if self.cushions.iter().any(|c| c.id == id) {
|
||||
Some(Target::Cushion(id))
|
||||
} else if self.falling.iter().any(|f| f.id == id) {
|
||||
Some(Target::Falling(id))
|
||||
} else if self.rockets.iter().any(|r| r.id == id) {
|
||||
@@ -1813,3 +1895,147 @@ mod modern_literal_tests {
|
||||
assert!(differences.is_empty(), "{}", differences.join("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod falling_live_adoption_tests {
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, commands::Target, load_resources};
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_core::{
|
||||
geometry::Vec3,
|
||||
position::{BlockPos, ChunkPos},
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::sync::Arc;
|
||||
fn with_game(test: impl FnOnce(&mut Game<'_>)) {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.game_dir = Some(
|
||||
std::path::PathBuf::from(std::env::var_os("HORIZON_HOME").unwrap())
|
||||
.join(format!("h098-{:?}", std::thread::current().id())),
|
||||
);
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
|
||||
game.world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
assert!(game.run_command("difficulty normal"));
|
||||
if !game.streaming.forced.contains(&(0, 0)) {
|
||||
assert!(game.run_command("forceload add 0 0"));
|
||||
}
|
||||
test(&mut game);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn commands_mutate_the_resident_falling_model_without_rejoining() {
|
||||
with_game(|game| {
|
||||
let uuid = "000003d4-0000-0000-0000-000000000001";
|
||||
assert!(game.run_command("summon falling_block 4.5 200 5.5 {UUID:[I;980,0,0,1],BlockState:\"minecraft:gravel\",Time:5,DropItem:1b,NoGravity:1b,Motion:[0d,0d,0d],Tags:[keep],Silent:1b}"));
|
||||
assert!(game.run_command(&format!(
|
||||
"execute as {uuid} if entity @s[type=falling_block]"
|
||||
)));
|
||||
let index = game
|
||||
.falling
|
||||
.iter()
|
||||
.position(|f| game.persist.uuid_of(f.id) == Some([980, 0, 0, 1]))
|
||||
.unwrap();
|
||||
game.falling[index].start_pos = BlockPos::new(9, 201, 11);
|
||||
game.falling[index].stuck = Vec3::new(0.2, 0.3, 0.4);
|
||||
game.falling[index].portal.cooldown = 17;
|
||||
game.falling[index].portal.process = Some(horizon_client::nether_portal::Process {
|
||||
entry: BlockPos::new(8, 199, 8),
|
||||
time: 7,
|
||||
inside: true,
|
||||
});
|
||||
let before = game.falling[index].clone();
|
||||
assert!(before.join.is_joined());
|
||||
assert_eq!((before.age, before.drop_item), (5, true));
|
||||
let next_id = game.next_entity;
|
||||
let clock = game.world.game_time;
|
||||
assert!(!game.run_command(&format!("data merge entity {uuid} {{Time:5}}")));
|
||||
assert!(
|
||||
game.run_command(&format!("data merge entity {uuid} {{Time:12}}")),
|
||||
"valid Time update must reach model"
|
||||
);
|
||||
assert_eq!(game.falling[index].age, 12);
|
||||
assert!(game.run_command(&format!("data merge entity {uuid} {{DropItem:0b}}")));
|
||||
assert!(!game.falling[index].drop_item);
|
||||
let f = &game.falling[index];
|
||||
assert_eq!(
|
||||
(f.id, f.join, f.start_pos, f.stuck),
|
||||
(before.id, before.join, before.start_pos, before.stuck)
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
f.position,
|
||||
f.previous,
|
||||
f.velocity,
|
||||
f.rotation,
|
||||
f.state,
|
||||
f.no_gravity,
|
||||
f.damage,
|
||||
f.fall_distance
|
||||
),
|
||||
(
|
||||
before.position,
|
||||
before.position,
|
||||
before.velocity,
|
||||
before.rotation,
|
||||
before.state,
|
||||
before.no_gravity,
|
||||
before.damage,
|
||||
before.fall_distance
|
||||
)
|
||||
);
|
||||
assert_eq!(f.portal.cooldown, before.portal.cooldown);
|
||||
let (a, b) = (f.portal.process.unwrap(), before.portal.process.unwrap());
|
||||
assert_eq!((a.entry, a.time, a.inside), (b.entry, b.time, b.inside));
|
||||
assert_eq!(f.saved.as_ref().unwrap().0, [980, 0, 0, 1]);
|
||||
assert_eq!(game.persist.uuid_of(f.id), Some([980, 0, 0, 1]));
|
||||
let saved = game.entity_nbt(Target::Falling(f.id)).unwrap();
|
||||
assert_eq!(saved.get("Time"), Some(&Tag::Int(12)));
|
||||
assert_eq!(saved.get("DropItem"), Some(&Tag::Byte(0)));
|
||||
assert_eq!(
|
||||
saved.get("BlockState"),
|
||||
Some(&Tag::String("minecraft:gravel".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
saved.get("Tags"),
|
||||
Some(&Tag::List(vec![Tag::String("keep".into())]))
|
||||
);
|
||||
assert_eq!(saved.get("Silent"), Some(&Tag::Byte(1)));
|
||||
assert_eq!(game.falling.len(), 1);
|
||||
assert_eq!(game.next_entity, next_id);
|
||||
assert_eq!(game.world.game_time, clock);
|
||||
});
|
||||
}
|
||||
}
|
||||
Loaded 100 of 652 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user