mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-08 17:19:10 -10:00
Compare commits
737
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 | ||
|
|
159b5c4be6 | ||
|
|
cf0b0d02c8 | ||
|
|
1b93764780 | ||
|
|
52aad2205d | ||
|
|
9bc110bae6 | ||
|
|
a392d4b3ba | ||
|
|
83535ddcc1 | ||
|
|
2c672eae2d | ||
|
|
796047f1e8 | ||
|
|
491710cd2f | ||
|
|
0932cefc79 | ||
|
|
2dbcb75cfa | ||
|
|
34f528ffcb | ||
|
|
7f6f26a33a | ||
|
|
3d8924dcc8 | ||
|
|
8ad82f9ac0 | ||
|
|
77290ac3c1 | ||
|
|
daa43de927 | ||
|
|
3fb98b187f | ||
|
|
77af961663 | ||
|
|
76db917ded | ||
|
|
1ba320ca73 | ||
|
|
2f60199b75 | ||
|
|
033b4a9e60 | ||
|
|
6a0b9e435b | ||
|
|
485226a6fd | ||
|
|
37b79d1b63 | ||
|
|
702b0895bd | ||
|
|
4465238c9c | ||
|
|
e4ce68e271 | ||
|
|
151aa6c82b | ||
|
|
43943df1ef | ||
|
|
8906102eb9 | ||
|
|
0a51d6dc5c | ||
|
|
21ca2fdd1d | ||
|
|
89a515a8d7 | ||
|
|
b5406e647a | ||
|
|
a0f9368edf | ||
|
|
1f86e14b9a | ||
|
|
5ec521bb7b | ||
|
|
80bbced3fc | ||
|
|
2d72b0b260 | ||
|
|
c30b863128 | ||
|
|
d79a36101f | ||
|
|
690d434afa | ||
|
|
35f3dad207 | ||
|
|
f57811dd0d | ||
|
|
73e054f827 | ||
|
|
5de5b6fd91 | ||
|
|
e1434f2ee4 | ||
|
|
67a39ecaac | ||
|
|
199d9d74f8 | ||
|
|
ca17c0c5ba | ||
|
|
23c3faedc1 | ||
|
|
1005076a59 | ||
|
|
8189734106 | ||
|
|
1f29c88a49 | ||
|
|
bf0f0dd811 | ||
|
|
ba3c3feebf | ||
|
|
ec298576f6 | ||
|
|
97c9d1a14f | ||
|
|
007458a627 | ||
|
|
9c30028167 | ||
|
|
0d49b282c0 | ||
|
|
099fb70e6a | ||
|
|
c7aef39dd5 | ||
|
|
d7c3fefcfa | ||
|
|
9099175f8a | ||
|
|
e1e39361db | ||
|
|
ef19179033 | ||
|
|
b189c72fcc | ||
|
|
8b48b0d74e | ||
|
|
3a229da6b3 | ||
|
|
e22ccc17d2 | ||
|
|
5664a950e8 | ||
|
|
538422f3c7 | ||
|
|
493dd75acf | ||
|
|
f4481233b0 | ||
|
|
63cbec701e | ||
|
|
a597387a66 | ||
|
|
80e3532c4a | ||
|
|
2d306dc52c | ||
|
|
b528247824 | ||
|
|
4f6be12c2e | ||
|
|
8c93f81143 | ||
|
|
a20e9ce2d0 | ||
|
|
aba1a90e6f | ||
|
|
73a3c0348d | ||
|
|
e8da3a8e21 | ||
|
|
c95f24bc32 | ||
|
|
ca0b945857 | ||
|
|
3004b64927 | ||
|
|
33be2a611a | ||
|
|
1199c117f2 | ||
|
|
2e749948f4 | ||
|
|
f5f96b1be4 | ||
|
|
abdf311e3b | ||
|
|
f96e0fba0f | ||
|
|
522634ca7f | ||
|
|
f7543ccffa | ||
|
|
1fd2f02081 | ||
|
|
653a4a99fe | ||
|
|
8d8c250f48 | ||
|
|
65c35da0dc | ||
|
|
d21bc19b84 | ||
|
|
3afc51e740 | ||
|
|
17778b9722 | ||
|
|
abaa2a9f68 | ||
|
|
d1c1684fb5 | ||
|
|
772088790c | ||
|
|
fdc87aecdb | ||
|
|
ad475b0573 | ||
|
|
52343380b3 | ||
|
|
aa019cfaa4 | ||
|
|
dd0050e268 | ||
|
|
9e962f6350 | ||
|
|
e2680a5450 | ||
|
|
78ea4b6d02 | ||
|
|
56a578f0a6 | ||
|
|
5e7d248148 | ||
|
|
67f34e0fdd | ||
|
|
17a68d7b97 | ||
|
|
90b30ee17b | ||
|
|
5578753b58 | ||
|
|
6faf3ef3f3 | ||
|
|
ab44611814 | ||
|
|
8c612bc687 | ||
|
|
9debdf26d4 | ||
|
|
27a2eb565e | ||
|
|
596fda5618 | ||
|
|
46b257b1d5 | ||
|
|
d7ae75b441 | ||
|
|
bc017eec9b | ||
|
|
693178dc36 | ||
|
|
8b0c8e40a6 | ||
|
|
d980cd810d | ||
|
|
196bea6f1a | ||
|
|
cdabdddb6b | ||
|
|
e5fe17622c | ||
|
|
b19634003c | ||
|
|
78efabcfa7 | ||
|
|
8d783c0ff0 | ||
|
|
cbe9ddfa3f | ||
|
|
749c5405f6 | ||
|
|
8711aabd75 | ||
|
|
76a5bd8a22 | ||
|
|
453d8b89ce | ||
|
|
845132a56b | ||
|
|
bc8912c13f | ||
|
|
051e3dd546 | ||
|
|
ab5dfae985 | ||
|
|
19a6e9b81b | ||
|
|
de118feb4e | ||
|
|
e508e7919f | ||
|
|
f222616f7e | ||
|
|
0f89fd7e00 | ||
|
|
6889d9366b | ||
|
|
40ad3a91c9 | ||
|
|
114b09babe | ||
|
|
0804e2219a | ||
|
|
1c6a22db1a | ||
|
|
0a85491338 | ||
|
|
8a9a6d5e3f | ||
|
|
d17e8fa343 | ||
|
|
6f374959b7 | ||
|
|
15a8b2a77c | ||
|
|
1206818cde | ||
|
|
12157a2d67 | ||
|
|
b029b9d04f | ||
|
|
9b89ff00ee | ||
|
|
2e13076a93 | ||
|
|
9974322832 | ||
|
|
b21f0c92f2 | ||
|
|
5f2e3cb14d | ||
|
|
a61d272adc | ||
|
|
d607c2f137 | ||
|
|
31545199d4 | ||
|
|
e54dcc07cf | ||
|
|
9a8781468e | ||
|
|
1117b98d15 | ||
|
|
8fe0e40d68 | ||
|
|
6079903ffc | ||
|
|
3a7f276eab | ||
|
|
f68c335eb2 | ||
|
|
beaad0e79d | ||
|
|
12dad67f8c | ||
|
|
922f00f52e | ||
|
|
ab37cfb5aa | ||
|
|
92b2977e3b | ||
|
|
200615a30a | ||
|
|
1ba0db3c3c | ||
|
|
0ce0e67282 | ||
|
|
f178cbe003 | ||
|
|
0478b4deec | ||
|
|
b5a1d40dc6 | ||
|
|
c794b87841 | ||
|
|
a61825404a | ||
|
|
1a9c87059b | ||
|
|
4d8e70da2c | ||
|
|
8efbe1c75e | ||
|
|
df25b701c5 | ||
|
|
3890e57c13 | ||
|
|
2282116773 | ||
|
|
0f8cf58043 | ||
|
|
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 | ||
|
|
dd93b0164a | ||
|
|
7622c02706 | ||
|
|
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 | ||
|
|
7cf67fa99d | ||
|
|
d4515b62ec | ||
|
|
ac02477732 | ||
|
|
4583362634 | ||
|
|
d00cf662b1 | ||
|
|
6deb10741a | ||
|
|
7770124ea0 | ||
|
|
78ab0c3b39 | ||
|
|
7c7d13c458 | ||
|
|
ed7114b4a0 | ||
|
|
acbb98aa5c | ||
|
|
385877d1fc | ||
|
|
771ccd721f | ||
|
|
b9e272b1ab | ||
|
|
37cee5b4fd | ||
|
|
8fe6762146 | ||
|
|
ff28824d42 | ||
|
|
45978aaf67 | ||
|
|
24fa04d753 | ||
|
|
1429b27098 | ||
|
|
c78882c7da | ||
|
|
40dd7109bb | ||
|
|
1db1b34dcb | ||
|
|
d30b514728 | ||
|
|
6993f213b6 | ||
|
|
511771b9f9 | ||
|
|
344382682e | ||
|
|
bb22bf6570 | ||
|
|
edef044a68 | ||
|
|
c7f54b7461 | ||
|
|
47fd58c339 | ||
|
|
88a75f983b | ||
|
|
dbd5775947 | ||
|
|
199e8193fd | ||
|
|
0748416e9e | ||
|
|
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 | ||
|
|
073e47caed | ||
|
|
e36bbcfc63 | ||
|
|
5e90d9f10b | ||
|
|
faa8a4a52a | ||
|
|
6c0de3509d | ||
|
|
70ac0a01b8 | ||
|
|
85a5c9f340 | ||
|
|
4aaf71dc39 | ||
|
|
6a3359d943 | ||
|
|
6397e50017 | ||
|
|
fe36131256 | ||
|
|
50467de40e | ||
|
|
a7f5e08990 | ||
|
|
d4d2214cfb | ||
|
|
8368732e4e | ||
|
|
a26743d278 | ||
|
|
e8edf93a48 | ||
|
|
dce6093276 | ||
|
|
b8a6663a3a | ||
|
|
05a81f37b5 | ||
|
|
f834560860 | ||
|
|
1bc23d7a17 | ||
|
|
e297f7245b | ||
|
|
640dd0bcf5 | ||
|
|
175fbe9e52 | ||
|
|
fde182860f | ||
|
|
3fc0eb473e | ||
|
|
f9eb279790 | ||
|
|
69ca4f94b7 | ||
|
|
b322275555 | ||
|
|
b8d3a3b132 | ||
|
|
d689881e68 | ||
|
|
03c5e6b091 | ||
|
|
f4cdc0873c | ||
|
|
f802d6cc2c | ||
|
|
29a511747d | ||
|
|
3afa8e234b | ||
|
|
efe0b28747 | ||
|
|
c7d9d1aedd | ||
|
|
6c6efb8d74 | ||
|
|
fc711c3f6d | ||
|
|
945f40e8ff | ||
|
|
4aeb14b3ba | ||
|
|
b9854b1d82 | ||
|
|
f3fcb0ca25 | ||
|
|
84f6959921 | ||
|
|
f2c9af4827 | ||
|
|
0de6eeb024 | ||
|
|
3aadba6aba | ||
|
|
8c991c7472 | ||
|
|
593e9e05cc | ||
|
|
e03f69d607 | ||
|
|
9ffa81a748 | ||
|
|
7ce65988b2 | ||
|
|
eadca0ce30 | ||
|
|
3e88f5909b | ||
|
|
d64be662fe | ||
|
|
3b3f6aa00f | ||
|
|
e2268eb854 | ||
|
|
8b9e8417af | ||
|
|
1426129516 | ||
|
|
a932bc6c86 | ||
|
|
2aa9308e47 | ||
|
|
23c572b374 | ||
|
|
8158bcae73 | ||
|
|
62724ffccf | ||
|
|
52105d2c9b | ||
|
|
9782763e82 | ||
|
|
2eb6044dfc | ||
|
|
dd47001112 | ||
|
|
02e7620924 | ||
|
|
6564896c45 | ||
|
|
b775c66123 | ||
|
|
e3295a9974 | ||
|
|
8554155932 | ||
|
|
d114d2ef89 | ||
|
|
30a3b019ff | ||
|
|
4807f13288 | ||
|
|
1d72cf2ed8 | ||
|
|
5b9fe567ec | ||
|
|
9558ecf848 | ||
|
|
d6802414fa | ||
|
|
a7d2edbfb9 | ||
|
|
5d44519d5b | ||
|
|
7914cfe741 | ||
|
|
c3326aed5e | ||
|
|
3cdf31772f | ||
|
|
088b9e37e5 | ||
|
|
98cfa7ef55 | ||
|
|
a79aa4f977 | ||
|
|
55d7ad61f1 | ||
|
|
2b839bce3d | ||
|
|
929ad64e2a | ||
|
|
5345f12b9d | ||
|
|
b1e79509ac | ||
|
|
bf40027865 | ||
|
|
0289686b53 | ||
|
|
eeb3c04215 | ||
|
|
9af63f81d3 | ||
|
|
4fd7c5c271 | ||
|
|
c0aef4ac41 | ||
|
|
9e11182d53 | ||
|
|
854e12bf62 | ||
|
|
858582b279 | ||
|
|
c06d1d81c4 | ||
|
|
a44cdec48e | ||
|
|
03e72bc0b0 | ||
|
|
197ad9fcc5 | ||
|
|
58feda2a09 | ||
|
|
c02c215c62 | ||
|
|
008aba983e | ||
|
|
0c66644118 | ||
|
|
fdc26c707e | ||
|
|
01b3be1502 | ||
|
|
9c292718aa | ||
|
|
7b53924423 | ||
|
|
d4984f2ca0 | ||
|
|
dd2d21f0f9 | ||
|
|
5fa72fe13a | ||
|
|
0b23212f49 | ||
|
|
5db025666c | ||
|
|
c558b29549 | ||
|
|
37f8ebb618 | ||
|
|
ab98869192 | ||
|
|
bedf076c56 | ||
|
|
7416b4a5f6 | ||
|
|
26210e2da4 | ||
|
|
b97af5d3da | ||
|
|
5c05eacc6d | ||
|
|
0fe1d0e15d | ||
|
|
3f38b2d0bd | ||
|
|
ed8eaed845 | ||
|
|
0a6d5eb0a7 | ||
|
|
ef6f7a2170 | ||
|
|
425b65a99f | ||
|
|
a222a7e47a | ||
|
|
c4dcf2d2c8 | ||
|
|
effe6ea423 | ||
|
|
be7591c70b | ||
|
|
be3fbc2d2b | ||
|
|
7af3e1ec44 | ||
|
|
79fe067a7e | ||
|
|
c40c92e0bc | ||
|
|
cb62d074db | ||
|
|
796db06bf8 | ||
|
|
177548d416 | ||
|
|
5ca811b47a | ||
|
|
7c32d1c4d6 | ||
|
|
2ce4e8da11 | ||
|
|
3f1af338cb | ||
|
|
786bd54096 | ||
|
|
16cdc3afd9 | ||
|
|
69d388deb0 | ||
|
|
0855321d41 | ||
|
|
db6a8b7f44 | ||
|
|
3443abbde5 | ||
|
|
e08c7a3ee0 | ||
|
|
93365662aa | ||
|
|
751423b897 | ||
|
|
219a56f260 | ||
|
|
cc3ee16e46 | ||
|
|
42372aa760 | ||
|
|
d29c8eb63f | ||
|
|
8852c0e0b8 | ||
|
|
21f7031e72 | ||
|
|
7dde5f518c | ||
|
|
b65d5f5dcd | ||
|
|
f88c50772f | ||
|
|
d064372d9d | ||
|
|
ca0a90eee3 | ||
|
|
c6780b8daf | ||
|
|
aca15c91d4 | ||
|
|
01166b25ae | ||
|
|
311cf0d31f | ||
|
|
9e9078f17f | ||
|
|
d00c6bcd25 | ||
|
|
a96e04043e | ||
|
|
424f045246 | ||
|
|
dfd4b135ef | ||
|
|
baceff1b55 | ||
|
|
de808d2d30 | ||
|
|
44338647fa | ||
|
|
3f41fda7a1 | ||
|
|
57bc596fe1 | ||
|
|
4090de57e0 | ||
|
|
1d78698703 |
No files matched your search
@@ -152,23 +152,23 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|
||||
|
||||
| Subsystem | Files |
|
||||
|---|---|
|
||||
| Game loop, tick, frame | `game.rs`: `fn run`, `impl ApplicationHandler for Game` (`window_event`, `about_to_wait`), `fn tick`, `fn tick_world`, `fn render`; L: `clock.rs`, `spans.rs`; `horizon-client-input/src/window.rs` |
|
||||
| Rendering | L: `gfx.rs` (`Renderer::new`, `Renderer::render`), `gfx/` (cull, occlusion, translucency, upload, timing, lod, weather, stars, end_sky), `*.wgsl`, `particles.rs`, `block_entity_render.rs`, `item_render.rs`, `fog.rs`, `clouds.rs`, `camera.rs`, `first_person.rs`; G: `render_tick.rs`, `item_draw.rs` |
|
||||
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `server_terrain.rs` (a server's kept chunks), `bluemap.rs` + `bluemap_detect.rs` (far terrain from its web map), `ticket_levels.rs`; G: `world_loading.rs`, `postprocess.rs`; game.rs `fn update_world` |
|
||||
| Game loop, tick, frame | `game.rs`: `fn run`, `fn tick`, `fn tick_world`, `fn render`; G: `window_handler.rs` (`impl ApplicationHandler for Game`: `window_event`, `about_to_wait`); L: `clock.rs`, `spans.rs`; `horizon-client-input/src/window.rs` |
|
||||
| Rendering | L: `gfx.rs` (`Renderer::new`, `Renderer::render`), `gfx/` (cull, occlusion, translucency, upload, timing, lod, weather, stars, end_sky), `*.wgsl`, `particles.rs`, `block_entity_render.rs`, `item_render.rs`, `fog.rs`, `clouds.rs`, `camera.rs`, `first_person.rs`; G: `render_tick.rs`, `item_draw.rs`, `screen_backgrounds.rs` (panorama, menu blur, border wall) |
|
||||
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `server_terrain.rs` (a server's kept chunks), `bluemap.rs` + `bluemap_detect.rs` (far terrain from its web map), `ticket_levels.rs`; G: `world_loading.rs`, `world_entry.rs`, `postprocess.rs`; game.rs `fn update_world` |
|
||||
| World generation | `crates/horizon-world/src/worldgen/` (`generator*.rs`, `feature_graph.rs`, `structure/`, `carver/`) |
|
||||
| Block behaviour | `crates/horizon-world/src/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `redstone.rs`, `place.rs`, `interact.rs`); game.rs `fn tick_blocks`, `fn process_level_events` |
|
||||
| Mobs | L: `mobs.rs` (types), `mobs/` (`ai.rs`, `goals.rs`, `brain.rs`, `*_brain.rs`, `nav.rs`, `path.rs`, `species.rs`, `spawning.rs`, `render.rs`, `anim.rs`); G: `creatures.rs` (ticking, spawning, mob events), one file per mob family (`dragon.rs`, `wither.rs`, `villagers.rs`, `piglins.rs`, `golems.rs`...), `natural_spawning.rs` |
|
||||
| Other entities | L: `entities.rs`, `projectiles.rs`, `arrows.rs`; G: `boats.rs`, `minecarts.rs`, `mounts.rs`, `armor_stands.rs`, `paintings.rs`, `frames.rs`, `fireworks.rs`, `tnt.rs`, `explosion.rs` |
|
||||
| Player actions | game.rs (`fn use_hand`, `fn use_item`, `fn attack`, `fn tick_mining`, `fn break_block`); G: `hand.rs`, `combat.rs`, `consume.rs`, `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`; crate `horizon-items`: `items.rs`, `item_data.rs`, `enchant.rs`, `blocks.rs` |
|
||||
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs` |
|
||||
| Player actions | game.rs (`fn use_hand`, `fn tick_mining`, `fn break_block`); G: `item_actions.rs` (`fn use_item`), `melee.rs` (`fn attack`), `hand.rs`, `combat.rs`, `consume.rs`, `using_item.rs` (an item in use, tick by tick), `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`; crate `horizon-items`: `items.rs`, `item_data.rs`, `enchant.rs`, `blocks.rs` |
|
||||
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs`, `inventory_input.rs` (keys and clicks over a container), `inventory_slots.rs` (slot layout and drawing) |
|
||||
| Commands | parse: crate `horizon-command` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`; `horizon-client-core`: `nbt_path.rs`, `scoreboard.rs`; G: `functions.rs`, `command_blocks.rs` |
|
||||
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs` |
|
||||
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `loading_screen.rs` (the loading screen, the pre-game `App`), `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs`, `creative_screen.rs` |
|
||||
| Multiplayer client | L: `net.rs` (network thread); G: `remote.rs` (`fn handle_packet`, `fn tick_remote`), `remote_*.rs` (mob data, objects, projectiles, vehicles, stations) |
|
||||
| LAN host / server side | L: `lan/` (`conn.rs` handshake + login threads, `hub.rs` + `hub/` play state, `chunks.rs`, `entity.rs`, `tracking.rs`, `guest.rs`, `playerdata.rs`); G: `lan.rs` (`LanHost`), `lan_actions.rs` (`fn as_guest`, `fn tick_guests`), `lan_*.rs` per feature, `lan_entities.rs` (tracked entities) |
|
||||
| Integrated server | G: `integrated.rs` (the server thread), `levels.rs` (one level per dimension, `fn tick_server`), `server_travel.rs` |
|
||||
| Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server`, `horizon-anticheat` (L: `lan/anticheat.rs`) |
|
||||
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
|
||||
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs` |
|
||||
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities), `world_session.rs` (opening and closing a world); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
|
||||
| Input | game.rs `fn window_event`; G: `play_keys.rs` (`fn key_pressed`), `play_input.rs` (`fn play_mouse`, toggled keys, the hotbar wheel); crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs`, `pointer_capture.rs` (mouse look, pointer grab) |
|
||||
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
|
||||
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `mobs/cem.rs` + `mobs/cem_anim.rs` (OptiFine/EMF entity models and their animations); `horizon-client-core/src/lang.rs`; crates `horizon-assets`, `horizon-optifine`; G: `packs.rs` |
|
||||
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
|
||||
@@ -191,7 +191,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|
||||
`levels.rs` swaps whole dimensions in the same way. Changes reach clients through the block log, level
|
||||
events and the entity tracker (`lan/tracking.rs`, `game/lan_entities.rs`).
|
||||
- Shared read-only data: `Resources` (`res`: blocks, items, recipes, mob types, biomes) and `PackResources`
|
||||
(`pack`), both in game.rs.
|
||||
(`pack`), both in game.rs, their methods in `game/resource_tables.rs`.
|
||||
|
||||
## Single player runs on a server thread
|
||||
|
||||
|
||||
@@ -61,6 +61,10 @@ another project's source may also be read and used, **only when that project's l
|
||||
"source available" licenses, all rights reserved (OptiFine), and any project with no license or an unclear one.
|
||||
Don't read, copy, translate or paraphrase their source, and don't send code an AI tool wrote from it. Being able
|
||||
to download a mod or a pack does not give you the right to read its files.
|
||||
- **Java's own library is GPL too.** OpenJDK's source (`java.lang.Math`, `StrictMath`, `FdLibm`, `HashMap`, the
|
||||
sorts, `Double.toString`, HotSpot's intrinsics...) is GPL-2.0, so it is off limits like the rest. Reproduce Java's
|
||||
behaviour from its API documentation (the Javadoc) and from what a JVM returns, or from a permissive original such
|
||||
as Sun's fdlibm C sources (see [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)); see #6741.
|
||||
- **Check the license yourself.** Read the LICENSE file at the commit you use and the header of each file you take:
|
||||
some repositories mix licenses, and a file under another license is off limits. A license that changed: the one at
|
||||
your commit counts. In doubt, don't: ask in #contributors first.
|
||||
|
||||
+55
-2
@@ -1,5 +1,36 @@
|
||||
# Third-party notices
|
||||
|
||||
## Code removed for its license
|
||||
|
||||
Horizon once shipped three pieces of code under MIT that came from projects whose licenses don't allow that. All
|
||||
three are gone from main. They are listed here for anyone who holds an older commit, tag or release: in those
|
||||
versions, that code is not covered by Horizon's MIT license. It stays under its authors' copyright and its original
|
||||
license.
|
||||
|
||||
- **Shader packs written from Iris's source.** `crates/horizon-shaderpack` and the render stages that used it were
|
||||
written from the source of [Iris](https://github.com/IrisShaders/Iris) (LGPL-3.0, copyright the Iris contributors).
|
||||
They were added from 15 October 2026 (`b22583d057`), shipped in the game releases `v2026.10.16.02` to `v0.2.46`,
|
||||
and were removed on 24 October 2026 ([#1561](https://github.com/horizon-rust/horizon/pull/1561)) after a developer
|
||||
of Iris showed us. Shader packs came back in `crates/horizon-shaders`, first written from a description of the
|
||||
pack format.
|
||||
- **Math functions translated from OpenJDK.** In `crates/horizon-core/src/jmath.rs`, `fdlibm::{log, log10, cbrt,
|
||||
hypot}` were translated from OpenJDK's `java/lang/FdLibm.java` and `round_f32` from its `java/lang/Math.java`
|
||||
(GPL-2.0 with the Classpath Exception), and `aarch64::{sin, cos}` from HotSpot's `macroAssembler_aarch64_trig.cpp`
|
||||
(GPL-2.0); copyright Oracle and/or its affiliates, and Cavium for that file. The `fdlibm` and `aarch64` functions
|
||||
were added on 13 October 2026 (`558d140fca`, first released in `v2026.10.13`). `round_f32` was added on 14 October
|
||||
2026 as `java_round` in `crates/horizon-client/src/mipmap.rs` (`95db0fce9d`) and moved into `jmath.rs` on 3
|
||||
November ([#7770](https://github.com/horizon-rust/horizon/pull/7770)). All of them were replaced in
|
||||
[#11944](https://github.com/horizon-rust/horizon/pull/11944) by code written without OpenJDK's sources, from Sun's
|
||||
fdlibm C sources, the Java SE API documentation and a description of the behaviour, and checked against a JVM
|
||||
([#6741](https://github.com/horizon-rust/horizon/issues/6741)).
|
||||
- **Java's `HashMap` translated from OpenJDK.** `crates/horizon-world/src/java_hash_map.rs`, which reproduces the
|
||||
iteration order of `java.util.HashMap`, was translated from OpenJDK's `java/util/HashMap.java` (GPL-2.0 with the
|
||||
Classpath Exception; copyright Oracle and/or its affiliates). It was added on 7 October 2026 (`118e93ea51` and
|
||||
`194db0dbb4`, first released in `v2026.10.12`). It was replaced in
|
||||
[#12043](https://github.com/horizon-rust/horizon/pull/12043) by code written without OpenJDK's sources, from a
|
||||
description of the behaviour, the Java SE API documentation and the textbook red-black tree, and checked against a
|
||||
JVM ([#6741](https://github.com/horizon-rust/horizon/issues/6741)).
|
||||
|
||||
## Catnip / Ponder
|
||||
|
||||
`crates/horizon-client-render/src/fluid_render.rs` translates the still-fluid face tiling and
|
||||
@@ -15,8 +46,11 @@ original classes, game shaders, textures or prepared resources are distributed h
|
||||
`third_party/wgpu-hal` is [wgpu-hal](https://github.com/gfx-rs/wgpu) 30.0.1 by the gfx-rs
|
||||
developers, with upstream fixes applied
|
||||
([gfx-rs/wgpu#10124](https://github.com/gfx-rs/wgpu/pull/10124),
|
||||
[gfx-rs/wgpu#10714](https://github.com/gfx-rs/wgpu/pull/10714)). It is licensed under MIT or
|
||||
Apache-2.0; both licenses are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
|
||||
[gfx-rs/wgpu#10714](https://github.com/gfx-rs/wgpu/pull/10714)). Its GL backend also reports
|
||||
the context's sampled textures and samplers per shader stage (usually 32 on desktop GL,
|
||||
where WebGL2's minimum 16 was reported before), so a pipeline layout the driver accepts is
|
||||
not refused. It is licensed under MIT or Apache-2.0; both licenses are in that folder
|
||||
(`LICENSE.MIT`, `LICENSE.APACHE`).
|
||||
|
||||
## naga
|
||||
|
||||
@@ -256,3 +290,22 @@ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
## fdlibm
|
||||
|
||||
`crates/horizon-core/src/jmath/fdlibm.rs` and the `fdlibm_asin`, `fdlibm_atan` and
|
||||
`fdlibm_atan2` functions in `crates/horizon-core/src/geometry/trig.rs` are Rust ports of
|
||||
[fdlibm](https://www.netlib.org/fdlibm/) 5.3, Sun's freely distributable math library, whose
|
||||
results the Java SE API specifies for `StrictMath`. `fdlibm.rs` ports `e_log.c`, `e_log10.c`,
|
||||
`s_cbrt.c` and `e_hypot.c`, and, with fused multiply-adds, `s_sin.c`, `s_cos.c`, `k_sin.c`,
|
||||
`k_cos.c` and `e_rem_pio2.c`; `trig.rs` ports `e_asin.c`, `s_atan.c` and `e_atan2.c`. This
|
||||
notice also heads both files:
|
||||
|
||||
```
|
||||
Copyright (C) 1993 by Sun Microsystems, Inc. All rights reserved.
|
||||
|
||||
Developed at SunSoft, a Sun Microsystems, Inc. business.
|
||||
Permission to use, copy, modify, and distribute this
|
||||
software is freely granted, provided that this notice
|
||||
is preserved.
|
||||
```
|
||||
@@ -917,6 +917,46 @@ impl NumericProperty {
|
||||
/// (`None` removes a prototype component).
|
||||
pub type Components = BTreeMap<String, Value>;
|
||||
|
||||
/// A stack's components read in place: its patch first, then its item's defaults. Reading one copies
|
||||
/// nothing, where [`ItemModels::components`] clones the defaults for a stack with a patch (an enchanted
|
||||
/// sword's are a few dozen JSON values, on every draw of it).
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct ComponentsView<'a> {
|
||||
prototype: &'a Components,
|
||||
patch: &'a BTreeMap<String, Option<Value>>,
|
||||
}
|
||||
|
||||
impl<'a> ComponentsView<'a> {
|
||||
pub fn get(&self, key: &str) -> Option<&'a Value> {
|
||||
match self.patch.get(key) {
|
||||
Some(patched) => patched.as_ref(),
|
||||
None => self.prototype.get(key),
|
||||
}
|
||||
}
|
||||
pub fn contains_key(&self, key: &str) -> bool {
|
||||
self.get(key).is_some()
|
||||
}
|
||||
/// Components with no patch: `components` as they are.
|
||||
pub fn of(components: &'a Components) -> Self {
|
||||
static NO_PATCH: BTreeMap<String, Option<Value>> = BTreeMap::new();
|
||||
Self {
|
||||
prototype: components,
|
||||
patch: &NO_PATCH,
|
||||
}
|
||||
}
|
||||
/// The merged map, for what needs one whole.
|
||||
pub fn to_map(&self) -> Components {
|
||||
let mut out = self.prototype.clone();
|
||||
for (key, value) in self.patch {
|
||||
match value {
|
||||
Some(v) => out.insert(key.clone(), v.clone()),
|
||||
None => out.remove(key),
|
||||
};
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// An item stack as the resolver reads it.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct StackView {
|
||||
@@ -1087,14 +1127,15 @@ impl ItemModels {
|
||||
if stack.patch.is_empty() {
|
||||
return std::borrow::Cow::Borrowed(prototype);
|
||||
}
|
||||
let mut out = prototype.clone();
|
||||
for (key, value) in &stack.patch {
|
||||
match value {
|
||||
Some(v) => out.insert(key.clone(), v.clone()),
|
||||
None => out.remove(key),
|
||||
};
|
||||
std::borrow::Cow::Owned(self.components_view(stack).to_map())
|
||||
}
|
||||
/// The stack's components, read in place (see [`ComponentsView`]).
|
||||
pub fn components_view<'a>(&'a self, stack: &'a StackView) -> ComponentsView<'a> {
|
||||
static NONE: Components = BTreeMap::new();
|
||||
ComponentsView {
|
||||
prototype: self.prototypes.get(&stack.item).unwrap_or(&NONE),
|
||||
patch: &stack.patch,
|
||||
}
|
||||
std::borrow::Cow::Owned(out)
|
||||
}
|
||||
/// `ItemModelResolver.updateForTopItem`.
|
||||
pub fn resolve(&self, stack: &StackView, ctx: &Context, env: &dyn ItemEnv) -> ResolvedItem {
|
||||
@@ -1112,7 +1153,7 @@ impl ItemModels {
|
||||
ctx: &Context,
|
||||
env: &dyn ItemEnv,
|
||||
) {
|
||||
let components = self.components(stack);
|
||||
let components = self.components_view(stack);
|
||||
let Some(Value::String(model_id)) = components.get("minecraft:item_model") else {
|
||||
return;
|
||||
};
|
||||
@@ -1122,7 +1163,7 @@ impl ItemModels {
|
||||
let mut state = Resolve {
|
||||
models: self,
|
||||
stack,
|
||||
components: &components,
|
||||
components,
|
||||
ctx,
|
||||
env,
|
||||
};
|
||||
@@ -1140,7 +1181,7 @@ impl ItemModels {
|
||||
self.properties(stack).2
|
||||
}
|
||||
fn properties(&self, stack: &StackView) -> (bool, bool, f32) {
|
||||
match self.components(stack).get("minecraft:item_model") {
|
||||
match self.components_view(stack).get("minecraft:item_model") {
|
||||
Some(Value::String(id)) => identifier(id)
|
||||
.ok()
|
||||
.and_then(|id| self.definitions.get(&id))
|
||||
@@ -1159,7 +1200,7 @@ impl ItemModels {
|
||||
struct Resolve<'a> {
|
||||
models: &'a ItemModels,
|
||||
stack: &'a StackView,
|
||||
components: &'a Components,
|
||||
components: ComponentsView<'a>,
|
||||
ctx: &'a Context<'a>,
|
||||
env: &'a dyn ItemEnv,
|
||||
}
|
||||
@@ -1455,9 +1496,9 @@ impl Resolve<'_> {
|
||||
fn numeric(&self, property: &NumericProperty) -> f32 {
|
||||
let owner = self.ctx.owner;
|
||||
match property {
|
||||
NumericProperty::BundleFullness => {
|
||||
self.env.bundle_fullness(self.stack, self.components)
|
||||
}
|
||||
NumericProperty::BundleFullness => self
|
||||
.env
|
||||
.bundle_fullness(self.stack, &self.components.to_map()),
|
||||
NumericProperty::Compass {
|
||||
wobble,
|
||||
target,
|
||||
|
||||
@@ -63,7 +63,7 @@ pub(super) fn decode(m: &Map<String, Value>) -> Result<Value> {
|
||||
/// with a matching level. An empty predicate list still needs the component.
|
||||
pub(super) fn matches(
|
||||
predicate: &Value,
|
||||
components: &Components,
|
||||
components: ComponentsView<'_>,
|
||||
env: &dyn ItemEnv,
|
||||
) -> Option<bool> {
|
||||
let (id, expected) = predicate.as_object()?.iter().next()?;
|
||||
@@ -143,13 +143,17 @@ mod tests {
|
||||
json!({"minecraft:density":0}),
|
||||
)]);
|
||||
assert_eq!(
|
||||
matches(&json!({"minecraft:enchantments":[{}]}), &components, &NoEnv),
|
||||
matches(
|
||||
&json!({"minecraft:enchantments":[{}]}),
|
||||
ComponentsView::of(&components),
|
||||
&NoEnv
|
||||
),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
matches(
|
||||
&json!({"minecraft:enchantments":[{"enchantments":"minecraft:density"}]}),
|
||||
&components,
|
||||
ComponentsView::of(&components),
|
||||
&NoEnv
|
||||
),
|
||||
Some(false)
|
||||
@@ -166,12 +170,21 @@ mod tests {
|
||||
"minecraft:enchantments".into(),
|
||||
json!({"minecraft:density":3}),
|
||||
)]);
|
||||
assert_eq!(matches(&predicate, &components, &Tags), Some(true));
|
||||
assert_eq!(matches(&predicate, &components, &NoEnv), Some(false));
|
||||
assert_eq!(
|
||||
matches(&predicate, ComponentsView::of(&components), &Tags),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
matches(&predicate, ComponentsView::of(&components), &NoEnv),
|
||||
Some(false)
|
||||
);
|
||||
components.insert(
|
||||
"minecraft:enchantments".into(),
|
||||
json!({"minecraft:density":2}),
|
||||
);
|
||||
assert_eq!(matches(&predicate, &components, &Tags), Some(false));
|
||||
assert_eq!(
|
||||
matches(&predicate, ComponentsView::of(&components), &Tags),
|
||||
Some(false)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn identifier_adds_default_namespace() {
|
||||
@@ -111,3 +112,49 @@ fn custom_condition_validates_its_name_and_both_branches() {
|
||||
assert!(matches!(node("example:charged", empty).unwrap(),
|
||||
ItemModelNode::Condition { property: ConditionProperty::Custom(name), .. } if name == "example:charged"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_patched_stack_reads_its_patch_over_its_items_defaults() {
|
||||
let mut models = ItemModels::default();
|
||||
models.prototypes.insert(
|
||||
"minecraft:diamond_sword".into(),
|
||||
Components::from_iter([
|
||||
(
|
||||
"minecraft:item_model".into(),
|
||||
json!("minecraft:diamond_sword"),
|
||||
),
|
||||
("minecraft:max_damage".into(), json!(1561)),
|
||||
("minecraft:rarity".into(), json!("common")),
|
||||
]),
|
||||
);
|
||||
let mut stack = StackView::new("minecraft:diamond_sword", 1);
|
||||
stack.patch.insert(
|
||||
"minecraft:enchantments".into(),
|
||||
Some(json!({"minecraft:sharpness": 5})),
|
||||
);
|
||||
stack
|
||||
.patch
|
||||
.insert("minecraft:rarity".into(), Some(json!("rare")));
|
||||
stack.patch.insert("minecraft:max_damage".into(), None);
|
||||
let view = models.components_view(&stack);
|
||||
assert_eq!(view.get("minecraft:rarity"), Some(&json!("rare")));
|
||||
assert_eq!(view.get("minecraft:max_damage"), None, "a removed default");
|
||||
assert_eq!(
|
||||
view.get("minecraft:item_model"),
|
||||
Some(&json!("minecraft:diamond_sword"))
|
||||
);
|
||||
assert!(view.contains_key("minecraft:enchantments"));
|
||||
assert!(!view.contains_key("minecraft:custom_name"));
|
||||
// Every key reads as the merged map has it.
|
||||
let merged = models.components(&stack);
|
||||
for key in [
|
||||
"minecraft:rarity",
|
||||
"minecraft:max_damage",
|
||||
"minecraft:item_model",
|
||||
"minecraft:enchantments",
|
||||
"minecraft:custom_name",
|
||||
] {
|
||||
assert_eq!(view.get(key), merged.get(key), "{key}");
|
||||
}
|
||||
assert_eq!(view.to_map(), *merged);
|
||||
}
|
||||
@@ -290,12 +290,12 @@ pub fn normalize_quaternion(q: [f32; 4]) -> [f32; 4] {
|
||||
[x * inv, y * inv, z * inv, w * inv]
|
||||
}
|
||||
/// JOML `Math.sin(float)` without fast math.
|
||||
fn joml_sin(rad: f32) -> f32 {
|
||||
pub fn joml_sin(rad: f32) -> f32 {
|
||||
f64::from(rad).sin() as f32
|
||||
}
|
||||
/// JOML `Math.cosFromSin(float, float)` without fast math: the magnitude from
|
||||
/// the sine, the sign from the angle's quadrant (exact zeros at right angles).
|
||||
fn cos_from_sin(sin: f32, angle: f32) -> f32 {
|
||||
pub fn cos_from_sin(sin: f32, angle: f32) -> f32 {
|
||||
let cos = (1.0 - sin * sin).sqrt();
|
||||
let tau = (std::f64::consts::PI * 2.0) as f32;
|
||||
let a = angle + std::f32::consts::FRAC_PI_2;
|
||||
|
||||
@@ -217,9 +217,10 @@ pub fn stack(tag: &Tag) -> Option<Tag> {
|
||||
Some(Tag::compound(out))
|
||||
}
|
||||
/// `ItemStackTemplate.CODEC`: an item id or `{id, count?, components?}`.
|
||||
/// Encoding uses the primary record form, including for a bare item id.
|
||||
fn template(tag: &Tag) -> Option<Tag> {
|
||||
match tag {
|
||||
Tag::String(_) => Some(tag.clone()),
|
||||
Tag::String(_) => Some(Tag::compound([("id", tag.clone())])),
|
||||
Tag::Compound(_) => {
|
||||
let mut out = vec![("id", string(&text(tag.get("id"))?))];
|
||||
if let Some(count) = int(tag.get("count")).filter(|c| *c != 1) {
|
||||
@@ -1011,3 +1012,69 @@ mod tests {
|
||||
assert_eq!(items[1].get("count"), Some(&Tag::Int(5)));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pot_template_tests {
|
||||
use super::*;
|
||||
use horizon_world::nbt::parse_snbt;
|
||||
|
||||
#[test]
|
||||
fn decorated_pot_string_template_load_save_uses_item_compound() {
|
||||
let parse = |s: &str| parse_snbt(s).expect("valid input NBT").0;
|
||||
let input = |front: &str| {
|
||||
let mut tag = parse(
|
||||
r#"{id:"minecraft:decorated_pot",x:4,y:200,z:4,sherds:{back:{id:"minecraft:brick"},right:{id:"minecraft:archer_pottery_sherd"}},item:{id:"minecraft:wheat",count:3,components:{"minecraft:custom_name":"H290 item"}}}"#,
|
||||
);
|
||||
let Tag::Compound(root) = &mut tag else {
|
||||
unreachable!()
|
||||
};
|
||||
let Some(Tag::Compound(sides)) = root.get_mut(&"sherds".into()) else {
|
||||
unreachable!()
|
||||
};
|
||||
sides.insert("front".into(), parse(front));
|
||||
tag
|
||||
};
|
||||
let other_fields = |loaded: &Tag, saved: &Tag| {
|
||||
assert_eq!(saved.get("id"), loaded.get("id"));
|
||||
for key in ["x", "y", "z", "item"] {
|
||||
assert_eq!(saved.get(key), loaded.get(key), "{key}");
|
||||
}
|
||||
let loaded_sides = loaded.get("sherds").unwrap();
|
||||
let saved_sides = saved.get("sherds").unwrap();
|
||||
for side in ["back", "right"] {
|
||||
assert_eq!(saved_sides.get(side), loaded_sides.get(side), "{side}");
|
||||
}
|
||||
assert_eq!(saved_sides.get("left"), None);
|
||||
};
|
||||
let plain = r#"{id:"minecraft:angler_pottery_sherd"}"#;
|
||||
let defaults = r#"{id:"minecraft:angler_pottery_sherd",count:1,components:{}}"#;
|
||||
let rich = r#"{id:"minecraft:angler_pottery_sherd",count:3,components:{"minecraft:custom_data":{h290:1},"minecraft:custom_name":"H290 sherd"}}"#;
|
||||
// Actual canonical load/save entry, not the private template helper.
|
||||
for (front, expected) in [(plain, plain), (defaults, plain), (rich, rich)] {
|
||||
let loaded = input(front);
|
||||
let original = loaded.clone();
|
||||
let saved = canonical(&loaded).expect("actual decorated-pot codec");
|
||||
other_fields(&loaded, &saved);
|
||||
assert_eq!(
|
||||
saved.get("sherds").unwrap().get("front"),
|
||||
Some(&parse(expected))
|
||||
);
|
||||
assert_eq!(canonical(&saved).as_ref(), Some(&saved));
|
||||
assert_eq!(loaded, original, "read-only canonicalization");
|
||||
}
|
||||
println!(
|
||||
"H290 actual canonical compound/default/count/components, other sides/item/name and idempotency controls admitted"
|
||||
);
|
||||
let loaded = input(r#""minecraft:angler_pottery_sherd""#);
|
||||
let original = loaded.clone();
|
||||
let saved = canonical(&loaded).expect("actual decorated-pot codec");
|
||||
other_fields(&loaded, &saved);
|
||||
assert_eq!(loaded, original, "read-only canonicalization");
|
||||
assert_eq!(
|
||||
saved.get("sherds").unwrap().get("front"),
|
||||
Some(&parse(plain)),
|
||||
"string template must save the primary item compound",
|
||||
);
|
||||
assert_eq!(canonical(&saved).as_ref(), Some(&saved));
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -20,6 +20,14 @@ use crate::{
|
||||
fn tr(key: &str) -> Text {
|
||||
Text::translate(key, &[])
|
||||
}
|
||||
|
||||
/// False only in a game built without the `horizon-extras` feature (docs/forking.md), which says
|
||||
/// so once at startup: the Options screen then has vanilla's Telemetry Data slot instead of
|
||||
/// Horizon Settings and Mods, and Credits & Attribution names the official build.
|
||||
pub static HORIZON_EXTRAS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
|
||||
fn horizon_extras() -> bool {
|
||||
HORIZON_EXTRAS.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
fn generic(caption: &str, value: Text) -> Text {
|
||||
Text::translate("options.generic_value", &[tr(caption), value])
|
||||
}
|
||||
@@ -309,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) => [
|
||||
@@ -856,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",
|
||||
@@ -1143,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
|
||||
@@ -2230,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();
|
||||
@@ -3113,6 +3135,22 @@ impl OptionsUi {
|
||||
("horizon.pages.open.mods", Some(Page::Mods)),
|
||||
("options.credits_and_attribution", Some(Page::Credits)),
|
||||
];
|
||||
// horizon-extras: without it, vanilla's last row instead of Horizon Settings and
|
||||
// Mods: Telemetry Data (greyed: nothing is sent) and Credits & Attribution.
|
||||
let plain;
|
||||
let entries: &[_] = if horizon_extras() {
|
||||
&entries
|
||||
} else {
|
||||
plain = [
|
||||
&entries[..entries.len() - 3],
|
||||
&[
|
||||
("options.telemetry", None),
|
||||
("options.credits_and_attribution", Some(Page::Credits)),
|
||||
],
|
||||
]
|
||||
.concat();
|
||||
&plain
|
||||
};
|
||||
let rows = entries.len().div_ceil(2) as f32;
|
||||
let gy = (61.0 + 30.0f32).min(h - 33.0 - rows * 24.0);
|
||||
for (i, (key, page)) in entries.iter().enumerate() {
|
||||
@@ -3162,6 +3200,22 @@ impl OptionsUi {
|
||||
let y = y + 28.0 * (i + 1) as f32;
|
||||
out.push(inactive(button(x, y, 210.0, tr(key), Act::None)));
|
||||
}
|
||||
// horizon-extras: a build without them says where the official one is.
|
||||
if !horizon_extras() {
|
||||
out.push(Widget {
|
||||
x: 0.0,
|
||||
y: (y + 92.0).min(h - 42.0),
|
||||
w,
|
||||
h: 9.0,
|
||||
kind: WKind::Text {
|
||||
text: tr("horizon.plain.official"),
|
||||
color: 0xFFA0A0A0,
|
||||
centered: true,
|
||||
},
|
||||
list: false,
|
||||
row: 0,
|
||||
});
|
||||
}
|
||||
out.push(done(h - 27.0));
|
||||
top = 0.0;
|
||||
bottom = 0.0;
|
||||
@@ -3668,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 {
|
||||
@@ -4883,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 {
|
||||
|
||||
@@ -58,6 +58,12 @@ pub struct ParticleOptions {
|
||||
/// A native mod's own particle type, by its namespaced name (`mymod:spark`):
|
||||
/// `SimpleParticleType` plus the provider `particles/<name>.json` names.
|
||||
pub mod_particle: Option<String>,
|
||||
/// Pre-1.20.5 entity-effect providers use spawn velocity as RGB; true is the ambient provider.
|
||||
pub legacy_entity_effect: Option<bool>,
|
||||
/// 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 {
|
||||
@@ -72,6 +78,8 @@ impl ParticleOptions {
|
||||
geyser: None,
|
||||
vibration: None,
|
||||
mod_particle: None,
|
||||
legacy_entity_effect: None,
|
||||
legacy_trail_lifetime: false,
|
||||
}
|
||||
}
|
||||
/// A native mod's `SimpleParticleType`: the sprite set of
|
||||
@@ -3167,8 +3175,16 @@ impl ParticleEngine {
|
||||
q.set_color(b, 0.0, b);
|
||||
}
|
||||
T::EntityEffect => {
|
||||
q.set_color(o.color[0], o.color[1], o.color[2]);
|
||||
q.rgba[3] = o.color[3];
|
||||
if let Some(ambient) = o.legacy_entity_effect {
|
||||
q.set_color(v[0] as f32, v[1] as f32, v[2] as f32);
|
||||
// AmbientMobProvider overrides constructor alpha; MobProvider only sets RGB.
|
||||
if ambient {
|
||||
q.rgba[3] = 0.15;
|
||||
}
|
||||
} else {
|
||||
q.set_color(o.color[0], o.color[1], o.color[2]);
|
||||
q.rgba[3] = o.color[3];
|
||||
}
|
||||
}
|
||||
T::Effect | T::InstantEffect => {
|
||||
q.set_color(o.color[0], o.color[1], o.color[2]);
|
||||
@@ -3177,7 +3193,12 @@ impl ParticleEngine {
|
||||
_ => {}
|
||||
}
|
||||
q.behaviour = Behaviour::Spell {
|
||||
original_alpha: q.rgba[3],
|
||||
// The old SpellParticle.tick always eased back to 1, including ambient effects.
|
||||
original_alpha: if o.legacy_entity_effect.is_some() {
|
||||
1.0
|
||||
} else {
|
||||
q.rgba[3]
|
||||
},
|
||||
};
|
||||
q.set = set;
|
||||
q
|
||||
@@ -3469,7 +3490,11 @@ impl ParticleEngine {
|
||||
scaled(o.color[2], jb),
|
||||
);
|
||||
q.size = 0.26;
|
||||
q.lifetime = duration;
|
||||
q.lifetime = if o.legacy_trail_lifetime {
|
||||
self.level_random.next_int_bound(40).unwrap_or(0) + 10
|
||||
} else {
|
||||
duration
|
||||
};
|
||||
q.has_physics = false;
|
||||
q.free_move = true;
|
||||
q.light = Light::FullBright;
|
||||
@@ -4941,6 +4966,45 @@ const CREEPER: [[f64; 2]; 12] = [
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn legacy_spell_providers_use_velocity_rgb_and_their_original_alpha_rules() {
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
let velocity = [0.25, 0.5, 0.75];
|
||||
for scoped in [false, true] {
|
||||
engine.set_scope_eye(scoped.then_some([0.0; 3]));
|
||||
for ambient in [false, true] {
|
||||
let mut options =
|
||||
ParticleOptions::colored(ParticleType::EntityEffect, [1.0, 0.0, 0.0, 0.8]);
|
||||
options.legacy_entity_effect = Some(ambient);
|
||||
let particle = engine.make(&options, [0.0; 3], velocity, 7).unwrap();
|
||||
let alpha = if ambient {
|
||||
0.15
|
||||
} else if scoped {
|
||||
0.0
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
assert_eq!(particle.rgba, [0.25, 0.5, 0.75, alpha]);
|
||||
assert_eq!(
|
||||
particle.behaviour,
|
||||
Behaviour::Spell {
|
||||
original_alpha: 1.0
|
||||
}
|
||||
);
|
||||
}
|
||||
let options =
|
||||
ParticleOptions::colored(ParticleType::EntityEffect, [1.0, 0.0, 0.0, 0.8]);
|
||||
let current = engine.make(&options, [0.0; 3], velocity, 7).unwrap();
|
||||
assert_eq!(current.rgba, options.color);
|
||||
assert_eq!(
|
||||
current.behaviour,
|
||||
Behaviour::Spell {
|
||||
original_alpha: 0.8
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn particle_light_cache_reuses_exact_blocks_and_fails_open_on_collisions() {
|
||||
let reads = std::cell::Cell::new(0);
|
||||
@@ -5700,6 +5764,40 @@ mod tests {
|
||||
assert!((0.15 * 0.4 - 1e-6..=0.45 * 0.4 + 1e-6).contains(&s));
|
||||
}
|
||||
#[test]
|
||||
fn legacy_trails_choose_a_lifetime_for_each_particle_from_the_level_random() {
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
engine.seed_randoms(1, 42, 2);
|
||||
let legacy = ParticleOptions {
|
||||
legacy_trail_lifetime: true,
|
||||
..ParticleOptions::trail([1.0, 2.0, 3.0], 0x7f3f1f, -1)
|
||||
};
|
||||
for _ in 0..5 {
|
||||
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!(
|
||||
engine
|
||||
.pending
|
||||
.iter()
|
||||
.map(|p| p.lifetime)
|
||||
.collect::<Vec<_>>(),
|
||||
[20, 13, 18, 14, 20]
|
||||
);
|
||||
engine.create(
|
||||
&ParticleOptions::trail([1.0, 2.0, 3.0], 0x7f3f1f, 37),
|
||||
[0.0; 3],
|
||||
[0.0; 3],
|
||||
);
|
||||
assert_eq!(engine.pending.last().unwrap().lifetime, 37);
|
||||
// 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() {
|
||||
let mut r = LegacyRandom::new(2);
|
||||
let mut p = Particle::at(&mut r, ParticleType::Flame, [0.0; 3]);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
//! The randoms vanilla creates unseeded: `RandomSource.create()` (sounds,
|
||||
//! screens, the torch flicker, a raid's random) and an entity's own random
|
||||
//! (`Entity.random`). Each is seeded from one shared counter and the clock, as
|
||||
//! `RandomSupport.generateUniqueSeed` does. Kept out of `horizon_core::random`,
|
||||
//! whose streams are only ever seeded explicitly.
|
||||
use horizon_core::random::LegacyRandom;
|
||||
|
||||
/// `RandomSource.create()`.
|
||||
pub fn create() -> LegacyRandom {
|
||||
LegacyRandom::new(unique_seed())
|
||||
}
|
||||
|
||||
/// An entity's own random (`Entity.random`). Vanilla's is
|
||||
/// `RandomSource.createThreadLocalInstance`, seeded from Java's
|
||||
/// `ThreadLocalRandom`; Horizon seeds it as `create` does. Draws from an
|
||||
/// entity's own random never touch the level's.
|
||||
pub fn entity() -> LegacyRandom {
|
||||
create()
|
||||
}
|
||||
|
||||
/// `RandomSupport.generateUniqueSeed`. Under `HORIZON_STILL` (screenshots that
|
||||
/// come out the same every run) the clock is left out, so the seeds repeat run
|
||||
/// to run, in the order they are taken.
|
||||
pub fn unique_seed() -> i64 {
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
static UNIQUIFIER: AtomicI64 = AtomicI64::new(8_682_522_807_148_012);
|
||||
let mut current = UNIQUIFIER.load(Ordering::Relaxed);
|
||||
let next = loop {
|
||||
let next = current.wrapping_mul(1_181_783_497_276_652_981);
|
||||
match UNIQUIFIER.compare_exchange(current, next, Ordering::Relaxed, Ordering::Relaxed) {
|
||||
Ok(_) => break next,
|
||||
Err(v) => current = v,
|
||||
}
|
||||
};
|
||||
if still() {
|
||||
return next;
|
||||
}
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_nanos() as i64);
|
||||
next ^ nanos
|
||||
}
|
||||
|
||||
/// `HORIZON_STILL=1`, read once, as the clock's `still_requested` reads it.
|
||||
fn still() -> bool {
|
||||
static STILL: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*STILL.get_or_init(|| std::env::var("HORIZON_STILL").is_ok_and(|v| v == "1"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_core::random::RandomSource;
|
||||
|
||||
/// One shared counter: seeds taken back to back never repeat, even when
|
||||
/// the clock hasn't moved between them.
|
||||
#[test]
|
||||
fn each_seed_and_each_random_is_new() {
|
||||
let seeds: std::collections::HashSet<i64> = (0..1000).map(|_| unique_seed()).collect();
|
||||
assert_eq!(seeds.len(), 1000);
|
||||
assert_ne!(create().next_long(), create().next_long());
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -109,12 +109,19 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
|
||||
|
||||
[features]
|
||||
default = ["audio", "controllers", "discord", "friends", "heartbeat"]
|
||||
default = ["audio", "controllers", "horizon-extras"]
|
||||
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
|
||||
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
|
||||
audio = ["horizon-client-render/audio", "horizon-client-lib/audio"]
|
||||
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
|
||||
controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
|
||||
# Horizon's own extras over the vanilla game, for a fork that wants them out (docs/forking.md):
|
||||
# cosmetics and emotes, the "Horizon player" presence, the official and partner servers added to
|
||||
# the server list, the Horizon Settings and Mods pages, Discord, Play with Friends and the
|
||||
# heartbeat. Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
|
||||
# every place it gates. Without it (`--no-default-features --features audio,controllers`) the
|
||||
# client is best effort: CI builds only the default.
|
||||
horizon-extras = ["discord", "friends", "heartbeat"]
|
||||
# Online extras a server or a port can leave out; the server build has none of them. "Playing Horizon"
|
||||
# on Discord (game/discord.rs).
|
||||
discord = ["horizon-client-lib/discord"]
|
||||
|
||||
@@ -47,6 +47,21 @@
|
||||
"horizon.studio.replay.seek.invalid": "Ungültige Wiedergabeposition",
|
||||
"horizon.studio.replay.status": "Studio-Wiedergabe: %s / %s ms (%s%%), Geschwindigkeit %s×, pausiert %s, beendet %s",
|
||||
"horizon.studio.replay.closed": "Studio-Wiedergabe geschlossen",
|
||||
"horizon.studio.freecam.enabled": "Studio-Freikamera aktiviert. WASD bewegt sie, Leertaste und Umschalt heben oder senken sie, Strg beschleunigt.",
|
||||
"horizon.studio.freecam.disabled": "Studio-Freikamera deaktiviert",
|
||||
"horizon.studio.freecam.unavailable": "Die Studio-Freikamera konnte keine Spielerkamera finden",
|
||||
"horizon.studio.freecam.replay_required": "Die Studio-Freikamera ist während der Wiedergabe verfügbar",
|
||||
"horizon.studio.freecam.usage": "Verwendung: /studio freecam <on|off>",
|
||||
"horizon.studio.keyframe.added": "Kameraschlüsselbild bei %s ms hinzugefügt. Der Pfad wird beim Schließen der Wiedergabe gespeichert.",
|
||||
"horizon.studio.keyframe.cleared": "Kamerapfad geleert. Die Änderung wird beim Schließen der Wiedergabe gespeichert.",
|
||||
"horizon.studio.keyframe.removed": "Kameraschlüsselbild %s entfernt. Die Änderung wird beim Schließen der Wiedergabe gespeichert.",
|
||||
"horizon.studio.keyframe.save_error": "Kamerapfad konnte nicht gespeichert werden: %s",
|
||||
"horizon.studio.keyframe.remove_usage": "Verwendung: /studio keyframe remove <INDEX>",
|
||||
"horizon.studio.keyframe.index_invalid": "Kameraschlüsselbildindex liegt außerhalb des Bereichs",
|
||||
"horizon.studio.keyframe.usage": "Verwendung: /studio keyframe <add|remove INDEX|clear>",
|
||||
"horizon.studio.path.locked": "Studio-Kamerapfadwiedergabe aktiviert",
|
||||
"horizon.studio.path.unlocked": "Studio-Kamerapfadwiedergabe gestoppt",
|
||||
"horizon.studio.path.usage": "Verwendung: /studio path <lock|unlock> (zuerst mindestens ein Schlüsselbild hinzufügen)",
|
||||
"horizon.studio.saved_recordings": "Gespeicherte Aufzeichnungen",
|
||||
"horizon.studio.empty": "Keine Aufzeichnungen im Aufnahmeordner gefunden.",
|
||||
"horizon.studio.empty_hint": "Nutze /studio record start <name> in einer Welt oder lege .mcpr-Dateien dort ab.",
|
||||
@@ -113,6 +128,7 @@
|
||||
"Resolution Scale": "Auflösungsskalierung",
|
||||
"Anti-aliasing": "Kantenglättung",
|
||||
"Upscaling": "Upscaling",
|
||||
"Upscaling Sharpness": "Upscaling-Schärfe",
|
||||
"Paper White": "Papierweiß",
|
||||
"Peak Brightness": "Spitzenhelligkeit",
|
||||
"Black Level": "Schwarzwert",
|
||||
@@ -208,6 +224,7 @@
|
||||
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Weltpixel pro Bildschirmpixel. Unter 100 % ist es auf der GPU schneller.",
|
||||
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Glättet Kanten. FXAA: weicher. TAA: am glattesten, evtl. Schlieren.",
|
||||
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Rendert die Welt kleiner und baut sie mit TAA wieder auf. Schneller, weicher.",
|
||||
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Schärft das hochskalierte Bild. Höher: schärfere Kanten, mehr Flimmern.",
|
||||
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR-Ausgabe auf HDR-Bildschirm: Lava, Feuer, Sonne heller.",
|
||||
"How frames reach the screen while VSync is off.": "Wie Bilder auf den Bildschirm gelangen, wenn VSync aus ist.",
|
||||
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Sparsam schläft bis zum nächsten Bild; Präzise wartet aktiv, wie in Vanilla.",
|
||||
@@ -226,6 +243,7 @@
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Pro Bild an die GPU gesendete Mesh-Daten. Weniger ist flüssiger beim Laden.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Bildrate und Bildzeiten über dem Spiel, mit Diagramm.",
|
||||
"horizon.multiplayer.finding_friend": "Die Welt deines Freundes wird gesucht …",
|
||||
"horizon.plain.official": "Offizielles Horizon mit Launcher, Mods und Updates: playhorizon.dev",
|
||||
"horizon.title.not_affiliated": "Nicht mit Mojang oder Microsoft verbunden",
|
||||
"horizon.extras.header.lighting": "Beleuchtung",
|
||||
"horizon.extras.header.third_person": "Dritte Person",
|
||||
|
||||
@@ -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.",
|
||||
@@ -366,6 +386,7 @@
|
||||
"Brighter-than-white output on an HDR display.": "Brighter-than-white output on an HDR display.",
|
||||
"Results for \"%s\"": "Results for \"%s\"",
|
||||
"No settings match.": "No settings match.",
|
||||
"horizon.plain.official": "Official Horizon with launcher, mods and updates: playhorizon.dev",
|
||||
"horizon.title.not_affiliated": "Not affiliated with Mojang or Microsoft",
|
||||
"horizon.extras.header.lighting": "Lighting",
|
||||
"horizon.extras.header.third_person": "Third person",
|
||||
@@ -946,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.",
|
||||
@@ -964,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...",
|
||||
@@ -295,6 +314,7 @@
|
||||
"Brighter-than-white output on an HDR display.": "Salida más brillante que el blanco en una pantalla HDR.",
|
||||
"Results for \"%s\"": "Resultados de \"%s\"",
|
||||
"No settings match.": "Ningún ajuste coincide.",
|
||||
"horizon.plain.official": "Horizon oficial con launcher, mods y actualizaciones: playhorizon.dev",
|
||||
"horizon.title.not_affiliated": "No está afiliado a Mojang ni a Microsoft",
|
||||
"horizon.extras.header.lighting": "Iluminación",
|
||||
"horizon.extras.header.third_person": "Tercera persona",
|
||||
@@ -881,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",
|
||||
|
||||
@@ -188,6 +188,7 @@
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Données de maillage vers le GPU par image. Moins : plus fluide.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Fréquence et temps d'image par-dessus le jeu, avec un graphique.",
|
||||
"horizon.multiplayer.finding_friend": "Recherche du monde de votre ami...",
|
||||
"horizon.plain.official": "Horizon officiel avec launcher, mods et mises à jour : playhorizon.dev",
|
||||
"horizon.title.not_affiliated": "Non affilié à Mojang ou à Microsoft",
|
||||
"horizon.extras.header.lighting": "Éclairage",
|
||||
"horizon.extras.header.third_person": "Troisième personne",
|
||||
|
||||
@@ -231,6 +231,7 @@
|
||||
"Brighter-than-white output on an HDR display.": "Uscita più luminosa del bianco su un display HDR.",
|
||||
"Results for \"%s\"": "Risultati per \"%s\"",
|
||||
"No settings match.": "Nessuna impostazione corrisponde.",
|
||||
"horizon.plain.official": "Horizon ufficiale con launcher, mod e aggiornamenti: playhorizon.dev",
|
||||
"horizon.title.not_affiliated": "Non affiliato con Mojang o Microsoft",
|
||||
"horizon.extras.header.lighting": "Illuminazione",
|
||||
"horizon.extras.header.third_person": "Terza persona",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"horizon.pages.extras": "Horizon エクストラ",
|
||||
"horizon.pages.settings": "Horizon 設定",
|
||||
"horizon.pages.agent": "AIエージェント",
|
||||
"horizon.plain.official": "ランチャー、Mod、アップデート付きの公式 Horizon: playhorizon.dev",
|
||||
"horizon.split.leave": "休暇を取る",
|
||||
"horizon.pages.split_settings": "分割画面設定",
|
||||
"horizon.pages.split_profiles": "誰が遊んでるの?",
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"horizon.pages.extras": "Horizon 추가 기능",
|
||||
"horizon.pages.settings": "Horizon 설정",
|
||||
"horizon.pages.agent": "AI 에이전트",
|
||||
"horizon.plain.official": "런처, 모드, 업데이트가 포함된 공식 Horizon: playhorizon.dev",
|
||||
"horizon.split.leave": "나가기",
|
||||
"horizon.pages.split_settings": "화면 분할 설정",
|
||||
"horizon.pages.split_profiles": "플레이어 선택",
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"Brighter-than-white output on an HDR display.": "Helderder dan witte uitvoer op een HDR-scherm.",
|
||||
"Results for \"%s\"": "Resultaten voor \"%s\"",
|
||||
"No settings match.": "Er komen geen instellingen overeen.",
|
||||
"horizon.plain.official": "Officiële Horizon met launcher, mods en updates: playhorizon.dev",
|
||||
"horizon.title.not_affiliated": "Niet aangesloten bij Mojang of Microsoft",
|
||||
"horizon.extras.header.lighting": "Verlichting",
|
||||
"horizon.extras.header.third_person": "Derde persoon",
|
||||
|
||||
@@ -222,6 +222,7 @@
|
||||
"Brighter-than-white output on an HDR display.": "Obraz jaśniejszy niż biel na ekranie HDR.",
|
||||
"Results for \"%s\"": "Wyniki dla \"%s\"",
|
||||
"No settings match.": "Żadne ustawienie nie pasuje.",
|
||||
"horizon.plain.official": "Oficjalny Horizon z launcherem, modami i aktualizacjami: playhorizon.dev",
|
||||
"horizon.title.not_affiliated": "Niepowiązane z Mojang ani Microsoft",
|
||||
"horizon.extras.header.lighting": "Oświetlenie",
|
||||
"horizon.extras.header.third_person": "Widok z trzeciej osoby",
|
||||
|
||||
@@ -233,6 +233,7 @@
|
||||
"Brighter-than-white output on an HDR display.": "Saída mais brilhante que o branco em uma tela HDR.",
|
||||
"Results for \"%s\"": "Resultados para \"%s\"",
|
||||
"No settings match.": "Nenhuma configuração corresponde.",
|
||||
"horizon.plain.official": "Horizon oficial com launcher, mods e atualizações: playhorizon.dev",
|
||||
"horizon.title.not_affiliated": "Não afiliado à Mojang ou Microsoft",
|
||||
"horizon.extras.header.lighting": "Iluminação",
|
||||
"horizon.extras.header.third_person": "Terceira pessoa",
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"Resolution Scale": "Масштаб разрешения",
|
||||
"Anti-aliasing": "Сглаживание",
|
||||
"Upscaling": "Апскейл",
|
||||
"Upscaling Sharpness": "Резкость апскейла",
|
||||
"Paper White": "Белая точка",
|
||||
"Peak Brightness": "Пиковая яркость",
|
||||
"Black Level": "Уровень чёрного",
|
||||
@@ -167,6 +168,7 @@
|
||||
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Пикселей мира на пиксель экрана. Ниже 100% — быстрее для видеокарты.",
|
||||
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Сглаживает рваные края. FXAA: мягче. TAA: глаже всего, но движущееся может шлейфить.",
|
||||
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Рисует мир мельче и восстанавливает его через TAA. Быстрее, но мягче.",
|
||||
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Повышает резкость увеличенной картинки. Выше: чётче края, больше мерцания.",
|
||||
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR-вывод на HDR-экране: лава, огонь и солнце светятся ярче.",
|
||||
"How frames reach the screen while VSync is off.": "Как кадры попадают на экран, когда вертикальная синхронизация выключена.",
|
||||
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "«Экономный» спит до следующего кадра; «Точный» крутится, как в ванили.",
|
||||
@@ -185,6 +187,7 @@
|
||||
"Frame rate and frame times over the game, with a graph.": "Частота кадров и их время поверх игры, с графиком.",
|
||||
"horizon.multiplayer.finding_friend": "Ищем мир друга…",
|
||||
"horizon.multiplayer.not_signed_in": "Этот сервер работает в онлайн-режиме. Войдите с помощью аккаунта Microsoft в лаунчере Horizon, чтобы присоединиться.",
|
||||
"horizon.plain.official": "Официальный Horizon с лаунчером, модами и обновлениями: playhorizon.dev",
|
||||
"horizon.realms.not_signed_in": "Войдите в лаунчере Horizon с учётной записью Microsoft, чтобы играть в Realms.",
|
||||
"horizon.multiplayer.invalid_address": "Недействительный адрес сервера: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Установлено прямое соединение с другом…",
|
||||
|
||||
@@ -187,6 +187,7 @@
|
||||
"horizon.pages.shader_options": "Gölgelendirici Seçenekleri",
|
||||
"horizon.pages.split_profiles": "Kim oynuyor?",
|
||||
"horizon.pages.split_profiles.tooltip": "Kim oynuyor: bu oyun klasöründe tutulan bir profil.",
|
||||
"horizon.plain.official": "Başlatıcı, modlar ve güncellemelerle resmi Horizon: playhorizon.dev",
|
||||
"horizon.shader_options.profile": "Profil",
|
||||
"Particle Culling": "Particle Culling",
|
||||
"Draws every particle.": "Draws every particle.",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"horizon.mods.dev": " (розробка)",
|
||||
"horizon.mods.settings": "Налаштування",
|
||||
"horizon.mods.open_folder": "Відкрити теку з модами",
|
||||
"horizon.plain.official": "Офіційний Horizon із лаунчером, модами та оновленнями: playhorizon.dev",
|
||||
"horizon.shader_options.profile": "Профіль",
|
||||
"horizon.agent.copy_details": "Копіювати дані з’єднання",
|
||||
"horizon.multiplayer.join": "Приєднатися",
|
||||
@@ -471,5 +472,450 @@
|
||||
"Pack settings": "Налаштування пакета",
|
||||
"Built-in settings": "Вбудовані налаштування",
|
||||
"%s settings": "Налаштування %s",
|
||||
"Settings made by the pack's author: they change only this pack.": "Налаштування, створені автором пакета: вони змінюють лише цей пакет."
|
||||
"Settings made by the pack's author: they change only this pack.": "Налаштування, створені автором пакета: вони змінюють лише цей пакет.",
|
||||
"horizon.extras.header.world": "Світ",
|
||||
"horizon.extras.pregen": "Попередня генерація чанків",
|
||||
"horizon.extras.pregen.tooltip": "Вмикає /pregen у ваших одиночних і LAN-світах. З увімкненими читами введіть /pregen start, щоб згенерувати область, перш ніж її досліджувати. На серверах, до яких ви приєднуєтесь, команда не вмикається.",
|
||||
"horizon.options.three_d_skin_layers": "3D-шари скіна",
|
||||
"horizon.options.play_in_background": "Гра у фоні",
|
||||
"horizon.options.trigger_threshold": "Поріг тригера",
|
||||
"horizon.studio.record.usage": "Використання: /studio record <start [назва]|stop|status>",
|
||||
"horizon.studio.record.started": "Запис Studio розпочато: %s",
|
||||
"horizon.studio.record.saved": "Запис Studio збережено: %s",
|
||||
"horizon.studio.record.error": "Помилка запису Studio: %s",
|
||||
"horizon.studio.record.status.active": "Запис Studio триває — %s с, %s пакетів, %s КБ (%s)",
|
||||
"horizon.studio.record.status.inactive": "Запис Studio не ведеться",
|
||||
"horizon.studio.replay.usage": "Використання: /studio replay <open ШЛЯХ|pause|resume|speed МНОЖНИК|seek МС|status|close>",
|
||||
"horizon.studio.replay.open.usage": "Використання: /studio replay open <ШЛЯХ>",
|
||||
"horizon.studio.replay.opened": "Повтор Studio відкрито: %s (%s с, %s пакетів)",
|
||||
"horizon.studio.replay.open_error": "Не вдалося відкрити повтор: %s",
|
||||
"horizon.studio.replay.paused": "Повтор Studio призупинено",
|
||||
"horizon.studio.replay.resumed": "Повтор Studio продовжено",
|
||||
"horizon.studio.replay.inactive": "Немає активного повтору Studio",
|
||||
"horizon.studio.replay.speed.usage": "Використання: /studio replay speed <МНОЖНИК>",
|
||||
"horizon.studio.replay.speed.set": "Швидкість повтору Studio: %s×",
|
||||
"horizon.studio.replay.speed.invalid": "Неправильна швидкість повтору",
|
||||
"horizon.studio.replay.seek.usage": "Використання: /studio replay seek <МІЛІСЕКУНДИ>",
|
||||
"horizon.studio.replay.seeked": "Повтор Studio перемотано на %s мс (%s с)",
|
||||
"horizon.studio.replay.seek.error": "Не вдалося перемотати повтор: %s",
|
||||
"horizon.studio.replay.seek.backward_requires_standalone": "Перемотування назад доступне лише в окремому повторі.",
|
||||
"horizon.studio.replay.seek.invalid": "Неправильна позиція повтору",
|
||||
"horizon.studio.replay.status": "Повтор Studio: %s / %s мс (%s%%), швидкість %s×, пауза: %s, завершено: %s",
|
||||
"horizon.studio.replay.closed": "Повтор Studio закрито",
|
||||
"horizon.pages.studio": "Студія повторів",
|
||||
"horizon.pages.music": "Музика",
|
||||
"horizon.pages.music.menu": "Меню",
|
||||
"horizon.pages.music.overworld": "Верхній світ",
|
||||
"horizon.pages.music.creative": "Творчий режим",
|
||||
"horizon.pages.music.underwater": "Під водою",
|
||||
"horizon.pages.music.end": "Енд",
|
||||
"horizon.pages.music.boss": "Бос",
|
||||
"horizon.pages.music.credits": "Титри",
|
||||
"horizon.pages.music.overworld_biomes": "Біоми Верхнього світу",
|
||||
"horizon.pages.music.nether_biomes": "Біоми Незеру",
|
||||
"horizon.pages.music.end_biomes": "Біоми Енду",
|
||||
"horizon.pages.music.other": "Інша музика",
|
||||
"horizon.pages.music.empty": "Немає музичних треків: звуки гри не завантажено.",
|
||||
"horizon.pages.music.tooltip": "Вбудовані треки за ситуаціями: увімкніть будь-який зараз або вимкніть, щоб гра більше його не обирала.",
|
||||
"horizon.pages.studio.tooltip": "Перегляд збережених записів і їхніх метаданих.",
|
||||
"horizon.pages.open.studio": "Студія повторів...",
|
||||
"horizon.studio.saved_recordings": "Збережені записи",
|
||||
"horizon.studio.empty": "У теці записів немає жодного запису.",
|
||||
"horizon.studio.empty_hint": "Введіть у світі /studio record start <назва> або покладіть туди файли .mcpr.",
|
||||
"horizon.studio.open_folder": "Відкрити записи",
|
||||
"horizon.studio.open_replay": "Відтворити повтор",
|
||||
"horizon.studio.refresh": "Оновити",
|
||||
"horizon.studio.unknown_server": "Невідомо",
|
||||
"horizon.pages.open.music": "Музика...",
|
||||
"horizon.mods.in_world": " (вийдіть зі світу, щоб змінити)",
|
||||
"horizon.mods.browse": "Огляд модів...",
|
||||
"horizon.mods.browse.title": "Огляд модів",
|
||||
"horizon.mods.browse.search": "Пошук модів...",
|
||||
"horizon.mods.browse.project": "Мод",
|
||||
"horizon.mods.browse.loading": "Читання реєстру модів Horizon...",
|
||||
"horizon.mods.browse.failed": "Не вдалося прочитати реєстр модів: %s",
|
||||
"horizon.mods.browse.none": "Модів не знайдено",
|
||||
"horizon.mods.browse.has_update": " (оновлення)",
|
||||
"horizon.mods.browse.is_installed": " (встановлено)",
|
||||
"horizon.mods.browse.by": "автор: %s",
|
||||
"horizon.mods.browse.installing": "Встановлення...",
|
||||
"horizon.mods.browse.install": "Встановити",
|
||||
"horizon.mods.browse.update": "Оновити",
|
||||
"horizon.mods.browse.installed": "Встановлено",
|
||||
"horizon.mods.browse.open_page": "Відкрити сторінку",
|
||||
"horizon.mods.browse.enabled": "Мод",
|
||||
"horizon.mods.browse.changelog": "Список змін",
|
||||
"horizon.mods.browse.done": "%s %s встановлено й увімкнено.",
|
||||
"horizon.mods.browse.install_failed": "Не вдалося встановити: %s",
|
||||
"horizon.mods.switch.worldgen": "змінює генерацію світу",
|
||||
"horizon.mods.switch.registries": "додає блоки, предмети чи інші записи реєстрів",
|
||||
"horizon.mods.switch.restart": "Перезапустіть гру, щоб застосувати %s: вони додають блоки, предмети або генерацію світу.",
|
||||
"GPU Culling": "Відсікання на GPU",
|
||||
"HDR Lighting": "HDR-освітлення",
|
||||
"Light sources, the sun and sunlight shine past white. Off: just as SDR.": "Джерела світла, сонце й сонячне світло сяють яскравіше за білий. Вимкнено: як у SDR.",
|
||||
"Smooth Gradients": "Плавні градієнти",
|
||||
"16-bit frame: dark skies and shadows don't band. A bit more VRAM. Next start.": "16-бітний кадр: темне небо й тіні без смуг. Трохи більше відеопам’яті. З наступного запуску.",
|
||||
"Not with software rendering, which runs it on the CPU.": "Не з програмним рендерингом, який виконує це на CPU.",
|
||||
"Out of video memory: off until the next start.": "Забракло відеопам’яті: вимкнено до наступного запуску.",
|
||||
"The graphics device was lost: off until the next start.": "Графічний пристрій втрачено: вимкнено до наступного запуску.",
|
||||
"Not on DirectX 12 yet: try the Vulkan graphics API.": "Поки не працює на DirectX 12: спробуйте графічний API Vulkan.",
|
||||
"Culls nearby terrain on the GPU: less CPU at high render distance, more GPU.": "Відсікає ближній рельєф на GPU: за великої дальності промальовування менше навантажує CPU, більше — GPU.",
|
||||
"horizon.realms.not_signed_in": "Увійдіть за допомогою акаунта Microsoft у лаунчері Horizon, щоб грати на Realms.",
|
||||
"horizon.extras.music_page": "Сторінка «Музика»",
|
||||
"horizon.extras.zoom": "Наближення",
|
||||
"horizon.extras.zoom_mod_install": "Наближення: встановіть у модах",
|
||||
"horizon.extras.wheel_cycles_list_settings.tooltip": "Коліщатко миші над кнопкою в списку налаштувань змінює її значення. Вимкнено, як у ванільній грі: коліщатко прокручує список.",
|
||||
"horizon.extras.pack_browser": "Огляд наборів ресурсів",
|
||||
"horizon.extras.music_page.tooltip": "У «Музика і звуки» з’являється сторінка «Музика...»: увімкніть будь-який вбудований трек зараз або вимкніть, щоб гра більше його не обирала. Нічого не змінюється, доки ви не вимкнете трек.",
|
||||
"horizon.friends.relay_stopped": "Забагато неправильних коротких кодів, тому ретранслятор вимкнено, доки ви знову не поділитеся світом. Друзі все ще можуть приєднатися за повним кодом.",
|
||||
"horizon.extras.pack_browser.tooltip": "У «Вибрати набори ресурсів» з’являється кнопка «Додати», яка шукає в Modrinth і встановлює набір у вашу теку наборів. Вимкнено: гра ніколи не звертається до Modrinth.",
|
||||
"horizon.splitscreen.online_only": "Цей сервер працює в онлайн-режимі: офлайн-профілі не можуть приєднатися, кожному гравцеві потрібен власний акаунт Minecraft.",
|
||||
"horizon.splitscreen.own_account": "Цей сервер вимагає від кожного гравця власного акаунта Minecraft",
|
||||
"horizon.splitscreen.local_only": "Інші гравці можуть приєднатися до цього сервера лише з власним акаунтом Minecraft: офлайн-профілі грають у вашому світі, через LAN або на сервері у вашій локальній мережі.",
|
||||
"Shaders": "Шейдери",
|
||||
"Shader packs, Horizon's and shaders/ folder ones, and their options.": "Пакети шейдерів — від Horizon і з теки shaders/ — та їхні налаштування.",
|
||||
"Shader packs": "Пакети шейдерів",
|
||||
"Vanilla's own preset: sets only vanilla's options, as Minecraft does.": "Власний пресет ванільної гри: задає лише ванільні налаштування, як це робить Minecraft.",
|
||||
"Fullscreen takes the display over, at the mode below.": "Повноекранний режим займає весь дисплей у режимі, вказаному нижче.",
|
||||
"The display mode exclusive fullscreen uses. Applies when you leave.": "Режим дисплея для ексклюзивного повноекранного режиму. Застосовується, коли ви виходите.",
|
||||
"Blurs the world behind menus.": "Розмиває світ за меню.",
|
||||
"How textures are sampled: sharper at a slant with Anisotropic.": "Як вибираються текстури: з анізотропною фільтрацією чіткіші під кутом.",
|
||||
"How far Anisotropic filtering goes. Higher is sharper at a slant.": "Рівень анізотропної фільтрації. Вищий — чіткіше під кутом.",
|
||||
"Only with Anisotropic texture filtering.": "Лише з анізотропною фільтрацією текстур.",
|
||||
"Picks a shader pack, Horizon's or a shaders/ folder one, and sets its options.": "Обирає пакет шейдерів — від Horizon чи з теки shaders/ — і задає його налаштування.",
|
||||
"Hidden Faces": "Приховані грані",
|
||||
"Drops leaf faces behind two leaf layers: faster, same crowns.": "Прибирає грані листя за двома шарами листя: швидше, а крони ті самі.",
|
||||
"Entity Culling Precheck": "Попередня перевірка відсікання сутностей",
|
||||
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Запускає попередню перевірку AABB мобів, коли завантажено стільки мобів. Нуль вимикає її.",
|
||||
"%s mobs": "%s мобів",
|
||||
"horizon.splitscreen.profiles.offline": " (Офлайн)",
|
||||
"horizon.splitscreen.profiles.microsoft": " (Microsoft)",
|
||||
"horizon.splitscreen.profiles.sign_in": "Увійдіть знову в лаунчері",
|
||||
"horizon.splitscreen.profiles.online": "Microsoft онлайн",
|
||||
"horizon.splitscreen.account_unavailable": "%s офлайн. Увійдіть знову в лаунчері, потім перезапустіть гру.",
|
||||
"horizon.splitscreen.account_expired": "Вхід Microsoft для %s закінчився. Увійдіть знову в лаунчері, потім перезапустіть гру.",
|
||||
"horizon.pack.add": "+",
|
||||
"horizon.pack.addInfo": "Додати набір ресурсів із Modrinth",
|
||||
"horizon.pack.addWorking": "Запит до Modrinth...",
|
||||
"horizon.pack.addNone": "Наборів ресурсів не знайдено",
|
||||
"horizon.pack.addTitle": "Додати набір ресурсів",
|
||||
"horizon.pack.install": "Встановити",
|
||||
"horizon.pack.installing": "Встановлення",
|
||||
"horizon.pack.installed": "Встановлено",
|
||||
"horizon.pack.search": "Пошук",
|
||||
"horizon.pack.searchHint": "Пошук у Modrinth...",
|
||||
"horizon.pack.filters": "Фільтри",
|
||||
"horizon.pack.filter.realistic": "Реалістичні",
|
||||
"horizon.pack.filter.simplistic": "Спрощені",
|
||||
"horizon.pack.filter.vanillaLike": "Як ванільні",
|
||||
"horizon.pack.filter.x16": "16x",
|
||||
"horizon.pack.filter.x32": "32x",
|
||||
"horizon.pack.filter.x64": "64x",
|
||||
"horizon.pack.addFailed": "Не вдалося додати набір: %s",
|
||||
"horizon.extras.header.cosmetics": "Косметика",
|
||||
"horizon.extras.cosmetics_mine": "Моя косметика",
|
||||
"horizon.extras.cosmetics_mine.tooltip": "Приховано: усі гравці Horizon бачать вас без косметики, і ви теж. Той самий перемикач, що й у Гардеробі лаунчера.",
|
||||
"key.emote": "Колесо емоцій",
|
||||
"horizon.emote_wheel.title": "Емоції",
|
||||
"horizon.emote_wheel.empty": "Емоцій поки немає",
|
||||
"horizon.emote_wheel.help": "Відпустіть або клацніть, щоб відтворити · 1–8 · Esc закриває",
|
||||
"horizon.extras.cosmetics_others": "Косметика інших гравців",
|
||||
"horizon.extras.cosmetics_others.tooltip": "Що носять інші гравці Horizon: усе, лише стилі ніків або нічого. Приховано, якщо у вас немає косметики, доки ви не оберете. Коли приховано, гра ніколи не питає, що вони носять.",
|
||||
"horizon.extras.cosmetics_hide_fx": "Приховати сліди й аури інших гравців",
|
||||
"horizon.extras.cosmetics_hide_fx.tooltip": "Сліди й аури інших гравців приховано; решту їхньої косметики видно. Типово вимкнено.",
|
||||
"horizon.extras.cosmetics.shown": "Показано",
|
||||
"horizon.extras.cosmetics.hidden": "Приховано",
|
||||
"horizon.extras.cosmetics.name_tags": "Лише ніки",
|
||||
"horizon.moderation.duration.minute": "%s хв",
|
||||
"horizon.moderation.duration.minutes": "%s хв",
|
||||
"horizon.moderation.duration.hour": "%s год",
|
||||
"horizon.moderation.duration.hours": "%s год",
|
||||
"horizon.moderation.duration.day": "%s дн.",
|
||||
"horizon.moderation.duration.days": "%s дн.",
|
||||
"horizon.moderation.reason": "%1$s: %2$s",
|
||||
"horizon.moderation.mutelist.empty": "Заглушених гравців немає",
|
||||
"horizon.moderation.mutelist.count": "Заглушених гравців: %s:",
|
||||
"horizon.moderation.chat_filter": "фільтр чату",
|
||||
"horizon.moderation.until_unmuted": "до зняття заглушення",
|
||||
"horizon.moderation.remaining": "ще на %s",
|
||||
"horizon.moderation.mutelist.entry": "%1$s заглушено гравцем %2$s %3$s",
|
||||
"horizon.moderation.invalid_duration": "%s — не тривалість: 10m, 2h, 1d",
|
||||
"horizon.moderation.usage.mute": "/mute <гравець> [тривалість] [причина]",
|
||||
"horizon.moderation.usage.tempmute": "/tempmute <гравець> <тривалість> [причина]",
|
||||
"horizon.moderation.usage.unmute": "/unmute <гравець>",
|
||||
"horizon.moderation.usage.mutelist": "/mutelist",
|
||||
"horizon.moderation.for_duration": "на %s",
|
||||
"horizon.moderation.muted": "Заглушено %1$s %2$s",
|
||||
"horizon.moderation.unmuted": "Знято заглушення з %s",
|
||||
"horizon.moderation.not_muted": "%s не заглушено",
|
||||
"horizon.tempban.usage": "/tempban <гравець> <тривалість> [причина]",
|
||||
"horizon.tempban.invalid_duration": "%s — не тривалість: 30m, 12h, 7d",
|
||||
"horizon.tempban.dedicated_required": "Тимчасові бани потребують виділеного сервера",
|
||||
"horizon.tempban.success": "%1$s на %2$s, до %3$s",
|
||||
"horizon.tempban.list.empty": "Тимчасових банів немає",
|
||||
"horizon.tempban.list.count": "Тимчасових банів: %s:",
|
||||
"horizon.tempban.list.entry": "%1$s забанено гравцем %2$s %3$s (до %4$s)",
|
||||
"horizon.options.pointer_speed": "Швидкість курсора в меню",
|
||||
"horizon.options.scroll_speed": "Швидкість прокрутки в меню",
|
||||
"horizon.options.repeat_delay": "Затримка повтору навігації",
|
||||
"horizon.options.repeat_interval": "Інтервал повтору навігації",
|
||||
"horizon.options.milliseconds": "%s мс",
|
||||
"horizon.extras.cosmetics_solo": "В одиночній грі",
|
||||
"horizon.extras.cosmetics_solo.tooltip": "Показувати вашу косметику й значок вашого ніка також в одиночних світах (сам нік — із «Власним ніком»). Вимкнено: лише на серверах.",
|
||||
"horizon.extras.horizon_marks": "Логотипи Horizon",
|
||||
"horizon.extras.horizon_marks.tooltip": "Показувати логотип Horizon біля гравців Horizon: маленький логотип перед іменами інших гравців, які грають у Horizon, над їхніми головами та в списку гравців. Його бачать лише гравці Horizon: сервери нічого не отримують.",
|
||||
"horizon.extras.horizon_me": "Показувати мене як гравця Horizon",
|
||||
"horizon.extras.horizon_me.tooltip": "Показувати мене як гравця Horizon: інші гравці Horizon бачать логотип біля вашого імені. Коли ввімкнено, сервіс відповідає кожному, хто про вас питає, що ви грали в Horizon за останні 3 години; коли вимкнено — ніколи про вас не повідомляє.",
|
||||
"horizon.pages.pvp": "PvP та HUD",
|
||||
"horizon.pages.pvp.tooltip": "Налаштування прицілу, HUD і PvP, усі типово вимкнені. Тут немає нічого, що показує більше, ніж ванільна гра.",
|
||||
"horizon.pages.open.pvp": "PvP та HUD...",
|
||||
"horizon.pvp.header.crosshair": "Приціл",
|
||||
"horizon.pvp.crosshair": "Власний приціл",
|
||||
"horizon.pvp.crosshair_shape": "Форма",
|
||||
"horizon.pvp.crosshair_size": "Розмір",
|
||||
"horizon.pvp.crosshair_gap": "Проміжок",
|
||||
"horizon.pvp.crosshair_thickness": "Товщина",
|
||||
"horizon.pvp.crosshair_colour": "Колір",
|
||||
"horizon.pvp.crosshair_outline": "Обведення",
|
||||
"horizon.pvp.crosshair_dot": "Точка в центрі",
|
||||
"horizon.pvp.crosshair_hover": "Колір на сутностях",
|
||||
"horizon.pvp.crosshair.tooltip": "Малює ваш власний приціл замість ванільного. Початкові налаштування виглядають як ванільний.",
|
||||
"horizon.pvp.crosshair_shape.tooltip": "Хрест, T, X, коло, квадрат або лише точка.",
|
||||
"horizon.pvp.crosshair_size.tooltip": "Довжина кожного променя в пікселях інтерфейсу.",
|
||||
"horizon.pvp.crosshair_gap.tooltip": "Порожнє місце посередині.",
|
||||
"horizon.pvp.crosshair_thickness.tooltip": "Ширина кожної лінії.",
|
||||
"horizon.pvp.crosshair_colour.tooltip": "«Інвертований» показує колір під прицілом інвертованим, як ванільний приціл.",
|
||||
"horizon.pvp.crosshair_outline.tooltip": "Тонкий чорний край, щоб приціл було видно на будь-якому фоні.",
|
||||
"horizon.pvp.crosshair_dot.tooltip": "Точка посередині прицілу.",
|
||||
"horizon.pvp.crosshair_hover.tooltip": "Інший колір, коли приціл наведено на моба чи гравця, якого можна вдарити, — як це визначає ванільний індикатор атаки.",
|
||||
"horizon.pvp.colour.inverted": "Інвертований",
|
||||
"horizon.pvp.colour.white": "Білий",
|
||||
"horizon.pvp.colour.red": "Червоний",
|
||||
"horizon.pvp.colour.orange": "Помаранчевий",
|
||||
"horizon.pvp.colour.yellow": "Жовтий",
|
||||
"horizon.pvp.colour.green": "Зелений",
|
||||
"horizon.pvp.colour.cyan": "Бірюзовий",
|
||||
"horizon.pvp.colour.blue": "Синій",
|
||||
"horizon.pvp.colour.magenta": "Пурпуровий",
|
||||
"horizon.pvp.colour.black": "Чорний",
|
||||
"horizon.pvp.shape.cross": "Хрест",
|
||||
"horizon.pvp.shape.t": "T",
|
||||
"horizon.pvp.shape.x": "X",
|
||||
"horizon.pvp.shape.circle": "Коло",
|
||||
"horizon.pvp.shape.square": "Квадрат",
|
||||
"horizon.pvp.shape.dot": "Точка",
|
||||
"horizon.pvp.outline": "Власне обведення блока",
|
||||
"horizon.pvp.outline.tooltip": "Змінює обведення навколо блока, на який ви дивитеся.",
|
||||
"horizon.pvp.outline_colour": "Колір",
|
||||
"horizon.pvp.outline_colour.tooltip": "Колір обведення. Чорний на 40% — ванільний.",
|
||||
"horizon.pvp.outline_opacity": "Непрозорість",
|
||||
"horizon.pvp.outline_opacity.tooltip": "Наскільки щільне обведення.",
|
||||
"horizon.pvp.outline_thickness": "Товщина",
|
||||
"horizon.pvp.outline_thickness.tooltip": "Ширина обведення, у разах від ванільної.",
|
||||
"horizon.pvp.outline_fill": "Заливка",
|
||||
"horizon.pvp.outline_fill.tooltip": "Злегка підфарбовує грані блока, на який ви дивитеся.",
|
||||
"horizon.pvp.hit": "Власний колір удару",
|
||||
"horizon.pvp.hit.tooltip": "Змінює колір, яким спалахують моби й гравці, коли їм завдають шкоди. Червоний на 30% — ванільний.",
|
||||
"horizon.pvp.hit_colour": "Колір",
|
||||
"horizon.pvp.hit_colour.tooltip": "Колір, яким спалахують поранені моби й гравці.",
|
||||
"horizon.pvp.hit_opacity": "Непрозорість",
|
||||
"horizon.pvp.hit_opacity.tooltip": "Наскільки колір їх покриває.",
|
||||
"horizon.pvp.header.outline": "Обведення блока",
|
||||
"horizon.pvp.header.hit": "Колір удару",
|
||||
"horizon.pvp.hud_armour": "Броня",
|
||||
"horizon.pvp.hud_armour.tooltip": "Броня, яку ви носите, починаючи з голови, і скільки використань лишилося в кожного предмета.",
|
||||
"horizon.pvp.hud_armour_corner": "Кут броні",
|
||||
"horizon.pvp.hud_armour_corner.tooltip": "Де показується броня.",
|
||||
"horizon.pvp.hud_armour_percent": "Міцність у %",
|
||||
"horizon.pvp.hud_armour_percent.tooltip": "Залишок використань у відсотках від максимуму предмета замість числа.",
|
||||
"horizon.pvp.hud_effects": "Ефекти",
|
||||
"horizon.pvp.hud_effects.tooltip": "Ваші ефекти з назвами, рівнями й часом, що лишився, замість значків угорі праворуч.",
|
||||
"horizon.pvp.hud_effects_corner": "Кут ефектів",
|
||||
"horizon.pvp.hud_effects_corner.tooltip": "Де показуються ефекти.",
|
||||
"horizon.pvp.hud_held": "Кількість предмета в руці",
|
||||
"horizon.pvp.hud_held.tooltip": "Скільки предметів, як у вашій руці, є в інвентарі; з луком чи арбалетом — стріл.",
|
||||
"horizon.pvp.hud_held_corner": "Кут кількості",
|
||||
"horizon.pvp.hud_held_corner.tooltip": "Де показується кількість.",
|
||||
"horizon.pvp.hud_background": "Фон віджетів",
|
||||
"horizon.pvp.hud_background.tooltip": "Темна підкладка під кожним віджетом.",
|
||||
"horizon.pvp.hud_opacity": "Непрозорість фону",
|
||||
"horizon.pvp.hud_opacity.tooltip": "Наскільки темні підкладки під віджетами.",
|
||||
"horizon.pvp.hud_text_colour": "Колір тексту",
|
||||
"horizon.pvp.hud_text_colour.tooltip": "Колір тексту віджетів. Міцність лишається від зеленого до червоного.",
|
||||
"horizon.pvp.hud_shadow": "Тінь тексту",
|
||||
"horizon.pvp.hud_shadow.tooltip": "Тінь під текстом віджетів, як у тексту ванільного HUD.",
|
||||
"horizon.pvp.header.hud": "Віджети HUD",
|
||||
"horizon.pvp.corner.top_left": "Угорі ліворуч",
|
||||
"horizon.pvp.corner.top_right": "Угорі праворуч",
|
||||
"horizon.pvp.corner.bottom_left": "Унизу ліворуч",
|
||||
"horizon.pvp.corner.bottom_right": "Унизу праворуч",
|
||||
"horizon.pvp.hud_ping": "Пінг",
|
||||
"horizon.pvp.hud_ping.tooltip": "Затримка, яку сервер повідомляє для вас, у мілісекундах. Лише в мережевій грі.",
|
||||
"horizon.pvp.hud_ping_corner": "Кут пінгу",
|
||||
"horizon.pvp.hud_ping_corner.tooltip": "Де показується пінг.",
|
||||
"horizon.pvp.hud_coords": "Координати",
|
||||
"horizon.pvp.hud_coords.tooltip": "Ваша позиція в блоках і напрямок погляду, як їх показує F3. Приховано, коли світ чи сервер скорочує F3.",
|
||||
"horizon.pvp.hud_coords_corner": "Кут координат",
|
||||
"horizon.pvp.hud_coords_corner.tooltip": "Де показуються координати.",
|
||||
"horizon.pvp.hud_server": "Адреса сервера",
|
||||
"horizon.pvp.hud_server.tooltip": "Адреса сервера, на якому ви граєте. Лише в мережевій грі.",
|
||||
"horizon.pvp.hud_server_corner": "Кут сервера",
|
||||
"horizon.pvp.hud_server_corner.tooltip": "Де показується адреса.",
|
||||
"horizon.pvp.hud_combo": "Лічильник комбо",
|
||||
"horizon.pvp.hud_combo.tooltip": "Удари поспіль, підтверджені сервером, доки вас не поранять або не мине 3 секунди без удару.",
|
||||
"horizon.pvp.hud_combo_corner": "Кут комбо",
|
||||
"horizon.pvp.hud_combo_corner.tooltip": "Де показується комбо.",
|
||||
"horizon.pvp.hud.ping": "Пінг: %s мс",
|
||||
"horizon.pvp.hud.coords": "XYZ: %s %s %s",
|
||||
"horizon.pvp.hud.facing": "Напрямок: %s",
|
||||
"horizon.pvp.hud.combo": "Комбо: %s",
|
||||
"horizon.pvp.hud.direction.north": "Північ",
|
||||
"horizon.pvp.hud.direction.south": "Південь",
|
||||
"horizon.pvp.hud.direction.east": "Схід",
|
||||
"horizon.pvp.hud.direction.west": "Захід",
|
||||
"horizon.pvp.hud_fps": "FPS",
|
||||
"horizon.pvp.hud_fps.tooltip": "Кадрів за останню секунду.",
|
||||
"horizon.pvp.hud_fps_corner": "Кут FPS",
|
||||
"horizon.pvp.hud_fps_corner.tooltip": "Де показується FPS.",
|
||||
"horizon.pvp.hud_cps": "Кліки за секунду",
|
||||
"horizon.pvp.hud_cps.tooltip": "Ваші ліві й праві кліки за останню секунду.",
|
||||
"horizon.pvp.hud_cps_corner": "Кут CPS",
|
||||
"horizon.pvp.hud_cps_corner.tooltip": "Де показуються кліки за секунду.",
|
||||
"horizon.pvp.hud_keys": "Натискання клавіш",
|
||||
"horizon.pvp.hud_keys.tooltip": "Клавіші руху, кнопки миші та стрибок, які підсвічуються, поки ви їх тримаєте, і кліки за секунду.",
|
||||
"horizon.pvp.hud_keys_corner": "Кут натискань",
|
||||
"horizon.pvp.hud_keys_corner.tooltip": "Де показуються натискання клавіш.",
|
||||
"horizon.pvp.hud_clock": "Годинник",
|
||||
"horizon.pvp.hud_clock.tooltip": "Час на вашому комп’ютері.",
|
||||
"horizon.pvp.hud_clock_corner": "Кут годинника",
|
||||
"horizon.pvp.hud_clock_corner.tooltip": "Де показується годинник.",
|
||||
"horizon.pvp.hud_reach": "Дальність",
|
||||
"horizon.pvp.hud_reach.tooltip": "На якій відстані від ваших очей влучив останній удар, протягом 3 секунд. Це лише вимірювання: ваша дальність лишається ванільною.",
|
||||
"horizon.pvp.hud_reach_corner": "Кут дальності",
|
||||
"horizon.pvp.hud_reach_corner.tooltip": "Де показується дальність.",
|
||||
"horizon.pvp.hud_speed": "Швидкість",
|
||||
"horizon.pvp.hud_speed.tooltip": "Як швидко ви рухаєтеся по землі, у блоках за секунду.",
|
||||
"horizon.pvp.hud_speed_corner": "Кут швидкості",
|
||||
"horizon.pvp.hud_speed_corner.tooltip": "Де показується швидкість.",
|
||||
"horizon.pvp.hud.fps": "%s FPS",
|
||||
"horizon.pvp.hud.cps": "CPS: %s | %s",
|
||||
"horizon.pvp.hud.reach": "Дальність: %s",
|
||||
"horizon.pvp.hud.speed": "Швидкість: %s б/с",
|
||||
"horizon.pvp.hud.keys.attack": "ЛКМ",
|
||||
"horizon.pvp.hud.keys.use": "ПКМ",
|
||||
"horizon.pvp.hud.keys.cps": "%s CPS",
|
||||
"horizon.pvp.numeric_enchants": "Рівні чар числами",
|
||||
"horizon.pvp.numeric_enchants.tooltip": "Підказки та чарувальний стіл показують «Гострота 5» замість «Гострота V».",
|
||||
"horizon.pvp.header.items": "Предмети",
|
||||
"horizon.pvp.fire_lower": "Нижчий вогонь",
|
||||
"horizon.pvp.fire_lower.tooltip": "Опускає полум’я, яке ви бачите, коли горите, щоб воно менше закривало екран.",
|
||||
"horizon.pvp.shield_size": "Розмір щита",
|
||||
"horizon.pvp.shield_size.tooltip": "Розмір щита у вашій руці від першої особи.",
|
||||
"horizon.pvp.totem_size": "Розмір анімації тотема",
|
||||
"horizon.pvp.totem_size.tooltip": "Розмір тотема, що заповнює екран, коли рятує вас.",
|
||||
"horizon.pvp.glint_colour": "Колір блиску чар",
|
||||
"horizon.pvp.glint_colour.tooltip": "Колір мерехтіння на зачарованих предметах і броні. Вимкнено — ванільний фіолетовий.",
|
||||
"horizon.pvp.clear_water": "Прозора вода",
|
||||
"horizon.pvp.clear_water.tooltip": "Під водою одразу бачите так далеко, як ванільна гра дозволяє лише після 30 секунд під водою.",
|
||||
"horizon.pvp.clear_lava": "Прозора лава",
|
||||
"horizon.pvp.clear_lava.tooltip": "У лаві бачите так далеко, як дозволяє ванільна Вогнестійкість, без цього ефекту.",
|
||||
"horizon.pvp.header.visual": "Візуальні зміни",
|
||||
"horizon.pvp.view_time": "Час доби",
|
||||
"horizon.pvp.view_time.tooltip": "Час доби, який ви бачите: лише небо та його світло. Час у світі йде далі, і на сервер нічого не надсилається.",
|
||||
"horizon.pvp.view_weather": "Погода",
|
||||
"horizon.pvp.view_weather.tooltip": "Погода, яку ви бачите: лише дощ, його світло й туман. Погода у світі не змінюється, і на сервер нічого не надсилається.",
|
||||
"horizon.pvp.header.view": "Що ви бачите",
|
||||
"horizon.pvp.view.world": "Як у світі",
|
||||
"horizon.pvp.time.morning": "Ранок",
|
||||
"horizon.pvp.time.noon": "Полудень",
|
||||
"horizon.pvp.time.sunset": "Захід сонця",
|
||||
"horizon.pvp.time.midnight": "Опівніч",
|
||||
"horizon.pvp.weather.clear": "Ясно",
|
||||
"horizon.pvp.weather.rain": "Дощ",
|
||||
"horizon.pvp.weather.thunder": "Гроза",
|
||||
"horizon.chat.timestamp": "[%s] ",
|
||||
"horizon.chat.repeats": " (x%s)",
|
||||
"horizon.pvp.header.chat": "Чат",
|
||||
"horizon.chat.copied": "Повідомлення скопійовано",
|
||||
"horizon.chat.search": "Пошук у чаті:",
|
||||
"horizon.pvp.chat_timestamps": "Позначки часу",
|
||||
"horizon.pvp.chat_timestamps.tooltip": "Перед кожним повідомленням — час, коли воно надійшло.",
|
||||
"horizon.pvp.chat_compact": "Компактний чат",
|
||||
"horizon.pvp.chat_compact.tooltip": "Повідомлення, повторене кілька разів поспіль, показується один раз із кількістю повторів: (x3).",
|
||||
"horizon.pvp.chat_copy": "Копіювання клацанням",
|
||||
"horizon.pvp.chat_copy.tooltip": "Клацання по повідомленню у відкритому чаті копіює його текст, якщо клацання не робить чогось іншого.",
|
||||
"horizon.pvp.chat_search": "Пошук у чаті",
|
||||
"horizon.pvp.chat_search.tooltip": "Ctrl+F у відкритому чаті: показуються лише повідомлення, що містять введений текст. Enter або повторне Ctrl+F завершує пошук.",
|
||||
"horizon.pvp.preset": "PvP-пресет",
|
||||
"horizon.pvp.preset.tooltip": "Вмикає власний приціл, віджети броні й ефектів, перемикання бігу та скрадання, нижчий вогонь, а також моди лічильника FPS і натискань клавіш, якщо їх встановлено. Ваші налаштування кожного з них зберігаються.",
|
||||
"horizon.pvp.hud_editor_key": "Клавіша редактора HUD",
|
||||
"horizon.pvp.hud_editor_key.tooltip": "Відкриває редактор HUD із гри. Клацніть, потім натисніть клавішу; Escape скасовує призначення.",
|
||||
"horizon.pvp.editor": "Редагувати HUD",
|
||||
"horizon.pvp.editor.tooltip": "Розставте віджети просто в грі: перетягуйте куди завгодно, змінюйте розмір коліщатком, клацніть правою кнопкою, щоб повернути віджет у його кут.",
|
||||
"horizon.pvp.editor.hint": "Перетягуйте віджети · Коліщатко: розмір · Права кнопка: назад у кут",
|
||||
"horizon.pvp.combat_feel": "Відчуття бою",
|
||||
"horizon.pvp.combat_feel.tooltip": "«Класичне» показує бої як у 1.8.9: предмет у руці, його змах, їжу, лук і піднятий меч, поки ви тримаєте «використати», без просідання після кожного удару та без індикатора атаки. Лише вигляд: те, що ви надсилаєте серверу, не змінюється.",
|
||||
"horizon.pvp.combat_indicator": "Індикатор атаки в «Класичному»",
|
||||
"horizon.pvp.combat_indicator.tooltip": "Залишає в «Класичному» індикатор атаки, якого в 1.8.9 не було.",
|
||||
"horizon.pvp.combat_feel.modern": "Сучасне",
|
||||
"horizon.pvp.combat_feel.legacy": "Класичне (1.8.9)",
|
||||
"horizon.pvp.header.combat": "Відчуття бою",
|
||||
"horizon.pvp.combat_sounds": "Звуки ударів 1.8",
|
||||
"horizon.pvp.combat_sounds.tooltip": "У «Класичному» прибирає звуки атаки, додані в 1.9 (сильний, слабкий, розмашистий, критичний удар...). Звуки поранення лишаються.",
|
||||
"horizon.pvp.combat_rules": "Бій 1.8 у ваших світах",
|
||||
"horizon.pvp.combat_rules.tooltip": "В одиночній грі та у світах, відкритих для LAN, бої йдуть за правилами 1.8.9: без перезарядки атаки й розмашистого удару, зі шкодою зброї, Силою, Гостротою і критичними ударами як у 1.8. Сервери, до яких ви приєднуєтесь, лишають свої правила.",
|
||||
"Bindings": "Призначення",
|
||||
"Play": "Гра",
|
||||
"Device": "Пристрій",
|
||||
"Look": "Огляд",
|
||||
"Movement": "Рух",
|
||||
"Menus": "Меню",
|
||||
"Actions": "Дії",
|
||||
"Reset": "Скинути",
|
||||
"Detected": "Виявлено",
|
||||
"Glyphs": "Значки кнопок",
|
||||
"Feedback": "Віддача",
|
||||
"Controller": "Геймпад",
|
||||
"How the sticks turn and move you, and how menus repeat and scroll.": "Як стіки повертають і рухають вас, а меню повторюють і прокручують.",
|
||||
"Which button does what. Each player has their own.": "Яка кнопка що робить. У кожного гравця — свої.",
|
||||
"The controller the game sees, its button glyphs and its feedback.": "Геймпад, який бачить гра, значки його кнопок і віддача.",
|
||||
"Press this row, then the button that should do it.": "Натисніть цей рядок, потім кнопку, яка має це робити.",
|
||||
"Off, the game ignores every controller for this player.": "Вимкнено: гра ігнорує всі геймпади для цього гравця.",
|
||||
"How fast the right stick turns the view.": "Як швидко правий стік повертає огляд.",
|
||||
"Turn the view the other way, left and right.": "Повертати огляд у протилежний бік ліворуч і праворуч.",
|
||||
"Turn the view the other way, up and down.": "Повертати огляд у протилежний бік угору і вниз.",
|
||||
"How far the right stick moves before the view turns. Raise it if the view drifts.": "Наскільки має відхилитися правий стік, перш ніж огляд почне повертатися. Збільште, якщо огляд сам зсувається.",
|
||||
"Hold the button to sneak, or press it once to start and again to stop.": "Тримайте кнопку, щоб скрадатися, або натисніть раз, щоб почати, і ще раз, щоб припинити.",
|
||||
"How far the left stick moves before you walk. Raise it if you drift.": "Наскільки має відхилитися лівий стік, перш ніж ви підете. Збільште, якщо вас самого зносить.",
|
||||
"How far a trigger is pulled before it counts as pressed.": "Наскільки треба натиснути тригер, щоб це вважалося натисканням.",
|
||||
"How fast the stick moves the crosshair in inventories.": "Як швидко стік рухає курсор в інвентарях.",
|
||||
"How fast the right stick scrolls a list.": "Як швидко правий стік прокручує список.",
|
||||
"How long a held direction waits before it repeats.": "Скільки утримуваний напрямок чекає перед першим повтором.",
|
||||
"How long a held direction takes between repeats.": "Скільки часу минає між повторами утримуваного напрямку.",
|
||||
"Which on-screen keyboard opens when a text field is pressed.": "Яка екранна клавіатура відкривається, коли натискаєте текстове поле.",
|
||||
"Every action back on its default button.": "Повернути всі дії на типові кнопки.",
|
||||
"These settings belong to this player's profile.": "Ці налаштування належать профілю цього гравця.",
|
||||
"The controller driving this player, or else the first one connected.": "Геймпад, яким керує цей гравець, або інакше перший підключений.",
|
||||
"The USB vendor and product the system reports. Useful when reporting a controller that misbehaves.": "Виробник і продукт USB, які повідомляє система. Знадобиться, коли повідомляєте про геймпад, що працює неправильно.",
|
||||
"The glyph family the game picked from the controller's maker.": "Набір значків, який гра обрала за виробником геймпада.",
|
||||
"Follow the controller, or always show one family's buttons.": "Як у підключеного геймпада або завжди кнопки одного набору.",
|
||||
"The buttons menus show right now.": "Кнопки, які меню показують зараз.",
|
||||
"Rumble when something happens to you.": "Вібрація, коли з вами щось стається.",
|
||||
"The controller keeps playing while this window isn't focused, and losing focus won't pause.": "Геймпад працює, навіть коли це вікно не у фокусі, а втрата фокусу не ставить гру на паузу.",
|
||||
"A DualSense's triggers push back on bows and tools.": "Тригери DualSense чинять опір, коли ви стріляєте з лука чи працюєте інструментами.",
|
||||
"What a DualSense's light shows, over USB or Bluetooth.": "Що показує підсвічування DualSense через USB або Bluetooth.",
|
||||
"Player profile": "Профіль гравця",
|
||||
"Vendor and product": "Виробник і продукт",
|
||||
"Detected glyphs": "Виявлені значки",
|
||||
"Glyphs in use": "Поточні значки",
|
||||
"Unknown": "Невідомо",
|
||||
"No controller": "Немає геймпада",
|
||||
"Editing: %s": "Редагування: %s",
|
||||
"Press the button for this. %s cancels.": "Натисніть кнопку для цього. %s — скасувати.",
|
||||
"This button also does: %s": "Ця кнопка також робить: %s",
|
||||
"horizon.pad.notice.connected.press": "Натисніть, щоб використовувати",
|
||||
"horizon.recipe_transfer.missing": "Бракує предметів",
|
||||
"horizon.recipe_transfer.no_room": "Інвентар заповнено",
|
||||
"horizon.recipe_transfer.unavailable": "Рецепт не підходить для цього меню",
|
||||
"horizon.recipe_transfer.unsupported": "Сервер вимагає відомого рецепта"
|
||||
}
|
||||
@@ -195,6 +195,7 @@
|
||||
"horizon.pages.shader_options": "光影选项",
|
||||
"horizon.pages.split_profiles": "谁在玩?",
|
||||
"horizon.pages.split_profiles.tooltip": "谁在玩:保存在此游戏文件夹中的一份档案。",
|
||||
"horizon.plain.official": "带启动器、模组和更新的官方 Horizon:playhorizon.dev",
|
||||
"horizon.shader_options.profile": "档案",
|
||||
"General": "常规",
|
||||
"Advanced": "高级",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,11 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
log::warn!("Trait mods already installed; ignoring the second list");
|
||||
}
|
||||
horizon_threads::configure_allocator();
|
||||
// horizon-extras (docs/forking.md): without the feature, no Horizon Settings or Mods page.
|
||||
if !cfg!(feature = "horizon-extras") {
|
||||
horizon_client::options_ui::HORIZON_EXTRAS
|
||||
.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
horizon_client::telemetry::mark_start();
|
||||
horizon_client::spans::startup::init(std::time::Instant::now());
|
||||
horizon_client::spans::trace::start_from_env();
|
||||
|
||||
+756
-7096
File diff suppressed because it is too large.
Load diff
Loaded 100 of 774 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user