mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-08 06:48:22 -10:00
Compare commits
586
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e06612e08 | ||
|
|
15c0246614 | ||
|
|
face534db3 | ||
|
|
0a6eca805d | ||
|
|
ed0be44325 | ||
|
|
97e46ce1d5 | ||
|
|
5669d66de3 | ||
|
|
f54fba8726 | ||
|
|
f86b98b203 | ||
|
|
9842e75046 | ||
|
|
99956fff22 | ||
|
|
b23ad347d1 | ||
|
|
350e156a34 | ||
|
|
502c15aba6 | ||
|
|
147980da63 | ||
|
|
97e99a45b4 | ||
|
|
37cda82c81 | ||
|
|
c76ed64570 | ||
|
|
f63ad014e1 | ||
|
|
7b33d702f5 | ||
|
|
dfae932fec | ||
|
|
bf3a5f162f | ||
|
|
14fc6d55e0 | ||
|
|
4e6a8131c2 | ||
|
|
01451da341 | ||
|
|
179ebb840a | ||
|
|
fca63b5eb8 | ||
|
|
85d56215e6 | ||
|
|
b13dbf0687 | ||
|
|
0cdb230b54 | ||
|
|
6e919e4356 | ||
|
|
92cf9ac888 | ||
|
|
0961939b71 | ||
|
|
2cd05e462e | ||
|
|
541f331f1e | ||
|
|
973287971d | ||
|
|
7648d874b9 | ||
|
|
b91f5f5892 | ||
|
|
f159a106a8 | ||
|
|
c805ee70c5 | ||
|
|
14bfb672f8 | ||
|
|
43832ec794 | ||
|
|
bc6016d19d | ||
|
|
40e3f368da | ||
|
|
b399f0ddc5 | ||
|
|
a3fd3597f4 | ||
|
|
1d9f455dac | ||
|
|
f97dffe988 | ||
|
|
fee1085bec | ||
|
|
272518c1f2 | ||
|
|
b95e34a990 | ||
|
|
e86ce41888 | ||
|
|
458f0e4134 | ||
|
|
29b3374b5d | ||
|
|
02042bf6b4 | ||
|
|
79e277451b | ||
|
|
e5c1ea0612 | ||
|
|
2f39e246fe | ||
|
|
2cd9e12ecd | ||
|
|
6d32a88a50 | ||
|
|
124e88e731 | ||
|
|
1a004614f8 | ||
|
|
1edf540479 | ||
|
|
9bccc1056f | ||
|
|
d144af4a11 | ||
|
|
616707f3d7 | ||
|
|
1c5744ac24 | ||
|
|
c124718cbc | ||
|
|
0b8f18dd40 | ||
|
|
e51c277db7 | ||
|
|
fc61e4e9ff | ||
|
|
2c300290d5 | ||
|
|
16260920c3 | ||
|
|
d84428c781 | ||
|
|
e5f23f8b40 | ||
|
|
17e4124d04 | ||
|
|
37eafb6c1b | ||
|
|
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 | ||
|
|
c56e9312ee | ||
|
|
9c0c0c7210 | ||
|
|
671a85a7f2 | ||
|
|
63a8290dcb | ||
|
|
fbd9b2ce23 | ||
|
|
e290e2c196 | ||
|
|
5671fc9619 | ||
|
|
436e839ebf | ||
|
|
860f3f05d5 | ||
|
|
88d7c0eb2c | ||
|
|
2cf505bbd7 | ||
|
|
cd218ea58e | ||
|
|
161c4e5677 | ||
|
|
a6dc594cdf | ||
|
|
013c3952a7 | ||
|
|
5365f2be63 | ||
|
|
0f23639844 | ||
|
|
997fe4515d | ||
|
|
5d16154c0b | ||
|
|
47e562e8b9 | ||
|
|
74b8550421 | ||
|
|
9f52bf9a83 | ||
|
|
54c3a21b29 | ||
|
|
d6f0b3042f | ||
|
|
0422dbc79f | ||
|
|
914c1e9a3b | ||
|
|
62dd244cb6 | ||
|
|
0e446e4336 | ||
|
|
48344d0a6b | ||
|
|
87d4a87960 | ||
|
|
cf0b0d02c8 | ||
|
|
1b93764780 | ||
|
|
52aad2205d | ||
|
|
9bc110bae6 | ||
|
|
83535ddcc1 | ||
|
|
2c672eae2d | ||
|
|
796047f1e8 | ||
|
|
2dbcb75cfa | ||
|
|
34f528ffcb | ||
|
|
7f6f26a33a | ||
|
|
3d8924dcc8 | ||
|
|
8ad82f9ac0 | ||
|
|
77290ac3c1 | ||
|
|
daa43de927 | ||
|
|
3fb98b187f | ||
|
|
76db917ded | ||
|
|
1ba320ca73 | ||
|
|
37b79d1b63 | ||
|
|
702b0895bd | ||
|
|
4465238c9c | ||
|
|
43943df1ef | ||
|
|
8906102eb9 | ||
|
|
0a51d6dc5c | ||
|
|
21ca2fdd1d | ||
|
|
89a515a8d7 | ||
|
|
a0f9368edf | ||
|
|
1f86e14b9a | ||
|
|
5ec521bb7b | ||
|
|
80bbced3fc | ||
|
|
c30b863128 | ||
|
|
690d434afa | ||
|
|
f57811dd0d | ||
|
|
73e054f827 | ||
|
|
e1434f2ee4 | ||
|
|
67a39ecaac | ||
|
|
199d9d74f8 | ||
|
|
1005076a59 | ||
|
|
8189734106 | ||
|
|
1f29c88a49 | ||
|
|
bf0f0dd811 | ||
|
|
007458a627 | ||
|
|
9c30028167 | ||
|
|
0d49b282c0 | ||
|
|
099fb70e6a | ||
|
|
c7aef39dd5 | ||
|
|
d7c3fefcfa | ||
|
|
8b48b0d74e | ||
|
|
5664a950e8 | ||
|
|
538422f3c7 | ||
|
|
80e3532c4a | ||
|
|
b528247824 | ||
|
|
e8da3a8e21 | ||
|
|
3004b64927 | ||
|
|
abdf311e3b | ||
|
|
f96e0fba0f | ||
|
|
653a4a99fe | ||
|
|
65c35da0dc | ||
|
|
772088790c | ||
|
|
ad475b0573 | ||
|
|
aa019cfaa4 | ||
|
|
dd0050e268 | ||
|
|
9e962f6350 | ||
|
|
78ea4b6d02 | ||
|
|
56a578f0a6 | ||
|
|
596fda5618 | ||
|
|
46b257b1d5 | ||
|
|
8b0c8e40a6 | ||
|
|
d980cd810d | ||
|
|
196bea6f1a | ||
|
|
cdabdddb6b | ||
|
|
e5fe17622c | ||
|
|
78efabcfa7 | ||
|
|
8d783c0ff0 | ||
|
|
cbe9ddfa3f | ||
|
|
749c5405f6 | ||
|
|
bc8912c13f | ||
|
|
40ad3a91c9 | ||
|
|
114b09babe | ||
|
|
0a85491338 | ||
|
|
5f2e3cb14d | ||
|
|
a61d272adc | ||
|
|
d607c2f137 | ||
|
|
e54dcc07cf | ||
|
|
6079903ffc | ||
|
|
3a7f276eab | ||
|
|
beaad0e79d | ||
|
|
ab37cfb5aa | ||
|
|
92b2977e3b | ||
|
|
f178cbe003 | ||
|
|
0478b4deec | ||
|
|
b5a1d40dc6 | ||
|
|
a61825404a | ||
|
|
1a9c87059b | ||
|
|
4d8e70da2c | ||
|
|
8efbe1c75e | ||
|
|
df25b701c5 | ||
|
|
3890e57c13 | ||
|
|
2282116773 | ||
|
|
c74ff0070b | ||
|
|
af13271b86 | ||
|
|
0df4792709 | ||
|
|
51eae8d435 | ||
|
|
7142128a7a | ||
|
|
81df2a1864 | ||
|
|
edb550ac32 | ||
|
|
f2e081520f | ||
|
|
f1ea540f08 | ||
|
|
aa2220fcf6 | ||
|
|
12dd25d421 | ||
|
|
c6c0a97e57 | ||
|
|
edab7b313f | ||
|
|
d94fdae0b9 | ||
|
|
4924374fa5 | ||
|
|
d1dd934be2 | ||
|
|
b86fa679b9 | ||
|
|
45da47c08b | ||
|
|
04449d8b73 | ||
|
|
da0b4899ff | ||
|
|
50b1d6232e | ||
|
|
706cf3b3fa | ||
|
|
439b609a94 | ||
|
|
537291b26d | ||
|
|
a95c0e022d | ||
|
|
5ee3b9da9a | ||
|
|
307d82bb63 | ||
|
|
1e38170d49 | ||
|
|
b0600cec3c | ||
|
|
c8753a2ea4 | ||
|
|
2f3743d282 | ||
|
|
3fc5044868 | ||
|
|
e44fc5af01 | ||
|
|
1eb3b7f6c3 | ||
|
|
43e106cece | ||
|
|
1c8113ce32 | ||
|
|
4b5b667560 | ||
|
|
f898655ae6 | ||
|
|
73fa431ac5 | ||
|
|
e135804a52 | ||
|
|
36add718c0 | ||
|
|
077164ff2f | ||
|
|
c953fe79cb | ||
|
|
92282c7476 | ||
|
|
6689a656a0 | ||
|
|
fc64f0ee27 | ||
|
|
052c2eb962 | ||
|
|
4a20af007c | ||
|
|
aadec58092 | ||
|
|
a90c5f8179 | ||
|
|
c1e649f61b | ||
|
|
fdc056c8ac | ||
|
|
eff292b1e2 | ||
|
|
a6b863754b | ||
|
|
04b8ed64ca | ||
|
|
1c81831b12 | ||
|
|
547c7decbc | ||
|
|
e8a5564059 | ||
|
|
70a0a29eec | ||
|
|
5cffcf1370 | ||
|
|
a52b9e48f3 | ||
|
|
21a27ca73e | ||
|
|
17e7655cdf | ||
|
|
f8ab91d422 | ||
|
|
6b72630759 | ||
|
|
d4515b62ec | ||
|
|
4583362634 | ||
|
|
d00cf662b1 | ||
|
|
6deb10741a | ||
|
|
7770124ea0 | ||
|
|
78ab0c3b39 | ||
|
|
7c7d13c458 | ||
|
|
ed7114b4a0 | ||
|
|
acbb98aa5c | ||
|
|
385877d1fc | ||
|
|
771ccd721f | ||
|
|
b9e272b1ab | ||
|
|
8fe6762146 | ||
|
|
ff28824d42 | ||
|
|
45978aaf67 | ||
|
|
24fa04d753 | ||
|
|
1429b27098 | ||
|
|
c78882c7da | ||
|
|
40dd7109bb | ||
|
|
1db1b34dcb | ||
|
|
d30b514728 | ||
|
|
6993f213b6 | ||
|
|
511771b9f9 | ||
|
|
344382682e | ||
|
|
bb22bf6570 | ||
|
|
edef044a68 | ||
|
|
c7f54b7461 | ||
|
|
47fd58c339 | ||
|
|
88a75f983b | ||
|
|
dbd5775947 | ||
|
|
199e8193fd | ||
|
|
5a65831d4a | ||
|
|
64f9bb1a61 | ||
|
|
991983679c | ||
|
|
c2b59b6f99 | ||
|
|
b844b69826 | ||
|
|
c2386a4616 | ||
|
|
c524b40874 | ||
|
|
d53411a24e | ||
|
|
db503217e3 | ||
|
|
dddf61b000 | ||
|
|
48986d7fd8 | ||
|
|
7746c0ef78 | ||
|
|
fbbe20dc9c | ||
|
|
b9928751e4 | ||
|
|
ed98ebc8a1 | ||
|
|
55a54edebe | ||
|
|
c0ca0cce68 | ||
|
|
984f2d9f06 | ||
|
|
3ed97c7339 | ||
|
|
3f1b851c07 | ||
|
|
aa7516532a | ||
|
|
c9f2efa210 | ||
|
|
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 | ||
|
|
8e0b157254 | ||
|
|
4bb27d7c90 | ||
|
|
5e90d9f10b | ||
|
|
faa8a4a52a | ||
|
|
6c0de3509d | ||
|
|
70ac0a01b8 | ||
|
|
85a5c9f340 | ||
|
|
4aaf71dc39 | ||
|
|
6a3359d943 | ||
|
|
6397e50017 | ||
|
|
fe36131256 | ||
|
|
50467de40e | ||
|
|
a7f5e08990 | ||
|
|
d4d2214cfb | ||
|
|
8368732e4e | ||
|
|
e8edf93a48 | ||
|
|
dce6093276 | ||
|
|
b8a6663a3a | ||
|
|
05a81f37b5 | ||
|
|
1bc23d7a17 | ||
|
|
e297f7245b | ||
|
|
640dd0bcf5 | ||
|
|
175fbe9e52 | ||
|
|
fde182860f | ||
|
|
3fc0eb473e | ||
|
|
f9eb279790 | ||
|
|
69ca4f94b7 | ||
|
|
b322275555 | ||
|
|
b8d3a3b132 | ||
|
|
d689881e68 | ||
|
|
03c5e6b091 | ||
|
|
f4cdc0873c | ||
|
|
f802d6cc2c | ||
|
|
29a511747d | ||
|
|
3afa8e234b | ||
|
|
efe0b28747 | ||
|
|
c7d9d1aedd | ||
|
|
6c6efb8d74 | ||
|
|
fc711c3f6d | ||
|
|
945f40e8ff | ||
|
|
4aeb14b3ba | ||
|
|
b9854b1d82 | ||
|
|
f3fcb0ca25 | ||
|
|
84f6959921 | ||
|
|
f2c9af4827 | ||
|
|
0de6eeb024 | ||
|
|
3aadba6aba | ||
|
|
8c991c7472 | ||
|
|
593e9e05cc | ||
|
|
e03f69d607 | ||
|
|
eadca0ce30 | ||
|
|
3e88f5909b | ||
|
|
d64be662fe | ||
|
|
3b3f6aa00f | ||
|
|
e2268eb854 | ||
|
|
8b9e8417af | ||
|
|
1426129516 | ||
|
|
a932bc6c86 | ||
|
|
2aa9308e47 | ||
|
|
23c572b374 | ||
|
|
8158bcae73 | ||
|
|
62724ffccf | ||
|
|
52105d2c9b | ||
|
|
9782763e82 | ||
|
|
2eb6044dfc | ||
|
|
dd47001112 | ||
|
|
6564896c45 | ||
|
|
b775c66123 | ||
|
|
e3295a9974 | ||
|
|
8554155932 | ||
|
|
d114d2ef89 | ||
|
|
30a3b019ff | ||
|
|
4807f13288 | ||
|
|
1d72cf2ed8 | ||
|
|
5b9fe567ec | ||
|
|
9558ecf848 | ||
|
|
d6802414fa | ||
|
|
a7d2edbfb9 | ||
|
|
5d44519d5b | ||
|
|
7914cfe741 | ||
|
|
c3326aed5e | ||
|
|
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 | ||
|
|
ed8eaed845 | ||
|
|
0a6d5eb0a7 | ||
|
|
ef6f7a2170 | ||
|
|
425b65a99f | ||
|
|
a222a7e47a | ||
|
|
c4dcf2d2c8 | ||
|
|
be7591c70b | ||
|
|
be3fbc2d2b | ||
|
|
7af3e1ec44 | ||
|
|
79fe067a7e | ||
|
|
cb62d074db | ||
|
|
796db06bf8 | ||
|
|
177548d416 | ||
|
|
5ca811b47a | ||
|
|
7c32d1c4d6 | ||
|
|
2ce4e8da11 | ||
|
|
3f1af338cb | ||
|
|
786bd54096 | ||
|
|
16cdc3afd9 | ||
|
|
69d388deb0 | ||
|
|
0855321d41 | ||
|
|
db6a8b7f44 | ||
|
|
3443abbde5 | ||
|
|
93365662aa | ||
|
|
751423b897 | ||
|
|
219a56f260 |
No files matched your search
@@ -46,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
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,21 @@ thread_local! {
|
||||
}
|
||||
/// The current language's `bidirectional`, read for every drawn string.
|
||||
static BIDIRECTIONAL: AtomicBool = AtomicBool::new(false);
|
||||
/// Counts the languages set (`set`): another count is another language, so text looked up
|
||||
/// before it may read otherwise.
|
||||
static GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
thread_local! {
|
||||
/// Counts the scopes this thread entered and left (`scoped`), which no other thread sees.
|
||||
static SCOPE_GENERATION: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
/// Which language text looks up in now, on this thread, as a count that changes whenever that
|
||||
/// can: both counts only grow, so their sum changes when either does.
|
||||
pub fn generation() -> u64 {
|
||||
GENERATION.load(Ordering::Relaxed) + SCOPE_GENERATION.get()
|
||||
}
|
||||
fn scope_changed() {
|
||||
SCOPE_GENERATION.set(SCOPE_GENERATION.get() + 1);
|
||||
}
|
||||
|
||||
/// A source of resource files: the prepared root plus packs, highest priority last.
|
||||
pub trait ResourceSource {
|
||||
@@ -524,6 +539,7 @@ pub fn available(sources: &[&dyn ResourceSource]) -> Vec<LanguageInfo> {
|
||||
pub fn set(language: Language) {
|
||||
BIDIRECTIONAL.store(language.bidirectional, Ordering::Relaxed);
|
||||
*CURRENT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(language));
|
||||
GENERATION.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
pub fn current() -> Option<Arc<Language>> {
|
||||
if let Some(language) = SCOPE.with(|s| s.borrow().as_ref().map(|s| s.language.clone())) {
|
||||
@@ -543,6 +559,7 @@ pub fn scoped<R>(language: Language, f: impl FnOnce() -> R) -> (R, Vec<String>)
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
SCOPE.with(|s| *s.borrow_mut() = self.0.take());
|
||||
scope_changed();
|
||||
}
|
||||
}
|
||||
let outer = SCOPE.with(|s| {
|
||||
@@ -552,6 +569,7 @@ pub fn scoped<R>(language: Language, f: impl FnOnce() -> R) -> (R, Vec<String>)
|
||||
})
|
||||
});
|
||||
let _restore = Restore(outer);
|
||||
scope_changed();
|
||||
let result = f();
|
||||
let missed = SCOPE.with(|s| {
|
||||
s.borrow_mut()
|
||||
|
||||
@@ -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<()>| {
|
||||
|
||||
@@ -47,6 +47,53 @@ struct Channel {
|
||||
world: u32,
|
||||
/// The sound file it started and when, to tell another player's copy of it.
|
||||
started: Option<(String, std::time::Instant)>,
|
||||
/// Its sound event plays clearly through a mod's muffle (`Audio::muffle`).
|
||||
unmuffled: bool,
|
||||
}
|
||||
/// The cutoff of the muffle's low-pass at full strength: the world heard through ringing ears.
|
||||
const MUFFLED_CUTOFF: f32 = 300.0;
|
||||
/// The cutoff it sweeps down from as its strength rises.
|
||||
const OPEN_CUTOFF: f32 = 20_000.0;
|
||||
/// How long the muffle takes to glide to a new level: a game tick's step (50 ms) would click.
|
||||
const MUFFLE_GLIDE: f32 = 0.02;
|
||||
/// A mod's muffle (`Audio::muffle`) in the mixer: the other sounds are mixed apart, low-passed by
|
||||
/// two one-pole stages and turned down, then added to the sounds it leaves clear. While it is off
|
||||
/// (never set, or faded out) the sounds mix straight into the output, as without it.
|
||||
#[derive(Default)]
|
||||
struct Muffle {
|
||||
/// `[amount, lowpass]`, each 0 to 1, as the game's tick last set them.
|
||||
target: [f32; 2],
|
||||
/// Where the output is now, gliding to `target`.
|
||||
current: [f32; 2],
|
||||
/// The low-pass stages' state for the left and right output channels.
|
||||
stages: [[f32; 2]; 2],
|
||||
/// The muffled sounds of one buffer, reused.
|
||||
bus: Vec<f32>,
|
||||
}
|
||||
impl Muffle {
|
||||
fn active(&self) -> bool {
|
||||
self.target != [0.0; 2] || self.current.iter().any(|v| *v > 1e-4)
|
||||
}
|
||||
/// Adds `bus` (the muffled sounds) to `data`, low-passed and turned down.
|
||||
fn apply(&mut self, bus: &[f32], data: &mut [f32], channels: usize, rate: u32) {
|
||||
let rate = rate as f32;
|
||||
let glide = 1.0 - (-1.0 / (MUFFLE_GLIDE * rate)).exp();
|
||||
for (frame, muffled) in data.chunks_mut(channels).zip(bus.chunks(channels)) {
|
||||
for (current, target) in self.current.iter_mut().zip(self.target) {
|
||||
*current += (target - *current) * glide;
|
||||
}
|
||||
let [amount, lowpass] = self.current;
|
||||
let gain = 1.0 - amount;
|
||||
let cutoff = OPEN_CUTOFF * (MUFFLED_CUTOFF / OPEN_CUTOFF).powf(lowpass);
|
||||
let a = 1.0 - (-std::f32::consts::TAU * cutoff / rate).exp();
|
||||
for ((out, s), stage) in frame.iter_mut().zip(muffled).zip(&mut self.stages) {
|
||||
stage[0] += (s - stage[0]) * a;
|
||||
stage[1] += (stage[0] - stage[1]) * a;
|
||||
// Dry at no strength, so a muffle that only turns sounds down leaves them as they were.
|
||||
*out += (s + (stage[1] - s) * lowpass) * gain;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Mixer {
|
||||
@@ -65,6 +112,8 @@ struct Mixer {
|
||||
cameras: Vec<([f64; 3], Basis, u32)>,
|
||||
/// Per-source gain/pan/step cache for one buffer, reused across callbacks.
|
||||
voices: Vec<(ChannelId, [f32; 3], f64)>,
|
||||
/// A mod's muffle of the sounds it does not exempt.
|
||||
muffle: Muffle,
|
||||
limiter_peak: f32,
|
||||
/// The output device the stream was opened on (`Library.currentDeviceName`).
|
||||
#[cfg(feature = "audio")]
|
||||
@@ -100,6 +149,21 @@ pub struct Heard {
|
||||
pub range: f32,
|
||||
pub looping: bool,
|
||||
}
|
||||
/// A mod's muffle (`Audio::muffle`): its full strength, and the ticks it fades over and has left.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct MuffleFade {
|
||||
amount: f32,
|
||||
lowpass: f32,
|
||||
ticks: u32,
|
||||
left: u32,
|
||||
}
|
||||
impl MuffleFade {
|
||||
/// `[amount, lowpass]` now: full when set, falling in a straight line to none.
|
||||
fn level(&self) -> [f32; 2] {
|
||||
let left = self.left as f32 / self.ticks as f32;
|
||||
[self.amount * left, self.lowpass * left]
|
||||
}
|
||||
}
|
||||
/// The mixer side of the engine (`Backend`).
|
||||
pub struct MixerBackend {
|
||||
mixer: Arc<Mutex<Mixer>>,
|
||||
@@ -247,6 +311,8 @@ pub struct Audio {
|
||||
pub stopped: std::collections::BTreeMap<String, u32>,
|
||||
/// The volume sliders' `SoundPreviewHandler`.
|
||||
preview: crate::sound_preview::SoundPreview,
|
||||
/// A mod's muffle fading out (`muffle`), advanced by `tick`.
|
||||
muffle: Option<MuffleFade>,
|
||||
/// `SoundEngine.lastSeenDevices`: the system default output last seen.
|
||||
#[cfg(feature = "audio")]
|
||||
default_seen: Option<String>,
|
||||
@@ -439,6 +505,7 @@ impl Backend for MixerBackend {
|
||||
owner: self.owner,
|
||||
world: self.world,
|
||||
started: None,
|
||||
unmuffled: false,
|
||||
},
|
||||
);
|
||||
Some(id)
|
||||
@@ -466,6 +533,9 @@ impl Backend for MixerBackend {
|
||||
fn set_relative(&mut self, channel: ChannelId, relative: bool) {
|
||||
self.with(channel, |c| c.relative = relative);
|
||||
}
|
||||
fn set_unmuffled(&mut self, channel: ChannelId, unmuffled: bool) {
|
||||
self.with(channel, |c| c.unmuffled = unmuffled);
|
||||
}
|
||||
/// `getCompleteBuffer` (cached, else decoded on the decoder thread) or
|
||||
/// `getStream` (decoded on a thread of its own), then attach and
|
||||
/// `alSourcePlay`. A sound that cannot load leaves its source initial,
|
||||
@@ -702,6 +772,13 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0));
|
||||
voices.push((*id, [left, right, gain], step));
|
||||
}
|
||||
// A mod's muffle: the sounds it doesn't exempt mix into a bus of their own first.
|
||||
let muffling = mixer.muffle.active();
|
||||
let mut bus = std::mem::take(&mut mixer.muffle.bus);
|
||||
if muffling {
|
||||
bus.clear();
|
||||
bus.resize(data.len(), 0.0);
|
||||
}
|
||||
for (id, [left, right, gain], step) in voices.iter().copied() {
|
||||
let Some(c) = mixer.channels.get_mut(&id) else {
|
||||
continue;
|
||||
@@ -711,7 +788,12 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
};
|
||||
let source_channels = source_channels as usize;
|
||||
let frames = samples.len() / source_channels;
|
||||
for frame in data.chunks_mut(channels) {
|
||||
let out: &mut [f32] = if muffling && !c.unmuffled {
|
||||
&mut bus
|
||||
} else {
|
||||
&mut *data
|
||||
};
|
||||
for frame in out.chunks_mut(channels) {
|
||||
let mut i = c.position as usize;
|
||||
if i + 1 >= frames {
|
||||
if c.looping && frames > 1 {
|
||||
@@ -746,6 +828,14 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
}
|
||||
}
|
||||
mixer.voices = voices;
|
||||
if muffling {
|
||||
mixer.muffle.apply(&bus, data, channels, rate);
|
||||
} else {
|
||||
// Off: the next muffle starts from silence, not from the last one's filter.
|
||||
mixer.muffle.current = [0.0; 2];
|
||||
mixer.muffle.stages = [[0.0; 2]; 2];
|
||||
}
|
||||
mixer.muffle.bus = bus;
|
||||
// Limit the whole buffer together, preserving waveform and stereo balance
|
||||
// instead of flattening each overloaded sample; release over 200 ms
|
||||
// (OpenAL Soft's output limiter, `ALC_OUTPUT_LIMITER_SOFT`, releases
|
||||
@@ -1071,12 +1161,13 @@ 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(),
|
||||
stopped: Default::default(),
|
||||
preview: Default::default(),
|
||||
muffle: None,
|
||||
#[cfg(feature = "audio")]
|
||||
default_seen: if live { default_output_name() } else { None },
|
||||
#[cfg(feature = "audio")]
|
||||
@@ -1232,12 +1323,13 @@ 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(),
|
||||
stopped: Default::default(),
|
||||
preview: Default::default(),
|
||||
muffle: None,
|
||||
// The device belongs to the first player's output, which looks after it.
|
||||
#[cfg(feature = "audio")]
|
||||
default_seen: self.default_seen.clone(),
|
||||
@@ -1370,6 +1462,50 @@ impl Audio {
|
||||
}
|
||||
self.sync_volumes();
|
||||
self.engine.tick(paused, ctx, &mut self.backend);
|
||||
// A paused game holds a mod's muffle where it is, as it holds the sounds.
|
||||
if !paused && let Some(fade) = &mut self.muffle {
|
||||
fade.left = fade.left.saturating_sub(1);
|
||||
let level = fade.level();
|
||||
if fade.left == 0 {
|
||||
self.muffle = None;
|
||||
self.engine.set_unmuffled(Vec::new(), &mut self.backend);
|
||||
}
|
||||
self.set_muffle_level(level);
|
||||
}
|
||||
}
|
||||
/// A mod's muffle (`native_muffle_sounds`): `amount` (0 to 1) of every sound's volume taken
|
||||
/// away and `lowpass` (0 to 1) dulling it, from untouched to a 300 Hz low-pass, fading in a
|
||||
/// straight line to none over `ticks` client ticks; the sound events in `exempt` (namespaced,
|
||||
/// `mymod:ear.ringing`) play clearly through it, those already playing too. It replaces any
|
||||
/// muffle in place; `ticks` 0 or no strength ends it. The output's, so in splitscreen every
|
||||
/// player hears it. False for an amount or strength outside 0 to 1.
|
||||
pub fn muffle(&mut self, amount: f32, lowpass: f32, ticks: u32, exempt: &[&str]) -> bool {
|
||||
if !(0.0..=1.0).contains(&amount) || !(0.0..=1.0).contains(&lowpass) {
|
||||
return false;
|
||||
}
|
||||
let on = ticks > 0 && (amount > 0.0 || lowpass > 0.0);
|
||||
let exempt = if on {
|
||||
exempt
|
||||
.iter()
|
||||
.map(|e| crate::sound_events::normalize(e))
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
self.engine.set_unmuffled(exempt, &mut self.backend);
|
||||
self.muffle = on.then_some(MuffleFade {
|
||||
amount,
|
||||
lowpass,
|
||||
ticks,
|
||||
left: ticks,
|
||||
});
|
||||
self.set_muffle_level(self.muffle.map_or([0.0; 2], |fade| fade.level()));
|
||||
true
|
||||
}
|
||||
fn set_muffle_level(&mut self, level: [f32; 2]) {
|
||||
if let Ok(mut m) = self.backend.mixer.lock() {
|
||||
m.muffle.target = level;
|
||||
}
|
||||
}
|
||||
/// The `uiSoundReserve` extra: positional sounds leave
|
||||
/// `sound_engine::HEARD_RESERVE` static channels free (off: vanilla's pool).
|
||||
@@ -1482,7 +1618,7 @@ impl Audio {
|
||||
"minecraft:ui.button.click",
|
||||
1.0,
|
||||
0.25,
|
||||
SoundEngine::unseeded(),
|
||||
crate::random_source::create(),
|
||||
),
|
||||
false,
|
||||
);
|
||||
@@ -1624,7 +1760,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.
|
||||
@@ -1835,6 +1971,7 @@ mod tests {
|
||||
owner: 0,
|
||||
world: 0,
|
||||
started: None,
|
||||
unmuffled: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2255,6 +2392,123 @@ mod tests {
|
||||
assert_eq!(data, [0.5; 2]);
|
||||
}
|
||||
|
||||
/// One second of a `hz` sine at 48 kHz, amplitude 0.3 (looped, it repeats seamlessly).
|
||||
fn sine(hz: f32) -> Vec<f32> {
|
||||
(0..48_000)
|
||||
.map(|i| 0.3 * (std::f32::consts::TAU * hz * i as f32 / 48_000.0).sin())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn rms(data: &[f32]) -> f32 {
|
||||
(data.iter().map(|s| s * s).sum::<f32>() / data.len() as f32).sqrt()
|
||||
}
|
||||
|
||||
/// `seconds` of the mixer's stereo output, in the 10 ms buffers an output stream asks for.
|
||||
fn listen(m: &mut Mixer, seconds: f32) -> Vec<f32> {
|
||||
let mut out = Vec::new();
|
||||
let mut buffer = [0.0; 960];
|
||||
for _ in 0..(seconds * 100.0).round() as usize {
|
||||
mix(m, &mut buffer, 2, 48_000);
|
||||
out.extend_from_slice(&buffer);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_muffle_dulls_and_quietens_every_sound_but_the_exempt_ones() {
|
||||
// A looping tone heard through a muffle of `[amount, lowpass]`, once it has settled.
|
||||
let heard = |hz: f32, muffle: [f32; 2], unmuffled: bool| {
|
||||
let mut m = Mixer::default();
|
||||
let mut c = channel(sine(hz), None, [0.0, 0.0, 2.0], true);
|
||||
c.unmuffled = unmuffled;
|
||||
m.channels.insert(1, c);
|
||||
m.muffle.target = muffle;
|
||||
listen(&mut m, 0.2);
|
||||
listen(&mut m, 0.2)
|
||||
};
|
||||
let plain = |hz: f32| rms(&heard(hz, [0.0; 2], false));
|
||||
// Fully low-passed, a 6 kHz whine all but goes and a 100 Hz hum keeps most of its level.
|
||||
assert!(rms(&heard(6000.0, [0.0, 1.0], false)) < plain(6000.0) * 0.05);
|
||||
assert!(rms(&heard(100.0, [0.0, 1.0], false)) > plain(100.0) * 0.8);
|
||||
// Half the volume taken and no low-pass: half the level, the tone itself untouched.
|
||||
let ducked = rms(&heard(6000.0, [0.5, 0.0], false)) / plain(6000.0);
|
||||
assert!((ducked - 0.5).abs() < 1e-3, "{ducked}");
|
||||
// An exempt sound plays through as if there were no muffle, sample for sample.
|
||||
assert_eq!(
|
||||
heard(6000.0, [0.5, 1.0], true),
|
||||
heard(6000.0, [0.0; 2], false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_faded_muffle_leaves_the_sound_as_it_was() {
|
||||
let mixer = || {
|
||||
let mut m = Mixer::default();
|
||||
m.channels
|
||||
.insert(1, channel(sine(3000.0), None, [2.0, 0.0, 1.0], true));
|
||||
m
|
||||
};
|
||||
let (mut muffled, mut plain) = (mixer(), mixer());
|
||||
muffled.muffle.target = [0.7, 1.0];
|
||||
assert_ne!(listen(&mut muffled, 0.1), listen(&mut plain, 0.1));
|
||||
muffled.muffle.target = [0.0; 2];
|
||||
listen(&mut muffled, 0.5);
|
||||
listen(&mut plain, 0.5);
|
||||
assert_eq!(listen(&mut muffled, 0.1), listen(&mut plain, 0.1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mods_muffle_fades_with_the_game_ticks_and_spares_its_exempt_sounds() {
|
||||
let mut audio = Audio::silent(Path::new("/nonexistent/horizon-test-assets"));
|
||||
audio.engine.registry.add_file(
|
||||
"minecraft",
|
||||
br#"{"ambient.cave":{"sounds":["ambient/cave/cave1"]}}"#,
|
||||
&|_, _| true,
|
||||
);
|
||||
audio.engine.registry.add_file(
|
||||
"hztest",
|
||||
br#"{"ear.ringing":{"sounds":["ear/ringing"]}}"#,
|
||||
&|_, _| true,
|
||||
);
|
||||
// The engine makes its channels; with no output, none loads a file.
|
||||
audio.engine.loaded = true;
|
||||
let marks = |audio: &Audio| {
|
||||
let m = audio.backend.mixer.lock().unwrap();
|
||||
let mut marks: Vec<_> = m
|
||||
.channels
|
||||
.iter()
|
||||
.map(|(&id, c)| (id, c.unmuffled))
|
||||
.collect();
|
||||
marks.sort();
|
||||
marks
|
||||
};
|
||||
let level = |audio: &Audio| audio.backend.mixer.lock().unwrap().muffle.target;
|
||||
let close =
|
||||
|a: [f32; 2], b: [f32; 2]| (a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6;
|
||||
// Two sounds already playing, and one exempt sound started under the muffle.
|
||||
audio.play("ambient.cave", None, 1.0, 1.0);
|
||||
audio.play("hztest:ear.ringing", None, 1.0, 1.0);
|
||||
assert!(audio.muffle(0.8, 1.0, 4, &["hztest:ear.ringing"]));
|
||||
audio.play("hztest:ear.ringing", None, 1.0, 1.0);
|
||||
assert_eq!(marks(&audio), [(1, false), (2, true), (3, true)]);
|
||||
assert!(close(level(&audio), [0.8, 1.0]));
|
||||
let ticks = TickContext::default();
|
||||
audio.tick(false, &ticks);
|
||||
assert!(close(level(&audio), [0.6, 0.75]), "{:?}", level(&audio));
|
||||
// A paused game holds it.
|
||||
audio.tick(true, &ticks);
|
||||
assert!(close(level(&audio), [0.6, 0.75]), "{:?}", level(&audio));
|
||||
for _ in 0..3 {
|
||||
audio.tick(false, &ticks);
|
||||
}
|
||||
assert_eq!(level(&audio), [0.0; 2]);
|
||||
// Over: its exemptions go with it.
|
||||
assert_eq!(marks(&audio), [(1, false), (2, false), (3, false)]);
|
||||
// Out of range changes nothing.
|
||||
assert!(!audio.muffle(1.5, 0.0, 10, &[]));
|
||||
assert_eq!(level(&audio), [0.0; 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_devices_are_looked_at_again_only_when_the_cards_change() {
|
||||
// No fingerprint (not Linux, or the cards could not be read): the list
|
||||
|
||||
@@ -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,20 +3,22 @@
|
||||
//! 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,
|
||||
block_entity_anim::{self, EnchantingBook},
|
||||
breaking::entity_geometry,
|
||||
fast_hash::FxHashMap,
|
||||
font::Glyph,
|
||||
font::{Font, Glyph},
|
||||
gfx::mark_oit,
|
||||
hud::Rect,
|
||||
mesher::{CULL_BACK, Vertex},
|
||||
models::load_visibility,
|
||||
signs::{self, SignData, SignText},
|
||||
text::{Style, Text},
|
||||
world::World,
|
||||
};
|
||||
use glam::{DVec3, Mat4, Quat, Vec3};
|
||||
@@ -128,6 +130,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 +175,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 +188,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,
|
||||
@@ -600,30 +621,26 @@ fn frac(x: f32) -> f32 {
|
||||
x - x.floor()
|
||||
}
|
||||
|
||||
/// `StringSplitter.splitLines(...).get(0)` for plain text: the first line,
|
||||
/// wrapped at the last space that fits or mid-word when none does.
|
||||
fn first_line(line: &str, max_width: f32, advance: &dyn Fn(char) -> f32) -> String {
|
||||
/// `StringSplitter.findLineBreak`: first line, with styled advances.
|
||||
fn first_line(line: &Text, max_width: f32, advance: &dyn Fn(char, &Style) -> f32) -> Text {
|
||||
let mut width = 0.0;
|
||||
let mut visible = false;
|
||||
let mut last_space = None;
|
||||
for (i, c) in line.char_indices() {
|
||||
for (i, (c, style)) in line.chars().enumerate() {
|
||||
if c == '\n' {
|
||||
return line[..i].to_owned();
|
||||
return line.slice(0, i);
|
||||
}
|
||||
if c == ' ' {
|
||||
last_space = Some(i);
|
||||
}
|
||||
width += advance(c);
|
||||
if width > max_width {
|
||||
let end = match last_space {
|
||||
Some(s) if s > 0 => s,
|
||||
_ if i > 0 => i,
|
||||
// The first char alone is too wide: take that one char.
|
||||
_ => c.len_utf8(),
|
||||
};
|
||||
return line[..end].to_owned();
|
||||
let step = advance(c, style);
|
||||
width += step;
|
||||
if visible && width > max_width.max(1.0) {
|
||||
return line.slice(0, last_space.unwrap_or(i));
|
||||
}
|
||||
visible |= step != 0.0;
|
||||
}
|
||||
line.to_owned()
|
||||
line.clone()
|
||||
}
|
||||
|
||||
/// `AbstractSignRenderer.isOutlineVisible`: the camera within 16 blocks of
|
||||
@@ -641,7 +658,7 @@ fn sign_text_near(pos: [i32; 3], camera: DVec3) -> bool {
|
||||
/// `AbstractSignRenderer.submitSignText`: four centred lines, glowing text
|
||||
/// full-bright with an 8x outline in the dark colour. The font colours each
|
||||
/// glyph by its style (`Font.StringRenderOutput`): a line's own colour wins,
|
||||
/// else the sign's. A line is one call per run of one colour.
|
||||
/// else the sign's. Keep style boundaries for glyphs and decoration effects.
|
||||
fn sign_text_calls(
|
||||
scene: &Scene,
|
||||
data: &SignData,
|
||||
@@ -653,27 +670,43 @@ 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, style: &Style| {
|
||||
let g = scene
|
||||
.font
|
||||
.map_or_else(|| (scene.glyph)(c), |f| f.glyph_styled(c, style));
|
||||
g.advance + if style.is_bold() { g.offset } else { 0.0 }
|
||||
};
|
||||
let advance = |c: char| (scene.glyph)(c).advance;
|
||||
let mut calls: Vec<TextCall> = Vec::with_capacity(text.lines.len());
|
||||
for i in 0..text.lines.len() {
|
||||
// The line that fits, in the order it is drawn (getVisualOrder).
|
||||
let line = text.line_text(i);
|
||||
let line = line.resolved();
|
||||
let flat: String = line.chars().map(|(c, _)| c).collect();
|
||||
let fits = first_line(&flat, data.max_line_width(), &advance);
|
||||
let shown = line.slice(0, fits.chars().count());
|
||||
let shown = if let Some(font) = scene.font {
|
||||
font.split_formatted(&line, data.max_line_width())
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.resolved()
|
||||
.into_owned()
|
||||
} else {
|
||||
first_line(&line.resolved(), data.max_line_width(), &advance)
|
||||
};
|
||||
let shown = visual(&shown);
|
||||
let width = shown.chars().map(|(c, _)| advance(c)).sum::<f32>().ceil();
|
||||
let width = shown
|
||||
.chars()
|
||||
.map(|(c, s)| advance(c, s))
|
||||
.sum::<f32>()
|
||||
.ceil();
|
||||
let x = -((width as i32) / 2) as f32;
|
||||
let y = i as f32 * line_height - midpoint;
|
||||
let first = calls.len();
|
||||
@@ -683,19 +716,22 @@ fn sign_text_calls(
|
||||
.style
|
||||
.color
|
||||
.map_or(color, |rgb| 0xFF00_0000 | (rgb & 0xFF_FFFF));
|
||||
match calls[first..].last_mut() {
|
||||
Some(last) if last.color == run => last.text.push_str(&span.text),
|
||||
_ => calls.push(TextCall {
|
||||
matrix: m,
|
||||
x: x + pen,
|
||||
y,
|
||||
text: span.text.clone(),
|
||||
color: run,
|
||||
outline,
|
||||
light,
|
||||
}),
|
||||
}
|
||||
pen += span.text.chars().map(advance).sum::<f32>();
|
||||
calls.push(TextCall {
|
||||
matrix: m,
|
||||
x: x + pen,
|
||||
y,
|
||||
text: span.text.clone(),
|
||||
style: span.style.clone(),
|
||||
color: run,
|
||||
outline,
|
||||
light,
|
||||
tier,
|
||||
});
|
||||
pen += span
|
||||
.text
|
||||
.chars()
|
||||
.map(|c| advance(c, &span.style))
|
||||
.sum::<f32>();
|
||||
}
|
||||
if calls.len() == first {
|
||||
// A line with no text is still a call, of no glyphs.
|
||||
@@ -704,15 +740,28 @@ fn sign_text_calls(
|
||||
x,
|
||||
y,
|
||||
text: String::new(),
|
||||
style: Style::default(),
|
||||
color,
|
||||
outline,
|
||||
light,
|
||||
tier,
|
||||
});
|
||||
}
|
||||
}
|
||||
calls
|
||||
}
|
||||
|
||||
fn sign_text_animated(data: &SignData) -> bool {
|
||||
[&data.front, &data.back].into_iter().any(|side| {
|
||||
(0..side.lines.len()).any(|i| {
|
||||
side.line_text(i)
|
||||
.resolved()
|
||||
.chars()
|
||||
.any(|(c, s)| c != ' ' && s.obfuscated == Some(true))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// How far the text of an outlined call sits in front of its outline, in
|
||||
/// text units (`Font.DisplayMode.POLYGON_OFFSET`, see `draw_text`).
|
||||
const TEXT_OVER_OUTLINE: f32 = 0.05;
|
||||
@@ -726,72 +775,131 @@ const TEXT_OVER_OUTLINE: f32 = 0.05;
|
||||
/// reader instead (a twentieth of a font pixel, invisible) rather than
|
||||
/// z-fighting with the outline under it.
|
||||
fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
|
||||
text_vertices(scene.glyph, call, out);
|
||||
let glyph = |c| {
|
||||
let Some(font) = scene.font else {
|
||||
return (scene.glyph)(c);
|
||||
};
|
||||
let g = font.glyph_styled(c, &call.style);
|
||||
if call.style.obfuscated == Some(true) && c != ' ' {
|
||||
font.random_glyph(&call.style, g.advance)
|
||||
} else {
|
||||
g
|
||||
}
|
||||
};
|
||||
text_vertices(&glyph, scene.font.map(Font::white), call, out);
|
||||
}
|
||||
|
||||
/// `draw_text` with the font's glyph cells and advances from `glyph`: the
|
||||
/// `BakedSheetGlyph.renderChar` / `Font.PreparedTextBuilder.accept`: the
|
||||
/// text of `call` as glyph quads in its matrix's plane, 1 unit a font pixel,
|
||||
/// y down as the font's, with its outline when it has one. A native mod's
|
||||
/// block entity draws its text this way (`native_draw_text`).
|
||||
pub fn text_vertices(glyph: &dyn Fn(char) -> Glyph, call: &TextCall, out: &mut Vec<Vertex>) {
|
||||
let mut emit = |dx: f32, dy: f32, z: f32, argb: u32| {
|
||||
let mut x = call.x + dx;
|
||||
let y0 = call.y + dy;
|
||||
for ch in call.text.chars() {
|
||||
pub fn text_vertices(
|
||||
glyph: &dyn Fn(char) -> Glyph,
|
||||
white: Option<Rect>,
|
||||
call: &TextCall,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let bold = call.style.is_bold();
|
||||
let mut quad = |corners: [(f32, f32); 4], cell: Rect, z: f32, argb: u32| {
|
||||
let uvs = [
|
||||
[cell.u0, cell.v1],
|
||||
[cell.u1, cell.v1],
|
||||
[cell.u1, cell.v0],
|
||||
[cell.u0, cell.v0],
|
||||
];
|
||||
let color = 0xFF00_0000 | (argb & 255) << 16 | (argb >> 8 & 255) << 8 | (argb >> 16 & 255);
|
||||
for ((x, y), uv) in corners.into_iter().zip(uvs) {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: call.matrix.transform_point3(Vec3::new(x, y, z)).to_array(),
|
||||
uv,
|
||||
layer: u32::from(call.tier) << 16,
|
||||
color,
|
||||
light: call.light,
|
||||
glint_uv: [0.0; 2],
|
||||
});
|
||||
}
|
||||
};
|
||||
let mut emit = |dx: f32, dy: f32, z: f32, argb: u32, outline: bool| {
|
||||
let mut pen = call.x;
|
||||
for (i, ch) in call.text.chars().enumerate() {
|
||||
let g = glyph(ch);
|
||||
let x = pen + dx * g.offset;
|
||||
let y = call.y + dy * g.offset;
|
||||
let advance = g.advance + if bold { g.offset } else { 0.0 };
|
||||
if let Some(cell) = g.rect {
|
||||
// BakedSheetGlyph: the glyph's own box from the pen, not a
|
||||
// fixed 8x8 cell (a Unifont glyph is 0.5 units per texel).
|
||||
let (x0, x1) = (x + g.left, x + g.right);
|
||||
let (top, bottom) = (y0 + g.top, y0 + g.bottom);
|
||||
let corners = [(x0, bottom), (x1, bottom), (x1, top), (x0, top)];
|
||||
let uvs = [
|
||||
[cell.u0, cell.v1],
|
||||
[cell.u1, cell.v1],
|
||||
[cell.u1, cell.v0],
|
||||
[cell.u0, cell.v0],
|
||||
];
|
||||
let c =
|
||||
0xFF00_0000 | (argb & 255) << 16 | (argb >> 8 & 255) << 8 | (argb >> 16 & 255);
|
||||
for ((cx, cy), uv) in corners.into_iter().zip(uvs) {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: call
|
||||
.matrix
|
||||
.transform_point3(Vec3::new(cx, cy, z))
|
||||
.to_array(),
|
||||
uv,
|
||||
layer: 0,
|
||||
color: c,
|
||||
light: call.light,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
let thick = if bold { 0.1 } else { 0.0 };
|
||||
let shear = |v| {
|
||||
if call.style.is_italic() {
|
||||
1.0 - 0.25 * v
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
};
|
||||
for copy in 0..=usize::from(bold) {
|
||||
let x0 = x + copy as f32 * g.offset + g.left - thick;
|
||||
let x1 = x + copy as f32 * g.offset + g.right + thick;
|
||||
let top = y + g.top - thick;
|
||||
let bottom = y + g.bottom + thick;
|
||||
quad(
|
||||
[
|
||||
(x0 + shear(g.bottom), bottom),
|
||||
(x1 + shear(g.bottom), bottom),
|
||||
(x1 + shear(g.top), top),
|
||||
(x0 + shear(g.top), top),
|
||||
],
|
||||
cell,
|
||||
z + copy as f32 * 0.001,
|
||||
argb,
|
||||
);
|
||||
}
|
||||
}
|
||||
x += g.advance;
|
||||
// `Font.prepare8xTextOutline` discards effects: only the front
|
||||
// text gets underline/strikethrough, including over spaces.
|
||||
if !outline && let Some(white) = white {
|
||||
let x0 = if i == 0 { x - 1.0 } else { x };
|
||||
for (enabled, top) in [
|
||||
(call.style.strikethrough, 3.5),
|
||||
(call.style.underlined, 8.0),
|
||||
] {
|
||||
if enabled == Some(true) {
|
||||
quad(
|
||||
[
|
||||
(x0, y + top + 1.0),
|
||||
(x + advance, y + top + 1.0),
|
||||
(x + advance, y + top),
|
||||
(x0, y + top),
|
||||
],
|
||||
white,
|
||||
z + 0.01,
|
||||
argb,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
pen += advance;
|
||||
}
|
||||
};
|
||||
if call.outline != 0 {
|
||||
for (dx, dy) in [
|
||||
(-1.0, -1.0),
|
||||
(0.0, -1.0),
|
||||
(1.0, -1.0),
|
||||
(-1.0, 0.0),
|
||||
(1.0, 0.0),
|
||||
(-1.0, 1.0),
|
||||
(0.0, 1.0),
|
||||
(1.0, 1.0),
|
||||
] {
|
||||
emit(dx, dy, 0.0, call.outline);
|
||||
for dx in -1..=1 {
|
||||
for dy in -1..=1 {
|
||||
if dx != 0 || dy != 0 {
|
||||
emit(dx as f32, dy as f32, 0.0, call.outline, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let z = if call.outline != 0 {
|
||||
TEXT_OVER_OUTLINE
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
emit(0.0, 0.0, z, call.color);
|
||||
emit(
|
||||
0.0,
|
||||
0.0,
|
||||
if call.outline != 0 {
|
||||
TEXT_OVER_OUTLINE
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
call.color,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Block-entity fields the level ticks, at one position (vanilla's own field
|
||||
@@ -824,12 +932,16 @@ pub struct TextCall {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub text: String,
|
||||
pub style: Style,
|
||||
/// ARGB.
|
||||
pub color: u32,
|
||||
/// ARGB outline colour, 0 for none.
|
||||
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.
|
||||
@@ -868,6 +980,8 @@ pub struct Scene<'a> {
|
||||
pub skull: &'a dyn Fn([i32; 3]) -> Option<String>,
|
||||
/// Font glyph: atlas cell (none for blank glyphs such as the space), box and advance.
|
||||
pub glyph: &'a dyn Fn(char) -> Glyph,
|
||||
/// Font providers, effects and random glyphs for styled sign text.
|
||||
pub font: Option<&'a Font>,
|
||||
/// Changes whenever `glyph` may answer differently (`Font::epoch`): sign
|
||||
/// text laid out under another value is laid out again.
|
||||
pub glyphs: u64,
|
||||
@@ -1029,6 +1143,7 @@ struct SignTextEntry {
|
||||
/// Game time it was last drawn.
|
||||
used: i64,
|
||||
vertices: Vec<Vertex>,
|
||||
animated: bool,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct StaticKey {
|
||||
@@ -2335,9 +2450,11 @@ impl BlockEntityRenderer {
|
||||
let entry = match signs.entry(pos) {
|
||||
std::collections::hash_map::Entry::Occupied(mut occupied) => {
|
||||
let entry = occupied.get_mut();
|
||||
let changed = entry.data != *data;
|
||||
if (entry.state, entry.packed, entry.near, entry.glyphs)
|
||||
!= (state, packed, near, glyphs)
|
||||
|| entry.data != *data
|
||||
|| changed
|
||||
|| entry.animated
|
||||
{
|
||||
entry.vertices.clear();
|
||||
self.render_one(
|
||||
@@ -2352,7 +2469,10 @@ impl BlockEntityRenderer {
|
||||
&mut entry.vertices,
|
||||
);
|
||||
entry.state = state;
|
||||
entry.data.clone_from(data);
|
||||
if changed {
|
||||
entry.animated = sign_text_animated(data);
|
||||
entry.data.clone_from(data);
|
||||
}
|
||||
entry.packed = packed;
|
||||
entry.near = near;
|
||||
entry.glyphs = glyphs;
|
||||
@@ -2373,6 +2493,7 @@ impl BlockEntityRenderer {
|
||||
&mut vertices,
|
||||
);
|
||||
vacant.insert(SignTextEntry {
|
||||
animated: sign_text_animated(data),
|
||||
state,
|
||||
data: data.clone(),
|
||||
packed,
|
||||
@@ -3612,6 +3733,7 @@ mod tests {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
// Compare the cached world path with the original model renderer,
|
||||
@@ -3774,6 +3896,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 {
|
||||
@@ -3907,6 +4128,7 @@ mod tests {
|
||||
skull: &|_| None,
|
||||
glyph: &glyph,
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let mut call = TextCall {
|
||||
@@ -3914,9 +4136,11 @@ mod tests {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
text: "Hi".into(),
|
||||
style: Style::default(),
|
||||
color: 0xFFFF_FF00,
|
||||
outline: 0xFF33_3300,
|
||||
light: 240 | 240 << 8,
|
||||
tier: 0,
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
draw_text(&scene, &call, &mut out);
|
||||
@@ -3980,6 +4204,7 @@ mod tests {
|
||||
skull: &|_| None,
|
||||
glyph: &glyph,
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let call = TextCall {
|
||||
@@ -3987,9 +4212,11 @@ mod tests {
|
||||
x: 10.0,
|
||||
y: 2.0,
|
||||
text: "ab".into(),
|
||||
style: Style::default(),
|
||||
color: 0xFF00_0000,
|
||||
outline: 0,
|
||||
light: 240 | 240 << 8,
|
||||
tier: 0,
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
draw_text(&scene, &call, &mut out);
|
||||
@@ -4009,6 +4236,83 @@ mod tests {
|
||||
assert_eq!(boxes, [[10.0, 14.0, 2.0, 10.0], [15.0, 19.0, 2.0, 10.0]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styled_sign_quads_follow_vanilla_glyph_and_effect_bounds() {
|
||||
let white = Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 0.1,
|
||||
v1: 0.1,
|
||||
w: 1,
|
||||
h: 1,
|
||||
};
|
||||
let glyph = |c| Glyph {
|
||||
rect: (c != ' ').then_some(white),
|
||||
left: 0.0,
|
||||
right: 4.0,
|
||||
top: 0.0,
|
||||
bottom: 8.0,
|
||||
advance: 5.0,
|
||||
offset: 0.5,
|
||||
};
|
||||
let mut call = TextCall {
|
||||
matrix: Mat4::IDENTITY,
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
text: "A ".into(),
|
||||
color: 0xff123456,
|
||||
outline: 0,
|
||||
light: 240,
|
||||
tier: crate::mesher::LOW,
|
||||
style: Style {
|
||||
bold: Some(true),
|
||||
italic: Some(true),
|
||||
underlined: Some(true),
|
||||
strikethrough: Some(true),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
text_vertices(&glyph, Some(white), &call, &mut out);
|
||||
// BakedSheetGlyph: bold expands by 0.1, italic shears by 1 - y/4,
|
||||
// the second bold glyph is 0.5 along and 0.001 in front (Unihex).
|
||||
assert_eq!(out.len(), 24);
|
||||
for (got, expected) in out[0].pos.iter().zip([-1.1, 8.1, 0.0]) {
|
||||
assert!((got - expected).abs() < 1e-6);
|
||||
}
|
||||
assert!((out[4].pos[0] - out[0].pos[0] - 0.5).abs() < 1e-6);
|
||||
assert_eq!(out[4].pos[2], 0.001);
|
||||
assert_eq!(out[8].pos, [-1.0, 4.5, 0.01]);
|
||||
assert_eq!(out[12].pos, [-1.0, 9.0, 0.01]);
|
||||
assert_eq!(out[21].pos, [11.0, 9.0, 0.01]); // underline crosses the space
|
||||
assert!(out.iter().all(|v| v.color == 0xff563412 && v.light == 240));
|
||||
call.outline = 0xff010203;
|
||||
out.clear();
|
||||
text_vertices(&glyph, Some(white), &call, &mut out);
|
||||
// Eight outlines contain glyphs only; effects appear once, in front.
|
||||
assert_eq!(out.len(), 8 * 8 + 24);
|
||||
assert!((out[0].pos[0] - -1.6).abs() < 1e-6);
|
||||
assert!(out[64..].iter().all(|v| v.pos[2] >= TEXT_OVER_OUTLINE));
|
||||
// The glow tier reaches bold copies, outlines and decoration quads.
|
||||
assert!(out.iter().all(|v| v.layer >> 16 == 1));
|
||||
call.tier = 0;
|
||||
out.clear();
|
||||
text_vertices(&glyph, Some(white), &call, &mut out);
|
||||
assert!(out.iter().all(|v| v.layer >> 16 == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styled_sign_clipping_counts_bold_width_and_preserves_resets() {
|
||||
let line = Text::legacy("§lAB§r東Z");
|
||||
let advance = |_: char, s: &Style| 5.0 + if s.is_bold() { 0.5 } else { 0.0 };
|
||||
let shown = first_line(&line, 16.0, &advance);
|
||||
assert_eq!(shown.plain(), "AB東");
|
||||
assert_eq!(
|
||||
shown.chars().map(|(_, s)| s.is_bold()).collect::<Vec<_>>(),
|
||||
[true, true, false]
|
||||
);
|
||||
}
|
||||
|
||||
/// The front text calls of a sign whose every glyph advances 6.
|
||||
fn front_calls(data: &SignData, near: bool) -> Vec<(String, f32, f32, u32, u32)> {
|
||||
let world = World::new(BlockStateId(0), 1);
|
||||
@@ -4037,6 +4341,7 @@ mod tests {
|
||||
skull: &|_| None,
|
||||
glyph: &glyph,
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
sign_text_calls(&scene, data, &data.front, Mat4::IDENTITY, 0, near)
|
||||
@@ -4176,7 +4481,9 @@ mod tests {
|
||||
..changed.clone()
|
||||
};
|
||||
let wide = std::cell::Cell::new(6.0);
|
||||
let glyph_reads = std::cell::Cell::new(0);
|
||||
let glyph = |c: char| {
|
||||
glyph_reads.set(glyph_reads.get() + 1);
|
||||
let u = f32::from(c as u8 % 16) / 16.0;
|
||||
let rect = Rect {
|
||||
u0: u,
|
||||
@@ -4219,6 +4526,7 @@ mod tests {
|
||||
skull: &|_| None,
|
||||
glyph: &glyph,
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let check = |scene: &Scene, pos: [i32; 3], state: u32| {
|
||||
@@ -4262,6 +4570,23 @@ mod tests {
|
||||
wide.set(5.0);
|
||||
scene.glyphs = 1;
|
||||
check(&scene, pos, 1);
|
||||
// With the same game time and data, plain text reuses its vertices,
|
||||
// but obfuscated text must ask the font for fresh glyphs each frame.
|
||||
let mut vertices = Vec::new();
|
||||
let reads = glyph_reads.get();
|
||||
renderer.render(&scene, [(pos, 1)].iter(), &mut vertices);
|
||||
assert_eq!(glyph_reads.get(), reads);
|
||||
let animated = SignData {
|
||||
front: text("§kSecret", false),
|
||||
..Default::default()
|
||||
};
|
||||
let animated_sign = |_| Some(&animated);
|
||||
scene.sign = &animated_sign;
|
||||
renderer.render(&scene, [(pos, 0)].iter(), &mut vertices);
|
||||
let reads = glyph_reads.get();
|
||||
renderer.render(&scene, [(pos, 0)].iter(), &mut vertices);
|
||||
assert!(glyph_reads.get() > reads);
|
||||
scene.sign = &first_sign;
|
||||
// Signs out of view for a while are dropped.
|
||||
scene.game_time = 2 * SIGN_TEXT_KEEP;
|
||||
check(&scene, pos, 0);
|
||||
@@ -4269,11 +4594,23 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn first_line_wraps_on_bytes_without_splitting_a_char() {
|
||||
let advance = |c: char| if c == ' ' { 4.0 } else { 6.0 };
|
||||
assert_eq!(first_line("ab cd ef", 13.0, &advance), "ab");
|
||||
assert_eq!(first_line("abcdef", 13.0, &advance), "ab");
|
||||
assert_eq!(first_line("\u{6771}", 3.0, &advance), "\u{6771}");
|
||||
assert_eq!(first_line("ab\ncd", 100.0, &advance), "ab");
|
||||
let advance = |c: char, _: &Style| if c == ' ' { 4.0 } else { 6.0 };
|
||||
assert_eq!(
|
||||
first_line(&Text::literal("ab cd ef"), 13.0, &advance).plain(),
|
||||
"ab"
|
||||
);
|
||||
assert_eq!(
|
||||
first_line(&Text::literal("abcdef"), 13.0, &advance).plain(),
|
||||
"ab"
|
||||
);
|
||||
assert_eq!(
|
||||
first_line(&Text::literal("\u{6771}"), 3.0, &advance).plain(),
|
||||
"\u{6771}"
|
||||
);
|
||||
assert_eq!(
|
||||
first_line(&Text::literal("ab\ncd"), 100.0, &advance).plain(),
|
||||
"ab"
|
||||
);
|
||||
}
|
||||
/// `rendertype_end_portal` culls back faces, and `AbstractEndPortalRenderer`
|
||||
/// submits the unit cube's two Y faces. Both are wound outward, so of each
|
||||
@@ -4377,6 +4714,7 @@ mod tests {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let mut verts = Vec::new();
|
||||
@@ -4463,6 +4801,7 @@ mod tests {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
// A closed single chest of `block` drawn by `renderer`: positions and UVs.
|
||||
|
||||
@@ -358,6 +358,7 @@ fn batched_callbacks_preserve_entity_ranges_and_allow_immediate_culling() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
// Reverse local-position order to check that cache indexing does not
|
||||
@@ -447,6 +448,7 @@ fn static_cache_qualifies_local_positions_by_section_and_drops_old_entries() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let options = CacheOptions {
|
||||
@@ -502,6 +504,7 @@ fn gpu_static_cache_uses_section_local_chests_and_keeps_visibility_separate() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let blocks = [([100, 70, -32], 0)];
|
||||
@@ -689,6 +692,7 @@ fn sign_text_cache_can_be_disabled_and_reenabled_without_changing_output() {
|
||||
skull: &|_| None,
|
||||
glyph: &glyph,
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let blocks = [([0, 64, 0], 0)];
|
||||
@@ -743,6 +747,7 @@ fn shulker_cache_tracks_settled_poses_and_does_not_freeze_opening_animation() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let blocks = [([0, 64, 0], 0)];
|
||||
@@ -845,6 +850,7 @@ fn shulker_cache_covers_all_colors_and_facing_directions() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let options = CacheOptions {
|
||||
@@ -900,6 +906,7 @@ fn cached_bells_keep_shaking_until_the_animation_ends() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let blocks = [([0, 64, 0], 0)];
|
||||
@@ -962,6 +969,7 @@ fn cached_closed_chests_match_the_direct_model_and_rebuild_when_opening() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let blocks = [([0, 64, 0], 0)];
|
||||
@@ -993,7 +1001,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]
|
||||
@@ -1058,6 +1068,7 @@ fn closed_chest_cache_covers_all_materials_and_atlas_reloads() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let pos = [0, 64, 0];
|
||||
@@ -1147,6 +1158,7 @@ fn double_chest_cache_bypasses_both_halves_while_either_lid_is_open() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let pair = [([0, 64, 0], 0), ([1, 64, 0], 1)];
|
||||
@@ -1223,6 +1235,7 @@ fn decorated_pot_cache_tracks_sherds_and_bypasses_wobble_animation() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let blocks = [([0, 64, 0], 0)];
|
||||
@@ -1302,6 +1315,7 @@ fn banner_cache_keeps_the_pole_static_while_the_flag_and_patterns_update() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let blocks = [([0, 64, 0], 0)];
|
||||
@@ -1369,6 +1383,7 @@ fn cached_wall_banner_poles_use_the_wall_model() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let pos = [0, 64, 0];
|
||||
@@ -1417,6 +1432,7 @@ fn copper_statue_cache_rebuilds_when_its_pose_state_changes() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let options = CacheOptions {
|
||||
@@ -1523,6 +1539,7 @@ fn copper_statue_cache_covers_weathering_waxing_and_all_poses() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let options = CacheOptions {
|
||||
|
||||
@@ -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(_)
|
||||
|
||||
@@ -83,6 +83,13 @@ fn choice_description(opt: Opt, pad: &PadSettings) -> Option<&'static str> {
|
||||
GlyphStyle::PlayStation => "Always shows PlayStation buttons.",
|
||||
GlyphStyle::Nintendo => "Always shows Nintendo buttons.",
|
||||
},
|
||||
Opt::PadNintendoButtons => {
|
||||
if pad.nintendo_letters {
|
||||
"A, on the right, confirms and jumps and B goes back, as on a Switch."
|
||||
} else {
|
||||
"B, at the bottom, confirms and jumps and A goes back, as on other controllers."
|
||||
}
|
||||
}
|
||||
Opt::PadKeyboard => match pad.keyboard {
|
||||
gamepad::TextKeyboard::Auto => {
|
||||
"Opens Steam's keyboard on a Steam Deck under Steam, and Horizon's otherwise."
|
||||
@@ -261,6 +268,10 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
|
||||
Opt::PadGlyphs,
|
||||
"Follow the controller, or always show one family's buttons.",
|
||||
),
|
||||
option(
|
||||
Opt::PadNintendoButtons,
|
||||
"Whether a Nintendo controller's A and B work by where they sit or by their letters.",
|
||||
),
|
||||
info(Info::Using, "The buttons menus show right now."),
|
||||
],
|
||||
),
|
||||
@@ -1332,6 +1343,7 @@ mod tests {
|
||||
Opt::PadLookDeadzone,
|
||||
Opt::PadTriggerThreshold,
|
||||
Opt::PadGlyphs,
|
||||
Opt::PadNintendoButtons,
|
||||
Opt::PadKeyboard,
|
||||
Opt::PadPointerSpeed,
|
||||
Opt::PadScrollSpeed,
|
||||
@@ -1429,6 +1441,53 @@ mod tests {
|
||||
assert!((s.controller.move_deadzone - before - 0.01).abs() < 0.001);
|
||||
}
|
||||
|
||||
/// The Device page's Nintendo Buttons row sits with the glyphs, steps between its two
|
||||
/// values rather than ticking a box, and what it sets is what the controller file keeps.
|
||||
#[test]
|
||||
fn the_nintendo_buttons_row_flips_letters_and_the_file_keeps_it() {
|
||||
let (page, group) = rows()
|
||||
.into_iter()
|
||||
.flat_map(|(page, groups)| groups.into_iter().map(move |g| (page, g)))
|
||||
.find(|(_, (_, rows))| {
|
||||
rows.iter()
|
||||
.any(|r| r.item == Item::Option(Opt::PadNintendoButtons))
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!((page, group.0), (Page::Device, "Glyphs"));
|
||||
let mut s = Settings::default();
|
||||
let item = Item::Option(Opt::PadNintendoButtons);
|
||||
assert!(item.steps(&s));
|
||||
s.controller.nintendo_letters = false;
|
||||
let mut ui = ControllerUi {
|
||||
focus: Some(Target::Item(item)),
|
||||
..ControllerUi::default()
|
||||
};
|
||||
// Vanilla's caption and value template; Horizon's own words come bundled.
|
||||
struct Vanilla;
|
||||
impl lang::ResourceSource for Vanilla {
|
||||
fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
(path == "assets/minecraft/lang/en_us.json")
|
||||
.then(|| br#"{"options.generic_value": "%s: %s"}"#.to_vec())
|
||||
}
|
||||
}
|
||||
let value = |ui: &ControllerUi, s: &Settings| {
|
||||
lang::scoped(lang::Language::load(&[&Vanilla], "en_us"), || {
|
||||
ui.parts(item, s)
|
||||
})
|
||||
.0
|
||||
};
|
||||
assert_eq!(value(&ui, &s).0, "Nintendo Buttons");
|
||||
assert_eq!(value(&ui, &s).1.as_deref(), Some("By Position"));
|
||||
let actions = key(&mut ui, &mut s, K::Enter);
|
||||
assert!(actions.contains(&Action::Changed), "{actions:?}");
|
||||
assert!(s.controller.nintendo_letters);
|
||||
assert_eq!(value(&ui, &s).1.as_deref(), Some("By Letter"));
|
||||
assert!(PadSettings::parse(&s.controller.serialize()).nintendo_letters);
|
||||
key(&mut ui, &mut s, K::Enter);
|
||||
assert!(!s.controller.nintendo_letters);
|
||||
assert!(!PadSettings::parse(&s.controller.serialize()).nintendo_letters);
|
||||
}
|
||||
|
||||
/// What the screen draws at the bottom of the page changes with the choice, in every one of
|
||||
/// these settings.
|
||||
#[test]
|
||||
@@ -1438,6 +1497,7 @@ mod tests {
|
||||
for (opt, choices) in [
|
||||
(Opt::PadGameplayGuide, 3),
|
||||
(Opt::PadGlyphs, 4),
|
||||
(Opt::PadNintendoButtons, 2),
|
||||
(Opt::PadKeyboard, 3),
|
||||
(Opt::PadLightbar, 3),
|
||||
] {
|
||||
@@ -1470,6 +1530,7 @@ mod tests {
|
||||
for opt in [
|
||||
Opt::PadGameplayGuide,
|
||||
Opt::PadGlyphs,
|
||||
Opt::PadNintendoButtons,
|
||||
Opt::PadKeyboard,
|
||||
Opt::PadLightbar,
|
||||
] {
|
||||
|
||||
@@ -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
|
||||
@@ -261,6 +278,43 @@ pub fn screen_packs(dir: &Path, resources: &Path) -> Vec<crate::pack_screen::Pac
|
||||
}
|
||||
infos
|
||||
}
|
||||
/// The folder of a game folder whose packs every new world there starts with: a modpack's profile
|
||||
/// keeps the packs of its data-only jars here (`horizon-port-pack`). Each world gets its own copy,
|
||||
/// as Create World's packs do, so a world keeps working when the folder changes. Vanilla has no
|
||||
/// such folder, and without one nothing here happens.
|
||||
pub const NEW_WORLD_PACKS: &str = "new-world-datapacks";
|
||||
|
||||
/// Create World's temporary pack folder holding `game_dir`'s [`NEW_WORLD_PACKS`], and their ids,
|
||||
/// to show them selected in the Data Packs screen. `None` when there are none.
|
||||
pub fn seed_create_world(game_dir: &Path) -> Option<(PathBuf, Vec<String>)> {
|
||||
let from = game_dir.join(NEW_WORLD_PACKS);
|
||||
let ids: Vec<String> = discover_folder(&from).into_iter().map(|p| p.id).collect();
|
||||
if ids.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let seeded = temp_folder()
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|dir| copy(&from, &dir).map(|()| dir));
|
||||
match seeded {
|
||||
Ok(dir) => Some((dir, ids)),
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: data packs for new worlds: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A world about to be created without Create World (`--world`, the control socket) gets
|
||||
/// `game_dir`'s [`NEW_WORLD_PACKS`]. The game turns on every pack it finds in a world.
|
||||
pub fn install_new_world_packs(game_dir: &Path, world: &Path) -> Result<(), String> {
|
||||
let from = game_dir.join(NEW_WORLD_PACKS);
|
||||
let exists = |name: &str| world.join(name).exists();
|
||||
if !from.is_dir() || exists("level.dat") || exists("level.dat_old") {
|
||||
return Ok(());
|
||||
}
|
||||
copy(&from, &world.join("datapacks"))
|
||||
}
|
||||
|
||||
/// A new empty folder for Create World's packs
|
||||
/// (`Files.createTempDirectory("mcworld-")`).
|
||||
pub fn temp_folder() -> std::io::Result<PathBuf> {
|
||||
@@ -608,7 +662,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 +671,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 +679,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 +713,7 @@ impl DataStore {
|
||||
"damage_type",
|
||||
"data_maps",
|
||||
"dialog",
|
||||
"enchantment",
|
||||
"function",
|
||||
"item_modifier",
|
||||
"loot_table",
|
||||
@@ -714,15 +769,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 +826,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 +1020,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!(
|
||||
@@ -951,6 +1138,35 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_game_folder_s_new_world_packs_go_into_new_worlds_only() {
|
||||
let root = std::env::temp_dir().join(format!("hz-new-world-packs-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let (game, world, old) = (root.join("game"), root.join("new"), root.join("old"));
|
||||
assert!(seed_create_world(&game).is_none());
|
||||
assert_eq!(install_new_world_packs(&game, &world), Ok(()));
|
||||
assert!(!world.join("datapacks").exists());
|
||||
for pack in ["void", "islands"] {
|
||||
let dir = game.join(NEW_WORLD_PACKS).join(pack);
|
||||
std::fs::create_dir_all(dir.join("data/v/function")).unwrap();
|
||||
std::fs::write(dir.join("pack.mcmeta"), "{}").unwrap();
|
||||
std::fs::write(dir.join("data/v/function/a.mcfunction"), "say a").unwrap();
|
||||
}
|
||||
let (temp, ids) = seed_create_world(&game).unwrap();
|
||||
assert_eq!(ids, ["file/islands", "file/void"]);
|
||||
assert!(temp.join("void/data/v/function/a.mcfunction").is_file());
|
||||
let _ = std::fs::remove_dir_all(&temp);
|
||||
install_new_world_packs(&game, &world).unwrap();
|
||||
let ids: Vec<String> = discover(&world).into_iter().map(|p| p.id).collect();
|
||||
assert_eq!(ids, ["file/islands", "file/void"]);
|
||||
// A world that exists already keeps the packs it has.
|
||||
std::fs::create_dir_all(&old).unwrap();
|
||||
std::fs::write(old.join("level.dat"), "").unwrap();
|
||||
install_new_world_packs(&game, &old).unwrap();
|
||||
assert!(discover(&old).is_empty());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_packs_override_worldgen_in_order() {
|
||||
let dir = std::env::temp_dir().join(format!("hz-datapack-worldgen-{}", std::process::id()));
|
||||
@@ -961,7 +1177,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 +1348,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 +1446,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)]
|
||||
|
||||
@@ -5,7 +5,7 @@ use crate::{
|
||||
lang,
|
||||
options_ui::{Event, Page},
|
||||
settings::Settings,
|
||||
settings_shell::{Group, Output, Row, Screen, Sheet, Value},
|
||||
settings_shell::{self as shell, Group, Output, Row, Screen, Sheet, Value},
|
||||
};
|
||||
|
||||
const TITLE: &str = "horizon.pages.extras";
|
||||
@@ -85,6 +85,10 @@ impl Screen for ExtrasScreen {
|
||||
fn note_lines(&self) -> usize {
|
||||
NOTE_LINES
|
||||
}
|
||||
/// The rows read the extras, the mods and whether cosmetics are offered.
|
||||
fn stamp(&self, s: &Settings) -> u64 {
|
||||
shell::stamp_of(&(&s.extras, &s.mods, horizon_cosmetics::enabled()))
|
||||
}
|
||||
fn sheets(&self, s: &Settings) -> Vec<Sheet<(), ExtraRow>> {
|
||||
let mut groups = vec![Group {
|
||||
name: MODS,
|
||||
@@ -228,4 +232,37 @@ mod tests {
|
||||
assert!(s.extras.sort);
|
||||
assert_eq!(screen.wheel_step(&mut s, ExtraRow::Zoom(0), true), None);
|
||||
}
|
||||
|
||||
/// A page with no page list has Done under the search field: Down goes to the first row
|
||||
/// instead, from the keys and, as the frame declares it, from a controller's focus.
|
||||
#[test]
|
||||
fn down_from_the_search_field_lands_on_the_first_row() {
|
||||
use crate::{hud::HudBatch, settings_shell::Columns, ui};
|
||||
use winit::keyboard::KeyCode as K;
|
||||
let atlas = crate::hud::HudAtlas::build(std::path::Path::new("/nonexistent")).unwrap();
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<ExtrasScreen>::default();
|
||||
let mut batch = HudBatch::new([1708, 960]);
|
||||
shell.draw(&s, &mut batch, &atlas, (0.0, 0.0), false, None);
|
||||
crate::widgets::end_frame(1);
|
||||
let size = (batch.width, batch.height);
|
||||
let cols = Columns::of(size.0, size.1).with_note_lines(NOTE_LINES);
|
||||
// The Mods group's note can't take the focus: the first item can.
|
||||
let first = ExtraRow::Item(Item::OwnNameTag);
|
||||
let at = shell.point_at(&s, size, first);
|
||||
let [x, y, w, h] = crate::widgets::linked_step(cols.search(), true).unwrap();
|
||||
assert!(ui::hovered(at, x, y, w, h));
|
||||
assert_eq!(crate::widgets::linked_step(cols.search(), false), None);
|
||||
let [x, y, w, h] = cols.search();
|
||||
shell.click(
|
||||
&mut s,
|
||||
(x + w / 2.0, y + h / 2.0),
|
||||
size,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
shell.key(&mut s, K::ArrowDown, None, false, size);
|
||||
assert_eq!(shell.focused_row(), Some(first));
|
||||
}
|
||||
}
|
||||
@@ -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,7 +171,10 @@ mod gpu_culling;
|
||||
mod hdr;
|
||||
mod hdr_icc;
|
||||
mod hdr_stats;
|
||||
pub(crate) mod hud_coverage;
|
||||
pub use hdr::Calibration as HdrCalibration;
|
||||
mod hold;
|
||||
pub use hold::WorldHold;
|
||||
pub mod block_light;
|
||||
mod custom_sky;
|
||||
mod displace;
|
||||
@@ -444,6 +447,52 @@ struct GpuStaticEntitySection {
|
||||
vertices: u32,
|
||||
}
|
||||
pub type SectionKey = (i32, i32, i32);
|
||||
/// What a section's mesh brings with it besides its quads (`Renderer::upload`): placed with
|
||||
/// the mesh, now or when a hold lets go of it.
|
||||
struct MeshFacts<'a> {
|
||||
visibility: u64,
|
||||
block_entities: &'a [([i32; 3], u32)],
|
||||
lights: &'a [([i32; 3], u32)],
|
||||
bounds: Option<[glam::Vec3; 2]>,
|
||||
/// It has translucent quads.
|
||||
translucent: bool,
|
||||
/// It has no quads at all: that is its mesh.
|
||||
empty: bool,
|
||||
}
|
||||
impl<'a> MeshFacts<'a> {
|
||||
fn of(mesh: &'a SectionMesh) -> Self {
|
||||
Self {
|
||||
visibility: mesh.visibility,
|
||||
block_entities: &mesh.block_entities,
|
||||
lights: &mesh.lights,
|
||||
bounds: mesh.bounds,
|
||||
translucent: !mesh.translucent.is_empty(),
|
||||
empty: mesh.solid.is_empty() && mesh.translucent.is_empty(),
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A section's new mesh waiting behind a hold (gfx/hold.rs): uploaded, with what goes with it.
|
||||
struct HeldMesh {
|
||||
gpu: Option<SectionGpu>,
|
||||
visibility: u64,
|
||||
block_entities: Vec<([i32; 3], u32)>,
|
||||
lights: Vec<([i32; 3], u32)>,
|
||||
bounds: Option<[glam::Vec3; 2]>,
|
||||
translucent: bool,
|
||||
empty: bool,
|
||||
}
|
||||
impl HeldMesh {
|
||||
fn facts(&self) -> MeshFacts<'_> {
|
||||
MeshFacts {
|
||||
visibility: self.visibility,
|
||||
block_entities: &self.block_entities,
|
||||
lights: &self.lights,
|
||||
bounds: self.bounds,
|
||||
translucent: self.translucent,
|
||||
empty: self.empty,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The water mirror's targets: the world as a camera mirrored about the water plane sees
|
||||
/// it, at half the world's resolution (the material stage's `reflection`).
|
||||
struct MirrorTarget {
|
||||
@@ -1171,6 +1220,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
|
||||
@@ -1191,6 +1241,8 @@ pub struct Renderer {
|
||||
static_entity_catalog_revision: u64,
|
||||
static_entity_frame_stats: StaticCacheFrameStats,
|
||||
hdr_mark_pipeline: wgpu::RenderPipeline,
|
||||
/// `hdr_mark_pipeline` at `entity_pipeline`'s depth bias, for what that drew.
|
||||
hdr_entity_mark_pipeline: wgpu::RenderPipeline,
|
||||
hdr_hand_unmark_pipeline: wgpu::RenderPipeline,
|
||||
hdr_mark_lightning_pipeline: wgpu::RenderPipeline,
|
||||
weather: weather::Weather,
|
||||
@@ -1328,6 +1380,9 @@ pub struct Renderer {
|
||||
lod_retiring: crate::fast_hash::FxHashSet<SectionKey>,
|
||||
/// Removed sections fading out over far terrain (`leave_section`).
|
||||
lod_leaving: Vec<(SectionKey, std::time::Instant)>,
|
||||
/// A mod's hold on the terrain's picture (`hold_world`, gfx/hold.rs) and the new meshes
|
||||
/// waiting behind it.
|
||||
hold: hold::Hold<HeldMesh>,
|
||||
/// Finished tile meshes `upload_lods` has yet to upload.
|
||||
lod_pending: Option<lod::Pending>,
|
||||
detail_columns: FxHashMap<(i32, i32), SectionMask>,
|
||||
@@ -1424,6 +1479,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,
|
||||
@@ -2450,6 +2507,14 @@ impl Renderer {
|
||||
"near_section_vertices",
|
||||
self.sections.values().map(used).sum(),
|
||||
),
|
||||
(
|
||||
"held_section_vertices",
|
||||
self.hold
|
||||
.sections()
|
||||
.filter_map(|held| held.gpu.as_ref())
|
||||
.map(used)
|
||||
.sum(),
|
||||
),
|
||||
(
|
||||
"far_tile_vertices",
|
||||
self.lods.values().map(|tile| used(&tile.mesh)).sum(),
|
||||
@@ -2693,6 +2758,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 +2915,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 +2958,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 +2992,7 @@ impl Renderer {
|
||||
depth_write,
|
||||
depth_test,
|
||||
blend,
|
||||
false,
|
||||
)
|
||||
};
|
||||
// A solid terrain pipeline (back faces culled, depth written, no blend) without HDR
|
||||
@@ -2931,6 +3015,7 @@ impl Renderer {
|
||||
true,
|
||||
true,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
};
|
||||
SolidPipeline {
|
||||
@@ -3175,6 +3260,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 +3629,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");
|
||||
@@ -3966,17 +4068,19 @@ impl Renderer {
|
||||
// paths here, so their fp32 depths of the same plane differ by a few dozen
|
||||
// ulps and the tie came out either way, as a jagged strip of the trapdoor
|
||||
// through the hull's floor. The same constant as the shadows' covers that
|
||||
// spread; a model's feet sit 0.001 above the ground, far beyond it.
|
||||
// spread; a model's feet sit 0.001 above the ground, far beyond it. HDR output's
|
||||
// marks on what it draws take it too (`hdr_entity_mark_pipeline`).
|
||||
let entity_bias = wgpu::DepthBiasState {
|
||||
constant: 128,
|
||||
slope_scale: 0.0,
|
||||
clamp: 0.0,
|
||||
};
|
||||
let entity_pipeline = entity_pipeline(
|
||||
"Entities",
|
||||
None,
|
||||
true,
|
||||
wgpu::CompareFunction::GreaterEqual,
|
||||
wgpu::DepthBiasState {
|
||||
constant: 128,
|
||||
slope_scale: 0.0,
|
||||
clamp: 0.0,
|
||||
},
|
||||
entity_bias,
|
||||
alpha_blend,
|
||||
false,
|
||||
);
|
||||
@@ -4048,12 +4152,26 @@ impl Renderer {
|
||||
"fs_hdr_mark",
|
||||
wgpu::ColorWrites::ALPHA,
|
||||
);
|
||||
// The same at `entity_pipeline`'s depth bias, for what it drew (entities, the hand,
|
||||
// translucent particles): without it the marks test a hair behind the depth it left
|
||||
// and never land.
|
||||
let hdr_entity_mark_pipeline = entity_pipeline_with(
|
||||
"HDR entity mark",
|
||||
None,
|
||||
false,
|
||||
wgpu::CompareFunction::GreaterEqual,
|
||||
entity_bias,
|
||||
wgpu::BlendState::REPLACE,
|
||||
false,
|
||||
"fs_hdr_mark",
|
||||
wgpu::ColorWrites::ALPHA,
|
||||
);
|
||||
let hdr_hand_unmark_pipeline = entity_pipeline_with(
|
||||
"HDR hand unmark",
|
||||
None,
|
||||
false,
|
||||
wgpu::CompareFunction::GreaterEqual,
|
||||
Default::default(),
|
||||
entity_bias,
|
||||
wgpu::BlendState::REPLACE,
|
||||
false,
|
||||
"fs_hdr_hand_unmark",
|
||||
@@ -4506,7 +4624,11 @@ impl Renderer {
|
||||
attributes: &TERRAIN_ATTRIBUTES,
|
||||
})];
|
||||
let info = adapter.get_info();
|
||||
// Metal's per-section path drops cutout leaves (the canopy above trunks vanishes),
|
||||
// and cave culling can drop the listed count below MIN_SECTIONS on a normal view:
|
||||
// so Metal draws vanilla terrain flat at any count.
|
||||
let min_sections = if flat::is_broken_intel_vulkan(&info)
|
||||
|| info.backend == wgpu::Backend::Metal
|
||||
|| std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1")
|
||||
{
|
||||
0
|
||||
@@ -4746,6 +4868,7 @@ impl Renderer {
|
||||
debug_lines_opaque,
|
||||
debug_lines,
|
||||
debug_lines_on_top,
|
||||
debug_crosshair,
|
||||
debug_line_buffer,
|
||||
debug_line_capacity: 4096,
|
||||
crosshair_group,
|
||||
@@ -4757,6 +4880,7 @@ impl Renderer {
|
||||
entity_pipeline,
|
||||
static_entity_pipeline,
|
||||
hdr_mark_pipeline,
|
||||
hdr_entity_mark_pipeline,
|
||||
hdr_hand_unmark_pipeline,
|
||||
hdr_mark_lightning_pipeline,
|
||||
weather,
|
||||
@@ -4860,6 +4984,7 @@ impl Renderer {
|
||||
lod_cutout: Default::default(),
|
||||
lod_retiring: Default::default(),
|
||||
lod_leaving: Vec::new(),
|
||||
hold: Default::default(),
|
||||
lod_pending: None,
|
||||
detail_columns: Default::default(),
|
||||
detail_revision: 0,
|
||||
@@ -4905,6 +5030,7 @@ impl Renderer {
|
||||
emission_stats,
|
||||
hdr_output,
|
||||
frame_format,
|
||||
hud_coverage,
|
||||
frame_kept_logged: false,
|
||||
hdr_marker: None,
|
||||
pack_post: None,
|
||||
@@ -4963,7 +5089,7 @@ impl Renderer {
|
||||
pass.draw_indexed(0..quads * 6, (first * 4) as i32, 0..1);
|
||||
if self.hdr.is_some() {
|
||||
// Drawn into the world: the light sources it holds are marked as the world's are.
|
||||
pass.set_pipeline(&self.hdr_mark_pipeline);
|
||||
pass.set_pipeline(&self.hdr_entity_mark_pipeline);
|
||||
pass.draw_indexed(0..quads * 6, (first * 4) as i32, 0..1);
|
||||
}
|
||||
}
|
||||
@@ -5454,12 +5580,28 @@ impl Renderer {
|
||||
if let Some(copies) = &mut self.capture_copies {
|
||||
copies.section(key, mesh);
|
||||
}
|
||||
if self.hold.holds(key) && self.shown(key) && self.upload_held(key, mesh) {
|
||||
return;
|
||||
}
|
||||
if let Some(gpu) = self.hold.take(key).and_then(|held| held.gpu) {
|
||||
self.release_mesh(gpu);
|
||||
}
|
||||
let facts = MeshFacts::of(mesh);
|
||||
let fading = self.before_mesh(key, &facts);
|
||||
let section = self.upload_mesh(mesh);
|
||||
self.after_mesh(key, &facts, section, fading);
|
||||
}
|
||||
/// What goes with section `key`'s new mesh, before its quads go in: its old mesh is
|
||||
/// released. The fade it keeps.
|
||||
fn before_mesh(&mut self, key: SectionKey, mesh: &MeshFacts) -> Option<Instant> {
|
||||
self.occlusion.set(key, mesh.visibility);
|
||||
let changed = if mesh.block_entities.is_empty() {
|
||||
self.block_entities.remove(&key).is_some()
|
||||
} else {
|
||||
let old = self.block_entities.insert(key, mesh.block_entities.clone());
|
||||
old.as_deref() != Some(&mesh.block_entities[..])
|
||||
let old = self
|
||||
.block_entities
|
||||
.insert(key, mesh.block_entities.to_vec());
|
||||
old.as_deref() != Some(mesh.block_entities)
|
||||
};
|
||||
if changed {
|
||||
self.block_entity_revision = next_block_entity_revision();
|
||||
@@ -5478,8 +5620,8 @@ impl Renderer {
|
||||
let lights_changed = if mesh.lights.is_empty() {
|
||||
self.light_blocks.remove(&key).is_some()
|
||||
} else {
|
||||
let old = self.light_blocks.insert(key, mesh.lights.clone());
|
||||
old.as_deref() != Some(&mesh.lights[..])
|
||||
let old = self.light_blocks.insert(key, mesh.lights.to_vec());
|
||||
old.as_deref() != Some(mesh.lights)
|
||||
};
|
||||
if lights_changed {
|
||||
self.light_revision += 1;
|
||||
@@ -5487,7 +5629,7 @@ impl Renderer {
|
||||
if !self.lod_retiring.is_empty() {
|
||||
self.lod_retiring.remove(&key);
|
||||
}
|
||||
if !mesh.translucent.is_empty() && !self.sections.contains(key) {
|
||||
if mesh.translucent && !self.sections.contains(key) {
|
||||
// New translucent terrain, not an edit's remesh: large pages wait for it.
|
||||
self.shrink_gate.wait(Instant::now(), SHRINK_SETTLE);
|
||||
}
|
||||
@@ -5506,13 +5648,19 @@ impl Renderer {
|
||||
self.newest_upload = Some(now);
|
||||
}
|
||||
self.release_section(key);
|
||||
let section = self
|
||||
.upload_mesh(mesh)
|
||||
.map(|section| SectionGpu { fading, ..section });
|
||||
self.set_detail(
|
||||
key,
|
||||
section.is_some() || (mesh.solid.is_empty() && mesh.translucent.is_empty()),
|
||||
);
|
||||
fading
|
||||
}
|
||||
/// Section `key`'s new mesh drawn from now on, its quads uploaded (`section`: none when it
|
||||
/// has none, or the arena had no room).
|
||||
fn after_mesh(
|
||||
&mut self,
|
||||
key: SectionKey,
|
||||
mesh: &MeshFacts,
|
||||
section: Option<SectionGpu>,
|
||||
fading: Option<Instant>,
|
||||
) {
|
||||
let section = section.map(|section| SectionGpu { fading, ..section });
|
||||
self.set_detail(key, section.is_some() || mesh.empty);
|
||||
if let Some(mut section) = section {
|
||||
let sections = self.layout.sections();
|
||||
if let Some(gpu) = &mut self.gpu_culling
|
||||
@@ -5536,6 +5684,94 @@ impl Renderer {
|
||||
.insert_marked(key, section, mesh.bounds, marked);
|
||||
}
|
||||
}
|
||||
/// Whether section `key` has its mesh on screen (`set_detail`): an empty one counts.
|
||||
fn shown(&self, key: SectionKey) -> bool {
|
||||
self.detail_columns
|
||||
.get(&(key.0, key.2))
|
||||
.is_some_and(|mask| mask.contains(key.1 as usize))
|
||||
}
|
||||
/// The bytes a section's quads take in the arenas.
|
||||
fn mesh_bytes(gpu: Option<&SectionGpu>) -> u64 {
|
||||
let stride = std::mem::size_of::<TerrainVertex>() as u64;
|
||||
gpu.map_or(0, |s| {
|
||||
(s.solid_slots + u64::from(s.translucent) * 4) * stride
|
||||
})
|
||||
}
|
||||
/// A mod's hold on the terrain's picture (gfx/hold.rs, `WorldHold`), asked once a frame
|
||||
/// before the frame's uploads: while held, a section on screen that is meshed again waits,
|
||||
/// uploaded, behind the mesh drawn. What no longer waits takes its place on this frame.
|
||||
pub fn hold_world(&mut self, ask: &WorldHold) {
|
||||
let waiting = match self.hold.ask(ask, Instant::now()) {
|
||||
hold::Release::None => return,
|
||||
hold::Release::All => self.hold.take_all(),
|
||||
hold::Release::Outside => self.hold.take_outside(),
|
||||
};
|
||||
self.place_held(waiting);
|
||||
}
|
||||
/// Whether a mod's hold covers chunk column `column` (`hold_world`): what changes there waits.
|
||||
pub fn world_holds_column(&self, column: (i32, i32)) -> bool {
|
||||
self.hold.holds((column.0, 0, column.1))
|
||||
}
|
||||
/// Section `key`'s new mesh waits behind the hold, uploaded. False when the arena has no
|
||||
/// room for it: what waited takes its place (giving the old meshes back), the hold is
|
||||
/// spent, and this one goes in as ever.
|
||||
fn upload_held(&mut self, key: SectionKey, mesh: &SectionMesh) -> bool {
|
||||
let facts = MeshFacts::of(mesh);
|
||||
let gpu = self.upload_mesh(mesh);
|
||||
if gpu.is_none() && !facts.empty {
|
||||
log::warn!("Horizon: no memory left for a held world's new sections: let go");
|
||||
self.hold.spend();
|
||||
let waiting = self.hold.take_all();
|
||||
self.place_held(waiting);
|
||||
return false;
|
||||
}
|
||||
let bytes = Self::mesh_bytes(gpu.as_ref());
|
||||
let held = HeldMesh {
|
||||
gpu,
|
||||
visibility: facts.visibility,
|
||||
block_entities: facts.block_entities.to_vec(),
|
||||
lights: facts.lights.to_vec(),
|
||||
bounds: facts.bounds,
|
||||
translucent: facts.translucent,
|
||||
empty: facts.empty,
|
||||
};
|
||||
let (old, over) = self.hold.wait(key, held, bytes);
|
||||
if let Some(gpu) = old.and_then(|old| old.gpu) {
|
||||
self.release_mesh(gpu);
|
||||
}
|
||||
if over {
|
||||
log::warn!(
|
||||
"Horizon: a held world's new sections came to {} MiB: let go",
|
||||
self.hold.bytes() >> 20
|
||||
);
|
||||
self.hold.spend();
|
||||
let waiting = self.hold.take_all();
|
||||
self.place_held(waiting);
|
||||
}
|
||||
true
|
||||
}
|
||||
/// Sections that waited behind the hold take their place, on this frame.
|
||||
fn place_held(&mut self, waiting: Vec<(SectionKey, HeldMesh)>) {
|
||||
if waiting.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (count, started) = (waiting.len(), Instant::now());
|
||||
let bytes: u64 = waiting
|
||||
.iter()
|
||||
.map(|(_, held)| Self::mesh_bytes(held.gpu.as_ref()))
|
||||
.sum();
|
||||
for (key, mut held) in waiting {
|
||||
let gpu = held.gpu.take();
|
||||
let facts = held.facts();
|
||||
let fading = self.before_mesh(key, &facts);
|
||||
self.after_mesh(key, &facts, gpu, fading);
|
||||
}
|
||||
log::debug!(
|
||||
"Horizon: {count} held sections ({} MiB) took their place in {:.1} ms",
|
||||
bytes >> 20,
|
||||
started.elapsed().as_secs_f64() * 1e3
|
||||
);
|
||||
}
|
||||
fn upload_mesh(&mut self, mesh: &SectionMesh) -> Option<SectionGpu> {
|
||||
let solid = (mesh.solid.len() / 4) as u32;
|
||||
let translucent = (mesh.translucent.len() / 4) as u32;
|
||||
@@ -5828,7 +6064,13 @@ impl Renderer {
|
||||
let meshes = self
|
||||
.sections
|
||||
.values_mut()
|
||||
.chain(self.lods.values_mut().flat_map(|tile| tile.meshes_mut()));
|
||||
.chain(self.lods.values_mut().flat_map(|tile| tile.meshes_mut()))
|
||||
// (meshes waiting behind a hold move with the rest, or they would keep the page)
|
||||
.chain(
|
||||
self.hold
|
||||
.sections_mut()
|
||||
.filter_map(|held| held.gpu.as_mut()),
|
||||
);
|
||||
let flat = self
|
||||
.flat
|
||||
.as_mut()
|
||||
@@ -5871,7 +6113,12 @@ impl Renderer {
|
||||
let meshes = self
|
||||
.sections
|
||||
.values_mut()
|
||||
.chain(self.lods.values_mut().flat_map(|tile| tile.meshes_mut()));
|
||||
.chain(self.lods.values_mut().flat_map(|tile| tile.meshes_mut()))
|
||||
.chain(
|
||||
self.hold
|
||||
.sections_mut()
|
||||
.filter_map(|held| held.gpu.as_mut()),
|
||||
);
|
||||
match begin_shrink(arena, &self.device, meshes, *part) {
|
||||
Some(true) => break,
|
||||
Some(false) => wait = SHRINK_RETRY, // the other arena may still go
|
||||
@@ -5895,7 +6142,13 @@ impl Renderer {
|
||||
let meshes = self
|
||||
.sections
|
||||
.values_mut()
|
||||
.chain(self.lods.values_mut().flat_map(|tile| tile.meshes_mut()));
|
||||
.chain(self.lods.values_mut().flat_map(|tile| tile.meshes_mut()))
|
||||
// (meshes waiting behind a hold move with the rest, or they would keep the page)
|
||||
.chain(
|
||||
self.hold
|
||||
.sections_mut()
|
||||
.filter_map(|held| held.gpu.as_mut()),
|
||||
);
|
||||
// As many quads a frame either way: a quad's indices are a quarter of its bytes.
|
||||
let cap = match part {
|
||||
Part::Indices => COMPACT_BUDGET / 4,
|
||||
@@ -6068,6 +6321,8 @@ impl Renderer {
|
||||
self.newest_lod_fade = None;
|
||||
self.block_entity_revision = next_block_entity_revision();
|
||||
self.sections.clear();
|
||||
// (a hold on land that is gone ends; the arenas' clearing below takes what waited)
|
||||
self.hold.clear();
|
||||
self.lods.clear();
|
||||
self.lod_cutout.clear();
|
||||
self.lod_retiring.clear();
|
||||
@@ -6138,6 +6393,13 @@ impl Renderer {
|
||||
if want == self.packed_quads {
|
||||
return;
|
||||
}
|
||||
let held_packed = |held: &HeldMesh| held.gpu.as_ref().is_some_and(|s| s.packed);
|
||||
if !want && self.hold.sections().any(held_packed) {
|
||||
// (held meshes in the layout going away take their place, and go with the rest)
|
||||
self.hold.spend();
|
||||
let waiting = self.hold.take_all();
|
||||
self.place_held(waiting);
|
||||
}
|
||||
self.packed_quads = want;
|
||||
packed::pack_quads(want);
|
||||
if !want && self.sections.values().any(|s| s.packed) {
|
||||
@@ -6245,6 +6507,11 @@ impl Renderer {
|
||||
if let Some(copies) = &mut self.capture_copies {
|
||||
copies.remove_columns(&columns);
|
||||
}
|
||||
if !self.hold.is_empty() && !columns.is_empty() {
|
||||
let gone = columns.iter().copied().collect();
|
||||
let held = self.hold.take_columns(&gone);
|
||||
self.release_meshes(held.into_iter().filter_map(|held| held.gpu).collect());
|
||||
}
|
||||
// Most of the view at once (a teleport at render distance 32 drops some 3700
|
||||
// columns): a pass over each store instead of a lookup per section key, which
|
||||
// took 10 ms of one frame. Far terrain fades the sections out instead.
|
||||
@@ -6497,8 +6764,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 +6858,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;
|
||||
@@ -10285,7 +10562,7 @@ impl Renderer {
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
|
||||
if self.hdr.is_some() {
|
||||
pass.set_pipeline(&self.hdr_mark_pipeline);
|
||||
pass.set_pipeline(&self.hdr_entity_mark_pipeline);
|
||||
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
|
||||
}
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
@@ -10747,7 +11024,7 @@ impl Renderer {
|
||||
);
|
||||
if self.hdr.is_some() {
|
||||
// Fireworks: their sparks are translucent-layer particles.
|
||||
pass.set_pipeline(&self.hdr_mark_pipeline);
|
||||
pass.set_pipeline(&self.hdr_entity_mark_pipeline);
|
||||
pass.draw_indexed(
|
||||
0..translucent_particle_quads * 6,
|
||||
((base_quads - translucent_item_quads - translucent_particle_quads) * 4)
|
||||
@@ -11097,10 +11374,18 @@ impl Renderer {
|
||||
);
|
||||
}
|
||||
laps.lap("r_passes");
|
||||
// What an SDR picture of the frame shows: on HDR output's layered frame its HUD target,
|
||||
// the HUD over the world clipped to white.
|
||||
let sdr_texture = self
|
||||
.hdr
|
||||
.as_ref()
|
||||
.and_then(|h| h.hud())
|
||||
.cloned()
|
||||
.unwrap_or_else(|| target_texture.clone());
|
||||
// Under `HORIZON_FRAMEGEN_DEBUG=generated` a screenshot is the made frame.
|
||||
let shown_texture = match &fg_frames {
|
||||
Some((_, generated)) if fg_made && frame_gen::generated_only() => generated,
|
||||
_ => &target_texture,
|
||||
_ => &sdr_texture,
|
||||
};
|
||||
let readback = readback.or_else(|| {
|
||||
self.screenshot
|
||||
@@ -11138,7 +11423,7 @@ impl Renderer {
|
||||
(Some(_), None) => {
|
||||
if let Some(image) = &self.offscreen {
|
||||
self.readback
|
||||
.draw_onto(&self.device, &mut encoder, &target_texture, image);
|
||||
.draw_onto(&self.device, &mut encoder, &sdr_texture, image);
|
||||
}
|
||||
}
|
||||
(None, _) => {}
|
||||
@@ -11329,8 +11614,15 @@ impl Renderer {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// HDR output's layered frame keeps the world after the hand and the camera overlays,
|
||||
// as light (`Hdr::layered`); else it is kept here, before them.
|
||||
let layered = self.hdr.as_ref().is_some_and(|h| h.layered());
|
||||
if let Some(hdr) = &self.hdr {
|
||||
hdr.keep_world(encoder, timing.as_mut());
|
||||
if layered {
|
||||
hdr.begin(&self.queue, !frame.title);
|
||||
} else {
|
||||
hdr.keep_world(encoder, timing.as_mut());
|
||||
}
|
||||
}
|
||||
if overlay.hand_quads > 0 {
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
@@ -11369,7 +11661,14 @@ impl Renderer {
|
||||
drop(pass);
|
||||
if let Some(hdr) = &self.hdr {
|
||||
hdr.mark_hand(encoder, timing.as_mut());
|
||||
if let Some(view) = hdr.hand_light_view() {
|
||||
// On the layered frame the hand's marks go to the frame, before they become
|
||||
// light with the world's.
|
||||
let marks = if layered {
|
||||
Some(view_target.clone())
|
||||
} else {
|
||||
hdr.hand_light_view()
|
||||
};
|
||||
if let Some(view) = marks {
|
||||
// The light sources in the hand may shine, the rest of it stays at the
|
||||
// world's brightness: nothing the world kept here shows through it.
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
@@ -11401,11 +11700,19 @@ impl Renderer {
|
||||
let quads = 0..overlay.hand_quads * 6;
|
||||
pass.set_pipeline(&self.hdr_hand_unmark_pipeline);
|
||||
pass.draw_indexed(quads.clone(), overlay.first_hand_vertex, 0..1);
|
||||
pass.set_pipeline(&self.hdr_mark_pipeline);
|
||||
pass.set_pipeline(&self.hdr_entity_mark_pipeline);
|
||||
pass.draw_indexed(quads, overlay.first_hand_vertex, 0..1);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The layered frame's marks become light: what draws from here draws on light, and
|
||||
// nothing is lifted after.
|
||||
if layered
|
||||
&& !frame.title
|
||||
&& let Some(hdr) = &self.hdr
|
||||
{
|
||||
hdr.resolve(encoder, timing.as_mut());
|
||||
}
|
||||
// A native mod's turn after the hand, on the real frame only.
|
||||
if overlay.real && !frame.title {
|
||||
native_render::native_hand_drawn(&mut native_render::HandDrawn {
|
||||
@@ -11538,11 +11845,25 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
}
|
||||
if layered && let Some(hdr) = &self.hdr {
|
||||
hdr.keep_world(encoder, timing.as_mut());
|
||||
}
|
||||
// What the HUD and menus draw on: the layered frame's HUD target, else the frame.
|
||||
let hud = self
|
||||
.hdr
|
||||
.as_ref()
|
||||
.and_then(|h| h.hud())
|
||||
.map(|t| t.create_view(&Default::default()));
|
||||
let hud_target = hud.as_ref().unwrap_or(view_target);
|
||||
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 {
|
||||
view: view_target,
|
||||
let mut attachments = vec![Some(wgpu::RenderPassColorAttachment {
|
||||
view: hud_target,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
@@ -11550,6 +11871,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 +11888,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;
|
||||
@@ -11576,7 +11904,8 @@ impl Renderer {
|
||||
let blur_at = menu_blur.map_or(0, |_| frame.hud.blur_at);
|
||||
self.hud.draw_range(
|
||||
encoder,
|
||||
view_target,
|
||||
hud_target,
|
||||
coverage.as_ref(),
|
||||
&self.depth,
|
||||
frame.hud,
|
||||
[self.config.width, self.config.height],
|
||||
@@ -11587,24 +11916,48 @@ impl Renderer {
|
||||
let format = self.frame_format;
|
||||
let device = &self.device;
|
||||
let software = is_software(self.info.device_type);
|
||||
self.menu_blur
|
||||
.get_or_insert_with(|| crate::menu_blur::MenuBlur::new(device, format, software))
|
||||
.apply(
|
||||
&self.device,
|
||||
let blur = self
|
||||
.menu_blur
|
||||
.get_or_insert_with(|| crate::menu_blur::MenuBlur::new(device, format, software));
|
||||
let size = [self.config.width, self.config.height];
|
||||
match self.hdr.as_ref().filter(|_| layered) {
|
||||
// The blur keeps light above white: it blurs the picture so far, the HUD over
|
||||
// the world's light, which becomes the world the menu draws over.
|
||||
Some(hdr) => {
|
||||
let (_, source) = blur.source(device, size);
|
||||
hdr.composite(encoder, &source, timing.as_mut());
|
||||
blur.blur(
|
||||
device,
|
||||
&self.queue,
|
||||
encoder,
|
||||
view_target,
|
||||
size,
|
||||
radius,
|
||||
timing.as_mut(),
|
||||
);
|
||||
hdr.keep_world(encoder, timing.as_mut());
|
||||
}
|
||||
None => blur.apply(
|
||||
device,
|
||||
&self.queue,
|
||||
encoder,
|
||||
target_texture,
|
||||
view_target,
|
||||
[self.config.width, self.config.height],
|
||||
size,
|
||||
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,
|
||||
hud_target,
|
||||
coverage.as_ref(),
|
||||
&self.depth,
|
||||
frame.hud,
|
||||
[self.config.width, self.config.height],
|
||||
|
||||
@@ -72,8 +72,29 @@ fn named(name: Option<&str>) -> Option<Output> {
|
||||
struct Targets {
|
||||
size: [u32; 2],
|
||||
frame: wgpu::Texture,
|
||||
/// The world as `keep_world` kept it; on the layered frame, the frame as the world and the
|
||||
/// hand marked it, for `resolve`.
|
||||
world: wgpu::Texture,
|
||||
coverage: Option<wgpu::Texture>,
|
||||
/// The layered frame's HUD target: what the HUD draws on.
|
||||
hud: Option<wgpu::Texture>,
|
||||
group: wgpu::BindGroup,
|
||||
/// The layered frame's passes' bindings: `resolve`, `keep_world` and `composite`.
|
||||
passes: Option<[wgpu::BindGroup; 3]>,
|
||||
}
|
||||
|
||||
/// The float frame with HUD coverage (`hud_coverage::supported`, docs/design/hdr-linear-light.md
|
||||
/// "The HUD and menus"): the world's marks become light once, after the world and the hand
|
||||
/// (`resolve`); the camera overlays draw on that light; the HUD draws on a target of its own,
|
||||
/// the world clipped to white (`keep_world`); and the last pass composites the HUD over the
|
||||
/// world's light by its coverage (`fs_hdr_layered`), as the menu blur does before it blurs
|
||||
/// (`composite`).
|
||||
struct Layered {
|
||||
resolve: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
|
||||
keep: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
|
||||
composite: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
|
||||
/// What `resolve` and `composite` read, written before they run (`begin`).
|
||||
params: wgpu::Buffer,
|
||||
}
|
||||
|
||||
/// How one tier of light sources (`mesher::hdr_tier`) shines: `share` of the headroom for
|
||||
@@ -155,6 +176,7 @@ pub struct Hdr {
|
||||
targets: Option<Targets>,
|
||||
/// `HORIZON_HDR_DEBUG=1`: grey what isn't expanded, tint what is magenta.
|
||||
debug: bool,
|
||||
layered: Option<Layered>,
|
||||
/// Paper white and peak brightness, in nits.
|
||||
pub paper_white: f32,
|
||||
pub peak: f32,
|
||||
@@ -181,28 +203,67 @@ 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");
|
||||
// The layered frame's last pass, else today's.
|
||||
let entry_point = if coverage { "fs_hdr_layered" } else { "fs_hdr" };
|
||||
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 = format!(
|
||||
"@fragment fn {entry_point}(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32>"
|
||||
);
|
||||
assert!(
|
||||
source.contains(&entry),
|
||||
"hdr.wgsl's {entry_point} changed its signature"
|
||||
);
|
||||
source = source.replace(&entry, "fn picture_hdr(pos: vec4<f32>) -> vec4<f32>");
|
||||
source.push_str(&entry);
|
||||
source.push_str(
|
||||
r#" {
|
||||
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,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
};
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Horizon HDR bindings"),
|
||||
entries: &[
|
||||
texture(0),
|
||||
texture(1),
|
||||
texture(3),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
// hdr.wgsl's bindings: the textures at `textures`, and its `Params` at 2 if `params`.
|
||||
let layout = |label, textures: &[u32], params: bool| {
|
||||
let texture = |binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
};
|
||||
let mut entries: Vec<_> = textures.iter().map(|&b| texture(b)).collect();
|
||||
if params {
|
||||
entries.push(wgpu::BindGroupLayoutEntry {
|
||||
binding: 2,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
@@ -211,17 +272,22 @@ impl Hdr {
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Horizon HDR"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let pipeline_for = |format| {
|
||||
});
|
||||
}
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some(label),
|
||||
entries: &entries,
|
||||
})
|
||||
};
|
||||
// One full-screen pass of hdr.wgsl's `entry` into `format`, reading `layout`.
|
||||
let pass = |label, layout: &wgpu::BindGroupLayout, entry, format| {
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some(label),
|
||||
bind_group_layouts: &[Some(layout)],
|
||||
..Default::default()
|
||||
});
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Horizon HDR"),
|
||||
label: Some(label),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
@@ -234,7 +300,7 @@ impl Hdr {
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_hdr"),
|
||||
entry_point: Some(entry),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
@@ -246,6 +312,30 @@ impl Hdr {
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let layout_bindings: &[u32] = if coverage { &[0, 3, 4, 5] } else { &[0, 1, 3] };
|
||||
let display = layout("Horizon HDR bindings", layout_bindings, true);
|
||||
let pipeline_for = |format| pass("Horizon HDR", &display, entry_point, format);
|
||||
let params = || {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon HDR parameters"),
|
||||
size: 64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
};
|
||||
let layered = coverage.then(|| {
|
||||
let with = |label, textures: &[u32], params, entry| {
|
||||
let layout = layout(label, textures, params);
|
||||
let pipeline = pass(label, &layout, entry, format);
|
||||
(layout, pipeline)
|
||||
};
|
||||
Layered {
|
||||
resolve: with("Horizon HDR marks to light", &[1], true, "fs_resolve"),
|
||||
keep: with("Horizon HDR HUD backdrop", &[0], false, "fs_keep"),
|
||||
composite: with("Horizon HDR composite", &[0, 4, 5], true, "fs_composite"),
|
||||
params: params(),
|
||||
}
|
||||
});
|
||||
let alpha_pass = |label, blend| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
@@ -288,27 +378,20 @@ impl Hdr {
|
||||
alpha: scale_alpha,
|
||||
}),
|
||||
);
|
||||
let params = || {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon HDR parameters"),
|
||||
size: 64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
};
|
||||
Self {
|
||||
logo: logo_mask(device, queue),
|
||||
layout,
|
||||
pipeline: pipeline_for(output.format()),
|
||||
capture_pipeline: pipeline_for(Output::Pq.format()),
|
||||
layout: display,
|
||||
clear_alpha,
|
||||
hand_mark,
|
||||
params: params(),
|
||||
capture_pipeline: pipeline_for(Output::Pq.format()),
|
||||
capture_params: params(),
|
||||
output,
|
||||
format,
|
||||
targets: None,
|
||||
debug: std::env::var_os("HORIZON_HDR_DEBUG").is_some_and(|v| v == "1"),
|
||||
debug: debug == Some("1"),
|
||||
layered,
|
||||
paper_white: 200.0,
|
||||
peak: 1000.0,
|
||||
black: 0.0,
|
||||
@@ -324,7 +407,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 +418,185 @@ 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 layered = self.layered.is_some();
|
||||
let coverage = layered.then(|| {
|
||||
texture(
|
||||
"HUD coverage",
|
||||
super::hud_coverage::FORMAT,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
)
|
||||
});
|
||||
// In the frame's format, so the HUD's pipelines draw on it as on the frame with HDR
|
||||
// off; the last pass clips it at white.
|
||||
let hud = layered.then(|| {
|
||||
texture(
|
||||
"HUD",
|
||||
self.format,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
)
|
||||
});
|
||||
let group = self.group(
|
||||
device,
|
||||
&frame,
|
||||
&world,
|
||||
coverage.as_ref(),
|
||||
hud.as_ref(),
|
||||
&self.params,
|
||||
);
|
||||
let passes = self
|
||||
.layered
|
||||
.as_ref()
|
||||
.zip(coverage.as_ref().zip(hud.as_ref()))
|
||||
.map(|(l, (coverage, hud))| {
|
||||
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
|
||||
let (frame, world, coverage, hud) =
|
||||
(view(&frame), view(&world), view(coverage), view(hud));
|
||||
let entry = |binding, view| wgpu::BindGroupEntry {
|
||||
binding,
|
||||
resource: wgpu::BindingResource::TextureView(view),
|
||||
};
|
||||
let params = wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: l.params.as_entire_binding(),
|
||||
};
|
||||
let group = |layout, entries: &[wgpu::BindGroupEntry]| {
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Horizon HDR layered bindings"),
|
||||
layout,
|
||||
entries,
|
||||
})
|
||||
};
|
||||
[
|
||||
group(&l.resolve.0, &[entry(1, &world), params.clone()]),
|
||||
group(&l.keep.0, &[entry(0, &frame)]),
|
||||
group(
|
||||
&l.composite.0,
|
||||
&[
|
||||
entry(0, &frame),
|
||||
params,
|
||||
entry(4, &coverage),
|
||||
entry(5, &hud),
|
||||
],
|
||||
),
|
||||
]
|
||||
});
|
||||
self.targets = Some(Targets {
|
||||
size,
|
||||
frame,
|
||||
world,
|
||||
coverage,
|
||||
hud,
|
||||
group,
|
||||
passes,
|
||||
});
|
||||
}
|
||||
self.targets.as_ref().unwrap().frame.clone()
|
||||
}
|
||||
|
||||
/// The float frame with HUD coverage (`Layered`).
|
||||
pub fn layered(&self) -> bool {
|
||||
self.layered.is_some()
|
||||
}
|
||||
|
||||
/// The layered frame's HUD target, which the HUD and menus draw on instead of the frame
|
||||
/// once `keep_world` made it, and which an ordinary screenshot shows.
|
||||
pub fn hud(&self) -> Option<&wgpu::Texture> {
|
||||
self.targets.as_ref()?.hud.as_ref()
|
||||
}
|
||||
|
||||
/// On the layered frame, before `resolve` and `composite`: what they read, for a frame that
|
||||
/// drew a world (`world`) or not.
|
||||
pub fn begin(&self, queue: &wgpu::Queue, world: bool) {
|
||||
if let Some(l) = &self.layered {
|
||||
queue.write_buffer(
|
||||
&l.params,
|
||||
0,
|
||||
bytemuck::cast_slice(&self.params(world, false)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// On the layered frame, after the world and the hand drew and marked it: the marks
|
||||
/// become light in the frame (`fs_resolve`), read from a copy of it in the world target.
|
||||
pub fn resolve(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
let (Some(l), Some(t)) = (&self.layered, &self.targets) else {
|
||||
return;
|
||||
};
|
||||
let Some([group, ..]) = &t.passes else {
|
||||
return;
|
||||
};
|
||||
encoder.copy_texture_to_texture(
|
||||
t.frame.as_image_copy(),
|
||||
t.world.as_image_copy(),
|
||||
t.frame.size(),
|
||||
);
|
||||
let view = t.frame.create_view(&Default::default());
|
||||
Self::draw(
|
||||
encoder,
|
||||
&l.resolve.1,
|
||||
group,
|
||||
&view,
|
||||
"HDR marks to light",
|
||||
timing,
|
||||
);
|
||||
}
|
||||
|
||||
/// On the layered frame: the picture so far (`fs_composite`), as the frame stores light,
|
||||
/// into `target`, for the menu blur to blur it and make it the world (`keep_world` after).
|
||||
pub fn composite(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
target: &wgpu::TextureView,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
if let (
|
||||
Some(l),
|
||||
Some(Targets {
|
||||
passes: Some([_, _, group]),
|
||||
..
|
||||
}),
|
||||
) = (&self.layered, &self.targets)
|
||||
{
|
||||
Self::draw(
|
||||
encoder,
|
||||
&l.composite.1,
|
||||
group,
|
||||
target,
|
||||
"HDR composite",
|
||||
timing,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -394,6 +610,9 @@ impl Hdr {
|
||||
/// Keeps the world as drawn so far, before the hand and HUD cover it, then zeroes the
|
||||
/// frame's alpha: the world's marks live on in the copy, and whatever draws from here
|
||||
/// on raises the alpha where it covers the world.
|
||||
///
|
||||
/// On the layered frame the frame keeps the world, the hand and the camera overlays, as
|
||||
/// light, and this makes the HUD's backdrop from it: clipped to white, alpha 0 (`fs_keep`).
|
||||
pub fn keep_world(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
@@ -402,6 +621,11 @@ impl Hdr {
|
||||
let Some(t) = &self.targets else {
|
||||
return;
|
||||
};
|
||||
if let (Some(l), Some(hud), Some([_, group, _])) = (&self.layered, &t.hud, &t.passes) {
|
||||
let view = hud.create_view(&Default::default());
|
||||
Self::draw(encoder, &l.keep.1, group, &view, "HDR HUD backdrop", timing);
|
||||
return;
|
||||
}
|
||||
encoder.copy_texture_to_texture(
|
||||
t.frame.as_image_copy(),
|
||||
t.world.as_image_copy(),
|
||||
@@ -429,13 +653,14 @@ impl Hdr {
|
||||
|
||||
/// After the hand: its pixels' alpha goes down to 1/255, below anything the HUD and
|
||||
/// menus leave, so HDR output tells the hand (the world's brightness, never lifted)
|
||||
/// from the UI (UI Brightness).
|
||||
/// from the UI (UI Brightness). The layered frame keeps the hand before `keep_world`,
|
||||
/// so it needs no mark there.
|
||||
pub fn mark_hand(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
let Some(t) = &self.targets else {
|
||||
let Some(t) = self.targets.as_ref().filter(|_| self.layered.is_none()) else {
|
||||
return;
|
||||
};
|
||||
let view = t.frame.create_view(&Default::default());
|
||||
@@ -466,9 +691,10 @@ impl Hdr {
|
||||
}
|
||||
|
||||
/// The world as `keep_world` kept it: where the hand's marks go (its alpha, which HDR output
|
||||
/// reads for it too), and what tints the world rather than covering it.
|
||||
/// reads for it too), and what tints the world rather than covering it. None on the
|
||||
/// layered frame, where both draw on the frame before `keep_world`.
|
||||
pub fn hand_light_view(&self) -> Option<wgpu::TextureView> {
|
||||
let t = self.targets.as_ref()?;
|
||||
let t = self.targets.as_ref().filter(|_| self.layered.is_none())?;
|
||||
Some(t.world.create_view(&Default::default()))
|
||||
}
|
||||
|
||||
@@ -494,7 +720,15 @@ impl Hdr {
|
||||
0,
|
||||
bytemuck::cast_slice(&self.params(world, self.output == Output::Pq)),
|
||||
);
|
||||
Self::draw(encoder, &self.pipeline, &t.group, target, timing);
|
||||
let view = target.create_view(&Default::default());
|
||||
Self::draw(
|
||||
encoder,
|
||||
&self.pipeline,
|
||||
&t.group,
|
||||
&view,
|
||||
"HDR output",
|
||||
timing,
|
||||
);
|
||||
}
|
||||
|
||||
/// The frame `present` writes, as HDR10 (BT.2020, PQ, 10 bits a channel) whatever
|
||||
@@ -517,38 +751,66 @@ 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(),
|
||||
t.hud.as_ref(),
|
||||
&self.capture_params,
|
||||
);
|
||||
queue.write_buffer(
|
||||
&self.capture_params,
|
||||
0,
|
||||
bytemuck::cast_slice(&self.params(world, true)),
|
||||
);
|
||||
Self::draw(encoder, &self.capture_pipeline, &group, &texture, None);
|
||||
let view = texture.create_view(&Default::default());
|
||||
Self::draw(
|
||||
encoder,
|
||||
&self.capture_pipeline,
|
||||
&group,
|
||||
&view,
|
||||
"HDR output",
|
||||
None,
|
||||
);
|
||||
Some(texture)
|
||||
}
|
||||
|
||||
fn group(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
frame: &wgpu::Texture,
|
||||
world: &wgpu::Texture,
|
||||
coverage: Option<&wgpu::Texture>,
|
||||
hud: Option<&wgpu::Texture>,
|
||||
params: &wgpu::Buffer,
|
||||
) -> wgpu::BindGroup {
|
||||
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
|
||||
let (frame, world) = (view(frame), view(world));
|
||||
let layered = coverage.zip(hud).map(|(c, h)| (view(c), view(h)));
|
||||
let texture = |binding, view| wgpu::BindGroupEntry {
|
||||
binding,
|
||||
resource: wgpu::BindingResource::TextureView(view),
|
||||
};
|
||||
let mut entries = vec![
|
||||
texture(0, &frame),
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: params.as_entire_binding(),
|
||||
},
|
||||
texture(3, &self.logo),
|
||||
];
|
||||
match &layered {
|
||||
Some((coverage, hud)) => entries.extend([texture(4, coverage), texture(5, hud)]),
|
||||
None => entries.push(texture(1, &world)),
|
||||
}
|
||||
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);
|
||||
@@ -577,16 +839,17 @@ impl Hdr {
|
||||
params
|
||||
}
|
||||
|
||||
/// One full-screen pass of `pipeline` into `view`.
|
||||
fn draw(
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
pipeline: &wgpu::RenderPipeline,
|
||||
group: &wgpu::BindGroup,
|
||||
target: &wgpu::Texture,
|
||||
view: &wgpu::TextureView,
|
||||
label: &'static str,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
let view = target.create_view(&Default::default());
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
@@ -595,8 +858,8 @@ impl Hdr {
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon HDR"),
|
||||
timestamp_writes: timing.and_then(|t| t.writes("HDR output")),
|
||||
label: Some(label),
|
||||
timestamp_writes: timing.and_then(|t| t.writes(label)),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
@@ -655,9 +918,279 @@ 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_shows_in_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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn gpu() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
}
|
||||
|
||||
/// Clears all of `texture` to `color`.
|
||||
fn fill(encoder: &mut wgpu::CommandEncoder, texture: &wgpu::Texture, color: [f64; 4]) {
|
||||
let view = texture.create_view(&Default::default());
|
||||
let mut attachment = super::super::hud_coverage::attachment(&view);
|
||||
let [r, g, b, a] = color;
|
||||
attachment.ops.load = wgpu::LoadOp::Clear(wgpu::Color { r, g, b, a });
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
color_attachments: &[Some(attachment)],
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
/// The first texel of `texture` once `encoder` ran: four half floats, or 10-bit PQ codes
|
||||
/// for HDR10.
|
||||
fn texel(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
mut encoder: wgpu::CommandEncoder,
|
||||
texture: &wgpu::Texture,
|
||||
) -> [f32; 4] {
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 256,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_texture_to_buffer(
|
||||
texture.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &buffer,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(256),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
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();
|
||||
if texture.format() == Output::Pq.format() {
|
||||
let pixel = u32::from_le_bytes(bytes[..4].try_into().unwrap());
|
||||
return [0, 10, 20, 30].map(|shift| ((pixel >> shift) & 0x3ff) as f32);
|
||||
}
|
||||
std::array::from_fn(|i| half::f16::from_le_bytes([bytes[2 * i], bytes[2 * i + 1]]).to_f32())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_layered_frame_shows_the_world_as_the_last_pass_lifted_it() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
return;
|
||||
};
|
||||
// What the last pass shows of a world pixel nothing covers, from today's frame
|
||||
// (`layered` false) or the layered one, whose marks turn into light before it.
|
||||
let shown = |layered: bool, world: [f64; 4]| {
|
||||
let mut hdr =
|
||||
Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, layered, None);
|
||||
let frame = hdr.frame(&device, [1, 1]);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
fill(&mut encoder, &frame, world);
|
||||
if layered {
|
||||
hdr.begin(&queue, true);
|
||||
hdr.resolve(&mut encoder, None);
|
||||
hdr.keep_world(&mut encoder, None);
|
||||
super::super::hud_coverage::clear(&mut encoder, &hdr.coverage_view().unwrap());
|
||||
} else {
|
||||
hdr.keep_world(&mut encoder, None);
|
||||
}
|
||||
let capture = hdr.capture(&device, &queue, &mut encoder, true).unwrap();
|
||||
texel(&device, &queue, encoder, &capture)
|
||||
};
|
||||
// Unmarked, below white and above it; HDR lighting; light sources of each tier.
|
||||
for world in [
|
||||
[0.5, 0.4, 0.3, 1.0],
|
||||
[1.5, 1.2, 0.6, 1.0],
|
||||
[0.8, 0.7, 0.6, 0.88],
|
||||
[0.9, 0.8, 0.4, 0.05],
|
||||
[0.9, 0.8, 0.4, 0.2],
|
||||
[0.9, 0.8, 0.4, 0.4],
|
||||
[0.9, 0.8, 0.4, 0.6],
|
||||
] {
|
||||
let (today, layered) = (shown(false, world), shown(true, world));
|
||||
// Half floats round the light apart by a step at most.
|
||||
assert!(
|
||||
today.iter().zip(layered).all(|(a, b)| (a - b).abs() <= 1.0),
|
||||
"{world:?}: {today:?} {layered:?}"
|
||||
);
|
||||
if world[3] < 1.0 {
|
||||
assert!(layered[0] > shown(true, [world[0], world[1], world[2], 1.0])[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_hud_lays_over_the_worlds_light_by_its_coverage() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let target = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: F::Rgba16Float,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
// The world's light, the HUD's target over it (on the world clipped to white), its
|
||||
// coverage, UI Brightness, and the composite, all in gamma as the frame stores them.
|
||||
for (world, hud, coverage, ui, want) in [
|
||||
// Nothing over it: the world, light above white included.
|
||||
(3.0, 1.0, 0.0, 1.0, 3.0),
|
||||
// Half-transparent black: half of the world shows, as the HUD blends.
|
||||
(2.0, 0.5, 0.5, 1.0, 1.0),
|
||||
// Covered: the HUD as drawn, at UI Brightness.
|
||||
(2.0, 0.3, 1.0, 1.0, 0.3),
|
||||
(2.0, 0.3, 1.0, 0.5, 0.3 * 0.5f32.powf(1.0 / 2.2)),
|
||||
// Added over the world (the text highlight), which it doesn't hide.
|
||||
(0.6, 0.8, 0.0, 1.0, 0.8),
|
||||
// At or below white, at 100%: the HUD's picture as drawn.
|
||||
(0.7, 0.45, 0.5, 1.0, 0.45),
|
||||
] {
|
||||
hdr.ui = ui;
|
||||
let frame = hdr.frame(&device, [1, 1]);
|
||||
let t = hdr.targets.as_ref().unwrap();
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
fill(&mut encoder, &frame, [world, world, world, 1.0]);
|
||||
fill(&mut encoder, t.hud.as_ref().unwrap(), [hud, hud, hud, 0.0]);
|
||||
fill(&mut encoder, t.coverage.as_ref().unwrap(), [coverage; 4]);
|
||||
hdr.begin(&queue, true);
|
||||
hdr.composite(&mut encoder, &target.create_view(&Default::default()), None);
|
||||
let got = texel(&device, &queue, encoder, &target);
|
||||
assert!(
|
||||
(got[0] - want).abs() < 0.01 && got[3] == 1.0,
|
||||
"{world} {hud} {coverage} {ui}: {got:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_menu_blur_keeps_the_light_it_blurs() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let size = [8, 8];
|
||||
let frame = hdr.frame(&device, size);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
fill(&mut encoder, &frame, [4.0, 4.0, 4.0, 1.0]);
|
||||
hdr.keep_world(&mut encoder, None);
|
||||
super::super::hud_coverage::clear(&mut encoder, &hdr.coverage_view().unwrap());
|
||||
let mut blur = crate::menu_blur::MenuBlur::new(&device, F::Rgba16Float, false);
|
||||
let (_, source) = blur.source(&device, size);
|
||||
hdr.begin(&queue, true);
|
||||
hdr.composite(&mut encoder, &source, None);
|
||||
let view = frame.create_view(&Default::default());
|
||||
blur.blur(&device, &queue, &mut encoder, &view, size, 3.0, None);
|
||||
hdr.keep_world(&mut encoder, None);
|
||||
let hud = hdr.hud().unwrap().clone();
|
||||
// The blurred world keeps its light, and the HUD's backdrop starts again from it.
|
||||
let [r, ..] = texel(&device, &queue, encoder, &frame);
|
||||
assert!((r - 4.0).abs() < 0.01, "{r}");
|
||||
let encoder = device.create_command_encoder(&Default::default());
|
||||
assert_eq!(texel(&device, &queue, encoder, &hud), [1.0, 1.0, 1.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_shaders_validate() {
|
||||
let entity = crate::overlay::wgsl_constants() + include_str!("../entity.wgsl");
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
//! A held world (`WorldHold`, the native hook `native_world_hold`): the terrain goes on being
|
||||
//! drawn as it stands while what changes under it is meshed, and all of it takes its place on
|
||||
//! one frame. A section already on screen that is meshed again is uploaded to the arena as ever,
|
||||
//! over as many frames as its mesh takes to come, but waits beside the mesh drawn (`Hold`);
|
||||
//! letting go only swaps which of the two is drawn, with nothing left to upload. Sections not on
|
||||
//! screen yet (terrain streaming in) are not held.
|
||||
//!
|
||||
//! Both meshes of a waiting section are in memory, so a hold costs up to what the changed land's
|
||||
//! new meshes come to. It is bounded, and each bound lets go at once rather than losing anything:
|
||||
//! `MAX_BYTES` of waiting meshes, `MAX_HOLD` of time, and the arena running out
|
||||
//! (`Renderer::upload`). It never outlives what asked for it: the mods are asked every frame,
|
||||
//! and the sections' being cleared (the world left, another dimension) drops what waits. A hold
|
||||
//! that ended by itself (spent) stays ended until no mod has asked for a frame.
|
||||
use super::SectionKey;
|
||||
use crate::fast_hash::{FxHashMap, FxHashSet};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The longest a hold lasts.
|
||||
pub(super) const MAX_HOLD: Duration = Duration::from_secs(30);
|
||||
/// The most bytes of new meshes that wait.
|
||||
pub(super) const MAX_BYTES: u64 = 1 << 30;
|
||||
/// The most circles a hold is kept to (`WorldHold::hold_within`).
|
||||
pub const MAX_AREAS: usize = 64;
|
||||
|
||||
/// What the mods ask of the terrain's picture this frame (`native_world_hold`): nothing, all of
|
||||
/// it held, or only the sections inside some circles. Several mods add up: one holding all of it
|
||||
/// holds all of it, and circles join.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct WorldHold {
|
||||
all: bool,
|
||||
within: Vec<[f64; 3]>,
|
||||
}
|
||||
impl WorldHold {
|
||||
/// Hold the whole world's terrain as it is drawn, this frame.
|
||||
pub fn hold(&mut self) {
|
||||
self.all = true;
|
||||
}
|
||||
/// Hold the sections whose middle lies within `radius` blocks of (`x`, `z`), this frame. A
|
||||
/// circle that isn't finite or has a negative radius is left out, as are those past
|
||||
/// `MAX_AREAS`.
|
||||
pub fn hold_within(&mut self, x: f64, z: f64, radius: f64) {
|
||||
let good = x.is_finite() && z.is_finite() && radius.is_finite() && radius >= 0.0;
|
||||
if good && self.within.len() < MAX_AREAS {
|
||||
self.within.push([x, z, radius]);
|
||||
}
|
||||
}
|
||||
/// Whether anything is held.
|
||||
pub fn is_held(&self) -> bool {
|
||||
self.all || !self.within.is_empty()
|
||||
}
|
||||
/// Whether section `key` is in what is held: all of the world, or a circle its middle lies in.
|
||||
pub fn covers(&self, key: SectionKey) -> bool {
|
||||
if self.all {
|
||||
return true;
|
||||
}
|
||||
let (x, z) = (f64::from(key.0) * 16.0 + 8.0, f64::from(key.2) * 16.0 + 8.0);
|
||||
self.within
|
||||
.iter()
|
||||
.any(|&[cx, cz, r]| (x - cx).powi(2) + (z - cz).powi(2) <= r * r)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a frame's ask means for the sections waiting.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(super) enum Release {
|
||||
/// They go on waiting.
|
||||
None,
|
||||
/// Those the hold no longer covers take their place.
|
||||
Outside,
|
||||
/// All of them take their place.
|
||||
All,
|
||||
}
|
||||
|
||||
/// A section waiting behind the hold and the bytes its new mesh takes.
|
||||
struct Waiting<S> {
|
||||
section: S,
|
||||
bytes: u64,
|
||||
}
|
||||
|
||||
/// The renderer's side of a hold: whether it is on, what it covers, and the sections waiting.
|
||||
pub(super) struct Hold<S> {
|
||||
ask: WorldHold,
|
||||
since: Option<Instant>,
|
||||
spent: bool,
|
||||
waiting: FxHashMap<SectionKey, Waiting<S>>,
|
||||
bytes: u64,
|
||||
}
|
||||
impl<S> Default for Hold<S> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
ask: WorldHold::default(),
|
||||
since: None,
|
||||
spent: false,
|
||||
waiting: FxHashMap::default(),
|
||||
bytes: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<S> Hold<S> {
|
||||
/// Whether a new mesh of section `key` waits.
|
||||
pub fn holds(&self, key: SectionKey) -> bool {
|
||||
self.since.is_some() && !self.spent && self.ask.covers(key)
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.waiting.is_empty()
|
||||
}
|
||||
pub fn bytes(&self) -> u64 {
|
||||
self.bytes
|
||||
}
|
||||
/// A frame's ask, at `now`.
|
||||
pub fn ask(&mut self, ask: &WorldHold, now: Instant) -> Release {
|
||||
if !ask.is_held() {
|
||||
let was = self.since.is_some();
|
||||
(self.since, self.spent) = (None, false);
|
||||
self.ask = WorldHold::default();
|
||||
return if was && !self.waiting.is_empty() {
|
||||
Release::All
|
||||
} else {
|
||||
Release::None
|
||||
};
|
||||
}
|
||||
if self.spent {
|
||||
return Release::None;
|
||||
}
|
||||
let since = *self.since.get_or_insert(now);
|
||||
if now.saturating_duration_since(since) >= MAX_HOLD {
|
||||
log::warn!(
|
||||
"Horizon: a mod held the world for {} s: let go",
|
||||
MAX_HOLD.as_secs()
|
||||
);
|
||||
self.spend();
|
||||
return Release::All;
|
||||
}
|
||||
if *ask == self.ask {
|
||||
return Release::None;
|
||||
}
|
||||
// (fewer circles or smaller: some of what waits is let go; more: nothing waits that didn't)
|
||||
self.ask.clone_from(ask);
|
||||
if self.waiting.is_empty() {
|
||||
Release::None
|
||||
} else {
|
||||
Release::Outside
|
||||
}
|
||||
}
|
||||
/// Ended by itself: nothing waits from now on, until no mod has asked for a frame.
|
||||
/// What waited is the caller's to place (`take_all`) or drop.
|
||||
pub fn spend(&mut self) {
|
||||
self.spent = self.since.is_some();
|
||||
}
|
||||
/// Section `key`'s new mesh waits: the one that waited before for it comes back, to free.
|
||||
/// The bool is whether what waits is now too much (`MAX_BYTES`).
|
||||
pub fn wait(&mut self, key: SectionKey, section: S, bytes: u64) -> (Option<S>, bool) {
|
||||
let old = self.take(key);
|
||||
self.bytes += bytes;
|
||||
self.waiting.insert(key, Waiting { section, bytes });
|
||||
(old, self.bytes > MAX_BYTES)
|
||||
}
|
||||
/// Section `key`'s waiting mesh, no longer waiting.
|
||||
pub fn take(&mut self, key: SectionKey) -> Option<S> {
|
||||
if self.waiting.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let w = self.waiting.remove(&key)?;
|
||||
self.bytes -= w.bytes;
|
||||
Some(w.section)
|
||||
}
|
||||
/// Everything waiting, no longer waiting.
|
||||
pub fn take_all(&mut self) -> Vec<(SectionKey, S)> {
|
||||
self.bytes = 0;
|
||||
self.waiting.drain().map(|(k, w)| (k, w.section)).collect()
|
||||
}
|
||||
/// What waits that the hold no longer covers.
|
||||
pub fn take_outside(&mut self) -> Vec<(SectionKey, S)> {
|
||||
let ask = &self.ask;
|
||||
let all = self.since.is_none() || self.spent;
|
||||
let out: Vec<_> = self
|
||||
.waiting
|
||||
.extract_if(|&k, _| all || !ask.covers(k))
|
||||
.collect();
|
||||
self.bytes -= out.iter().map(|(_, w)| w.bytes).sum::<u64>();
|
||||
out.into_iter().map(|(k, w)| (k, w.section)).collect()
|
||||
}
|
||||
/// What waits in these columns, which are going.
|
||||
pub fn take_columns(&mut self, columns: &FxHashSet<(i32, i32)>) -> Vec<S> {
|
||||
if self.waiting.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let out: Vec<_> = self
|
||||
.waiting
|
||||
.extract_if(|k, _| columns.contains(&(k.0, k.2)))
|
||||
.collect();
|
||||
self.bytes -= out.iter().map(|(_, w)| w.bytes).sum::<u64>();
|
||||
out.into_iter().map(|(_, w)| w.section).collect()
|
||||
}
|
||||
/// The sections are all gone: what waits goes with them and the hold is spent.
|
||||
pub fn clear(&mut self) {
|
||||
self.waiting.clear();
|
||||
self.bytes = 0;
|
||||
self.spend();
|
||||
}
|
||||
pub fn sections(&self) -> impl Iterator<Item = &S> {
|
||||
self.waiting.values().map(|w| &w.section)
|
||||
}
|
||||
pub fn sections_mut(&mut self) -> impl Iterator<Item = &mut S> {
|
||||
self.waiting.values_mut().map(|w| &mut w.section)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn all() -> WorldHold {
|
||||
let mut h = WorldHold::default();
|
||||
h.hold();
|
||||
h
|
||||
}
|
||||
fn within(circles: &[[f64; 3]]) -> WorldHold {
|
||||
let mut h = WorldHold::default();
|
||||
for &[x, z, r] in circles {
|
||||
h.hold_within(x, z, r);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hold_covers_sections_by_their_middle() {
|
||||
assert!(all().covers((900, 0, -900)));
|
||||
assert!(!WorldHold::default().covers((0, 0, 0)));
|
||||
assert!(within(&[[8.0, 8.0, 1.0]]).covers((0, 3, 0)));
|
||||
assert!(!within(&[[0.0, 0.0, 11.0]]).covers((0, 3, 0)));
|
||||
assert!(within(&[[0.0, 0.0, 12.0]]).covers((0, 3, 0)));
|
||||
assert!(within(&[[1000.0, 0.0, 1.0], [24.0, 8.0, 0.5]]).covers((1, 0, 0)));
|
||||
assert!(within(&[[-8.0, -8.0, 0.0]]).covers((-1, 0, -1)));
|
||||
}
|
||||
#[test]
|
||||
fn bad_circles_are_left_out_and_there_are_at_most_max_areas() {
|
||||
let h = within(&[
|
||||
[f64::NAN, 0.0, 5.0],
|
||||
[0.0, f64::INFINITY, 5.0],
|
||||
[0.0, 0.0, -1.0],
|
||||
[0.0, 0.0, f64::INFINITY],
|
||||
]);
|
||||
assert!(!h.is_held());
|
||||
let mut many = WorldHold::default();
|
||||
for i in 0..MAX_AREAS + 10 {
|
||||
many.hold_within(i as f64 * 100.0, 0.0, 1.0);
|
||||
}
|
||||
assert_eq!(many.within.len(), MAX_AREAS);
|
||||
// (several mods: all of it from one holds all of it)
|
||||
many.hold();
|
||||
assert!(many.covers((-5000, 0, 5000)));
|
||||
}
|
||||
#[test]
|
||||
fn a_hold_starts_and_lets_go_when_no_one_asks() {
|
||||
let (mut g, t) = (Hold::<u32>::default(), Instant::now());
|
||||
assert_eq!(g.ask(&WorldHold::default(), t), Release::None);
|
||||
assert!(!g.holds((0, 0, 0)));
|
||||
assert_eq!(g.ask(&all(), t), Release::None);
|
||||
assert!(g.holds((0, 0, 0)));
|
||||
assert_eq!(g.ask(&all(), t + Duration::from_secs(5)), Release::None);
|
||||
// (nothing waited: nothing to place)
|
||||
assert_eq!(g.ask(&WorldHold::default(), t), Release::None);
|
||||
g.ask(&all(), t);
|
||||
g.wait((0, 0, 0), 7, 100);
|
||||
assert_eq!(g.ask(&WorldHold::default(), t), Release::All);
|
||||
assert_eq!(g.take_all(), vec![((0, 0, 0), 7)]);
|
||||
assert!(!g.holds((0, 0, 0)));
|
||||
}
|
||||
#[test]
|
||||
fn a_hold_ends_by_itself_and_stays_ended_until_let_go() {
|
||||
let (mut g, t) = (Hold::<u32>::default(), Instant::now());
|
||||
g.ask(&all(), t);
|
||||
assert_eq!(g.ask(&all(), t + MAX_HOLD), Release::All);
|
||||
assert!(!g.holds((0, 0, 0)));
|
||||
assert_eq!(g.ask(&all(), t + MAX_HOLD * 2), Release::None);
|
||||
assert!(!g.holds((0, 0, 0)));
|
||||
g.ask(&WorldHold::default(), t + MAX_HOLD * 2);
|
||||
g.ask(&all(), t + MAX_HOLD * 3);
|
||||
assert!(g.holds((0, 0, 0)));
|
||||
}
|
||||
#[test]
|
||||
fn smaller_circles_let_go_what_they_leave() {
|
||||
let (mut g, t) = (Hold::<u32>::default(), Instant::now());
|
||||
g.ask(&within(&[[0.0, 0.0, 100.0]]), t);
|
||||
assert!(g.holds((0, 0, 0)) && g.holds((5, 0, 0)) && !g.holds((7, 0, 0)));
|
||||
g.wait((0, 0, 0), 1, 10);
|
||||
g.wait((5, 0, 0), 2, 20);
|
||||
assert_eq!(g.ask(&within(&[[0.0, 0.0, 100.0]]), t), Release::None);
|
||||
assert_eq!(g.ask(&within(&[[0.0, 0.0, 40.0]]), t), Release::Outside);
|
||||
assert_eq!(g.take_outside(), vec![((5, 0, 0), 2)]);
|
||||
assert_eq!((g.sections().count(), g.bytes()), (1, 10));
|
||||
assert!(!g.holds((5, 0, 0)));
|
||||
// (to all of it again: nothing more is let go, and all of it waits again)
|
||||
assert_eq!(g.ask(&all(), t), Release::Outside);
|
||||
assert!(g.take_outside().is_empty());
|
||||
assert!(g.holds((5, 0, 0)));
|
||||
}
|
||||
#[test]
|
||||
fn what_waits_is_counted_and_capped() {
|
||||
let (mut g, t) = (Hold::<u32>::default(), Instant::now());
|
||||
g.ask(&all(), t);
|
||||
assert_eq!(g.wait((0, 0, 0), 1, MAX_BYTES - 10), (None, false));
|
||||
// (a newer mesh of the same section replaces it, and the old one comes back to free)
|
||||
assert_eq!(g.wait((0, 0, 0), 2, MAX_BYTES - 20), (Some(1), false));
|
||||
assert_eq!(g.bytes(), MAX_BYTES - 20);
|
||||
assert_eq!(g.wait((1, 0, 0), 3, 20), (None, false));
|
||||
assert_eq!(g.wait((2, 0, 0), 4, 1), (None, true));
|
||||
assert_eq!(g.take((1, 0, 0)), Some(3));
|
||||
assert_eq!(g.take((1, 0, 0)), None);
|
||||
assert_eq!(g.bytes(), MAX_BYTES - 19);
|
||||
}
|
||||
#[test]
|
||||
fn columns_going_and_the_sections_cleared_drop_what_waits() {
|
||||
let (mut g, t) = (Hold::<u32>::default(), Instant::now());
|
||||
g.ask(&all(), t);
|
||||
g.wait((0, 0, 0), 1, 10);
|
||||
g.wait((0, 5, 0), 2, 10);
|
||||
g.wait((3, 0, 0), 3, 10);
|
||||
let gone: FxHashSet<(i32, i32)> = [(0, 0)].into_iter().collect();
|
||||
let mut dropped = g.take_columns(&gone);
|
||||
dropped.sort_unstable();
|
||||
assert_eq!(dropped, vec![1, 2]);
|
||||
assert_eq!((g.sections().count(), g.bytes()), (1, 10));
|
||||
g.clear();
|
||||
assert!(g.is_empty() && g.bytes() == 0 && !g.holds((3, 0, 0)));
|
||||
// (cleared while held: spent until let go; with no hold, nothing to end)
|
||||
assert_eq!(g.ask(&all(), t), Release::None);
|
||||
assert!(!g.holds((3, 0, 0)));
|
||||
let mut none = Hold::<u32>::default();
|
||||
none.clear();
|
||||
none.ask(&all(), t);
|
||||
assert!(none.holds((3, 0, 0)));
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -49,9 +49,23 @@ struct HorizonMaterialOut {
|
||||
@location(14) @interpolate(flat) normal: vec3<f32>,
|
||||
}
|
||||
fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
|
||||
light: vec2<u32>, origin: vec3<f32>) -> HorizonMaterialOut {
|
||||
let pos = horizon_material_displace(position + origin, uv, layer, color, light);
|
||||
return horizon_material_out(pos, uv, layer, color, vec2<f32>(light));
|
||||
light: vec2<u32>, origin: vec4<f32>) -> HorizonMaterialOut {
|
||||
let pos = horizon_material_displace(position + origin.xyz, uv, layer, color, light);
|
||||
var out = horizon_material_out(pos, uv, layer, color, vec2<f32>(light));
|
||||
// The section's fade-in (`RenderSection.getVisibility`), as how far it is from whole, in the
|
||||
// high 16 bits of `hdr_code`: no varying of its own, and 0 (whole) where nothing fades. It is
|
||||
// the same for the whole section, so the flat varying loses nothing.
|
||||
let instance = horizon_terrain_instance_visibility(origin.w);
|
||||
out.hdr_code |= u32(round((1.0 - instance) * 65535.0)) << 16u;
|
||||
return out;
|
||||
}
|
||||
// terrain.wgsl's fade-in (terrain.vsh's chunkVisibility): whole within 16 blocks, the section's
|
||||
// own visibility from 32 out, toward the fog colour, alpha kept. The distance blend runs here on
|
||||
// the interpolated distance, as vanilla's interpolated `visibility`, not once per triangle.
|
||||
fn horizon_material_faded(in: HorizonMaterialOut, color: vec4<f32>) -> vec4<f32> {
|
||||
let instance = 1.0 - f32(in.hdr_code >> 16u) / 65535.0;
|
||||
let visibility = mix(1.0, instance, clamp((in.spherical - 16.0) / 16.0, 0.0, 1.0));
|
||||
return vec4(mix(horizon_terrain_globals.fog_color.rgb, color.rgb, visibility), color.a);
|
||||
}
|
||||
// `light`: sky, block, 16 per level.
|
||||
fn horizon_material_out(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
|
||||
@@ -94,7 +108,7 @@ fn horizon_material_out(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f
|
||||
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>,
|
||||
@location(5) origin: vec4<f32>) -> HorizonMaterialOut {
|
||||
return horizon_material_vertex(position, horizon_terrain_terrain_uv(uv, origin.w), layer,
|
||||
color, light, origin.xyz);
|
||||
color, light, origin);
|
||||
}
|
||||
@vertex fn horizon_material_vs_flat(
|
||||
@builtin(vertex_index) vertex: u32,
|
||||
@@ -103,7 +117,7 @@ fn horizon_material_out(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f
|
||||
) -> HorizonMaterialOut {
|
||||
let origin = horizon_terrain_slot_origins[horizon_terrain_block_slots[vertex >> 8u]];
|
||||
return horizon_material_vertex(position, horizon_terrain_terrain_uv(uv, origin.w), layer,
|
||||
color, light, origin.xyz);
|
||||
color, light, origin);
|
||||
}
|
||||
// The prelude's `block_normal_map` and `block_specular_map`: the resource packs' LabPBR maps
|
||||
// in the pack's frame group (3), one array each, a blank 1x1 without them.
|
||||
@@ -181,9 +195,10 @@ struct HorizonMaterialSolid {
|
||||
// What `fs_solid` leaves in the frame's alpha for HDR output, from the sprite before the pack's
|
||||
// material: the pack decides the colour, Horizon still knows which pixels are light sources.
|
||||
fn horizon_material_mark(in: HorizonMaterialOut, texel: vec4<f32>) -> f32 {
|
||||
if in.hdr_code > 0u {
|
||||
let whole = in.hdr_code > 4u;
|
||||
let tier = select(in.hdr_code, in.hdr_code - 4u, whole);
|
||||
let code = in.hdr_code & 15u;
|
||||
if code > 0u {
|
||||
let whole = code > 4u;
|
||||
let tier = select(code, code - 4u, whole);
|
||||
let l = in.layer & 0x7fffu;
|
||||
let t = textureLoad(horizon_terrain_emission_thresholds,
|
||||
vec2(i32(l % horizon_terrain_SPRITE_SPLIT), i32(l / horizon_terrain_SPRITE_SPLIT)), 0);
|
||||
@@ -202,7 +217,7 @@ fn horizon_material_mark(in: HorizonMaterialOut, texel: vec4<f32>) -> f32 {
|
||||
return horizon_material_solid(in);
|
||||
}
|
||||
fn horizon_material_solid(in: HorizonMaterialOut) -> HorizonMaterialSolid {
|
||||
let color = horizon_material_color(in, false);
|
||||
let color = horizon_material_faded(in, horizon_material_color(in, false));
|
||||
let texel = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
|
||||
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
|
||||
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
|
||||
@@ -216,7 +231,7 @@ fn horizon_material_solid(in: HorizonMaterialOut) -> HorizonMaterialSolid {
|
||||
return out;
|
||||
}
|
||||
@fragment fn horizon_material_fs_translucent(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
|
||||
let color = horizon_material_color(in, true);
|
||||
let color = horizon_material_faded(in, horizon_material_color(in, true));
|
||||
if color.a < 0.01 { discard; }
|
||||
return horizon_material_fogged(color, in.spherical, in.cylindrical);
|
||||
}
|
||||
@@ -582,6 +597,25 @@ mod tests {
|
||||
report.stages.remove(0)
|
||||
}
|
||||
|
||||
/// The fade-in packed above the HDR code: a section a quarter visible (`section_instance`
|
||||
/// with step 1: mantissa `1 - visibility` = 0.75) keeps its visibility through the code, and
|
||||
/// a fragment 24 blocks away, half-way into the 16-block blend, is 1 - (1 - 0.25) / 2 = 0.625
|
||||
/// visible; the code's low nibble survives.
|
||||
#[test]
|
||||
fn the_fade_in_rides_the_hdr_code() {
|
||||
let w = f32::from_bits(1.0f32.to_bits() | (0.75 * 8_388_608.0) as u32);
|
||||
// `horizon_material_vertex`.
|
||||
let instance = 1.0 - (w.to_bits() & 0x7fffff) as f32 / 8_388_608.0;
|
||||
let code = 7u32 | (((1.0 - instance) * 65535.0).round() as u32) << 16;
|
||||
// `horizon_material_faded` and `horizon_material_mark`.
|
||||
let unpacked = 1.0 - (code >> 16) as f32 / 65535.0;
|
||||
assert!((unpacked - 0.25).abs() < 1.0 / 65535.0);
|
||||
let blend = ((24.0f32 - 16.0) / 16.0).clamp(0.0, 1.0);
|
||||
let visibility = 1.0 + (unpacked - 1.0) * blend;
|
||||
assert!((visibility - 0.625).abs() < 1.0 / 65535.0);
|
||||
assert_eq!(code & 15, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_terrain_takes_a_prefix_and_validates_with_a_pack() {
|
||||
let terrain = vanilla_terrain();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -728,6 +728,13 @@ impl PackAtlas {
|
||||
),
|
||||
("mc_Entity", entity),
|
||||
("at_midBlock", mid_block),
|
||||
// Horizon's own: the face's HDR tier (`mesher::emitting`'s code in the colour's low
|
||||
// alpha nibble): 0 for none, 1 to 4 for low to very bright, plus 4 when the whole
|
||||
// face shines (`mesher::WHOLE`).
|
||||
(
|
||||
"horizonTier",
|
||||
"float(15u - (uint(round(hz_v_color.a * 255.0)) & 15u))",
|
||||
),
|
||||
(
|
||||
"mc_midTexCoord",
|
||||
"vec4((hz_cell + hz_mid) * hz_scale, 0.0, 1.0)",
|
||||
|
||||
@@ -157,6 +157,7 @@ fn terrain_fade_reaches_pack_pixels_through_real_instances() {
|
||||
flat,
|
||||
geometry,
|
||||
origin,
|
||||
0x80ffffff,
|
||||
);
|
||||
let fade = if requested { expected } else { 1.0 };
|
||||
let expected = [
|
||||
@@ -182,6 +183,96 @@ fn terrain_fade_reaches_pack_pixels_through_real_instances() {
|
||||
assert_eq!(count, 98);
|
||||
}
|
||||
|
||||
/// `horizonTier`: the HDR tier the mesher marks a light source's faces with (`mesher::emitting`),
|
||||
/// read by a pack's terrain program, flat or instanced.
|
||||
#[test]
|
||||
fn terrain_tier_reaches_pack_pixels() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = match pollster::block_on(instance.request_adapter(&Default::default())) {
|
||||
Ok(adapter) => adapter,
|
||||
Err(error) => {
|
||||
eprintln!("SKIP terrain tier GPU control: no adapter: {error}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let array = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 2,
|
||||
height: 2,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let atlas = PackAtlas::new(&device, &mut encoder, &[array], 1, 1, 2).unwrap();
|
||||
queue.submit([encoder.finish()]);
|
||||
let folder = ProbePack(
|
||||
std::env::temp_dir().join(format!("horizon-terrain-tier-{}", std::process::id())),
|
||||
);
|
||||
std::fs::create_dir_all(folder.0.join("shaders")).unwrap();
|
||||
std::fs::write(
|
||||
folder.0.join("shaders/gbuffers_terrain.vsh"),
|
||||
"#version 330\nin float horizonTier;\nout vec4 observation;\n\
|
||||
void main() {\nobservation = vec4(horizonTier, 0.0, 0.0, 0.0);\n\
|
||||
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);\n}",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
folder.0.join("shaders/gbuffers_terrain.fsh"),
|
||||
"#version 330\nin vec4 observation;\nlayout(location = 0) out uvec4 pixel;\n\
|
||||
void main() { pixel = floatBitsToUint(observation); }",
|
||||
)
|
||||
.unwrap();
|
||||
let pack = horizon_shaders::Pack::open(&folder.0).unwrap();
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
use crate::mesher::{BRIGHT, LOW, VERY_BRIGHT, WHOLE, emitting};
|
||||
// No light, low, very bright, and bright all over (4 more).
|
||||
let cases = [
|
||||
(0, 0.0),
|
||||
(LOW, 1.0),
|
||||
(VERY_BRIGHT, 4.0),
|
||||
(BRIGHT | WHOLE, 7.0),
|
||||
];
|
||||
for flat in [false, true] {
|
||||
let post = horizon_shaders::load_post(
|
||||
&pack,
|
||||
"",
|
||||
&properties,
|
||||
¯os,
|
||||
&horizon_shaders::GameInputs {
|
||||
terrain: Some(atlas.terrain_inputs(flat)),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let program = post
|
||||
.terrain
|
||||
.as_ref()
|
||||
.unwrap_or_else(|| panic!("{:?}", post.notes));
|
||||
for (tier, expected) in cases {
|
||||
let origin = section_instance([0.0; 3], 0, 1.0);
|
||||
let pixel = sample(
|
||||
&device,
|
||||
&queue,
|
||||
&atlas,
|
||||
program.program.as_ref().unwrap(),
|
||||
flat,
|
||||
false,
|
||||
origin,
|
||||
emitting(0xffff_ffff, tier),
|
||||
);
|
||||
assert_eq!(pixel[0], expected, "tier {tier} flat={flat}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sample(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
@@ -190,6 +281,7 @@ fn sample(
|
||||
flat: bool,
|
||||
geometry: bool,
|
||||
origin: [f32; 4],
|
||||
color: u32,
|
||||
) -> [f32; 4] {
|
||||
let buffer = |bytes: &[u8], usage| {
|
||||
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
@@ -209,7 +301,7 @@ fn sample(
|
||||
let slots = buffer(bytemuck::bytes_of(&0u32), wgpu::BufferUsages::STORAGE);
|
||||
let vertices: Vec<_> = [[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.]]
|
||||
.into_iter()
|
||||
.map(|pos| TerrainVertex::new(pos, [0.25, 0.75], 0, 0x80ffffff, 0))
|
||||
.map(|pos| TerrainVertex::new(pos, [0.25, 0.75], 0, color, 0))
|
||||
.collect();
|
||||
let data = buffer(
|
||||
bytemuck::cast_slice(&vertices),
|
||||
|
||||
@@ -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\
|
||||
@@ -107,6 +107,10 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
"at_velocity",
|
||||
"hz_velocity(gbufferModelView, gbufferPreviousModelView)",
|
||||
),
|
||||
// Horizon's own, as for terrain: the HDR tier of a light-giving item, particle or glowing
|
||||
// text (`mesher::hdr_tier`, the overlay's high bits), 0 for none, 1 to 4 for low to very
|
||||
// bright.
|
||||
("horizonTier", "float((hz_v_overlay >> 16u) & 255u)"),
|
||||
];
|
||||
horizon_shaders::GbuffersInputs {
|
||||
declarations,
|
||||
@@ -1628,3 +1632,44 @@ mod velocity_translate_tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tier_translate_tests {
|
||||
/// A pack's entity and hand programs may read `horizonTier`, the overlay's HDR tier.
|
||||
#[test]
|
||||
fn a_pack_reading_horizon_tier_translates_for_entities_and_the_hand() {
|
||||
let dir = std::env::temp_dir().join(format!("hz-entity-tier-{}", std::process::id()));
|
||||
let shaders = dir.join("shaders");
|
||||
std::fs::create_dir_all(&shaders).unwrap();
|
||||
std::fs::write(
|
||||
shaders.join("gbuffers_entities.vsh"),
|
||||
"#version 120\nattribute float horizonTier;\nvarying float t;\n\
|
||||
void main() {\n gl_Position = ftransform();\n t = horizonTier;\n}\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
shaders.join("gbuffers_entities.fsh"),
|
||||
"#version 120\nvarying float t;\nvoid main() {\n gl_FragData[0] = vec4(t);\n}\n",
|
||||
)
|
||||
.unwrap();
|
||||
let pack = horizon_shaders::Pack::open(&dir).unwrap();
|
||||
let m = horizon_shaders::Macros::standard();
|
||||
let vsh = horizon_shaders::preprocess(&pack, "gbuffers_entities.vsh", &m);
|
||||
let fsh = horizon_shaders::preprocess(&pack, "gbuffers_entities.fsh", &m);
|
||||
for (who, inputs) in [
|
||||
("entities", super::inputs()),
|
||||
("hand", super::super::pack_hand::inputs()),
|
||||
] {
|
||||
let attribute = inputs.attributes.iter().find(|(n, _)| n == "horizonTier");
|
||||
assert!(attribute.is_some(), "{who}");
|
||||
horizon_shaders::translate_gbuffers(
|
||||
&vsh,
|
||||
&fsh,
|
||||
horizon_shaders::Target::Buffers,
|
||||
&inputs,
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("{who}: {e:?}"));
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -355,6 +355,25 @@ pub const NAMES: &[&str] = &[
|
||||
"hasSkylight",
|
||||
"hasCeiling",
|
||||
"cloudTime",
|
||||
// Horizon's own: Horizon Extras' dynamic lights (`dynamic_light.rs`), nearest first: how
|
||||
// many, and each one's camera-relative position and light level (zeros past the count).
|
||||
"horizonDynamicLightCount",
|
||||
"horizonDynamicLight0",
|
||||
"horizonDynamicLight1",
|
||||
"horizonDynamicLight2",
|
||||
"horizonDynamicLight3",
|
||||
"horizonDynamicLight4",
|
||||
"horizonDynamicLight5",
|
||||
"horizonDynamicLight6",
|
||||
"horizonDynamicLight7",
|
||||
"horizonDynamicLight8",
|
||||
"horizonDynamicLight9",
|
||||
"horizonDynamicLight10",
|
||||
"horizonDynamicLight11",
|
||||
"horizonDynamicLight12",
|
||||
"horizonDynamicLight13",
|
||||
"horizonDynamicLight14",
|
||||
"horizonDynamicLight15",
|
||||
"isElytraFlying",
|
||||
"cloudHeight",
|
||||
];
|
||||
@@ -929,6 +948,12 @@ impl FrameUniforms {
|
||||
"gbufferProjectionInverse" => m4(projection_inverse),
|
||||
"gbufferPreviousModelView" => m4(previous_view),
|
||||
"gbufferPreviousProjection" => m4(previous_projection),
|
||||
"horizonDynamicLightCount" => Value::i(&[frame.dynamic_lights.len() as i32]),
|
||||
n if dynamic_light_index(n).is_some() => Value::f(
|
||||
&(dynamic_light_index(n).and_then(|k| frame.dynamic_lights.get(k)))
|
||||
.copied()
|
||||
.unwrap_or([0.0; 4]),
|
||||
),
|
||||
"heldItemId" => Value::i(&[inputs.held_items[0]]),
|
||||
"heldItemId2" => Value::i(&[inputs.held_items[1]]),
|
||||
"heldBlockLightValue" => Value::i(&[inputs.held_light[0]]),
|
||||
@@ -1785,6 +1810,29 @@ pub(super) mod initial_history_tests {
|
||||
assert_eq!(crate::clouds::drift(256 * 400 + 3, 0.25, 256), 3.25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_dynamic_lights_reach_the_pack_nearest_first() {
|
||||
let hud = HudBatch::new([800, 450]);
|
||||
let mut f = frame(&hud);
|
||||
let lights = [[1.0, 2.0, 3.0, 14.0], [-4.0, 0.5, 6.0, 7.0]];
|
||||
f.dynamic_lights = &lights;
|
||||
let mut u = FrameUniforms::default();
|
||||
u.update(&f, &PackInputs::default(), [800, 450]);
|
||||
let count = index("horizonDynamicLightCount").unwrap();
|
||||
assert_eq!(int(&u, count), 2);
|
||||
assert_eq!(float(&u, "horizonDynamicLight0")[..4], lights[0]);
|
||||
assert_eq!(float(&u, "horizonDynamicLight1")[..4], lights[1]);
|
||||
// Past the count, and the last of the sixteen: zeros.
|
||||
assert_eq!(float(&u, "horizonDynamicLight2")[..4], [0.0; 4]);
|
||||
assert_eq!(float(&u, "horizonDynamicLight15")[..4], [0.0; 4]);
|
||||
assert_eq!(index("horizonDynamicLight16"), None);
|
||||
// Off (no lights): none.
|
||||
f.dynamic_lights = &[];
|
||||
u.update(&f, &PackInputs::default(), [800, 450]);
|
||||
assert_eq!(int(&u, count), 0);
|
||||
assert_eq!(float(&u, "horizonDynamicLight0")[..4], [0.0; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environmental_smoothing_starts_at_the_first_inputs_without_resetting_each_frame() {
|
||||
let hud = HudBatch::new([800, 450]);
|
||||
@@ -2534,6 +2582,12 @@ pub(super) mod initial_history_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `horizonDynamicLight<k>`'s `k`, for the lights `Frame::dynamic_lights` holds.
|
||||
fn dynamic_light_index(name: &str) -> Option<usize> {
|
||||
let k: usize = name.strip_prefix("horizonDynamicLight")?.parse().ok()?;
|
||||
(k < crate::dynamic_light::MAX).then_some(k)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "pack_uniforms/ftransform_test.rs"]
|
||||
mod ftransform_test;
|
||||
@@ -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,
|
||||
|
||||
@@ -33,6 +33,11 @@ struct Params {
|
||||
@group(0) @binding(2) var<uniform> params: Params;
|
||||
// Horizon's logo, 1 inside, 0 outside, for calibration's test pictures.
|
||||
@group(0) @binding(3) var logo: texture_2d<f32>;
|
||||
// The float frame with HUD coverage (Hdr::layered): the frame keeps the world as light
|
||||
// (fs_resolve), the HUD draws on a picture of its own (`hud`, fs_keep), and `coverage` says how
|
||||
// much of the world it hides there (gfx/hud_coverage.rs).
|
||||
@group(0) @binding(4) var coverage: texture_2d<f32>;
|
||||
@group(0) @binding(5) var hud: texture_2d<f32>;
|
||||
|
||||
// Light sources are lifted evenly rather than by a highlight curve: vanilla's
|
||||
// textures were drawn for SDR, so a curve on their brightest pixels blows out
|
||||
@@ -149,49 +154,29 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
|
||||
return vec4(0.0);
|
||||
}
|
||||
|
||||
@fragment fn fs_hdr(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let at = vec2<i32>(pos.xy);
|
||||
// Calibration: above its controls the test picture, straight in nits, past
|
||||
// paper white, the lift and the black level.
|
||||
let size = vec2<f32>(textureDimensions(frame));
|
||||
if params.calibration.x > 0.5 && pos.y < size.y * params.calibration.z {
|
||||
let nits = vec3(calibration_nits(pos.xy, size));
|
||||
if params.pq > 0.5 {
|
||||
return vec4(pq(nits), 1.0);
|
||||
}
|
||||
return vec4(nits / 80.0, 1.0);
|
||||
}
|
||||
let f = frame_texel(textureLoad(frame, at, 0));
|
||||
// What covers the world here (Hdr::keep_world): nothing at 0, the hand at
|
||||
// 1/255 (Hdr::mark_hand: drawn at the world's brightness), else the HUD or a
|
||||
// menu, by how much.
|
||||
let covered = f.a > 0.0;
|
||||
let hand = covered && f.a < 1.5 / 255.0;
|
||||
let ui = select(f.a, 0.0, hand);
|
||||
let w = frame_texel(textureLoad(world, at, 0));
|
||||
// The world's alpha is below 0.75 on light sources, the sun and the moon
|
||||
// (terrain.wgsl, entity.wgsl): only those shine, and only where nothing was
|
||||
// drawn over them since: grey text over the white sun still looks like the sun
|
||||
// darkened, but it raised the frame's alpha. Its band of 0.175 is the tier, the
|
||||
// lower the brighter, and where in the band how much (hdr_mark). The hand
|
||||
// writes its own alpha there (Hdr::hand_light_view): 1, or a light source it holds.
|
||||
// The light of colour `f` under the world's mark `a` (the alpha the world left, hdr_mark) where
|
||||
// the world `shows`, else the colour clipped at white; and whether a light source lifted it.
|
||||
struct Lit {
|
||||
light: vec3<f32>,
|
||||
lifted: bool,
|
||||
};
|
||||
|
||||
fn lit(f: vec3<f32>, a: f32, shows: bool) -> Lit {
|
||||
var lifted = false;
|
||||
let shows = params.world > 0.5 && params.peak > 1.0
|
||||
&& (hand || (!covered && shows_world(f.rgb, w.rgb)));
|
||||
// Light over 1 (a float frame's) shows only where the world does and carries
|
||||
// no mark (alpha 1), up to the peak: light written as light. Marked pixels clip
|
||||
// at white before their lift, as on a unorm frame, so light sources, the sun and
|
||||
// HDR lighting look as they do there; so do the HUD, menus and HDR Lighting off.
|
||||
let c = select(min(f.rgb, vec3(1.0)), f.rgb, shows && w.a == 1.0);
|
||||
let c = select(min(f, vec3(1.0)), f, shows && a == 1.0);
|
||||
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
|
||||
var light = pow(c, vec3(2.2));
|
||||
// HDR lighting: alpha between 0.76 and 1 is how much of it the surface gets. Vanilla's
|
||||
// light is albedo times lightmap, so this is the lightmap going past 1. It eases into the
|
||||
// peak from 60% of it, so bright textures keep their detail. Unmarked light (alpha 1)
|
||||
// gets no gain and no knee: the clamp to the peak below is all it needs here.
|
||||
if shows && w.a >= 0.75 && w.a < 1.0 {
|
||||
if shows && a >= 0.75 && a < 1.0 {
|
||||
let top = max(light.r, max(light.g, light.b));
|
||||
let gain = 1.0 + (params.scene - 1.0) * (1.0 - w.a) / 0.24;
|
||||
let gain = 1.0 + (params.scene - 1.0) * (1.0 - a) / 0.24;
|
||||
let v = top * gain;
|
||||
let knee = max(0.6 * params.peak, 1.0);
|
||||
var out = v;
|
||||
@@ -200,10 +185,13 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
|
||||
}
|
||||
light *= select(gain, out / max(top, 1e-4), v > knee);
|
||||
}
|
||||
if shows && w.a < 0.75 {
|
||||
let band = u32(clamp(floor((w.a + 0.005) / 0.175), 0.0, 3.0));
|
||||
// The world's alpha is below 0.75 on light sources, the sun and the moon
|
||||
// (terrain.wgsl, entity.wgsl): its band of 0.175 is the tier, the lower the
|
||||
// brighter, and where in the band how much (hdr_mark).
|
||||
if shows && a < 0.75 {
|
||||
let band = u32(clamp(floor((a + 0.005) / 0.175), 0.0, 3.0));
|
||||
let tier = 3u - band;
|
||||
let lift = 1.0 - clamp((w.a - 0.175 * f32(band)) / 0.165, 0.0, 1.0);
|
||||
let lift = 1.0 - clamp((a - 0.175 * f32(band)) / 0.165, 0.0, 1.0);
|
||||
if lift > 0.0 {
|
||||
let top = max(light.r, max(light.g, light.b));
|
||||
let gain = min(1.0 + (params.peak - 1.0) * params.share[tier] * lift, params.peak / max(top, 1e-4));
|
||||
@@ -220,9 +208,27 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
|
||||
light = vec3(dot(light, vec3(0.2126, 0.7152, 0.0722)));
|
||||
}
|
||||
}
|
||||
light = min(light, vec3(params.peak));
|
||||
// The HUD and menus at their own brightness, by how much they cover.
|
||||
light *= mix(1.0, params.ui, ui);
|
||||
return Lit(min(light, vec3(params.peak)), lifted);
|
||||
}
|
||||
|
||||
// Calibration: above its controls the test picture, straight in nits, past
|
||||
// paper white, the lift and the black level.
|
||||
fn calibrating(pos: vec4<f32>) -> bool {
|
||||
let size = vec2<f32>(textureDimensions(frame));
|
||||
return params.calibration.x > 0.5 && pos.y < size.y * params.calibration.z;
|
||||
}
|
||||
|
||||
fn calibration_picture(pos: vec4<f32>) -> vec4<f32> {
|
||||
let nits = vec3(calibration_nits(pos.xy, vec2<f32>(textureDimensions(frame))));
|
||||
if params.pq > 0.5 {
|
||||
return vec4(pq(nits), 1.0);
|
||||
}
|
||||
return vec4(nits / 80.0, 1.0);
|
||||
}
|
||||
|
||||
// Light in units of paper white, as the swapchain takes it: HDR10 or scRGB, the black level
|
||||
// lifted.
|
||||
fn display(light: vec3<f32>) -> vec4<f32> {
|
||||
let scrgb = light * params.paper;
|
||||
if params.pq > 0.5 {
|
||||
let e = pq(BT709_TO_BT2020 * scrgb * 80.0);
|
||||
@@ -233,3 +239,67 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
|
||||
}
|
||||
return vec4(scrgb, 1.0);
|
||||
}
|
||||
|
||||
@fragment fn fs_hdr(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
if calibrating(pos) {
|
||||
return calibration_picture(pos);
|
||||
}
|
||||
let at = vec2<i32>(pos.xy);
|
||||
let f = frame_texel(textureLoad(frame, at, 0));
|
||||
// What covers the world here (Hdr::keep_world): nothing at 0, the hand at
|
||||
// 1/255 (Hdr::mark_hand: drawn at the world's brightness), else the HUD or a
|
||||
// menu, by how much.
|
||||
let covered = f.a > 0.0;
|
||||
let hand = covered && f.a < 1.5 / 255.0;
|
||||
let ui = select(f.a, 0.0, hand);
|
||||
let w = frame_texel(textureLoad(world, at, 0));
|
||||
// The world's marks lift only where nothing was drawn over them since: grey text over
|
||||
// the white sun still looks like the sun darkened, but it raised the frame's alpha. The
|
||||
// hand writes its own alpha there (Hdr::hand_light_view): 1, or a light source it holds.
|
||||
let shows = params.world > 0.5 && params.peak > 1.0
|
||||
&& (hand || (!covered && shows_world(f.rgb, w.rgb)));
|
||||
var light = lit(f.rgb, w.a, shows).light;
|
||||
// The HUD and menus at their own brightness, by how much they cover.
|
||||
light *= mix(1.0, params.ui, ui);
|
||||
return display(light);
|
||||
}
|
||||
|
||||
// Hdr::resolve, after the world and the hand: the marks they left (`world` holds the frame as
|
||||
// they drew it) become light in the frame, in gamma as the frame stores it, alpha 1. What draws
|
||||
// after it (camera overlays, the menu blur) draws on light, and nothing is lifted later.
|
||||
@fragment fn fs_resolve(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let w = frame_texel(textureLoad(world, vec2<i32>(pos.xy), 0));
|
||||
let shows = params.world > 0.5 && params.peak > 1.0;
|
||||
return vec4(pow(lit(w.rgb, w.a, shows).light, vec3(1.0 / 2.2)), 1.0);
|
||||
}
|
||||
|
||||
// Hdr::keep_world: the HUD's backdrop, the world clipped to white with alpha 0, the frame as
|
||||
// the HUD found it before.
|
||||
@fragment fn fs_keep(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let w = textureLoad(frame, vec2<i32>(pos.xy), 0).rgb;
|
||||
return vec4(clamp(w, vec3(0.0), vec3(1.0)), 0.0);
|
||||
}
|
||||
|
||||
// The HUD over the world in gamma, the space the HUD blends in: its own part, what its backdrop
|
||||
// B doesn't explain (H = P - (1 - c) B, exact for the layering blends), at UI Brightness, over
|
||||
// (1 - c) of the world, light above white included. Where the world is at or below white, at
|
||||
// 100%, that is the HUD's picture as drawn.
|
||||
fn layered(at: vec2<i32>) -> vec3<f32> {
|
||||
let w = max(textureLoad(frame, at, 0).rgb, vec3(0.0));
|
||||
let p = clamp(textureLoad(hud, at, 0).rgb, vec3(0.0), vec3(1.0));
|
||||
let c = textureLoad(coverage, at, 0).r;
|
||||
let h = max(p - (1.0 - c) * min(w, vec3(1.0)), vec3(0.0));
|
||||
return h * pow(params.ui, 1.0 / 2.2) + (1.0 - c) * w;
|
||||
}
|
||||
|
||||
// Hdr::composite: the picture so far, stored as the frame stores light, for the menu blur.
|
||||
@fragment fn fs_composite(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
return vec4(layered(vec2<i32>(pos.xy)), 1.0);
|
||||
}
|
||||
|
||||
@fragment fn fs_hdr_layered(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
if calibrating(pos) {
|
||||
return calibration_picture(pos);
|
||||
}
|
||||
return display(min(pow(layered(vec2<i32>(pos.xy)), vec3(2.2)), vec3(params.peak)));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -202,20 +202,44 @@ impl MenuBlur {
|
||||
target: &wgpu::TextureView,
|
||||
size: [u32; 2],
|
||||
radius: f32,
|
||||
mut timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
|
||||
timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
|
||||
) {
|
||||
self.ensure(device, size);
|
||||
let work = self.work_size(size);
|
||||
let copy = if self.half { 2 } else { 0 };
|
||||
let copy = self.source(device, size).0;
|
||||
encoder.copy_texture_to_texture(
|
||||
frame.as_image_copy(),
|
||||
self.targets[copy].0.as_image_copy(),
|
||||
copy.as_image_copy(),
|
||||
wgpu::Extent3d {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
self.blur(device, queue, encoder, target, size, radius, timing);
|
||||
}
|
||||
/// The blur's copy of a frame of `size`, for a picture drawn into it rather than copied
|
||||
/// (HDR output's composite, `Hdr::composite`), then `blur`.
|
||||
pub fn source(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
size: [u32; 2],
|
||||
) -> (wgpu::Texture, wgpu::TextureView) {
|
||||
self.ensure(device, size);
|
||||
self.targets[if self.half { 2 } else { 0 }].clone()
|
||||
}
|
||||
/// Blur what `source` holds into `target`, as `apply` blurs its copy of the frame.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn blur(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
target: &wgpu::TextureView,
|
||||
size: [u32; 2],
|
||||
radius: f32,
|
||||
mut timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
|
||||
) {
|
||||
self.ensure(device, size);
|
||||
let work = self.work_size(size);
|
||||
let radius = if self.half { radius / 2.0 } else { radius };
|
||||
if self.half {
|
||||
// A radius of 0 samples each half-size pixel's 2x2 block at its shared
|
||||
|
||||
@@ -42,7 +42,12 @@ struct OitFragment {
|
||||
@fragment fn fs_oit_cull(@builtin(position) pos: vec4<f32>) -> OitFragment {
|
||||
let b = textureLoad(oit_bounds, vec2<i32>(pos.xy), 0);
|
||||
let opaque = oit_linear_depth(b.a);
|
||||
return OitFragment(vec4(b.r, min(b.g, opaque), b.b, b.a), b.a);
|
||||
// The later stages test GreaterEqual against this depth, so the opaque fragment itself must still
|
||||
// pass: Metal's `position.z` can differ from the rasterizer's depth by an ulp, which dropped
|
||||
// random pixels of an opaque body. A few ulps farther (a positive float's bits minus one is
|
||||
// the next smaller value) keeps it, and culls anything behind it by a visible distance.
|
||||
let culled_depth = select(0.0, bitcast<f32>(bitcast<u32>(b.a) - 4u), b.a > 0.0);
|
||||
return OitFragment(vec4(b.r, min(b.g, opaque), b.b, b.a), culled_depth);
|
||||
}
|
||||
// The sum over the world, renormalized to the coverage the transform gives.
|
||||
@fragment fn fs_oit_composite(@builtin(position) pos: vec4<f32>) -> OitFragment {
|
||||
|
||||
@@ -317,6 +317,7 @@ impl Opt {
|
||||
Opt::PadLookDeadzone => "horizon.options.look_deadzone",
|
||||
Opt::PadTriggerThreshold => "horizon.options.trigger_threshold",
|
||||
Opt::PadGlyphs => "horizon.options.button_glyphs",
|
||||
Opt::PadNintendoButtons => "horizon.options.nintendo_buttons",
|
||||
Opt::PadKeyboard => "horizon.options.on_screen_keyboard",
|
||||
Opt::PadGameplayGuide => "horizon.options.gameplay_guide",
|
||||
Opt::ModelPart(i) => [
|
||||
@@ -864,6 +865,11 @@ impl Opt {
|
||||
GlyphStyle::PlayStation => "horizon.options.glyphs.playstation",
|
||||
GlyphStyle::Nintendo => "horizon.options.glyphs.nintendo",
|
||||
}),
|
||||
Opt::PadNintendoButtons => tr(if s.controller.nintendo_letters {
|
||||
"horizon.options.nintendo_buttons.letter"
|
||||
} else {
|
||||
"horizon.options.nintendo_buttons.position"
|
||||
}),
|
||||
Opt::PadKeyboard => tr(match s.controller.keyboard {
|
||||
gamepad::TextKeyboard::Auto => "horizon.options.keyboard.auto",
|
||||
gamepad::TextKeyboard::Horizon => "horizon.options.keyboard.horizon",
|
||||
@@ -1151,6 +1157,9 @@ impl Opt {
|
||||
Opt::PadInvertX => s.controller.invert_x = !s.controller.invert_x,
|
||||
Opt::PadInvertY => s.controller.invert_y = !s.controller.invert_y,
|
||||
Opt::PadToggleSneak => s.controller.toggle_sneak = !s.controller.toggle_sneak,
|
||||
Opt::PadNintendoButtons => {
|
||||
s.controller.nintendo_letters = !s.controller.nintendo_letters
|
||||
}
|
||||
Opt::PadLightbar => {
|
||||
let all = gamepad::Lightbar::ALL;
|
||||
let i = all
|
||||
@@ -2238,6 +2247,11 @@ impl OptionsUi {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// A controller's or the keyboard's focus landed on `rect`: a row of a shell screen's list
|
||||
/// that is partly hidden scrolls all into view. How far it moved down, when it moved.
|
||||
pub fn reveal(&mut self, s: &Settings, size: (f32, f32), rect: [f32; 4]) -> Option<f32> {
|
||||
self.shell_mut()?.reveal(s, size, rect)
|
||||
}
|
||||
/// What a shell screen's outputs do to these options.
|
||||
fn shell_events(&mut self, s: &mut Settings, outputs: Vec<shell::Output>) -> Vec<Event> {
|
||||
let mut events = Vec::new();
|
||||
@@ -3708,6 +3722,8 @@ impl OptionsUi {
|
||||
let in_world = self.in_world;
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
shell.draw(s, batch, atlas, cursor, in_world, waiting);
|
||||
// The keys lay the screen out at the size it was drawn at.
|
||||
self.size = (batch.width, batch.height);
|
||||
return;
|
||||
}
|
||||
if self.page() == Page::SplitSetup {
|
||||
@@ -4923,10 +4939,12 @@ impl OptionsUi {
|
||||
}
|
||||
return events;
|
||||
}
|
||||
let gui = (
|
||||
self.size.0.max(MIN_KEY_GUI.0),
|
||||
self.size.1.max(MIN_KEY_GUI.1),
|
||||
);
|
||||
// The size it was drawn at; before the first draw, one its keys can lay out at.
|
||||
let gui = if self.size.0 > 0.0 && self.size.1 > 0.0 {
|
||||
self.size
|
||||
} else {
|
||||
MIN_KEY_GUI
|
||||
};
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
let outputs = shell.key(s, key, text, shift, gui);
|
||||
return self.shell_events(s, outputs);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -60,6 +60,10 @@ pub struct ParticleOptions {
|
||||
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>,
|
||||
/// A pre-1.21.4 server's trail, which had no duration: TrailParticle.Provider chose
|
||||
/// `nextInt(40) + 10` after constructing the particle. Set by the client for an older
|
||||
/// server only, never read from the wire.
|
||||
pub legacy_trail_lifetime: bool,
|
||||
}
|
||||
impl ParticleOptions {
|
||||
pub fn simple(ty: ParticleType) -> Self {
|
||||
@@ -75,6 +79,7 @@ impl ParticleOptions {
|
||||
vibration: None,
|
||||
mod_particle: None,
|
||||
legacy_entity_effect: None,
|
||||
legacy_trail_lifetime: false,
|
||||
}
|
||||
}
|
||||
/// A native mod's `SimpleParticleType`: the sprite set of
|
||||
@@ -3485,9 +3490,7 @@ impl ParticleEngine {
|
||||
scaled(o.color[2], jb),
|
||||
);
|
||||
q.size = 0.26;
|
||||
// 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 {
|
||||
q.lifetime = if o.legacy_trail_lifetime {
|
||||
self.level_random.next_int_bound(40).unwrap_or(0) + 10
|
||||
} else {
|
||||
duration
|
||||
@@ -5764,12 +5767,12 @@ mod tests {
|
||||
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);
|
||||
let legacy = ParticleOptions {
|
||||
legacy_trail_lifetime: true,
|
||||
..ParticleOptions::trail([1.0, 2.0, 3.0], 0x7f3f1f, -1)
|
||||
};
|
||||
for _ in 0..5 {
|
||||
engine.create(
|
||||
&ParticleOptions::trail([1.0, 2.0, 3.0], 0x7f3f1f, -1),
|
||||
[0.0; 3],
|
||||
[0.0; 3],
|
||||
);
|
||||
engine.create(&legacy, [0.0; 3], [0.0; 3]);
|
||||
}
|
||||
// java.util.Random(42).nextInt(40) + 10, as vanilla 1.21.3 TrailParticle.Provider does.
|
||||
assert_eq!(
|
||||
@@ -5786,6 +5789,13 @@ mod tests {
|
||||
[0.0; 3],
|
||||
);
|
||||
assert_eq!(engine.pending.last().unwrap().lifetime, 37);
|
||||
// A current server's -1 is a lifetime like any other, not the old marker.
|
||||
engine.create(
|
||||
&ParticleOptions::trail([1.0, 2.0, 3.0], 0x7f3f1f, -1),
|
||||
[0.0; 3],
|
||||
[0.0; 3],
|
||||
);
|
||||
assert_eq!(engine.pending.last().unwrap().lifetime, -1);
|
||||
}
|
||||
#[test]
|
||||
fn sizes_follow_vanilla_curves() {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -845,6 +845,8 @@ pub enum Opt {
|
||||
PadLookDeadzone,
|
||||
PadTriggerThreshold,
|
||||
PadGlyphs,
|
||||
/// A Nintendo pad's face buttons by place or by letter (`PadSettings::nintendo_letters`).
|
||||
PadNintendoButtons,
|
||||
PadKeyboard,
|
||||
PadGameplayGuide,
|
||||
/// `options.modelPart.*`: one skin layer (index into `options_file::MODEL_PARTS`).
|
||||
|
||||
@@ -14,6 +14,7 @@ use crate::{
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
use std::rc::Rc;
|
||||
|
||||
/// The screen's parts in GUI pixels. Down the left: the mark, the search
|
||||
/// field, the page list, and Undo and Done at the foot. On the right: the
|
||||
@@ -865,6 +866,11 @@ pub(crate) trait Screen {
|
||||
NOTE_LINES
|
||||
}
|
||||
fn sheets(&self, s: &Settings) -> Vec<Sheet<Self::Page, Self::Row>>;
|
||||
/// A fingerprint ([`stamp_of`]) of what `sheets` reads that can change while the screen is
|
||||
/// open, besides through `press`, `slide` and `wheel_step` (the shell builds again after
|
||||
/// those): the rows are built again only when it changes, or the search, the page, the size or
|
||||
/// the language does.
|
||||
fn stamp(&self, s: &Settings) -> u64;
|
||||
/// A row was pressed (`back`: Shift held); the shell has played the click.
|
||||
fn press(&mut self, s: &mut Settings, row: Self::Row, back: bool) -> Vec<Output>;
|
||||
/// A slider row moved to `position`.
|
||||
@@ -881,6 +887,23 @@ pub(crate) trait Screen {
|
||||
}
|
||||
}
|
||||
|
||||
/// A fingerprint of `parts` for [`Screen::stamp`]: their `Debug` text, hashed as it is written,
|
||||
/// without keeping it.
|
||||
pub(crate) fn stamp_of(parts: &impl std::fmt::Debug) -> u64 {
|
||||
use std::{fmt::Write, hash::Hasher};
|
||||
struct Hashing(std::collections::hash_map::DefaultHasher);
|
||||
impl Write for Hashing {
|
||||
fn write_str(&mut self, text: &str) -> std::fmt::Result {
|
||||
self.0.write(text.as_bytes());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
let mut hashing = Hashing(Default::default());
|
||||
// Writing into a hasher can't fail.
|
||||
let _ = write!(hashing, "{parts:?}");
|
||||
hashing.0.finish()
|
||||
}
|
||||
|
||||
/// A [`Shell`] of any screen, as `OptionsUi` drives it.
|
||||
pub(crate) trait Hosted {
|
||||
/// The screen opens, afresh.
|
||||
@@ -907,6 +930,10 @@ pub(crate) trait Hosted {
|
||||
) -> Vec<Output>;
|
||||
/// Lets go of a slider (true: it plays its click) or the scrollbar.
|
||||
fn release(&mut self) -> bool;
|
||||
/// A controller's or the keyboard's focus is on the row at `rect`: if the list hides part of
|
||||
/// it, it scrolls all into view (`AbstractSelectionList.setSelected`). How far the row moved
|
||||
/// down, when it moved.
|
||||
fn reveal(&mut self, s: &Settings, size: (f32, f32), rect: [f32; 4]) -> Option<f32>;
|
||||
/// The mouse wheel at `at`: it scrolls, unless the row under it takes the step (`wheel_step`).
|
||||
fn wheel(
|
||||
&mut self,
|
||||
@@ -961,6 +988,19 @@ struct View<P, R> {
|
||||
content: f32,
|
||||
}
|
||||
|
||||
/// What a [`View`] was built for: when any of it changes, the rows are built again.
|
||||
#[derive(PartialEq)]
|
||||
struct Built<P, R> {
|
||||
stamp: u64,
|
||||
/// Which language the rows' text is in (`lang::generation`).
|
||||
language: u64,
|
||||
size: (f32, f32),
|
||||
query: String,
|
||||
page: Option<P>,
|
||||
/// While searching, the rows the focus and a drag keep in the list.
|
||||
kept: (Option<R>, Option<R>),
|
||||
}
|
||||
|
||||
/// A screen on the shell, with all that isn't the screen's: where the list is, what has the focus,
|
||||
/// what is held and what is searched.
|
||||
pub(crate) struct Shell<S: Screen> {
|
||||
@@ -980,6 +1020,8 @@ pub(crate) struct Shell<S: Screen> {
|
||||
/// While searching, the page last picked in the page list: current until the list scrolls.
|
||||
picked: Option<S::Page>,
|
||||
waiting: Option<&'static str>,
|
||||
/// The rows as last built, and what for.
|
||||
built: Option<(Built<S::Page, S::Row>, Rc<View<S::Page, S::Row>>)>,
|
||||
}
|
||||
impl<S: Screen + Default> Default for Shell<S> {
|
||||
fn default() -> Self {
|
||||
@@ -998,6 +1040,7 @@ impl<S: Screen + Default> Default for Shell<S> {
|
||||
page: None,
|
||||
picked: None,
|
||||
waiting: None,
|
||||
built: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1008,10 +1051,43 @@ impl<S: Screen> Shell<S> {
|
||||
fn shows_focus(&self, target: Target<S::Page, S::Row>) -> bool {
|
||||
self.keyed && self.focus == Some(target)
|
||||
}
|
||||
/// The screen's rows now: the open page's or, while searching, every page's whose name or
|
||||
/// The screen's rows now, built again only when what they were built for changed
|
||||
/// ([`Built`]). The scroll is kept within them.
|
||||
fn view(&mut self, s: &Settings, size: (f32, f32)) -> Rc<View<S::Page, S::Row>> {
|
||||
let query = self.query();
|
||||
let searching = !query.is_empty();
|
||||
let focused = match self.focus {
|
||||
Some(Target::Row(row)) if searching => Some(row),
|
||||
_ => None,
|
||||
};
|
||||
let key = Built {
|
||||
stamp: self.screen.stamp(s),
|
||||
language: lang::generation(),
|
||||
size,
|
||||
query,
|
||||
page: self.page,
|
||||
kept: (focused, self.dragging.filter(|_| searching)),
|
||||
};
|
||||
let view = match &self.built {
|
||||
Some((built, view)) if *built == key => view.clone(),
|
||||
_ => {
|
||||
let view = Rc::new(self.build(s, size));
|
||||
self.built = Some((key, view.clone()));
|
||||
view
|
||||
}
|
||||
};
|
||||
self.scroll
|
||||
.clamp(view.cols.top, view.cols.bottom, view.content);
|
||||
view
|
||||
}
|
||||
/// A row may read otherwise now: the screen changed something.
|
||||
fn rebuild(&mut self) {
|
||||
self.built = None;
|
||||
}
|
||||
/// The screen's rows: the open page's or, while searching, every page's whose name or
|
||||
/// description has the search in it, under their pages' names. The row being changed stays
|
||||
/// whatever it reads now. The scroll is kept within it.
|
||||
fn view(&mut self, s: &Settings, (w, h): (f32, f32)) -> View<S::Page, S::Row> {
|
||||
/// whatever it reads now.
|
||||
fn build(&self, s: &Settings, (w, h): (f32, f32)) -> View<S::Page, S::Row> {
|
||||
let cols = Columns::of(w, h).with_note_lines(self.screen.note_lines());
|
||||
let query = self.query();
|
||||
let sheets = self.screen.sheets(s);
|
||||
@@ -1048,7 +1124,6 @@ impl<S: Screen> Shell<S> {
|
||||
}
|
||||
}
|
||||
let (lines, content) = lines.finish();
|
||||
self.scroll.clamp(cols.top, cols.bottom, content);
|
||||
View {
|
||||
cols,
|
||||
pages,
|
||||
@@ -1136,12 +1211,14 @@ impl<S: Screen> Shell<S> {
|
||||
};
|
||||
self.dragging = Some(row.id);
|
||||
self.screen.slide(s, row.id, position);
|
||||
self.rebuild();
|
||||
vec![DRAGGED]
|
||||
}
|
||||
_ => {
|
||||
self.can_change_value = false;
|
||||
let mut outputs = vec![CLICK];
|
||||
outputs.extend(self.screen.press(s, row.id, back));
|
||||
self.rebuild();
|
||||
outputs
|
||||
}
|
||||
}
|
||||
@@ -1171,6 +1248,22 @@ impl<S: Screen> Shell<S> {
|
||||
out.push((Target::Done, cols.button(0)));
|
||||
out
|
||||
}
|
||||
/// Where Down from the search field goes on a screen with no page list, whose foot is under the
|
||||
/// field instead: the first row to use that is all in view.
|
||||
fn first_row(
|
||||
&self,
|
||||
view: &View<S::Page, S::Row>,
|
||||
) -> Option<(Target<S::Page, S::Row>, [f32; 4])> {
|
||||
if !self.tabs(view).is_empty() {
|
||||
return None;
|
||||
}
|
||||
let cols = &view.cols;
|
||||
let mut placed = self.placed(view);
|
||||
let first = placed.find(|&(row, y)| {
|
||||
row.live && row.value != Value::Note && y >= cols.top && y + style::ROW <= cols.bottom
|
||||
});
|
||||
first.map(|(row, y)| (Target::Row(row.id), cols.row(y)))
|
||||
}
|
||||
/// Focus `target`, scrolling its row into the list's view.
|
||||
fn focus_on(
|
||||
&mut self,
|
||||
@@ -1325,6 +1418,9 @@ impl<S: Screen + Default> Hosted for Shell<S> {
|
||||
},
|
||||
);
|
||||
scroller(batch, atlas, &cols, view.content, self.scroll.amount);
|
||||
if let Some((_, row)) = self.first_row(&view) {
|
||||
crate::widgets::focus_link(cols.search(), true, row);
|
||||
}
|
||||
self.draw_dock(batch, atlas, &view, described, current);
|
||||
let done = cols.button(0);
|
||||
let label = Text::literal(lang::get("Done"));
|
||||
@@ -1358,6 +1454,7 @@ impl<S: Screen + Default> Hosted for Shell<S> {
|
||||
}
|
||||
if let Some(row) = self.dragging {
|
||||
self.screen.slide(s, row, cols.slider_at(x));
|
||||
self.rebuild();
|
||||
return vec![DRAGGED];
|
||||
}
|
||||
return Vec::new();
|
||||
@@ -1409,6 +1506,22 @@ impl<S: Screen + Default> Hosted for Shell<S> {
|
||||
self.scroll.release();
|
||||
self.dragging.take().is_some()
|
||||
}
|
||||
fn reveal(&mut self, s: &Settings, size: (f32, f32), [x, y, w, h]: [f32; 4]) -> Option<f32> {
|
||||
let view = self.view(s, size);
|
||||
let cols = &view.cols;
|
||||
let middle = (x + w / 2.0, y + h / 2.0);
|
||||
let (_, row_y) = self.placed(&view).find(|&(_, ry)| {
|
||||
let [rx, ry, rw, rh] = cols.row(ry);
|
||||
ui::hovered(middle, rx, ry, rw, rh)
|
||||
})?;
|
||||
let before = self.scroll.amount;
|
||||
scroll_into_view(&mut self.scroll, cols.row(row_y), cols, view.content);
|
||||
let moved = before - self.scroll.amount;
|
||||
(moved != 0.0).then(|| {
|
||||
self.picked = None;
|
||||
moved
|
||||
})
|
||||
}
|
||||
fn wheel(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
@@ -1421,6 +1534,7 @@ impl<S: Screen + Default> Hosted for Shell<S> {
|
||||
if let Some(row) = self.row_at(&view, at).filter(|row| row.live)
|
||||
&& let Some(outputs) = self.screen.wheel_step(s, row.id, amount > 0.0)
|
||||
{
|
||||
self.rebuild();
|
||||
return outputs;
|
||||
}
|
||||
}
|
||||
@@ -1471,6 +1585,14 @@ impl<S: Screen + Default> Hosted for Shell<S> {
|
||||
self.focus_on(focusables[next], &view);
|
||||
return Vec::new();
|
||||
}
|
||||
if key == K::ArrowDown
|
||||
&& self.focus == Some(Target::Search)
|
||||
&& let Some(first) = self.first_row(&view)
|
||||
{
|
||||
self.keyed = true;
|
||||
self.focus_on(first, &view);
|
||||
return Vec::new();
|
||||
}
|
||||
let focused = view
|
||||
.rows
|
||||
.iter()
|
||||
@@ -1486,6 +1608,7 @@ impl<S: Screen + Default> Hosted for Shell<S> {
|
||||
let width = view.cols.track().1 + 8.0;
|
||||
let next = slider_step(position, steps, width, key == K::ArrowRight);
|
||||
self.screen.slide(s, row.id, next);
|
||||
self.rebuild();
|
||||
return vec![DRAGGED];
|
||||
}
|
||||
if matches!(
|
||||
@@ -1576,6 +1699,13 @@ impl<S: Screen> Shell<S> {
|
||||
pub(crate) fn scroll_amount(&self) -> f32 {
|
||||
self.scroll.amount
|
||||
}
|
||||
/// The row the shell's own focus is on.
|
||||
pub(crate) fn focused_row(&self) -> Option<S::Row> {
|
||||
match self.focus {
|
||||
Some(Target::Row(row)) => Some(row),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The captions of the rows the list has now, as the widget tree reads them.
|
||||
pub(crate) fn row_labels(&mut self, s: &Settings) -> Vec<String> {
|
||||
let view = self.view(s, (854.0, 480.0));
|
||||
@@ -1601,6 +1731,8 @@ mod tests {
|
||||
slider: f32,
|
||||
choice: usize,
|
||||
pressed: Vec<(u32, bool)>,
|
||||
/// How many times its rows were built.
|
||||
built: std::cell::Cell<usize>,
|
||||
}
|
||||
const CHOICES: [(&str, &str); 2] = [("Red", "Paints it red."), ("Blue", "Paints it blue.")];
|
||||
const SLIDER: u32 = 0;
|
||||
@@ -1614,7 +1746,12 @@ mod tests {
|
||||
fn title(&self) -> &'static str {
|
||||
"Probe"
|
||||
}
|
||||
fn sheets(&self, _: &Settings) -> Vec<Sheet<u8, u32>> {
|
||||
/// Its rows read the field of view, besides its own state.
|
||||
fn stamp(&self, s: &Settings) -> u64 {
|
||||
stamp_of(&s.fov)
|
||||
}
|
||||
fn sheets(&self, s: &Settings) -> Vec<Sheet<u8, u32>> {
|
||||
self.built.set(self.built.get() + 1);
|
||||
let (value, chosen) = CHOICES[self.choice];
|
||||
let mut rows = vec![
|
||||
Row::new(
|
||||
@@ -1623,7 +1760,7 @@ mod tests {
|
||||
Value::Slider {
|
||||
position: self.slider,
|
||||
steps: Some(10),
|
||||
text: "x".to_owned(),
|
||||
text: s.fov.to_string(),
|
||||
},
|
||||
),
|
||||
Row {
|
||||
@@ -1677,6 +1814,90 @@ mod tests {
|
||||
shell.point_at(&Settings::default(), size, row)
|
||||
}
|
||||
|
||||
fn atlas() -> HudAtlas {
|
||||
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
|
||||
}
|
||||
|
||||
/// What one frame asks of a screen: a draw, then the stick's scroll, an arrow key and the wheel
|
||||
/// away from any row, at the size it was drawn at.
|
||||
fn frame(shell: &mut Shell<Probe>, s: &mut Settings, atlas: &HudAtlas) -> (f32, f32) {
|
||||
let mut batch = HudBatch::new([1708, 960]);
|
||||
shell.draw(s, &mut batch, atlas, (0.0, 0.0), false, None);
|
||||
let size = (batch.width, batch.height);
|
||||
shell.scroll(s, size, -1.0);
|
||||
shell.key(s, K::ArrowDown, None, false, size);
|
||||
shell.wheel(s, size, (0.0, 0.0), 1.0);
|
||||
size
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_are_built_once_and_again_only_when_what_they_read_changes() {
|
||||
let atlas = atlas();
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let built = |shell: &Shell<Probe>| shell.screen.built.get();
|
||||
let reads = |shell: &mut Shell<Probe>, s: &Settings, size, end: &str| {
|
||||
let view = shell.view(s, size);
|
||||
let mut labels = view.rows.iter().map(|r| r.label(r.value_text().as_deref()));
|
||||
labels.any(|label| label.ends_with(end))
|
||||
};
|
||||
// Each of the four built them before: now the first frame builds them once, the next none.
|
||||
let size = frame(&mut shell, &mut s, &atlas);
|
||||
assert_eq!(built(&shell), 1);
|
||||
frame(&mut shell, &mut s, &atlas);
|
||||
assert_eq!(built(&shell), 1);
|
||||
// A press changes the screen's own state: its row reads the new value.
|
||||
let at = middle_of(&mut shell, CHOICE, size);
|
||||
shell.click(&mut s, at, size, false, false, false);
|
||||
assert!(reads(&mut shell, &s, size, "Blue"));
|
||||
// A setting changed elsewhere (a key bound, a command) shows on the next frame.
|
||||
s.fov = 90.0;
|
||||
frame(&mut shell, &mut s, &atlas);
|
||||
assert!(reads(&mut shell, &s, size, "90"));
|
||||
let before = built(&shell);
|
||||
frame(&mut shell, &mut s, &atlas);
|
||||
assert_eq!(built(&shell), before);
|
||||
// Searching, and another size, build them again.
|
||||
shell.type_text("filler");
|
||||
shell.view(&s, size);
|
||||
assert_eq!(built(&shell), before + 1);
|
||||
shell.view(&s, SHORT);
|
||||
assert_eq!(built(&shell), before + 2);
|
||||
// So does another language, and coming back from it.
|
||||
lang::scoped(lang::Language::load(&[], "de_de"), || shell.view(&s, SHORT));
|
||||
assert_eq!(built(&shell), before + 3);
|
||||
shell.view(&s, SHORT);
|
||||
assert_eq!(built(&shell), before + 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_the_list_hides_part_of_scrolls_into_view_when_focused_and_then_takes_a_press() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let cols = Columns::of(SHORT.0, SHORT.1);
|
||||
// A filler far down, scrolled to just over the bottom: its middle is under the list's edge.
|
||||
let cut = FIRST_FILLER + 20;
|
||||
let view = shell.view(&s, SHORT);
|
||||
let (_, y) = shell.placed(&view).find(|(r, _)| r.id == cut).unwrap();
|
||||
shell.scroll.amount = y - cols.bottom + style::ROW / 2.0 - 1.0;
|
||||
let rect = cols.row(y - shell.scroll.amount);
|
||||
assert!(
|
||||
rect[1] + rect[3] / 2.0 >= cols.bottom,
|
||||
"the row's middle is hidden"
|
||||
);
|
||||
let moved = Hosted::reveal(&mut shell, &s, SHORT, rect).unwrap();
|
||||
let shown = [rect[0], rect[1] + moved, rect[2], rect[3]];
|
||||
assert!(moved < 0.0 && shown[1] + shown[3] <= cols.bottom);
|
||||
// All in view, it stays put.
|
||||
assert_eq!(Hosted::reveal(&mut shell, &s, SHORT, shown), None);
|
||||
let middle = (shown[0] + 4.0, shown[1] + shown[3] / 2.0);
|
||||
assert_eq!(
|
||||
shell.click(&mut s, middle, SHORT, false, false, false),
|
||||
vec![CLICK, CHANGED]
|
||||
);
|
||||
assert_eq!(shell.screen.pressed, [(cut, false)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_scroll_path_moves_the_list_for_wheel_and_stick_alike() {
|
||||
let mut s = Settings::default();
|
||||
|
||||
@@ -861,6 +861,9 @@ pub trait Backend {
|
||||
fn unpause(&mut self, channel: ChannelId);
|
||||
/// `Channel.stopped`.
|
||||
fn stopped(&mut self, channel: ChannelId) -> bool;
|
||||
/// Whether the channel plays clearly through a mod's muffle (`Audio::muffle`): its sound
|
||||
/// event is one the mod exempted. A backend without a muffle ignores it.
|
||||
fn set_unmuffled(&mut self, _channel: ChannelId, _unmuffled: bool) {}
|
||||
}
|
||||
/// A `SoundEventListener.onPlaySound` call.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -937,6 +940,9 @@ pub struct SoundEngine {
|
||||
/// listener: 0 as vanilla, `HEARD_RESERVE` with the `uiSoundReserve` extra.
|
||||
pub listener_reserve: usize,
|
||||
pub notifications: Vec<Notification>,
|
||||
/// The sound events a mod's muffle leaves clear (`Audio::muffle`), normalized: their channels
|
||||
/// are marked as they start, and those playing when the list changes are marked again.
|
||||
unmuffled: Vec<String>,
|
||||
}
|
||||
fn handle_channel(h: &Handle) -> Option<ChannelId> {
|
||||
h.channel
|
||||
@@ -966,6 +972,21 @@ impl SoundEngine {
|
||||
listening: false,
|
||||
listener_reserve: 0,
|
||||
notifications: Vec::new(),
|
||||
unmuffled: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// The sound events that play clearly through a mod's muffle from now on, normalized
|
||||
/// (`minecraft:ui.button.click`); the channels playing now follow the new list too.
|
||||
pub fn set_unmuffled(&mut self, events: Vec<String>, backend: &mut impl Backend) {
|
||||
self.unmuffled = events;
|
||||
for (id, handle) in &self.instance_to_channel {
|
||||
let (Some(instance), Some(channel)) = (
|
||||
self.instances.get(id),
|
||||
self.handles.get(handle).and_then(handle_channel),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
backend.set_unmuffled(channel, self.unmuffled.contains(&instance.identifier));
|
||||
}
|
||||
}
|
||||
/// Isolate a fixture's dispatch from process-wide native installation.
|
||||
@@ -1315,6 +1336,7 @@ impl SoundEngine {
|
||||
}
|
||||
let position = instance.position;
|
||||
let looping = instance.looping && instance.delay <= 0;
|
||||
let unmuffled = self.unmuffled.contains(&instance.identifier);
|
||||
let streaming = sound.stream;
|
||||
let pool = usize::from(streaming);
|
||||
// With the `uiSoundReserve` extra, sounds out in the world leave a few static channels free, so a flood of
|
||||
@@ -1367,6 +1389,7 @@ impl SoundEngine {
|
||||
backend.set_looping(channel, looping && !streaming);
|
||||
backend.set_position(channel, position);
|
||||
backend.set_relative(channel, relative);
|
||||
backend.set_unmuffled(channel, unmuffled);
|
||||
backend.start(channel, &sound, looping);
|
||||
if tickable {
|
||||
self.ticking.push(id);
|
||||
@@ -1580,27 +1603,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 +1776,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 +1823,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 +1838,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 +1852,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 +1955,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 +1995,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]));
|
||||
}
|
||||
@@ -105,6 +105,8 @@ thread_local! {
|
||||
static ACTION_SCREENS: Cell<(u64, u64)> = const { Cell::new((0, 0)) };
|
||||
/// The lists drawn with a scrollbar since `end_frame`, and the last frame's.
|
||||
static SCROLL_AREAS: RefCell<(Vec<ScrollArea>, Vec<ScrollArea>)> = const { RefCell::new((Vec::new(), Vec::new())) };
|
||||
/// The steps screens declared since `end_frame` ([`focus_link`]), and the last frame's.
|
||||
static FOCUS_LINKS: RefCell<(Vec<FocusLink>, Vec<FocusLink>)> = const { RefCell::new((Vec::new(), Vec::new())) };
|
||||
}
|
||||
|
||||
/// Splitscreen: one player's buttons and focus targets, kept aside while another player's game
|
||||
@@ -117,6 +119,7 @@ pub struct Saved(
|
||||
bool,
|
||||
(u64, u64),
|
||||
(Vec<ScrollArea>, Vec<ScrollArea>),
|
||||
(Vec<FocusLink>, Vec<FocusLink>),
|
||||
);
|
||||
|
||||
/// This thread's buttons and targets, leaving none.
|
||||
@@ -128,6 +131,7 @@ pub fn take() -> Saved {
|
||||
ACTION_GLYPHS.take(),
|
||||
ACTION_SCREENS.take(),
|
||||
SCROLL_AREAS.take(),
|
||||
FOCUS_LINKS.take(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -136,6 +140,7 @@ pub fn put(saved: Saved) {
|
||||
WIDGETS.set(saved.0);
|
||||
TARGETS.set(saved.1);
|
||||
SCROLL_AREAS.set(saved.5);
|
||||
FOCUS_LINKS.set(saved.6);
|
||||
ACTIONS.set(saved.2);
|
||||
ACTION_GLYPHS.set(saved.3);
|
||||
ACTION_SCREENS.set(saved.4);
|
||||
@@ -282,6 +287,7 @@ pub fn cover_frame() {
|
||||
TARGETS.with_borrow_mut(|(current, _, _)| current.clear());
|
||||
ACTIONS.with_borrow_mut(|(current, _)| current.clear());
|
||||
SCROLL_AREAS.with_borrow_mut(|(current, _)| current.clear());
|
||||
FOCUS_LINKS.with_borrow_mut(|(current, _)| current.clear());
|
||||
}
|
||||
|
||||
/// This frame drew a list with something to scroll, in `rect`, scrolled `amount`
|
||||
@@ -315,6 +321,38 @@ pub fn over_scroll_area(at: (f32, f32)) -> (f32, f32) {
|
||||
})
|
||||
}
|
||||
|
||||
/// A step the arrows and the D-pad take that the rectangles don't show: `down` (else up) from
|
||||
/// a focus whose middle is in `from`, to the target at `to`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct FocusLink {
|
||||
pub from: [f32; 4],
|
||||
pub down: bool,
|
||||
pub to: [f32; 4],
|
||||
}
|
||||
|
||||
/// Down (or up) from what is in `from` goes to `to`: a screen whose layout puts the next control
|
||||
/// off to one side, where vanilla's step would pass it by (the settings shell's search field over
|
||||
/// its list, on a screen with no page list).
|
||||
pub fn focus_link(from: [f32; 4], down: bool, to: [f32; 4]) {
|
||||
FOCUS_LINKS.with_borrow_mut(|(current, _)| current.push(FocusLink { from, down, to }));
|
||||
}
|
||||
|
||||
/// Where the last drawn frame said a step `down` (else up) from `focus` goes, if it said.
|
||||
pub fn linked_step(focus: [f32; 4], down: bool) -> Option<[f32; 4]> {
|
||||
let [x, y, w, h] = focus;
|
||||
let middle = (x + w / 2.0, y + h / 2.0);
|
||||
FOCUS_LINKS.with_borrow(|(_, last)| {
|
||||
last.iter()
|
||||
.find(|l| {
|
||||
let [lx, ly, lw, lh] = l.from;
|
||||
l.down == down
|
||||
&& (lx..lx + lw).contains(&middle.0)
|
||||
&& (ly..ly + lh).contains(&middle.1)
|
||||
})
|
||||
.map(|l| l.to)
|
||||
})
|
||||
}
|
||||
|
||||
/// The focus targets of the last drawn frame.
|
||||
pub fn last_targets() -> Vec<Target> {
|
||||
TARGETS.with_borrow(|(_, last, _)| last.clone())
|
||||
@@ -392,6 +430,9 @@ pub fn end_frame(frames: u64) {
|
||||
SCROLL_AREAS.with_borrow_mut(|(current, last)| {
|
||||
*last = std::mem::take(current);
|
||||
});
|
||||
FOCUS_LINKS.with_borrow_mut(|(current, last)| {
|
||||
*last = std::mem::take(current);
|
||||
});
|
||||
NARRATION.with_borrow_mut(|(current, last)| {
|
||||
*last = std::mem::replace(current, Narration::EMPTY);
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -76,6 +76,9 @@
|
||||
"horizon.options.glyphs.xbox": "Xbox",
|
||||
"horizon.options.glyphs.playstation": "PlayStation",
|
||||
"horizon.options.glyphs.nintendo": "Nintendo",
|
||||
"horizon.options.nintendo_buttons": "Nintendo Buttons",
|
||||
"horizon.options.nintendo_buttons.position": "By Position",
|
||||
"horizon.options.nintendo_buttons.letter": "By Letter",
|
||||
"horizon.options.keyboard.auto": "Auto",
|
||||
"horizon.options.keyboard.horizon": "Horizon",
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
@@ -104,6 +107,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 +234,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 +336,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.",
|
||||
@@ -947,6 +967,8 @@
|
||||
"Always shows Xbox buttons.": "Always shows Xbox buttons.",
|
||||
"Always shows PlayStation buttons.": "Always shows PlayStation buttons.",
|
||||
"Always shows Nintendo buttons.": "Always shows Nintendo buttons.",
|
||||
"A, on the right, confirms and jumps and B goes back, as on a Switch.": "A, on the right, confirms and jumps and B goes back, as on a Switch.",
|
||||
"B, at the bottom, confirms and jumps and A goes back, as on other controllers.": "B, at the bottom, confirms and jumps and A goes back, as on other controllers.",
|
||||
"Opens Steam's keyboard on a Steam Deck under Steam, and Horizon's otherwise.": "Opens Steam's keyboard on a Steam Deck under Steam, and Horizon's otherwise.",
|
||||
"Always opens Horizon's keyboard.": "Always opens Horizon's keyboard.",
|
||||
"Always opens Steam's keyboard.": "Always opens Steam's keyboard.",
|
||||
@@ -965,6 +987,7 @@
|
||||
"The USB vendor and product the system reports. Useful when reporting a controller that misbehaves.": "The USB vendor and product the system reports. Useful when reporting a controller that misbehaves.",
|
||||
"The glyph family the game picked from the controller's maker.": "The glyph family the game picked from the controller's maker.",
|
||||
"Follow the controller, or always show one family's buttons.": "Follow the controller, or always show one family's buttons.",
|
||||
"Whether a Nintendo controller's A and B work by where they sit or by their letters.": "Whether a Nintendo controller's A and B work by where they sit or by their letters.",
|
||||
"The buttons menus show right now.": "The buttons menus show right now.",
|
||||
"Rumble when something happens to you.": "Rumble when something happens to you.",
|
||||
"The controller keeps playing while this window isn't focused, and losing focus won't pause.": "The controller keeps playing while this window isn't focused, and losing focus won't pause.",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"horizon.options.controller": "Mando",
|
||||
"horizon.options.three_d_skin_layers": "Capas de skin 3D",
|
||||
"horizon.options.vibration": "Vibración",
|
||||
"horizon.options.play_in_background": "Jugar en segundo plano",
|
||||
"horizon.options.adaptive_triggers": "Gatillos adaptativos",
|
||||
"horizon.options.lightbar": "Barra de luz",
|
||||
"horizon.options.look_sensitivity": "Sensibilidad de vista",
|
||||
@@ -34,6 +35,23 @@
|
||||
"horizon.studio.record.error": "Error de grabación de Studio: %s",
|
||||
"horizon.studio.record.status.active": "Grabación de Studio activa — %s s, %s paquetes, %s KB (%s)",
|
||||
"horizon.studio.record.status.inactive": "Grabación de Studio inactiva",
|
||||
"horizon.studio.replay.usage": "Uso: /studio replay <open RUTA|pause|resume|speed MULTIPLICADOR|seek MS|status|close>",
|
||||
"horizon.studio.replay.open.usage": "Uso: /studio replay open <RUTA>",
|
||||
"horizon.studio.replay.opened": "Replay de Studio abierto: %s (%s s, %s paquetes)",
|
||||
"horizon.studio.replay.open_error": "No se pudo abrir el replay: %s",
|
||||
"horizon.studio.replay.paused": "Replay de Studio en pausa",
|
||||
"horizon.studio.replay.resumed": "Replay de Studio reanudado",
|
||||
"horizon.studio.replay.inactive": "No hay ningún replay de Studio activo",
|
||||
"horizon.studio.replay.speed.usage": "Uso: /studio replay speed <MULTIPLICADOR>",
|
||||
"horizon.studio.replay.speed.set": "Velocidad del replay de Studio ajustada a %s×",
|
||||
"horizon.studio.replay.speed.invalid": "Velocidad de replay no válida",
|
||||
"horizon.studio.replay.seek.usage": "Uso: /studio replay seek <MILISEGUNDOS>",
|
||||
"horizon.studio.replay.seeked": "Replay de Studio situado en %s ms (%s s)",
|
||||
"horizon.studio.replay.seek.error": "No se pudo mover el replay: %s",
|
||||
"horizon.studio.replay.seek.backward_requires_standalone": "Rebobinar solo está disponible para un replay independiente.",
|
||||
"horizon.studio.replay.seek.invalid": "Posición de replay no válida",
|
||||
"horizon.studio.replay.status": "Replay de Studio: %s / %s ms (%s%%), velocidad %s×, en pausa %s, finalizado %s",
|
||||
"horizon.studio.replay.closed": "Replay de Studio cerrado",
|
||||
"horizon.pages.mods": "Mods",
|
||||
"horizon.pages.shader_packs": "Paquetes de shaders",
|
||||
"horizon.pages.shader_options": "Opciones de shaders",
|
||||
@@ -88,6 +106,7 @@
|
||||
"horizon.studio.empty": "No se han encontrado grabaciones en la carpeta de grabaciones.",
|
||||
"horizon.studio.empty_hint": "Usa /studio record start <nombre> en un mundo o deja archivos .mcpr allí.",
|
||||
"horizon.studio.open_folder": "Abrir grabaciones",
|
||||
"horizon.studio.open_replay": "Reproducir replay",
|
||||
"horizon.studio.refresh": "Actualizar",
|
||||
"horizon.studio.unknown_server": "Desconocido",
|
||||
"horizon.pages.open.music": "Música...",
|
||||
@@ -882,6 +901,7 @@
|
||||
"Follow the controller, or always show one family's buttons.": "Sigue al mando o muestra siempre los botones de una familia.",
|
||||
"The buttons menus show right now.": "Los botones que muestran los menús ahora mismo.",
|
||||
"Rumble when something happens to you.": "Vibra cuando te pasa algo.",
|
||||
"The controller keeps playing while this window isn't focused, and losing focus won't pause.": "El mando sigue funcionando aunque esta ventana no tenga el foco, y perder el foco no pone el juego en pausa.",
|
||||
"A DualSense's triggers push back on bows and tools.": "Los gatillos de un DualSense ofrecen resistencia con arcos y herramientas.",
|
||||
"What a DualSense's light shows, over USB or Bluetooth.": "Lo que muestra la luz de un DualSense, por USB o Bluetooth.",
|
||||
"Player profile": "Perfil de jugador",
|
||||
|
||||
@@ -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.": "«Экономный» спит до следующего кадра; «Точный» крутится, как в ванили.",
|
||||
|
||||
@@ -472,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": "Сервер вимагає відомого рецепта"
|
||||
}
|
||||
@@ -7,6 +7,18 @@ fn main() {
|
||||
let out_dir = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
|
||||
horizon_mod_api_gen::generate(&root, &out_dir, horizon_mod_api_gen::Target::Client)
|
||||
.expect("generating native mod API");
|
||||
// A workspace horizon-mixins changed holds native mods, whichever tool ran it (the launcher,
|
||||
// or a mod's own script with no `native-mods.json` beside its game): `HORIZON_MIXIN_MODS`
|
||||
// names them for `game::mods::built_with_native_mods`. Watched only once it exists: a
|
||||
// bundle applied in place also edits `game/`, which `generate` watches.
|
||||
let stage = root.join(horizon_mod_api_gen::MIXINS_STAGE);
|
||||
if stage.is_file() {
|
||||
println!("cargo:rerun-if-changed={}", stage.display());
|
||||
}
|
||||
let staged = horizon_mod_api_gen::staged_mods(&root);
|
||||
if !staged.is_empty() {
|
||||
println!("cargo:rustc-env=HORIZON_MIXIN_MODS={}", staged.join(","));
|
||||
}
|
||||
let manifest_dir = std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap());
|
||||
let ico = manifest_dir
|
||||
.join("../..")
|
||||
|
||||
@@ -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::{
|
||||
@@ -10,6 +10,7 @@ use horizon_client::{
|
||||
block_entity_anim::EnchantingBook,
|
||||
block_entity_render::{
|
||||
BlockAnim, BlockEntityRenderer, END_GATEWAY_LAYER, END_PORTAL_LAYER, Scene, TextCall,
|
||||
text_vertices,
|
||||
},
|
||||
chunk::{Chunk, Light},
|
||||
hud::{HudAtlas, Rect},
|
||||
@@ -86,12 +87,19 @@ fn find_state(registry: &BlockRegistry, spec: &str) -> Option<u32> {
|
||||
|
||||
fn sign_side(v: &Value) -> SignText {
|
||||
let mut side = SignText::default();
|
||||
for (line, text) in side
|
||||
.lines
|
||||
.iter_mut()
|
||||
.zip(v["lines"].as_array().into_iter().flatten())
|
||||
for (i, value) in v["lines"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.take(4)
|
||||
.enumerate()
|
||||
{
|
||||
*line = text.as_str().unwrap_or_default().to_owned();
|
||||
let line = horizon_client::scoreboard::comp_text(value);
|
||||
side.lines[i] = line.plain();
|
||||
side.saved[i] = Some(horizon_client::signs::SavedLine {
|
||||
text: line.plain(),
|
||||
component: horizon_client::lan::data::json_to_nbt(value),
|
||||
});
|
||||
}
|
||||
let color = v["color"].as_str().unwrap_or("black");
|
||||
side.color = DYE_TEXT.iter().position(|(n, _)| *n == color).unwrap_or(15) as u8;
|
||||
@@ -373,6 +381,7 @@ pub fn main() -> Result<()> {
|
||||
skull: &|_| None,
|
||||
glyph: &|c| atlas.glyph_cell(c),
|
||||
glyphs: atlas.font.epoch(),
|
||||
font: Some(&atlas.font),
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let mut verts = Vec::new();
|
||||
@@ -417,7 +426,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
|
||||
@@ -425,8 +435,26 @@ pub fn main() -> Result<()> {
|
||||
.iter()
|
||||
.filter(|t| !t.text.is_empty())
|
||||
.map(|t| {
|
||||
let mut x = t.x;
|
||||
let glyphs: Vec<_> = t.text.chars().map(|c| {
|
||||
let g = json!({"matrix": t.matrix.to_cols_array(), "x": x, "y": t.y,
|
||||
"text": c.to_string(), "color": t.color,
|
||||
"light": ((t.light & 0xFF) << 16 | (t.light >> 8 & 0xFF)),
|
||||
"style": [t.style.is_bold(), t.style.is_italic(), t.style.underlined == Some(true),
|
||||
t.style.strikethrough == Some(true), t.style.obfuscated == Some(true)]});
|
||||
x += atlas.font.advance(c, &t.style);
|
||||
g
|
||||
}).collect();
|
||||
let mut vertices = Vec::new();
|
||||
let mut local = t.clone();
|
||||
local.matrix = Mat4::IDENTITY;
|
||||
text_vertices(&|c| atlas.font.glyph_styled(c, &t.style), Some(atlas.font.white()), &local, &mut vertices);
|
||||
let vertices: Vec<_> = vertices.iter().map(|v| {
|
||||
let c = v.color;
|
||||
json!([v.pos[0], v.pos[1], v.pos[2], (c & 0xff00ff00) | (c & 255) << 16 | (c >> 16 & 255)])
|
||||
}).collect();
|
||||
json!({"matrix": t.matrix.to_cols_array(), "x": t.x, "y": t.y, "text": t.text,
|
||||
"color": t.color, "outline": t.outline,
|
||||
"color": t.color, "outline": t.outline, "glyphs": glyphs, "textVertices": vertices,
|
||||
"light": ((t.light & 0xFF) << 16 | (t.light >> 8 & 0xFF))})
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -283,6 +283,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
loot: &loot,
|
||||
damage: Some(source),
|
||||
player: player.filter(|_| pkill),
|
||||
player_killed: pkill,
|
||||
luck: get("luck", "0").parse()?,
|
||||
vehicle: c.get("vehicle").map(|v| LootEntity::new(v)),
|
||||
mob_drops: true,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -95,6 +95,7 @@ fn read_endpoint(path: &Path) -> io::Result<(u16, String)> {
|
||||
|
||||
/// Connect to a running game's control endpoint (and authenticate over TCP).
|
||||
pub fn connect(path: &Path) -> io::Result<Stream> {
|
||||
private_dir(path, false)?;
|
||||
#[cfg(unix)]
|
||||
if !uses_tcp(path) {
|
||||
return UnixStream::connect(path).map(Stream::Unix);
|
||||
@@ -158,10 +159,82 @@ pub fn default_path() -> PathBuf {
|
||||
if !cfg!(unix) {
|
||||
return std::env::temp_dir().join("horizon-control.json");
|
||||
}
|
||||
let dir = std::env::var_os("XDG_RUNTIME_DIR").map_or_else(std::env::temp_dir, PathBuf::from);
|
||||
let dir = std::env::var_os("XDG_RUNTIME_DIR")
|
||||
.map(PathBuf::from)
|
||||
.or_else(fallback_dir)
|
||||
.unwrap_or_else(std::env::temp_dir);
|
||||
dir.join("horizon-control.sock")
|
||||
}
|
||||
|
||||
/// Without `$XDG_RUNTIME_DIR` (containers, some WSL and minimal setups), when the temporary
|
||||
/// directory is shared by every user (`/tmp`), a folder of this user's own in it,
|
||||
/// `horizon-<uid>`: a fixed name there let another user bind the socket first, so that
|
||||
/// `--control` failed and `horizon-ctl` talked to theirs. [`private_dir`] checks it before the
|
||||
/// socket is made or reached. A temporary directory of the user's own (macOS's `$TMPDIR`) is
|
||||
/// used as it is. The launcher finds the endpoint the same way (`game_tools.rs`).
|
||||
fn fallback_dir() -> Option<PathBuf> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let temp = std::env::temp_dir();
|
||||
let shared = std::fs::metadata(&temp).is_ok_and(|m| m.permissions().mode() & 0o002 != 0);
|
||||
let uid = own_uid().filter(|_| shared)?;
|
||||
Some(temp.join(format!("horizon-{uid}")))
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
None
|
||||
}
|
||||
|
||||
/// This process's user: the owner of `/proc/self` where there is one, else of the home folder.
|
||||
#[cfg(unix)]
|
||||
fn own_uid() -> Option<u32> {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
std::fs::metadata("/proc/self")
|
||||
.ok()
|
||||
.or_else(|| std::fs::metadata(std::env::var_os("HOME")?).ok())
|
||||
.map(|m| m.uid())
|
||||
}
|
||||
|
||||
/// For an endpoint in [`fallback_dir`]: [`check_private`] on that folder. Elsewhere (the user's
|
||||
/// `$HORIZON_CONTROL`, the runtime directory) nothing to check.
|
||||
fn private_dir(path: &Path, make: bool) -> io::Result<()> {
|
||||
match fallback_dir() {
|
||||
Some(dir) if path.parent() == Some(dir.as_path()) => check_private(&dir, make),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `dir` made (mode 0700) when `make`, and refused unless it is a real folder, this user's, that
|
||||
/// no one else may enter.
|
||||
#[cfg(unix)]
|
||||
fn check_private(dir: &Path, make: bool) -> io::Result<()> {
|
||||
use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt};
|
||||
if make {
|
||||
match std::fs::DirBuilder::new().mode(0o700).create(dir) {
|
||||
Err(e) if e.kind() != io::ErrorKind::AlreadyExists => return Err(e),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let meta = std::fs::symlink_metadata(dir)?;
|
||||
if !meta.is_dir()
|
||||
|| meta.permissions().mode() & 0o077 != 0
|
||||
|| own_uid().is_some_and(|uid| uid != meta.uid())
|
||||
{
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
format!(
|
||||
"{} isn't a folder only this user can open; set $HORIZON_CONTROL or $XDG_RUNTIME_DIR",
|
||||
dir.display()
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
fn check_private(_: &Path, _: bool) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether `path` can be an endpoint at all, without touching it: a Unix socket's path has a
|
||||
/// length limit (`sockaddr_un`); a TCP endpoint is an ordinary file and has none.
|
||||
pub fn check_path(path: &Path) -> io::Result<()> {
|
||||
@@ -184,6 +257,7 @@ pub fn check_path(path: &Path) -> io::Result<()> {
|
||||
impl Server {
|
||||
pub fn start(path: &Path) -> io::Result<Self> {
|
||||
check_path(path)?;
|
||||
private_dir(path, true)?;
|
||||
// An endpoint left by a crashed game refuses connections; replace it.
|
||||
if path.exists() && connect(path).is_err() {
|
||||
std::fs::remove_file(path)?;
|
||||
@@ -392,6 +466,36 @@ fn serve(stream: Stream, sender: &mpsc::Sender<Request>) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn the_fallback_socket_folder_must_be_private() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let base = std::env::temp_dir().join(format!("horizon-control-dir-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
// Made 0700 when missing, and then fine to reach.
|
||||
let made = base.join("made");
|
||||
super::check_private(&made, true).unwrap();
|
||||
let mode = std::fs::metadata(&made).unwrap().permissions().mode();
|
||||
assert_eq!(mode & 0o777, 0o700);
|
||||
super::check_private(&made, false).unwrap();
|
||||
// One another user could enter or write: refused, made or not.
|
||||
let open = base.join("open");
|
||||
std::fs::create_dir(&open).unwrap();
|
||||
std::fs::set_permissions(&open, std::fs::Permissions::from_mode(0o777)).unwrap();
|
||||
assert!(super::check_private(&open, true).is_err());
|
||||
assert!(super::check_private(&open, false).is_err());
|
||||
// A link to a private folder, or a file: refused.
|
||||
std::os::unix::fs::symlink(&made, base.join("link")).unwrap();
|
||||
assert!(super::check_private(&base.join("link"), false).is_err());
|
||||
std::fs::write(base.join("file"), b"").unwrap();
|
||||
assert!(super::check_private(&base.join("file"), true).is_err());
|
||||
// Missing and not to be made: an error, not a folder made by whoever connects.
|
||||
assert!(super::check_private(&base.join("missing"), false).is_err());
|
||||
assert!(!base.join("missing").exists());
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The check agrees with the system at every length around its limit.
|
||||
|
||||
@@ -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.
|
||||
@@ -1114,13 +1118,15 @@ pub fn mob_to_nbt(
|
||||
);
|
||||
let mut memories: Vec<(&str, Tag)> = Vec::new();
|
||||
if m.extra.tame {
|
||||
memories.push((
|
||||
"minecraft:liked_player",
|
||||
Tag::compound([(
|
||||
"value",
|
||||
Tag::IntArray(m.extra.owner.unwrap_or([0, 0, 0, 1]).to_vec()),
|
||||
)]),
|
||||
));
|
||||
// `MemorySlot.visit`: the time to live only when it can expire.
|
||||
let mut liked = vec![(
|
||||
"value",
|
||||
Tag::IntArray(m.extra.owner.unwrap_or([0, 0, 0, 1]).to_vec()),
|
||||
)];
|
||||
if let Some(ttl) = m.extra.allay.as_ref().and_then(|st| st.liked_player_ttl) {
|
||||
liked.push(("ttl", Tag::Long(ttl)));
|
||||
}
|
||||
memories.push(("minecraft:liked_player", Tag::compound(liked)));
|
||||
}
|
||||
// GlobalPos.CODEC, and the cooldown while it runs.
|
||||
if let Some(liked) = m.extra.allay.as_ref().and_then(|st| st.noteblock) {
|
||||
@@ -1149,6 +1155,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 +1201,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 +1262,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 +1719,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 +2020,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;
|
||||
@@ -2195,6 +2220,20 @@ pub fn mob_from_nbt(
|
||||
m.extra.owner = liked
|
||||
.and_then(|l| l.get("value"))
|
||||
.and_then(|v| crate::save::uuid_from_tag(Some(v)));
|
||||
// `Brain.readAdditionalSaveData`: a memory saved with a time to live
|
||||
// (`Codec.LONG`, read from any integer tag). `Long.MAX_VALUE` is
|
||||
// `MemorySlot.NEVER_EXPIRE`: that memory never expires.
|
||||
if let Some(st) = m.extra.allay.as_mut() {
|
||||
st.liked_player_ttl = liked
|
||||
.and_then(|l| match l.get("ttl")? {
|
||||
Tag::Byte(v) => Some(i64::from(*v)),
|
||||
Tag::Short(v) => Some(i64::from(*v)),
|
||||
Tag::Int(v) => Some(i64::from(*v)),
|
||||
Tag::Long(v) => Some(*v),
|
||||
_ => None,
|
||||
})
|
||||
.filter(|&ttl| ttl != i64::MAX);
|
||||
}
|
||||
let memory = |key: &str| {
|
||||
tag.get("Brain")
|
||||
.and_then(|b| b.get("memories"))
|
||||
@@ -2226,6 +2265,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 +2345,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 +2438,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 +3442,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 +3452,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 +4550,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 +4829,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 +5736,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())
|
||||
@@ -5762,6 +5967,15 @@ pub fn bucket_data(m: &Mob) -> Tag {
|
||||
if let Some(ttl) = crate::mobs::animal_brain::hunting_cooldown(m) {
|
||||
put(&mut data, "HuntingCooldown", Tag::Long(ttl));
|
||||
}
|
||||
// SulfurCube.saveToBucketTag: after the default data, its age.
|
||||
if m.species() == crate::mobs::Species::SulfurCube {
|
||||
put(&mut data, "age", Tag::Int(m.extra.age));
|
||||
put(
|
||||
&mut data,
|
||||
"age_locked",
|
||||
Tag::Byte(i8::from(m.extra.age_locked)),
|
||||
);
|
||||
}
|
||||
data
|
||||
}
|
||||
pub fn bucket_components(m: &Mob) -> crate::item_data::StackData {
|
||||
@@ -5773,6 +5987,15 @@ pub fn bucket_components(m: &Mob) -> crate::item_data::StackData {
|
||||
..Default::default()
|
||||
};
|
||||
data.set_other("minecraft:bucket_entity_data", Some(bucket_data(m)));
|
||||
// SulfurCube.saveToBucketTag: the body block, when it has one.
|
||||
if m.species() == crate::mobs::Species::SulfurCube
|
||||
&& let Some(body) = &m.extra.body
|
||||
{
|
||||
data.set_other(
|
||||
"minecraft:sulfur_cube_content",
|
||||
Some(Tag::compound([("id", string(body))])),
|
||||
);
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Axolotl {
|
||||
let variant = AXOLOTL_VARIANTS
|
||||
.get(usize::from(m.extra.variant))
|
||||
@@ -9168,3 +9391,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);
|
||||
}
|
||||
}
|
||||
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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -505,6 +505,9 @@ impl Game<'_> {
|
||||
"food": p.health.food.food,
|
||||
"saturation": p.health.food.saturation,
|
||||
"shield_cooldown": self.shield_cooldown,
|
||||
// ItemCooldowns.getCooldownPercent of the shield: what this window's hotbar draws
|
||||
// over it (on a server's client, only what the server's Cooldown packets told it).
|
||||
"shield_cooldown_shown": self.cooldowns.percent("minecraft:shield", 0.0),
|
||||
"xp": { "level": self.experience.level, "progress": self.experience.progress, "total": self.experience.total },
|
||||
"effects": self.potions.effects.list.iter().map(|e| json!({ "id": e.id, "duration_ticks": e.duration, "amplifier": e.amplifier })).collect::<Vec<_>>(),
|
||||
})
|
||||
|
||||
@@ -377,9 +377,8 @@ impl Game<'_> {
|
||||
// `Allay.mobInteract` hands over one item, its components and all.
|
||||
given.count = 1;
|
||||
m.extra.set_main_hand_stack(Some(given));
|
||||
m.extra.tame = true;
|
||||
// `Allay.mobInteract` remembers LIKED_PLAYER by UUID.
|
||||
m.extra.owner = Some(acting);
|
||||
allay::like_player(m, acting);
|
||||
self.play_mob_bound_sound(
|
||||
index,
|
||||
"minecraft:entity.allay.item_given",
|
||||
@@ -402,8 +401,7 @@ impl Game<'_> {
|
||||
.unwrap_or_else(|| ItemStack::new("minecraft:air", 1));
|
||||
m.extra.set_main_hand(None);
|
||||
let carried = m.extra.allay.as_mut().and_then(|st| st.inventory.take());
|
||||
m.extra.tame = false;
|
||||
m.extra.owner = None;
|
||||
allay::forget_liked_player(m);
|
||||
self.play_mob_bound_sound(
|
||||
index,
|
||||
"minecraft:entity.allay.item_taken",
|
||||
|
||||
@@ -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:
|
||||
@@ -1527,14 +1478,55 @@ impl Game<'_> {
|
||||
let Some(info) = self.items().get(&stack.item) else {
|
||||
return false;
|
||||
};
|
||||
let (index, slot) = match info.equip_slot.as_deref() {
|
||||
let components = self
|
||||
.pack
|
||||
.item_renderer
|
||||
.models
|
||||
.components(&horizon_client::item_render::stack_view(stack));
|
||||
let Some(equippable) = components.get("minecraft:equippable") else {
|
||||
return false;
|
||||
};
|
||||
let actual = stack
|
||||
.data()
|
||||
.and_then(|data| data.other("minecraft:equippable"));
|
||||
// Only adopt the default-allowed shape here. Restricted or otherwise
|
||||
// shaped overrides keep their existing admission until supported.
|
||||
let supported = actual.is_none_or(|tag| {
|
||||
matches!(tag, horizon_world::nbt::Tag::Compound(map)
|
||||
if map.keys().all(|key| matches!(key.to_string_lossy().as_str(), "slot" | "swappable")))
|
||||
&& matches!(tag.get("slot"), Some(horizon_world::nbt::Tag::String(_)))
|
||||
&& tag.get("swappable").is_none_or(|flag| {
|
||||
horizon_client::item_data::component_bool(flag).is_some()
|
||||
})
|
||||
});
|
||||
let slot = if supported {
|
||||
equippable.get("slot").and_then(serde_json::Value::as_str)
|
||||
} else {
|
||||
info.equip_slot.as_deref()
|
||||
};
|
||||
let (index, slot) = match slot {
|
||||
Some("head") => (0, "head"),
|
||||
Some("chest") => (1, "chest"),
|
||||
Some("legs") => (2, "legs"),
|
||||
Some("feet") => (3, "feet"),
|
||||
_ => return false,
|
||||
};
|
||||
if !info.swappable {
|
||||
// The component codec replaces the whole value: an override which
|
||||
// omits swappable uses true, rather than its item's prototype flag.
|
||||
let swappable = if !supported {
|
||||
info.swappable
|
||||
} else if let Some(actual) = actual {
|
||||
actual
|
||||
.get("swappable")
|
||||
.and_then(horizon_client::item_data::component_bool)
|
||||
.unwrap_or(true)
|
||||
} else {
|
||||
equippable
|
||||
.get("swappable")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(true)
|
||||
};
|
||||
if !swappable {
|
||||
return false;
|
||||
}
|
||||
let current = self.armor[index].clone();
|
||||
@@ -1961,3 +1953,140 @@ mod layer_color_tests {
|
||||
assert_eq!(layer_color(None, Some(&dyed)), 0xFFFF_FFFF);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pumpkin_equippable_use_tests {
|
||||
use super::*;
|
||||
use horizon_client::lan::{Joined, NetConfig, PlayerSender, guest::Guest, hub::HubConfig};
|
||||
use horizon_core::position::ChunkPos;
|
||||
use horizon_protocol::packets::{
|
||||
configuration::ClientInformation,
|
||||
play::{Serverbound, UseItem},
|
||||
};
|
||||
use std::sync::{atomic::AtomicI32, mpsc};
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn h207_connected_use_item_adopts_actual_pumpkin_equippable_and_preserves_controls() {
|
||||
let mut game = crate::game::native_machines::tests::server_game();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
assert!(game.run_command("fill 0 199 0 15 199 15 stone"));
|
||||
assert!(game.world.loaded(8, 8));
|
||||
game.lan_host(
|
||||
crate::game::lan::LanSettings {
|
||||
port: 0,
|
||||
guest_commands: true,
|
||||
force_game_mode: false,
|
||||
},
|
||||
NetConfig {
|
||||
announce: false,
|
||||
..NetConfig::loopback(0, "pumpkin-use-test".into(), 2)
|
||||
},
|
||||
HubConfig {
|
||||
default_game_mode: 0,
|
||||
guest_commands: true,
|
||||
force_game_mode: false,
|
||||
hardcore: false,
|
||||
view_distance: 2,
|
||||
simulation_distance: 2,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
allow_flight: false,
|
||||
seed: 0,
|
||||
max_players: 2,
|
||||
integrated: false,
|
||||
levels: Default::default(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let (tx, _rx) = mpsc::channel();
|
||||
let mut guest = Guest::new(
|
||||
Joined {
|
||||
conn: 2,
|
||||
uuid: 207,
|
||||
name: "PumpkinGuest".into(),
|
||||
properties: Vec::new(),
|
||||
info: ClientInformation::new(2),
|
||||
brand: "test".into(),
|
||||
sender: PlayerSender::new(tx, Arc::default()),
|
||||
latency: Arc::new(AtomicI32::new(0)),
|
||||
address: "127.0.0.1:25565".parse().unwrap(),
|
||||
expected: false,
|
||||
configured: Default::default(),
|
||||
},
|
||||
2,
|
||||
[8.5, 200.0, 8.5],
|
||||
2,
|
||||
);
|
||||
guest.dimension = "minecraft:overworld".into();
|
||||
guest.placed = true;
|
||||
guest.loaded = true;
|
||||
guest.selected = 0;
|
||||
guest.permission = 2;
|
||||
let mut host = game.lan.take().unwrap();
|
||||
host.hub.guests.push(guest);
|
||||
let clock = game.world.game_time;
|
||||
let eq = "minecraft:equippable={slot:\"head\"}";
|
||||
let marker = "minecraft:custom_data={h207:207}";
|
||||
let helmet = "minecraft:diamond_helmet[minecraft:custom_data={old:1}]";
|
||||
let mut failures = Vec::new();
|
||||
for (i, (label, mode, component, count, old, changed)) in [
|
||||
("empty", "survival", Some(eq), 3, None, true),
|
||||
("swap", "survival", Some(eq), 1, Some(helmet), true),
|
||||
("creative", "creative", Some(eq), 3, None, true),
|
||||
("prototype", "survival", None, 3, None, false),
|
||||
("false", "survival", Some("minecraft:equippable={slot:\"head\",swappable:0b}"), 3, None, false),
|
||||
("binding", "survival", Some(eq), 3, Some("minecraft:diamond_helmet[minecraft:enchantments={\"minecraft:binding_curse\":1},minecraft:custom_data={old:1}]"), false),
|
||||
("same", "survival", Some(eq), 3, Some("minecraft:carved_pumpkin[minecraft:equippable={slot:\"head\"},minecraft:custom_data={h207:207}]"), false),
|
||||
].into_iter().enumerate() {
|
||||
let held = format!("minecraft:carved_pumpkin[{}{}{}]", component.unwrap_or(""), if component.is_some() { "," } else { "" }, marker);
|
||||
let before = game.as_guest(&mut host, 2, |g| {
|
||||
assert_eq!(g.ui.registries.permission, 2);
|
||||
assert!(g.run_command("clear @s") || g.inventory.iter().all(Option::is_none));
|
||||
g.run_command("item replace entity @s armor.head with air");
|
||||
g.run_command(&format!("gamemode {mode}"));
|
||||
assert!(g.run_command(&format!("item replace entity @s weapon.mainhand with {held} {count}")));
|
||||
if let Some(old) = old { assert!(g.run_command(&format!("item replace entity @s armor.head with {old}"))); }
|
||||
assert_eq!(g.player.as_ref().unwrap().position, Vec3::new(8.5, 200.0, 8.5));
|
||||
let hand = g.held().unwrap().clone();
|
||||
assert_eq!(hand.item.as_ref(), "minecraft:carved_pumpkin");
|
||||
assert_eq!(hand.count, count);
|
||||
assert!(hand.data().unwrap().other("minecraft:custom_data").is_some());
|
||||
let actual = hand.data().unwrap().other("minecraft:equippable");
|
||||
assert_eq!(actual.is_some(), component.is_some());
|
||||
if let Some(actual) = actual {
|
||||
assert_eq!(actual.get("slot"), Some(&horizon_world::nbt::Tag::String("head".into())));
|
||||
assert_eq!(actual.get("swappable").and_then(horizon_client::item_data::component_bool), if label == "false" { Some(false) } else { None });
|
||||
}
|
||||
if label == "binding" { assert_eq!(g.armor[0].as_ref().unwrap().enchantment_level("minecraft:binding_curse"), 1); }
|
||||
if label == "same" { assert!(g.armor[0].as_ref().unwrap().same_item_same_components(&hand)); }
|
||||
(g.inventory.clone(), g.armor.clone(), hand)
|
||||
}).unwrap();
|
||||
let sequence = 207 + i as i32;
|
||||
game.lan_guest_packet(&mut host, 2, Serverbound::UseItem(UseItem { hand: 0, yaw: 0.0, pitch: 0.0, sequence }));
|
||||
assert!(host.hub.guest(2).unwrap().ack_up_to.get() >= sequence);
|
||||
game.as_guest(&mut host, 2, |g| {
|
||||
let mut expected_inventory = before.0.clone();
|
||||
let mut expected_armor = before.1.clone();
|
||||
if changed {
|
||||
let mut worn = before.2.clone();
|
||||
worn.count = 1;
|
||||
expected_armor[0] = Some(worn);
|
||||
if label == "swap" { expected_inventory[0] = before.1[0].clone(); }
|
||||
else if label == "empty" { expected_inventory[0].as_mut().unwrap().count = 2; }
|
||||
}
|
||||
if g.inventory != expected_inventory || g.armor != expected_armor {
|
||||
failures.push((label, g.inventory.clone(), g.armor.clone()));
|
||||
}
|
||||
assert_eq!(g.world.game_time, clock);
|
||||
}).unwrap();
|
||||
}
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"actual connected UseItem Equippable adoption failures: {failures:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -26,6 +26,7 @@ use horizon_core::{
|
||||
random::RandomSource,
|
||||
};
|
||||
use horizon_world::collision_context::CollisionContext;
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::{collections::HashMap, f32::consts::PI, sync::Arc};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -80,6 +81,9 @@ pub(super) struct Boat {
|
||||
pub hurt_time: i32,
|
||||
pub hurt_dir: i32,
|
||||
pub damage: f32,
|
||||
/// `Entity.remainingFireTicks` (`Fire` in its save): burning while
|
||||
/// positive, a fire block's cooldown while negative.
|
||||
pub fire_ticks: i32,
|
||||
/// `Entity.noGravity` (`NoGravity` in its save).
|
||||
pub no_gravity: bool,
|
||||
pub paddles: [f32; 2],
|
||||
@@ -148,9 +152,19 @@ fn snowflake_velocity(known: Vec3, random: &mut impl RandomSource) -> Option<[f6
|
||||
let za = (random.next_float() * 2.0 - 1.0) * 0.083_333_336_f32;
|
||||
Some([f64::from(xa), f64::from(0.05_f32), f64::from(za)])
|
||||
}
|
||||
/// `Entity.load`'s `Fire` (a short; 0 when absent).
|
||||
pub(super) fn saved_fire(tag: &Tag) -> i32 {
|
||||
i32::from(horizon_client::save::number(tag.get("Fire")).unwrap_or(0) as i16)
|
||||
}
|
||||
pub(super) const WIDTH: f64 = 1.375;
|
||||
pub(super) const HEIGHT: f64 = 0.5625;
|
||||
impl Boat {
|
||||
/// `AbstractBoat.tick`: the hurt wobble and the damage wear off, before
|
||||
/// `Entity.baseTick` can burn the boat.
|
||||
fn wear_off_hurt(&mut self) {
|
||||
self.hurt_time = (self.hurt_time - 1).max(0);
|
||||
self.damage = (self.damage - 1.0).max(0.0);
|
||||
}
|
||||
/// `AbstractBoat.tick`: passengers lose control after 60 consecutive
|
||||
/// underwater ticks; any other status resets the counter.
|
||||
fn tick_submersion(&mut self) {
|
||||
@@ -191,6 +205,7 @@ impl Boat {
|
||||
hurt_time: 0,
|
||||
hurt_dir: 1,
|
||||
damage: 0.0,
|
||||
fire_ticks: 0,
|
||||
no_gravity: false,
|
||||
paddles: [0.0; 2],
|
||||
paddling: [false; 2],
|
||||
@@ -224,6 +239,23 @@ impl Boat {
|
||||
Vec3::new(p.x + w, p.y + HEIGHT, p.z + w),
|
||||
)
|
||||
}
|
||||
/// `Entity.saveWithoutId` with `AbstractChestBoat`'s items: the loaded
|
||||
/// compound `raw` under the live fields.
|
||||
pub(super) fn to_nbt(&self, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
let mut tag = horizon_client::entity_nbt::boat_to_nbt(
|
||||
&self.entity_type(),
|
||||
self.position,
|
||||
self.velocity,
|
||||
[self.yaw, self.pitch],
|
||||
uuid,
|
||||
raw,
|
||||
self.chest.then_some(&self.items[..]),
|
||||
);
|
||||
if let Tag::Compound(map) = &mut tag {
|
||||
map.insert("Fire".into(), Tag::Short(self.fire_ticks as i16));
|
||||
}
|
||||
tag
|
||||
}
|
||||
/// The start of `Entity.move`: a boat made stuck since its last move goes its
|
||||
/// velocity times the multiplier, and its velocity and the multiplier are spent.
|
||||
fn take_movement(&mut self) -> Vec3 {
|
||||
@@ -648,8 +680,10 @@ impl Game<'_> {
|
||||
b.old_status = b.status;
|
||||
b.status = self.boat_status(&mut b);
|
||||
b.tick_submersion();
|
||||
b.hurt_time = (b.hurt_time - 1).max(0);
|
||||
b.damage = (b.damage - 1.0).max(0.0);
|
||||
// `tick_boats_now` wears it off for the boats it ticks, before they burn.
|
||||
if controlled {
|
||||
b.wear_off_hurt();
|
||||
}
|
||||
// A remote client gets the controller from the passenger packet, even when
|
||||
// this window occupies the second seat. Local worlds derive it here.
|
||||
if self.remote.is_none() {
|
||||
@@ -854,7 +888,17 @@ impl Game<'_> {
|
||||
|| driven.contains(&id)
|
||||
|| !ticking(p.x.floor() as i32, p.z.floor() as i32)
|
||||
{
|
||||
// Its rider's client moves it, but the lead still ticks.
|
||||
// Its rider's client moves it, but the server still wears
|
||||
// off its damage and then burns it.
|
||||
if ticking(p.x.floor() as i32, p.z.floor() as i32) {
|
||||
if driven.contains(&id) {
|
||||
self.boats[i].wear_off_hurt();
|
||||
}
|
||||
if self.boat_fire_tick(i) {
|
||||
break 'boat;
|
||||
}
|
||||
}
|
||||
// The lead still ticks too.
|
||||
if ticking(p.x.floor() as i32, p.z.floor() as i32) && self.remote.is_none() {
|
||||
let mut b = self.boats[i].clone();
|
||||
self.tick_boat_leash(&mut b);
|
||||
@@ -879,9 +923,13 @@ impl Game<'_> {
|
||||
else {
|
||||
break 'boat;
|
||||
};
|
||||
let b = self.boats[i].clone();
|
||||
self.boats[i] = self.tick_boat(b, false);
|
||||
self.boat_server_passes(id, &driven, &ticking, &round);
|
||||
self.boats[i].wear_off_hurt();
|
||||
// Entity.baseTick burns it before it moves; the fire may break it.
|
||||
if !self.boat_fire_tick(i) {
|
||||
let b = self.boats[i].clone();
|
||||
self.boats[i] = self.tick_boat(b, false);
|
||||
self.boat_server_passes(id, &driven, &ticking, &round);
|
||||
}
|
||||
self.native_tick_post_one(&round, id, &kind);
|
||||
}
|
||||
i = match self.boats.get(i) {
|
||||
@@ -916,6 +964,9 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
if self.remote.is_none() {
|
||||
if self.boat_fire_passes(i) {
|
||||
return;
|
||||
}
|
||||
self.boat_powder_snow_snowflakes(i);
|
||||
}
|
||||
// `AbstractBoat.tick` counts submersion on the server even when
|
||||
@@ -1125,6 +1176,121 @@ impl Game<'_> {
|
||||
}
|
||||
cells
|
||||
}
|
||||
/// `BaseFireBlock.fireDamage` of a fire block at `p`.
|
||||
fn fire_damage_at(&self, p: [i32; 3]) -> Option<f32> {
|
||||
match &*self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(p[0], p[1], p[2]))
|
||||
.name
|
||||
{
|
||||
"minecraft:fire" => Some(1.0),
|
||||
"minecraft:soul_fire" => Some(2.0),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// `Entity.baseTick` on the server: a burning boat takes 1 fire damage
|
||||
/// every 20 ticks (none in lava) and burns a tick down. True when the fire
|
||||
/// broke it.
|
||||
fn boat_fire_tick(&mut self, i: usize) -> bool {
|
||||
if self.remote.is_some() || self.boats[i].fire_ticks <= 0 {
|
||||
return false;
|
||||
}
|
||||
let burn = self.boats[i].fire_ticks % 20 == 0 && !self.in_lava(self.boats[i].bounds());
|
||||
self.boats[i].fire_ticks -= 1;
|
||||
burn && self.burn_boat(i, 1.0)
|
||||
}
|
||||
/// The fire of the two `applyEffectsFromBlocks` in `AbstractBoat.tick`.
|
||||
/// `Entity.checkInsideBlocks` meets the blocks of the box the boat started
|
||||
/// its move in first, then the ones it reached, each block once; in each
|
||||
/// step fire blocks light it (`BaseFireBlock.fireIgnite`, a cooldown tick
|
||||
/// at a time) and each burns it, then a burning boat melts the powder snow
|
||||
/// (with `mob_griefing`) and water and powder snow put it out. Rain puts
|
||||
/// it out too, and a boat neither burning nor lit has its cooldown
|
||||
/// cleared. True when the fire broke it.
|
||||
fn boat_fire_passes(&mut self, i: usize) -> bool {
|
||||
let b = &self.boats[i];
|
||||
let mut steps = vec![Boat::bounds_at(b.previous)];
|
||||
if b.previous != b.position {
|
||||
steps.push(b.bounds());
|
||||
}
|
||||
// `Entity.collidedWithFluid` sweeps the box along the whole move:
|
||||
// water reaches it down to the lower of its two bottoms.
|
||||
let low = b.previous.y.min(b.position.y);
|
||||
let (at, top) = (b.position, b.bounds().max_position().y);
|
||||
let rain = self.in_rain(at, top);
|
||||
for _ in 0..2 {
|
||||
let before = self.boats[i].fire_ticks;
|
||||
let mut seen = Vec::new();
|
||||
for &bounds in &steps {
|
||||
let cells = powder_snow_cells(bounds, |p| !seen.contains(&p));
|
||||
seen.extend_from_slice(&cells);
|
||||
let fires: Vec<f32> = cells
|
||||
.iter()
|
||||
.filter_map(|&p| self.fire_damage_at(p))
|
||||
.collect();
|
||||
if !fires.is_empty() {
|
||||
let fire = &mut self.boats[i].fire_ticks;
|
||||
if *fire < 0 {
|
||||
*fire += 1;
|
||||
}
|
||||
if *fire >= 0 {
|
||||
// igniteForSeconds(8)
|
||||
*fire = (*fire).max(160);
|
||||
}
|
||||
for &damage in &fires {
|
||||
if self.burn_boat(i, damage) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let water = cells.iter().any(|&p| self.water_reaches(p, low));
|
||||
let snow: Vec<[i32; 3]> = cells
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&p| self.is_powder_snow(p))
|
||||
.collect();
|
||||
if self.boats[i].fire_ticks > 0 && self.ui.rules.bool("mob_griefing") {
|
||||
for &cell in &snow {
|
||||
self.powder_snow_melt(BlockPos::new(cell[0], cell[1], cell[2]));
|
||||
}
|
||||
}
|
||||
if water || !snow.is_empty() {
|
||||
// clearFire
|
||||
let fire = &mut self.boats[i].fire_ticks;
|
||||
*fire = (*fire).min(0);
|
||||
}
|
||||
}
|
||||
let fire = &mut self.boats[i].fire_ticks;
|
||||
if rain {
|
||||
*fire = (*fire).min(0);
|
||||
}
|
||||
let now = *fire;
|
||||
if now <= 0 && now <= before {
|
||||
*fire = 0;
|
||||
}
|
||||
if before > 0 && now <= 0 {
|
||||
self.extinguish_sound([at.x, at.y, at.z]);
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
/// Whether the water at cell `p` reaches a box whose bottom is `low`
|
||||
/// (`Entity.collidedWithFluid`).
|
||||
fn water_reaches(&self, p: [i32; 3], low: f64) -> bool {
|
||||
self.fluid_height_of(p[0], p[1], p[2], Some(Fluid::Water))
|
||||
.is_some_and(|(height, _)| low + 1.0e-5 < f64::from(p[1]) + f64::from(height))
|
||||
}
|
||||
/// `VehicleEntity.hurtServer` for fire (`inFire`, `onFire`), which an
|
||||
/// `Invulnerable` boat ignores. True when the boat broke.
|
||||
fn burn_boat(&mut self, i: usize, damage: f32) -> bool {
|
||||
let invulnerable = self
|
||||
.persist
|
||||
.raw
|
||||
.get(&self.boats[i].id)
|
||||
.is_some_and(|tag| horizon_client::entity_nbt::flag(tag, "Invulnerable"));
|
||||
!invulnerable && self.hurt_boat(i, damage, false)
|
||||
}
|
||||
/// `PowderSnowBlock.entityInside`'s `ServerLevel.sendParticles`: a boat that moved
|
||||
/// horizontally in powder snow throws snowflakes the players around see. `AbstractBoat.tick`
|
||||
/// calls `applyEffectsFromBlocks` twice.
|
||||
@@ -1521,7 +1687,8 @@ impl Game<'_> {
|
||||
}
|
||||
// `VehicleEntity.destroy(level, dropItem)`: `kill` (the boat's own
|
||||
// ENTITY_DIE), then the boat item on the death path only, and only
|
||||
// with `entity_drops` on.
|
||||
// with `entity_drops` on, by `spawnAtLocation` (an ItemEntity's own
|
||||
// random motion).
|
||||
if !creative {
|
||||
self.entity_killed_event(at, false);
|
||||
}
|
||||
@@ -1535,7 +1702,7 @@ impl Game<'_> {
|
||||
extra: None,
|
||||
},
|
||||
at,
|
||||
[0.0, 0.1, 0.0],
|
||||
super::dispenser::new_item_motion(),
|
||||
10,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1799,6 +1883,8 @@ impl Game<'_> {
|
||||
}
|
||||
self.ui.chat.clear();
|
||||
self.build_registries();
|
||||
// The world opens with its test instances (`Registries.TEST_INSTANCE`).
|
||||
self.snapshot_test_defs();
|
||||
self.sync_rules();
|
||||
}
|
||||
/// Save the clock beside level.dat (Level.write saves the game rules: a
|
||||
@@ -2144,6 +2230,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 +2522,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"),
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
//! `CloneCommands.clone` ends with `LevelTicks.copyAreaFrom`: every block
|
||||
//! tick pending in the source box is scheduled at the same offset in the
|
||||
//! destination, with its trigger time, whatever the clone placed there
|
||||
//! (tools/test_clone_block_ticks_parity.py measures the same on vanilla 26.3).
|
||||
use super::*;
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_core::position::{BlockPos, ChunkPos};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn game() -> Game<'static> {
|
||||
let root = std::path::PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES")
|
||||
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
|
||||
);
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
|
||||
let res: &'static _ = Box::leak(Box::new(loaded.resources));
|
||||
let mut game = Game::new(
|
||||
res,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
// A loaded chunk whose block tick queue is registered.
|
||||
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("fill 0 99 0 15 99 15 stone"));
|
||||
game
|
||||
}
|
||||
|
||||
/// The block ticks queued at `pos`: (block, trigger tick).
|
||||
fn ticks_at(game: &Game<'_>, pos: BlockPos) -> Vec<(u32, i64)> {
|
||||
game.block_ticks
|
||||
.blocks
|
||||
.chunk(ChunkPos::new(0, 0))
|
||||
.unwrap()
|
||||
.queued()
|
||||
.iter()
|
||||
.filter(|t| t.pos == pos)
|
||||
.map(|t| (t.kind.0, t.trigger_tick))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_strict_clone_copies_the_pending_block_ticks_of_its_source() {
|
||||
let mut game = game();
|
||||
let source = BlockPos::new(1, 101, 1);
|
||||
assert!(game.run_command("setblock 1 101 1 sand"));
|
||||
let pending = ticks_at(&game, source);
|
||||
assert_eq!(pending.len(), 1, "the placed sand's fall is queued");
|
||||
// Strict: the clone places the sand without its own tick.
|
||||
assert!(game.run_command("clone 1 101 1 1 101 1 3 101 1 strict replace"));
|
||||
assert_eq!(
|
||||
ticks_at(&game, BlockPos::new(3, 101, 1)),
|
||||
pending,
|
||||
"copied as it was"
|
||||
);
|
||||
assert_eq!(ticks_at(&game, source), pending, "the source keeps its own");
|
||||
// Filtered: the sand's tick comes with it, the dirt below has none.
|
||||
assert!(game.run_command("setblock 5 100 1 dirt"));
|
||||
assert!(game.run_command("setblock 5 101 1 sand"));
|
||||
let pending = ticks_at(&game, BlockPos::new(5, 101, 1));
|
||||
assert!(game.run_command("clone 5 100 1 5 101 1 7 100 1 strict filtered minecraft:sand"));
|
||||
assert_eq!(ticks_at(&game, BlockPos::new(7, 101, 1)), pending);
|
||||
assert_eq!(ticks_at(&game, BlockPos::new(7, 100, 1)), vec![]);
|
||||
// Move: the copy is made after the source was cleared.
|
||||
assert!(game.run_command("setblock 9 101 1 sand"));
|
||||
let pending = ticks_at(&game, BlockPos::new(9, 101, 1));
|
||||
assert!(game.run_command("clone 9 101 1 9 101 1 11 101 1 strict replace move"));
|
||||
assert_eq!(ticks_at(&game, BlockPos::new(11, 101, 1)), pending);
|
||||
}
|
||||
|
||||
/// Added controls (after the baseline). A destination that already holds a
|
||||
/// tick for the same block keeps that one (`LevelChunkTicks.schedule` keeps
|
||||
/// one tick per position and type). A copied tick keeps its priority and
|
||||
/// comes after the ticks queued before it. The copy is made before the
|
||||
/// command fails on an empty count, as `copyAreaFrom` is called before
|
||||
/// `ERROR_FAILED`.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn copied_block_ticks_keep_order_priority_and_dedup() {
|
||||
let mut game = game();
|
||||
let queued = |game: &Game<'_>, pos: BlockPos| {
|
||||
game.block_ticks
|
||||
.blocks
|
||||
.chunk(ChunkPos::new(0, 0))
|
||||
.unwrap()
|
||||
.queued()
|
||||
.iter()
|
||||
.filter(|t| t.pos == pos)
|
||||
.copied()
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
// The destination's own tick stays the only one.
|
||||
assert!(game.run_command("setblock 7 101 5 sand"));
|
||||
let own = queued(&game, BlockPos::new(7, 101, 5));
|
||||
assert_eq!(own.len(), 1);
|
||||
assert!(game.run_command("setblock 5 101 5 sand"));
|
||||
assert!(game.run_command("clone 5 101 5 5 101 5 7 101 5 strict replace"));
|
||||
assert_eq!(
|
||||
queued(&game, BlockPos::new(7, 101, 5)),
|
||||
own,
|
||||
"kept, not doubled"
|
||||
);
|
||||
// Priority kept; ordered after what was queued before.
|
||||
assert!(game.run_command("setblock 1 101 9 sand"));
|
||||
let source = queued(&game, BlockPos::new(1, 101, 9))[0];
|
||||
assert!(game.run_command("clone 1 101 9 1 101 9 3 101 9 strict replace"));
|
||||
let copied = queued(&game, BlockPos::new(3, 101, 9));
|
||||
assert_eq!(copied.len(), 1);
|
||||
assert_eq!(
|
||||
(copied[0].kind, copied[0].trigger_tick, copied[0].priority),
|
||||
(source.kind, source.trigger_tick, source.priority)
|
||||
);
|
||||
assert!(copied[0].sub_tick_order > source.sub_tick_order);
|
||||
// A clone that places nothing (a barrier) fails, its tick copied first.
|
||||
assert!(game.run_command("setblock 9 101 9 sand"));
|
||||
let pending = queued(&game, BlockPos::new(9, 101, 9));
|
||||
assert!(game.run_command("setblock 9 101 9 barrier strict"));
|
||||
assert_eq!(
|
||||
queued(&game, BlockPos::new(9, 101, 9)),
|
||||
pending,
|
||||
"the tick stays"
|
||||
);
|
||||
assert!(!game.run_command("clone 9 101 9 9 101 9 11 101 9 strict replace"));
|
||||
let moved: Vec<_> = queued(&game, BlockPos::new(11, 101, 9))
|
||||
.iter()
|
||||
.map(|t| (t.kind, t.trigger_tick))
|
||||
.collect();
|
||||
assert_eq!(moved, vec![(pending[0].kind, pending[0].trigger_tick)]);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -670,6 +680,49 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
"mob_interact" => {
|
||||
// The owner's real Interact packet on a live mob, by UUID.
|
||||
let uuid = u128::from_str_radix(
|
||||
&op["uuid"]
|
||||
.as_str()
|
||||
.ok_or("mob click: missing UUID")?
|
||||
.replace('-', ""),
|
||||
16,
|
||||
)?;
|
||||
self.in_owner_level(|g| -> Result<()> {
|
||||
let id = g
|
||||
.mob_by_uuid(horizon_client::lan::playerdata::uuid_ints(uuid))
|
||||
.filter(|id| g.mobs.iter().any(|m| m.id == *id && !m.removed))
|
||||
.ok_or("mob click: missing live mob")?;
|
||||
let owner = g.serving.as_ref().ok_or("mob click: no server")?.owner;
|
||||
let conn = g
|
||||
.lan
|
||||
.as_ref()
|
||||
.and_then(|h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|p| p.uuid == owner && p.placed)
|
||||
.map(|p| p.conn)
|
||||
})
|
||||
.ok_or("mob click: owner not connected")?;
|
||||
let mut host = g.lan.take().ok_or("mob click: no hub")?;
|
||||
g.lan_guest_packet(
|
||||
&mut host,
|
||||
conn,
|
||||
horizon_protocol::packets::play::Serverbound::Interact(
|
||||
horizon_protocol::packets::play::Interact {
|
||||
id: id as i32,
|
||||
hand: 0,
|
||||
location: [0.0; 3],
|
||||
secondary: false,
|
||||
},
|
||||
),
|
||||
);
|
||||
g.lan = Some(host);
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
"use_item" => {
|
||||
let owner = self
|
||||
.serving
|
||||
|
||||
@@ -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)?
|
||||
@@ -2590,8 +2638,23 @@ impl Game<'_> {
|
||||
.resolve(&state)
|
||||
.map_err(|_| err("argument.block.id.invalid", &[&b.id]))
|
||||
}
|
||||
/// BlockPredicateArgument test.
|
||||
fn block_matches(&self, b: &BlockInput, state: BlockStateId) -> bool {
|
||||
/// BlockPredicateArgument test of the block at `p`: its state, then any
|
||||
/// `{...}` against the block entity's `saveWithFullMetadata`
|
||||
/// (`NbtUtils.compareNbt(…, true)`); no block entity fails.
|
||||
fn block_matches(&self, b: &BlockInput, p: [i32; 3]) -> bool {
|
||||
let state = self.world.block(p[0], p[1], p[2]);
|
||||
if !self.block_state_matches(b, state) {
|
||||
return false;
|
||||
}
|
||||
let Some(nbt) = &b.nbt else { return true };
|
||||
// (the argument's own grammar, `TagParser`, as the predicate was read)
|
||||
let Ok(pattern) = horizon_client::command::snbt::parse_fully(nbt) else {
|
||||
return false;
|
||||
};
|
||||
horizon_client::nbt_path::compare_nbt(&pattern, self.block_entity_nbt(p).as_ref())
|
||||
}
|
||||
/// The state part of `block_matches`: name or tag, then properties.
|
||||
fn block_state_matches(&self, b: &BlockInput, state: BlockStateId) -> bool {
|
||||
block_input_matches(
|
||||
self.pack.assets.registry,
|
||||
[&self.res.items().block_tags, &self.items().block_tags],
|
||||
@@ -2641,6 +2704,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 +2737,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 +2846,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);
|
||||
@@ -2969,12 +3042,17 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
if let Some(f) = &filter {
|
||||
let ok = match matched.get(¤t) {
|
||||
Some(&ok) => ok,
|
||||
None => {
|
||||
let ok = self.block_matches(f, current);
|
||||
matched.insert(current, ok);
|
||||
ok
|
||||
// (a filter with NBT reads each block's own block entity)
|
||||
let ok = if f.nbt.is_some() {
|
||||
self.block_matches(f, [x, y, z])
|
||||
} else {
|
||||
match matched.get(¤t) {
|
||||
Some(&ok) => ok,
|
||||
None => {
|
||||
let ok = self.block_state_matches(f, current);
|
||||
matched.insert(current, ok);
|
||||
ok
|
||||
}
|
||||
}
|
||||
};
|
||||
if !ok {
|
||||
@@ -3058,7 +3136,7 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
if let Some(f) = &filter
|
||||
&& !self.block_matches(f, s)
|
||||
&& !self.block_matches(f, [x, y, z])
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -3101,6 +3179,19 @@ impl Game<'_> {
|
||||
};
|
||||
}
|
||||
}
|
||||
// LevelTicks.copyAreaFrom: the block ticks pending anywhere in the
|
||||
// source box go to the same offset in the destination, before the
|
||||
// command can fail on an empty count.
|
||||
{
|
||||
use horizon_core::position::BlockPos;
|
||||
self.block_ticks.blocks.copy_area(
|
||||
horizon_core::ticks::TickArea::new(
|
||||
BlockPos::new(min[0], min[1], min[2]),
|
||||
BlockPos::new(max[0], max[1], max[2]),
|
||||
),
|
||||
BlockPos::new(dest[0] - min[0], dest[1] - min[1], dest[2] - min[2]),
|
||||
);
|
||||
}
|
||||
if count == 0 {
|
||||
return Err(err("commands.clone.failed", &[]));
|
||||
}
|
||||
@@ -4413,6 +4504,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 +4775,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 +5127,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:#?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Loaded 100 of 614 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user