mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-08 08:07:53 -10:00
Compare commits
530
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0395b7220e | ||
|
|
43f69216d3 | ||
|
|
6651840801 | ||
|
|
dd2aa4c808 | ||
|
|
5919b2ee37 | ||
|
|
a3ea52c7ae | ||
|
|
f606cb820c | ||
|
|
76ef5f41ee | ||
|
|
b5fd86ea95 | ||
|
|
bdc89c7247 | ||
|
|
c98a51a664 | ||
|
|
9d55121379 | ||
|
|
3a90aecb12 | ||
|
|
524543dfa8 | ||
|
|
53a993c972 | ||
|
|
36c8983d68 | ||
|
|
39c43b82bd | ||
|
|
0b0aded17c | ||
|
|
d978845087 | ||
|
|
957eb14a5e | ||
|
|
601772b4fa | ||
|
|
6a61fdbe28 | ||
|
|
be8b04774f | ||
|
|
0698e8d46d | ||
|
|
9afeda6be3 | ||
|
|
89bb9243ab | ||
|
|
981b36ef36 | ||
|
|
c3f2a9928e | ||
|
|
5a8e4ea92c | ||
|
|
3427559f65 | ||
|
|
ecf510cfc2 | ||
|
|
8201422dc0 | ||
|
|
dec609bb7c | ||
|
|
d67da2cf27 | ||
|
|
4aa63389e5 | ||
|
|
92be6a71c2 | ||
|
|
f1d2c12e29 | ||
|
|
4e5296d180 | ||
|
|
07c9753b76 | ||
|
|
b79fd522cb | ||
|
|
de5617664b | ||
|
|
e9d179e183 | ||
|
|
e790fdeead | ||
|
|
7e78e51558 | ||
|
|
ee2e77db35 | ||
|
|
c0e2fb0c99 | ||
|
|
2e237b9aa1 | ||
|
|
2bac359308 | ||
|
|
ef65ffc544 | ||
|
|
a33d9e37c8 | ||
|
|
f496fff3af | ||
|
|
6c68f82884 | ||
|
|
def33e3892 | ||
|
|
01ecc1c7b5 | ||
|
|
facc7f17ea | ||
|
|
0aebe90bbc | ||
|
|
c73b892e42 | ||
|
|
4273e513c0 | ||
|
|
c288428168 | ||
|
|
fa99fc98e6 | ||
|
|
5cc05d62f4 | ||
|
|
11102a5651 | ||
|
|
0cf6116d8d | ||
|
|
bf53e59fae | ||
|
|
affb4cbc98 | ||
|
|
ab6fe1802d | ||
|
|
c23c62df76 | ||
|
|
67093685db | ||
|
|
012391f4aa | ||
|
|
4757e2ddca | ||
|
|
c1e8fc5d4b | ||
|
|
aa3d35ebc7 | ||
|
|
d178e80ea5 | ||
|
|
7f117f484e | ||
|
|
d3910293e0 | ||
|
|
c3d6c640ac | ||
|
|
37e9e82dfd | ||
|
|
f4eebc417e | ||
|
|
0e83631f9d | ||
|
|
a2140a9ef6 | ||
|
|
35b2938c47 | ||
|
|
236b13f290 | ||
|
|
838ae17615 | ||
|
|
5883c0a485 | ||
|
|
66ff309502 | ||
|
|
bdc8a23f21 | ||
|
|
cfeab5a7e9 | ||
|
|
087ce1708b | ||
|
|
61b7fa5b26 | ||
|
|
1df93f9739 | ||
|
|
17e2982614 | ||
|
|
eb74ad2a63 | ||
|
|
34aa927de4 | ||
|
|
491403897f | ||
|
|
3f7e6970ae | ||
|
|
d609f56d42 | ||
|
|
37ed06402d | ||
|
|
e5043bb090 | ||
|
|
1c1ad8ec32 | ||
|
|
d035060e66 | ||
|
|
afb2ce0f1e | ||
|
|
2ffc8b5f56 | ||
|
|
447e9d277d | ||
|
|
d0d9e050d3 | ||
|
|
678dfde14b | ||
|
|
32715a15da | ||
|
|
1597853de7 | ||
|
|
7d0f866a47 | ||
|
|
600fd5f04a | ||
|
|
2145f26929 | ||
|
|
36e962a58c | ||
|
|
3ce27e636b | ||
|
|
db2b5ff76b | ||
|
|
06fe2cc687 | ||
|
|
a9033e8c41 | ||
|
|
c3a1e1293f | ||
|
|
1e3e77270d | ||
|
|
138616afdb | ||
|
|
1863a04353 | ||
|
|
3fa4cbd9a8 | ||
|
|
8634b05990 | ||
|
|
f2719daa05 | ||
|
|
bf928658ef | ||
|
|
8c58505b3c | ||
|
|
d3151e0cc2 | ||
|
|
2017a3b977 | ||
|
|
a3b56d6218 | ||
|
|
30aee13f1d | ||
|
|
fec26a0044 | ||
|
|
e9aa68c3ed | ||
|
|
9fd2c8c339 | ||
|
|
3f58a9511b | ||
|
|
2d087e4826 | ||
|
|
d426f98ae2 | ||
|
|
48f02115ed | ||
|
|
06b5147e56 | ||
|
|
9fc6abb08b | ||
|
|
1a9663f21a | ||
|
|
8b4a95b099 | ||
|
|
eaca6fe37a | ||
|
|
c52446638a | ||
|
|
e8acedb5c2 | ||
|
|
a0d1a9f0e3 | ||
|
|
4b2cba530a | ||
|
|
4b99e9a701 | ||
|
|
4497f18b31 | ||
|
|
a978363647 | ||
|
|
e761e6394a | ||
|
|
bced9e4ecd | ||
|
|
c853168fb7 | ||
|
|
4dd22ecd6a | ||
|
|
ba041dc3bb | ||
|
|
861ac2b28d | ||
|
|
706aad56c0 | ||
|
|
564f031294 | ||
|
|
f18c761edc | ||
|
|
c2661a333d | ||
|
|
c09321478d | ||
|
|
519d9b6d97 | ||
|
|
fe3ebc3b8d | ||
|
|
98e37bbb84 | ||
|
|
7965bc432e | ||
|
|
50f53a5a2b | ||
|
|
113ac28e79 | ||
|
|
509deae34e | ||
|
|
156f335169 | ||
|
|
f4cbc6311e | ||
|
|
7a4012d00f | ||
|
|
dca8dbddd1 | ||
|
|
6410d3f446 | ||
|
|
059eb482c4 | ||
|
|
3835921949 | ||
|
|
f8be508301 | ||
|
|
8b06e4ba1e | ||
|
|
197a6ef7dc | ||
|
|
ba54ecff05 | ||
|
|
44a27421ca | ||
|
|
eb8f8e38b5 | ||
|
|
d276c60075 | ||
|
|
ee91d5907a | ||
|
|
1cec3b3751 | ||
|
|
04ead01cb4 | ||
|
|
447341b6a4 | ||
|
|
2bb7df1bf9 | ||
|
|
13c56dd065 | ||
|
|
cceab3b2b5 | ||
|
|
6e00e1bbf1 | ||
|
|
fc14c6fc28 | ||
|
|
d03fbd593a | ||
|
|
0335218910 | ||
|
|
3b76fcb951 | ||
|
|
a7f89f1c62 | ||
|
|
73b2d7434f | ||
|
|
e19018da1c | ||
|
|
5a94e4c188 | ||
|
|
6db9351111 | ||
|
|
7d2f225c85 | ||
|
|
f15f153836 | ||
|
|
c9f62a1572 | ||
|
|
067a6a1db1 | ||
|
|
7ba6ef0912 | ||
|
|
34c91f0c34 | ||
|
|
0f36e33ccf | ||
|
|
dcc73b1491 | ||
|
|
94e2918668 | ||
|
|
ecc9a21e18 | ||
|
|
00127a51c7 | ||
|
|
92fd45976d | ||
|
|
3bf71f9926 | ||
|
|
df92a7cbc5 | ||
|
|
b7215560ba | ||
|
|
79bd301973 | ||
|
|
ec73a1af84 | ||
|
|
bbbbfa6dd2 | ||
|
|
33facd006a | ||
|
|
0ad6f93762 | ||
|
|
1467568676 | ||
|
|
da428abd4d | ||
|
|
02deae8774 | ||
|
|
90267f9863 | ||
|
|
88dd9e8d1e | ||
|
|
8ce5ba0e52 | ||
|
|
9fb2965c3a | ||
|
|
5c616363b1 | ||
|
|
58140be02b | ||
|
|
4fab87e3ce | ||
|
|
d61235cb39 | ||
|
|
38d670c0ac | ||
|
|
e5cad94537 | ||
|
|
1c17df10b4 | ||
|
|
d1b5000930 | ||
|
|
83d9347f4b | ||
|
|
0dab8b30f5 | ||
|
|
5740311a36 | ||
|
|
17f3a0ff81 | ||
|
|
c0f9600544 | ||
|
|
f1be49b7df | ||
|
|
a1ecb7f6de | ||
|
|
6880103394 | ||
|
|
4d5de18e40 | ||
|
|
340d4d8b22 | ||
|
|
d3c183549e | ||
|
|
f711009645 | ||
|
|
9cde3d1f9a | ||
|
|
c7c143cbf1 | ||
|
|
cd313d031f | ||
|
|
9bf65b95b4 | ||
|
|
4efbd5ea91 | ||
|
|
09d0f6eb7c | ||
|
|
ebec6fdd1c | ||
|
|
dabd3c04cc | ||
|
|
cec040fef1 | ||
|
|
a2d300a03f | ||
|
|
b2e0010655 | ||
|
|
d0815a9d0b | ||
|
|
217affdc9e | ||
|
|
5674b63b57 | ||
|
|
255d45aa56 | ||
|
|
5f599617e6 | ||
|
|
668555dd33 | ||
|
|
db09adeca9 | ||
|
|
e46c586cec | ||
|
|
1bc5012f4f | ||
|
|
7976203343 | ||
|
|
aaf53aa0bd | ||
|
|
6ff8c950e4 | ||
|
|
f8f7d2574b | ||
|
|
0d27a2a38f | ||
|
|
728141ad4f | ||
|
|
329aae2ae3 | ||
|
|
db270fecca | ||
|
|
4bf7247bcb | ||
|
|
92fc757a73 | ||
|
|
8a964bbb79 | ||
|
|
8a84813b69 | ||
|
|
6fefa6ec54 | ||
|
|
362e77ba6c | ||
|
|
65f1235daf | ||
|
|
cdfe986e8c | ||
|
|
b56f598a71 | ||
|
|
b778599d5f | ||
|
|
518adb688e | ||
|
|
1699b82741 | ||
|
|
79723cb13e | ||
|
|
ee61f0ea71 | ||
|
|
84e22207fb | ||
|
|
83de5c24d6 | ||
|
|
2d22e6a7e3 | ||
|
|
e1315b7e55 | ||
|
|
dd8daeb619 | ||
|
|
6f856b2c0a | ||
|
|
b0ad9abd0c | ||
|
|
c5c53d4c6c | ||
|
|
3bca17f46a | ||
|
|
7e306e4b25 | ||
|
|
26d73e3f28 | ||
|
|
929cb85c85 | ||
|
|
e3966b586a | ||
|
|
7a1fe7f702 | ||
|
|
443f9b0642 | ||
|
|
79738505fb | ||
|
|
851bda5b5e | ||
|
|
ce322efe53 | ||
|
|
238bf31f77 | ||
|
|
aa1f9c5dc9 | ||
|
|
b4ed614faa | ||
|
|
e3082a0281 | ||
|
|
013fb324e6 | ||
|
|
0a871808fa | ||
|
|
0e6d23f59b | ||
|
|
170cfb468c | ||
|
|
0d0b58a960 | ||
|
|
92e3012389 | ||
|
|
0427c41bde | ||
|
|
54bedee730 | ||
|
|
23bd1b7627 | ||
|
|
920346fb43 | ||
|
|
632d6cd4f4 | ||
|
|
98342f1bd0 | ||
|
|
d2cadb1c39 | ||
|
|
cab807c9d7 | ||
|
|
70e0a37264 | ||
|
|
58b2eaa55e | ||
|
|
104fe56b30 | ||
|
|
98d9f172bd | ||
|
|
b49f32e4ab | ||
|
|
32613f5637 | ||
|
|
3f604c3028 | ||
|
|
ed4077ec1d | ||
|
|
f9b605a934 | ||
|
|
fbd34a0343 | ||
|
|
e9208d8d86 | ||
|
|
e0081cb2ae | ||
|
|
9990881f3a | ||
|
|
24a3e509bd | ||
|
|
7dda0df3c0 | ||
|
|
6539a95ef6 | ||
|
|
4a1adf44fd | ||
|
|
4cf8cd0957 | ||
|
|
eb835292f5 | ||
|
|
ddaada5bb9 | ||
|
|
6b33c13380 | ||
|
|
b03828a569 | ||
|
|
99bd306532 | ||
|
|
2fae2a9101 | ||
|
|
6d72b0375e | ||
|
|
4f71890226 | ||
|
|
47a832db72 | ||
|
|
3627361d25 | ||
|
|
db1e3398ce | ||
|
|
5288273f01 | ||
|
|
e55b51a4b0 | ||
|
|
b7468c0196 | ||
|
|
282b7b3085 | ||
|
|
7d269ccea1 | ||
|
|
c072d21438 | ||
|
|
94bd299a9a | ||
|
|
bf6a7d910c | ||
|
|
944a3e1c80 | ||
|
|
f57866e72f | ||
|
|
8c8615875e | ||
|
|
dde2b2abff | ||
|
|
85b61b6f6e | ||
|
|
24c3cabed3 | ||
|
|
61d43cf005 | ||
|
|
b62553197b | ||
|
|
911bab7e0f | ||
|
|
030e7e6ff6 | ||
|
|
32cc9c56e0 | ||
|
|
02ff63cf4b | ||
|
|
1cccd4167d | ||
|
|
849c291561 | ||
|
|
ca441c3ea0 | ||
|
|
dba8453681 | ||
|
|
5ee77de8c8 | ||
|
|
ba5f08c10c | ||
|
|
35ab3f27b0 | ||
|
|
ed1d96c911 | ||
|
|
d7c560ec78 | ||
|
|
dc41d4bc93 | ||
|
|
826fdf6306 | ||
|
|
1c878671de | ||
|
|
b1a75237d0 | ||
|
|
414fcee500 | ||
|
|
f62c4b546e | ||
|
|
ff0ca9ba17 | ||
|
|
c60ed93aff | ||
|
|
0538f95dd8 | ||
|
|
8f6ef43b0d | ||
|
|
8ee2a1ab6a | ||
|
|
13217d3b4c | ||
|
|
82ed0f3ac8 | ||
|
|
96f8110001 | ||
|
|
89d7b8e65b | ||
|
|
0f69036171 | ||
|
|
2ed136b137 | ||
|
|
e299e931f5 | ||
|
|
4cbca57026 | ||
|
|
1835133113 | ||
|
|
1d533463ca | ||
|
|
87dfb297ef | ||
|
|
45d46d46f4 | ||
|
|
4635eed508 | ||
|
|
01c1e66168 | ||
|
|
c7fc1c994f | ||
|
|
4eba8a6991 | ||
|
|
01fcfc4849 | ||
|
|
391da75bf3 | ||
|
|
13dc865c9a | ||
|
|
80d929f7d6 | ||
|
|
75cd96be53 | ||
|
|
5bf7ce508d | ||
|
|
a869493ff4 | ||
|
|
ed656ba6cf | ||
|
|
1e743c812e | ||
|
|
7cec42ea0b | ||
|
|
75a2643501 | ||
|
|
6e4c79f33b | ||
|
|
65b2833bb9 | ||
|
|
0972bbea7b | ||
|
|
5452da616f | ||
|
|
c5b9f07b5d | ||
|
|
64821ce0bb | ||
|
|
eaceaeda73 | ||
|
|
e67b56eec6 | ||
|
|
f9b2840d07 | ||
|
|
0b30eba4e0 | ||
|
|
0270b0133d | ||
|
|
1b7cec46d9 | ||
|
|
6922878d94 | ||
|
|
9a442e5382 | ||
|
|
2294609404 | ||
|
|
092ae508ba | ||
|
|
44caa43238 | ||
|
|
325c627cdb | ||
|
|
5bea37b4be | ||
|
|
4f56dfc42b | ||
|
|
f1af021d5c | ||
|
|
fcd33abc9e | ||
|
|
8a218ed405 | ||
|
|
9b6074a363 | ||
|
|
8fd45d810e | ||
|
|
2183972c00 | ||
|
|
03df68c41d | ||
|
|
a77d401f53 | ||
|
|
1769fff834 | ||
|
|
e5d8856821 | ||
|
|
93bb7f3230 | ||
|
|
816d917134 | ||
|
|
aee1b8b81c | ||
|
|
2a33c013d7 | ||
|
|
b58576b523 | ||
|
|
31d39aec80 | ||
|
|
8f9e984d63 | ||
|
|
39701a9334 | ||
|
|
7339c9057a | ||
|
|
2979cf024d | ||
|
|
46c6bdc8ac | ||
|
|
9f0b1d99c1 | ||
|
|
08ce93b6f1 | ||
|
|
181ca3d833 | ||
|
|
a75f3169eb | ||
|
|
b2d87410fc | ||
|
|
753477f5b4 | ||
|
|
a191dcd2b2 | ||
|
|
1bf526ca7f | ||
|
|
635f14ea0e | ||
|
|
3dcb5cba92 | ||
|
|
4adf2afffb | ||
|
|
5440872e93 | ||
|
|
ce3a7a7587 | ||
|
|
9f6aa5f2f6 | ||
|
|
37db8a337e | ||
|
|
c3f5770bb9 | ||
|
|
5a07a7edfe | ||
|
|
3cbe777d44 | ||
|
|
28299018c7 | ||
|
|
7e8c7a27c1 | ||
|
|
08f2880b2a | ||
|
|
03d1f86f4a | ||
|
|
65d3e70f5d | ||
|
|
5547077993 | ||
|
|
14428f3233 | ||
|
|
4a697d3689 | ||
|
|
baab15d42d | ||
|
|
71c7dfaf97 | ||
|
|
d12d6262bb | ||
|
|
c36df3986f | ||
|
|
38f887eab2 | ||
|
|
71c1240617 | ||
|
|
14100456d1 | ||
|
|
32109b87b6 | ||
|
|
79fe52def4 | ||
|
|
66ecf65d4e | ||
|
|
1315c0e4db | ||
|
|
4169af3fa6 | ||
|
|
306f9425e8 | ||
|
|
b0047b5518 | ||
|
|
1990e4aecc | ||
|
|
16d77f41d4 | ||
|
|
7f21b95989 | ||
|
|
76123f5638 | ||
|
|
3b8ac89d2c | ||
|
|
f8aa8e0e0d | ||
|
|
6ec1865ac1 | ||
|
|
18cb5eb6d0 | ||
|
|
bb9e6d6ebf | ||
|
|
66931c6958 | ||
|
|
0ba45ef93e | ||
|
|
68e1549ebd | ||
|
|
b2c9470eb7 | ||
|
|
dc8f47b2e8 | ||
|
|
02dc69348d | ||
|
|
65e6b5ce95 | ||
|
|
3db0da6dd2 | ||
|
|
2aa319becc | ||
|
|
bca4a22e08 | ||
|
|
8bb5a572ec | ||
|
|
91b3a7410e | ||
|
|
b918309242 | ||
|
|
68f5909ef6 | ||
|
|
995a99b984 | ||
|
|
6a5fc04295 | ||
|
|
5dc59396d6 | ||
|
|
5c591b13a4 | ||
|
|
137486c83a | ||
|
|
fec7a5f9e6 | ||
|
|
41d699a458 | ||
|
|
160e997e4a | ||
|
|
d2cb5a272b |
No files matched your search
@@ -25,7 +25,8 @@
|
||||
|
||||
<!-- "none", or for each project you read or copied from: its link at the commit, its license (only MIT,
|
||||
BSD-2-Clause, BSD-3-Clause, Apache-2.0, ISC, zlib, 0BSD, MIT-0, Unlicense or CC0-1.0), and what you took.
|
||||
CONTRIBUTING.md "Only original code". -->
|
||||
CONTRIBUTING.md "Only original code". Shader packs: also the sections of docs/cleanroom/shader-packs-spec.md,
|
||||
the documentation pages and the packs you used. -->
|
||||
|
||||
## Self-check
|
||||
|
||||
|
||||
@@ -291,6 +291,11 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo run --locked -p xtask -- package "$BIN/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
|
||||
# Windows x64: Microsoft's DXC, pinned and checksummed, which DirectX 12 uses on integrated GPUs
|
||||
# (gfx/dx12_compiler.rs), with its licence. ARM64 keeps FXC (THIRD_PARTY_NOTICES.md says why).
|
||||
if [[ "${{ matrix.name }}" == windows ]]; then
|
||||
python tools/ci/fetch_dxc.py "dist/${{ matrix.dir }}"
|
||||
fi
|
||||
cd dist
|
||||
rm -f "${{ matrix.asset }}"
|
||||
case "${{ matrix.asset }}" in
|
||||
|
||||
@@ -162,7 +162,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|
||||
| 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`, `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` |
|
||||
| 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`, `hud_kit/` (Horizon overlays' `Room` and `Plate`, [docs/ui-kit.md](docs/ui-kit.md)), `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` |
|
||||
|
||||
+3
-1
@@ -76,7 +76,9 @@ another project's source may also be read and used, **only when that project's l
|
||||
|
||||
User documentation and file formats are fine to read, and packs and mods are fine to run and observe, whatever their
|
||||
license. Shader packs are a clean-room rewrite of the pack format, produced from a written description of it (see
|
||||
#1561); they carry no foreign source, and the rules above still apply to anything you read while working on them.
|
||||
#1561): the [specification and its provenance log](docs/cleanroom/README.md) are public. They carry no foreign
|
||||
source, and the rules above still apply to anything you read while working on them. A shader-pack pull request
|
||||
names its sources: the sections of that specification, the documentation pages and the packs it ran.
|
||||
|
||||
## Before asking for review
|
||||
|
||||
|
||||
Generated
+2
@@ -1588,6 +1588,7 @@ dependencies = [
|
||||
"horizon-mod-api-gen",
|
||||
"horizon-narrator",
|
||||
"horizon-optifine",
|
||||
"horizon-polytone",
|
||||
"horizon-protocol",
|
||||
"horizon-shader-api",
|
||||
"horizon-shaders",
|
||||
@@ -1775,6 +1776,7 @@ dependencies = [
|
||||
name = "horizon-polytone"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"regex",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
@@ -63,6 +63,32 @@ variable's array specifier does not change later variables.
|
||||
It also reads a struct member outside a constant expression through a pointer to the member,
|
||||
not out of a loaded copy of the whole struct, so a member an atomic function uses can be read.
|
||||
|
||||
## DirectX Shader Compiler
|
||||
|
||||
The Windows x64 game folder holds `dxcompiler.dll`, Microsoft's DirectX Shader Compiler
|
||||
[v1.9.2609](https://github.com/microsoft/DirectXShaderCompiler/releases/tag/v1.9.2609), unmodified: the
|
||||
`bin/x64/dxcompiler.dll` of `dxc_2026_09_29.zip` (zip SHA-256
|
||||
`ad31b1fc8443175d204f77a611fdb3ef2ec42759bdc2f1167368de24a4a7e7f1`, DLL SHA-256
|
||||
`0689e2fee0a1c9df8662a59680469f24e2a3ad7ce6bbbcc9284f7a83172bfc59`, 22,274,872 bytes). The release build downloads
|
||||
it and checks both checksums (`tools/ci/fetch_dxc.py`); it is not in this repository. DirectX 12 loads it only on
|
||||
integrated GPUs (`crates/horizon-client-render/src/gfx/dx12_compiler.rs`,
|
||||
[#8513](https://github.com/horizon-rust/horizon/issues/8513)); every other GPU compiles with FXC, part of Windows.
|
||||
|
||||
Copyright (c) Microsoft Corporation. It is not covered by Horizon's MIT license. It is distributed under the
|
||||
"Microsoft Software License Terms, Microsoft DirectX Shader Compiler" (`LICENSE-MS.txt` of that release). Its
|
||||
Distributable Code terms are the Windows SDK's: end users get terms that protect the code and Microsoft at least as
|
||||
much, and the distributor indemnifies Microsoft. The project accepted them on 2026-11-08. Its open-source part is also under the University of Illinois/NCSA license (`LICENSE-LLVM.txt`). Both
|
||||
files ship beside the DLL as `DXC-LICENSE-MS.txt` and `DXC-LICENSE-LLVM.txt`, with `DXC-NOTICE.txt`
|
||||
(`packaging/windows/`), which tells players those terms apply and that deleting the DLL brings back FXC.
|
||||
|
||||
- Only x64: the Windows SDK's list of distributable files
|
||||
([REDIST.TXT lists](https://learn.microsoft.com/en-us/legal/windows-sdk/redist), updated 2024-10-21) names
|
||||
`Redist\D3D\x64\dxcompiler.dll` and `dxil.dll` and their x86 copies, for classic Windows applications (Horizon is
|
||||
one), and no ARM64 copy. The release zip's own `LICENSE-MS.txt` refers to a list of distributable files that the zip
|
||||
doesn't contain, so the SDK's list is the one followed. The Windows ARM64 build ships no DXC and compiles with FXC.
|
||||
- No `dxil.dll`: since DXC 1.8.2505 the compiler signs shaders with its own validator and no longer loads it, and
|
||||
wgpu never does.
|
||||
|
||||
## libmimalloc-sys and mimalloc
|
||||
|
||||
`third_party/libmimalloc-sys` is [libmimalloc-sys](https://github.com/purpleprotocol/mimalloc_rust)
|
||||
|
||||
@@ -97,8 +97,8 @@ rustix = { version = "1", default-features = false, features = ["std", "time", "
|
||||
# Use the registered URI handler without routing links through Explorer/cmd.
|
||||
open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
|
||||
# DirectX 12, where Vulkan has no adapter or device, or by choice (`graphicsApi`, graphics.rs).
|
||||
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or DXC when dxcompiler.dll is
|
||||
# on the PATH.
|
||||
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or on integrated GPUs with DXC
|
||||
# (dxcompiler.dll beside the game, gfx/dx12_compiler.rs).
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
|
||||
@@ -23,6 +23,7 @@ horizon-narrator = { path = "../horizon-narrator" }
|
||||
# OptiFine-format pack files: random entity textures (hud.rs), connected textures
|
||||
# (connected.rs) and custom item texture rules (cit.rs).
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
horizon-polytone = { path = "../horizon-polytone" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
//! A world warp (`native_world_warp`, docs/native-mods.md "World warps") through the real
|
||||
//! `Renderer`, offscreen: a wall of terrain and an entity quad drawn without a warp, with one,
|
||||
//! with one that is off, with one that cuts the wall, with a pixel stage of its own and with
|
||||
//! one that doesn't compile. Run with throwaway HORIZON_HOME, HORIZON_LAUNCHER_HOME,
|
||||
//! XDG_DATA_HOME and XDG_CONFIG_HOME (AGENTS.md):
|
||||
//! `cargo run -p horizon-client-render --example world_warp_check -- <output-dir>`
|
||||
//! `--release ... -- <output-dir> --bench`: instead, the time of a frame of rolling land 49 by 49
|
||||
//! chunks around the camera without a warp, and with one (nothing culled), at 1920 by 1080.
|
||||
use client::{
|
||||
gfx::{Frame, Renderer, ScreenshotRequest, native_render},
|
||||
graphics::GraphicsApi,
|
||||
hud::HudBatch,
|
||||
mesher::{SectionMesh, TerrainVertex, Vertex},
|
||||
};
|
||||
use horizon_client_render as client;
|
||||
use std::{
|
||||
path::Path,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
use winit::{
|
||||
application::ApplicationHandler,
|
||||
event::WindowEvent,
|
||||
event_loop::{ActiveEventLoop, EventLoop},
|
||||
window::{Window, WindowId},
|
||||
};
|
||||
|
||||
/// What the mod below gives the renderer each frame.
|
||||
static WARP: Mutex<Option<native_render::WorldWarp>> = Mutex::new(None);
|
||||
struct Mod;
|
||||
impl native_render::RenderHooks for Mod {
|
||||
fn world_warp(&self, event: &mut native_render::WorldWarpEvent<'_>) {
|
||||
if let Some(warp) = WARP.lock().unwrap().as_ref() {
|
||||
*event.warp = warp.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lifts what is left of x = 8 by `params[0].x` and tints it red.
|
||||
const LIFT: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
|
||||
let here = pos.x + warp.camera.x + warp.camera_rest.x;
|
||||
if here < 8.0 { return Warped(pos + vec3(0.0, warp.params[0].x, 0.0), vec3(1.0, 0.0, 0.0)); }
|
||||
return Warped(pos, vec3(1.0));
|
||||
}
|
||||
";
|
||||
/// Cuts every face.
|
||||
const CUT: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
|
||||
return Warped(pos, vec3(-1.0));
|
||||
}
|
||||
";
|
||||
/// Paints the terrain green by its pixels and moves nothing.
|
||||
const PAINT: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
|
||||
return Warped(pos, vec3(1.0));
|
||||
}
|
||||
// [fragments]
|
||||
fn world_fragment(f: Fragment) -> Painted {
|
||||
if !f.front { return Painted(vec4(0.0, 0.0, 0.0, -1.0), 1.0); }
|
||||
return Painted(vec4(0.0, 1.0, 0.0, 1.0), 0.0);
|
||||
}
|
||||
";
|
||||
/// Moves entities ten blocks right: off the screen's middle.
|
||||
const SHIFT: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
|
||||
return Warped(pos + vec3(-10.0, 0.0, 0.0), vec3(1.0));
|
||||
}
|
||||
";
|
||||
|
||||
fn read(path: &Path) -> Vec<[u8; 4]> {
|
||||
let decoder = png::Decoder::new(std::io::BufReader::new(std::fs::File::open(path).unwrap()));
|
||||
let mut reader = decoder.read_info().unwrap();
|
||||
let mut bytes = vec![0; reader.output_buffer_size().unwrap()];
|
||||
let info = reader.next_frame(&mut bytes).unwrap();
|
||||
assert_eq!(info.color_type, png::ColorType::Rgba);
|
||||
bytes.truncate(info.buffer_size());
|
||||
bytes.as_chunks::<4>().0.to_vec()
|
||||
}
|
||||
|
||||
/// Pixels like `want` (within 40 a channel), of an image.
|
||||
fn count(pixels: &[[u8; 4]], want: [u8; 3]) -> usize {
|
||||
pixels
|
||||
.iter()
|
||||
.filter(|p| (0..3).all(|i| p[i].abs_diff(want[i]) < 40))
|
||||
.count()
|
||||
}
|
||||
|
||||
/// A wall across x 0..16, y 0..16 at z = 12 of the section at y 96, both windings.
|
||||
fn wall() -> SectionMesh {
|
||||
let mut mesh = SectionMesh::default();
|
||||
let corners = [
|
||||
[0., 0., 12.],
|
||||
[0., 16., 12.],
|
||||
[16., 16., 12.],
|
||||
[16., 0., 12.],
|
||||
];
|
||||
let uvs = [[0., 1.], [0., 0.], [1., 0.], [1., 1.]];
|
||||
for order in [[0, 1, 2, 3], [3, 2, 1, 0]] {
|
||||
for i in order {
|
||||
mesh.solid
|
||||
.push(TerrainVertex::new(corners[i], uvs[i], 0, u32::MAX, 0xf0f0));
|
||||
}
|
||||
}
|
||||
mesh.sort_solid();
|
||||
mesh
|
||||
}
|
||||
|
||||
fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let out = std::env::args().nth(1).ok_or("output directory required")?;
|
||||
let out = Path::new(&out);
|
||||
std::fs::create_dir_all(out)?;
|
||||
native_render::install(Box::new([&Mod])).map_err(|_| "mod already installed")?;
|
||||
let white = [255; 16 * 16 * 4];
|
||||
let zero = [0; 4];
|
||||
let mut renderer = pollster::block_on(Renderer::new(
|
||||
window,
|
||||
true,
|
||||
&white,
|
||||
16,
|
||||
16,
|
||||
1,
|
||||
&[],
|
||||
&[],
|
||||
(&white, 16, 16),
|
||||
(&zero, 1, 1),
|
||||
(&zero, 1, 1),
|
||||
(&zero, 1),
|
||||
Arc::new(client::gfx::panorama::Faces::colour([0, 0, 0, 255])),
|
||||
None,
|
||||
None,
|
||||
GraphicsApi::Vulkan,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
))?;
|
||||
renderer.resize(320, 180);
|
||||
renderer.fullbright = true;
|
||||
// (keys count sections from the bottom of the world, y -64: 10 is y 96 to 112)
|
||||
renderer.upload((0, 10, 0), &wall());
|
||||
// an entity quad in front of the wall, middle of the screen
|
||||
let entity: Vec<Vertex> = [
|
||||
[7.5, 103.5, 6.0],
|
||||
[7.5, 104.5, 6.0],
|
||||
[8.5, 104.5, 6.0],
|
||||
[8.5, 103.5, 6.0],
|
||||
]
|
||||
.into_iter()
|
||||
.zip([[0., 1.], [0., 0.], [1., 0.], [1., 1.]])
|
||||
.map(|(p, uv)| Vertex {
|
||||
pos: [p[0] - 8.0, p[1] - 104.0, p[2] - 0.5],
|
||||
uv,
|
||||
layer: 0,
|
||||
color: 0xff40_40ff,
|
||||
light: 0xf0,
|
||||
normal: [0, 0, 0, 0],
|
||||
glint_uv: [0.0; 2],
|
||||
})
|
||||
.collect();
|
||||
if std::env::args().any(|a| a == "--bench") {
|
||||
return bench(&mut renderer, out);
|
||||
}
|
||||
let hud = HudBatch::new([320, 180]);
|
||||
let mut frame = fixture_frame(&hud);
|
||||
frame.entities = &entity;
|
||||
let mut shot = |name: &str, warp: Option<native_render::WorldWarp>| {
|
||||
*WARP.lock().unwrap() = warp;
|
||||
let path = out.join(format!("{name}.png"));
|
||||
renderer.screenshot = Some(ScreenshotRequest {
|
||||
path: path.clone(),
|
||||
size: None,
|
||||
scaled: false,
|
||||
});
|
||||
// twice: the first frame of a new warp builds it
|
||||
renderer.render(&frame).unwrap();
|
||||
renderer.render(&frame).unwrap();
|
||||
read(&path)
|
||||
};
|
||||
let warp = |source: &str, on: bool, lift: f32| {
|
||||
let mut w = native_render::WorldWarp::default();
|
||||
w.source = Some(source.into());
|
||||
w.on = on;
|
||||
w.params[0] = lift;
|
||||
Some(w)
|
||||
};
|
||||
let plain = shot("plain", None);
|
||||
let wall_px = count(&plain, [255, 255, 255]);
|
||||
let entity_px = count(&plain, [255, 64, 64]);
|
||||
assert!(wall_px > 1000, "the wall shows without a warp: {wall_px}");
|
||||
assert!(
|
||||
entity_px > 50,
|
||||
"the entity shows without a warp: {entity_px}"
|
||||
);
|
||||
|
||||
let off = shot("off", warp(LIFT, false, 4.0));
|
||||
assert!(off == plain, "a warp that is off draws as no warp");
|
||||
|
||||
let lifted = shot("lift", warp(LIFT, true, 4.0));
|
||||
let red = count(&lifted, [255, 0, 0]);
|
||||
assert!(red > 500, "the warp tints the wall's left half red: {red}");
|
||||
assert!(lifted != plain);
|
||||
|
||||
let cut = shot("cut", warp(CUT, true, 0.0));
|
||||
let left = count(&cut, [255, 255, 255]);
|
||||
assert!(
|
||||
left < wall_px / 20,
|
||||
"a cut leaves the wall out: {left} of {wall_px}"
|
||||
);
|
||||
|
||||
let painted = shot("paint", warp(PAINT, true, 0.0));
|
||||
let green = count(&painted, [0, 255, 0]);
|
||||
assert!(
|
||||
green > wall_px / 2,
|
||||
"the warp's own pixels paint the wall: {green}"
|
||||
);
|
||||
|
||||
let shifted = shot("shift", warp(SHIFT, true, 0.0));
|
||||
let entity_left = count(&shifted[..], [255, 64, 64]);
|
||||
let mid = |p: &[[u8; 4]]| -> Vec<[u8; 4]> {
|
||||
(60..120)
|
||||
.flat_map(|y| p[y * 320 + 130..y * 320 + 190].to_vec())
|
||||
.collect()
|
||||
};
|
||||
assert!(
|
||||
count(&mid(&shifted), [255, 64, 64]) < count(&mid(&plain), [255, 64, 64]) / 4,
|
||||
"the warp moves the entity out of the middle ({entity_left} red-ish pixels left)"
|
||||
);
|
||||
|
||||
let broken = shot(
|
||||
"broken",
|
||||
warp("fn world_warp() -> f32 { return 1; }", true, 0.0),
|
||||
);
|
||||
assert!(broken == plain, "a warp that doesn't compile is left out");
|
||||
|
||||
let back = shot("after", None);
|
||||
assert!(back == plain, "with the warp gone the frame is as before");
|
||||
std::fs::write(
|
||||
out.join("result.json"),
|
||||
serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"wall_pixels": wall_px, "entity_pixels": entity_px, "lifted_red_pixels": red,
|
||||
"cut_wall_pixels_left": left, "painted_green_pixels": green,
|
||||
"off_equals_plain": true, "broken_equals_plain": true, "after_equals_plain": true,
|
||||
"graphics_adapter": client::crash::system_details("fixture").into_iter()
|
||||
.find(|(name, _)| name == "Graphics Adapter").map(|(_, detail)| detail),
|
||||
}))?,
|
||||
)?;
|
||||
println!(
|
||||
"world warp check passed: {}",
|
||||
out.join("result.json").display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rolling land: a column of a section's top faces and the sides they show, at y 64 to 80.
|
||||
fn hills(cx: i32, cz: i32) -> SectionMesh {
|
||||
let height = |x: i32, z: i32| {
|
||||
let (x, z) = (f64::from(cx * 16 + x), f64::from(cz * 16 + z));
|
||||
(8.0 + 4.0 * (x * 0.09).sin() + 3.0 * (z * 0.07 + x * 0.02).cos()).floor() as i32
|
||||
};
|
||||
let mut mesh = SectionMesh::default();
|
||||
let mut quad = |c: [[f32; 3]; 4]| {
|
||||
for (p, uv) in c.into_iter().zip([[0., 1.], [0., 0.], [1., 0.], [1., 1.]]) {
|
||||
mesh.solid
|
||||
.push(TerrainVertex::new(p, uv, 0, 0xff60_a060, 0xf0));
|
||||
}
|
||||
};
|
||||
for x in 0..16 {
|
||||
for z in 0..16 {
|
||||
let h = height(x, z);
|
||||
let (fx, fz, fh) = (x as f32, z as f32, h as f32);
|
||||
quad([
|
||||
[fx, fh, fz],
|
||||
[fx, fh, fz + 1.],
|
||||
[fx + 1., fh, fz + 1.],
|
||||
[fx + 1., fh, fz],
|
||||
]);
|
||||
// the step down to a lower neighbour, toward +x and +z
|
||||
for (dx, dz) in [(1, 0), (0, 1)] {
|
||||
let n = height(x + dx, z + dz);
|
||||
if n < h {
|
||||
let (lo, hi) = (n as f32, fh);
|
||||
if dx == 1 {
|
||||
quad([
|
||||
[fx + 1., lo, fz],
|
||||
[fx + 1., hi, fz],
|
||||
[fx + 1., hi, fz + 1.],
|
||||
[fx + 1., lo, fz + 1.],
|
||||
]);
|
||||
} else {
|
||||
quad([
|
||||
[fx, lo, fz + 1.],
|
||||
[fx + 1., lo, fz + 1.],
|
||||
[fx + 1., hi, fz + 1.],
|
||||
[fx, hi, fz + 1.],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
mesh.sort_solid();
|
||||
mesh
|
||||
}
|
||||
|
||||
/// `--bench`: milliseconds a frame without a warp and with one.
|
||||
fn bench(renderer: &mut Renderer, out: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
||||
const R: i32 = 24;
|
||||
renderer.resize(1920, 1080);
|
||||
let mut sections = 0;
|
||||
for cx in -R..=R {
|
||||
for cz in -R..=R {
|
||||
// (keys count sections from y -64: 8 is y 64 to 80)
|
||||
renderer.upload((cx, 8, cz), &hills(cx, cz));
|
||||
sections += 1;
|
||||
}
|
||||
}
|
||||
let hud = HudBatch::new([1920, 1080]);
|
||||
let mut frame = fixture_frame(&hud);
|
||||
frame.camera = glam::DVec3::new(8.0, 90.0, 8.0);
|
||||
frame.eye = frame.camera;
|
||||
frame.pitch = 15.0;
|
||||
frame.eye_rotation = [0.0, 15.0];
|
||||
frame.distance = 16.0 * R as f32;
|
||||
frame.detail_distance = frame.distance;
|
||||
frame.depth_far = 2000.0;
|
||||
frame.cave_culling = true;
|
||||
let mut time = |warp: Option<native_render::WorldWarp>| {
|
||||
*WARP.lock().unwrap() = warp;
|
||||
for _ in 0..30 {
|
||||
renderer.render(&frame).unwrap();
|
||||
}
|
||||
let started = std::time::Instant::now();
|
||||
const FRAMES: u32 = 240;
|
||||
for _ in 0..FRAMES {
|
||||
renderer.render(&frame).unwrap();
|
||||
}
|
||||
// the last frame's picture, so the GPU has caught up
|
||||
renderer.screenshot = Some(ScreenshotRequest {
|
||||
path: out.join("bench.png"),
|
||||
size: None,
|
||||
scaled: false,
|
||||
});
|
||||
renderer.render(&frame).unwrap();
|
||||
renderer.render(&frame).unwrap();
|
||||
started.elapsed().as_secs_f64() * 1000.0 / f64::from(FRAMES + 2)
|
||||
};
|
||||
let mut wave = native_render::WorldWarp::default();
|
||||
wave.source = Some(
|
||||
"fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
|
||||
let x = pos.x + warp.camera.x + warp.camera_rest.x;
|
||||
return Warped(pos + vec3(0.0, sin(x * 0.05) * 3.0, 0.0), vec3(1.0));
|
||||
}\n"
|
||||
.into(),
|
||||
);
|
||||
wave.on = true;
|
||||
let plain = time(None);
|
||||
let warped = time(Some(wave));
|
||||
let again = time(None);
|
||||
let result = serde_json::json!({
|
||||
"sections": sections, "frame_ms_without_warp": plain, "frame_ms_with_warp": warped,
|
||||
"frame_ms_without_warp_after": again,
|
||||
"graphics_adapter": client::crash::system_details("bench").into_iter()
|
||||
.find(|(name, _)| name == "Graphics Adapter").map(|(_, detail)| detail),
|
||||
});
|
||||
println!("{result}");
|
||||
std::fs::write(out.join("bench.json"), serde_json::to_vec_pretty(&result)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct App;
|
||||
impl ApplicationHandler for App {
|
||||
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||
let window = Arc::new(
|
||||
event_loop
|
||||
.create_window(Window::default_attributes().with_visible(false))
|
||||
.unwrap(),
|
||||
);
|
||||
if let Err(error) = fixture(window) {
|
||||
eprintln!("world warp check failed: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
event_loop.exit();
|
||||
}
|
||||
fn window_event(&mut self, _: &ActiveEventLoop, _: WindowId, _: WindowEvent) {}
|
||||
}
|
||||
fn main() {
|
||||
for key in [
|
||||
"HORIZON_HOME",
|
||||
"HORIZON_LAUNCHER_HOME",
|
||||
"XDG_DATA_HOME",
|
||||
"XDG_CONFIG_HOME",
|
||||
] {
|
||||
assert!(
|
||||
std::env::var_os(key).is_some(),
|
||||
"set throwaway {key} (AGENTS.md)"
|
||||
);
|
||||
}
|
||||
EventLoop::new().unwrap().run_app(&mut App).unwrap();
|
||||
}
|
||||
|
||||
fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
Frame {
|
||||
weather: &[],
|
||||
weather_time: client::weather::Time::new(0, 0.0),
|
||||
camera: glam::DVec3::new(8.0, 104.0, 0.5),
|
||||
yaw: 0.0,
|
||||
pitch: 0.0,
|
||||
fov_degrees: 85.0,
|
||||
eye: glam::DVec3::new(8.0, 104.0, 0.5),
|
||||
eye_rotation: [0.0, 0.0],
|
||||
first_person: true,
|
||||
bob: glam::Mat4::IDENTITY,
|
||||
spin: glam::Mat4::IDENTITY,
|
||||
sky: client::environment::Sky::default(),
|
||||
shadow_scene_revision: (0, 0),
|
||||
shadow_cache: false,
|
||||
distance: 128.0,
|
||||
detail_distance: 128.0,
|
||||
depth_far: 1000.0,
|
||||
underwater: false,
|
||||
water_plane: None,
|
||||
cave_culling: false,
|
||||
title: false,
|
||||
sky_blocked: false,
|
||||
skybox: false,
|
||||
end_sky: false,
|
||||
end_sky_sprite: None,
|
||||
boss_world_fog: false,
|
||||
environment_fog: None,
|
||||
water_fog: [0.0; 3],
|
||||
fog: None,
|
||||
dark_disc: false,
|
||||
outline: Vec::new(),
|
||||
outline_after_terrain: false,
|
||||
outline_high_contrast: false,
|
||||
pvp: Default::default(),
|
||||
debug_lines: &[],
|
||||
debug_lines_on_top: &[],
|
||||
debug_crosshair: &[],
|
||||
hud,
|
||||
glint_alpha: 0.75,
|
||||
glint_ticks: 0.0,
|
||||
nether_lighting: false,
|
||||
entities: &[],
|
||||
static_block_entities: &[],
|
||||
visible_static_block_entity_sections: &[],
|
||||
static_block_entity_stats: Default::default(),
|
||||
entity_motion: &[],
|
||||
entity_motion_posed: &[],
|
||||
entity_spans: &[],
|
||||
skin: None,
|
||||
glowing: &[],
|
||||
dynamic_lights: &[],
|
||||
colored_lights: &[],
|
||||
emissive_layers: &[],
|
||||
clouds: None,
|
||||
lightning: Default::default(),
|
||||
additive: &[],
|
||||
see_through: &[],
|
||||
world_primitives: Default::default(),
|
||||
shadow_only: &[],
|
||||
celestial: &[],
|
||||
stars: None,
|
||||
hand: &[],
|
||||
hand_items: &[],
|
||||
hand_fov_degrees: 70.0,
|
||||
screen: &[],
|
||||
pack: Default::default(),
|
||||
border: None,
|
||||
game_time: 0.0,
|
||||
shader_game_time: 0.0,
|
||||
end_portal_sprites: [[0.0; 4]; 2],
|
||||
crumbling_sprite: [0.0; 4],
|
||||
ticks: 0,
|
||||
darkness: (0.0, 0.0),
|
||||
night_vision: 0.0,
|
||||
block_flicker: 0.0,
|
||||
boss_darken: 0.0,
|
||||
chunk_fade: 0.0,
|
||||
post_effects: client::post_chain::Request::none(),
|
||||
menu_blur: None,
|
||||
}
|
||||
}
|
||||
@@ -191,13 +191,18 @@ pub fn discovery_url() -> String {
|
||||
.unwrap_or_else(|| DEFAULT_DISCOVERY.to_owned())
|
||||
}
|
||||
|
||||
fn agent() -> ureq::Agent {
|
||||
pub(crate) fn agent() -> ureq::Agent {
|
||||
ureq::AgentBuilder::new()
|
||||
.timeout(Duration::from_secs(15))
|
||||
.user_agent(&format!("Horizon/{}", horizon_core::release::VERSION))
|
||||
.build()
|
||||
}
|
||||
|
||||
/// Whether `HORIZON_API_DISCOVERY` names another services environment.
|
||||
pub fn discovery_overridden() -> bool {
|
||||
discovery_url() != DEFAULT_DISCOVERY
|
||||
}
|
||||
|
||||
/// The discovery document, fetched once per game (a failure is retried next time).
|
||||
pub(crate) fn discovery() -> Result<Arc<serde_json::Value>, JoinError> {
|
||||
static CACHE: Mutex<Option<Arc<serde_json::Value>>> = Mutex::new(None);
|
||||
|
||||
@@ -17,11 +17,15 @@ pub struct BanDetails {
|
||||
pub reason_message: Option<String>,
|
||||
}
|
||||
|
||||
/// authlib's `UserProperties`, as far as the title screen reads it.
|
||||
/// authlib's `UserProperties`, as far as the title screen and the friends service read it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct UserProperties {
|
||||
/// `UserFlag.SERVERS_ALLOWED` (`privileges.multiplayerServer.enabled`).
|
||||
pub servers_allowed: bool,
|
||||
/// `UserFlag.FRIENDS_ENABLED` (`friendsPreferences.friends`).
|
||||
pub friends_enabled: bool,
|
||||
/// `UserFlag.ACCEPT_FRIEND_INVITES` (`friendsPreferences.acceptInvites`).
|
||||
pub accept_friend_invites: bool,
|
||||
/// `bannedScopes.get("MULTIPLAYER")`.
|
||||
pub multiplayer_ban: Option<BanDetails>,
|
||||
}
|
||||
@@ -30,6 +34,8 @@ impl Default for UserProperties {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
servers_allowed: true,
|
||||
friends_enabled: false,
|
||||
accept_friend_invites: false,
|
||||
multiplayer_ban: None,
|
||||
}
|
||||
}
|
||||
@@ -209,6 +215,13 @@ pub fn parse_properties(body: &str) -> Option<UserProperties> {
|
||||
.pointer("/privileges/multiplayerServer/enabled")
|
||||
.and_then(|e| e.as_bool())
|
||||
.unwrap_or(false);
|
||||
let toggle = |name: &str| {
|
||||
v.pointer(&format!("/friendsPreferences/{name}"))
|
||||
.and_then(|f| f.as_str())
|
||||
.is_some_and(|f| f.eq_ignore_ascii_case("ENABLED"))
|
||||
};
|
||||
let friends_enabled = toggle("friends");
|
||||
let accept_friend_invites = toggle("acceptInvites");
|
||||
let multiplayer_ban = v
|
||||
.pointer("/banStatus/bannedScopes/MULTIPLAYER")
|
||||
.filter(|b| b.is_object())
|
||||
@@ -222,6 +235,8 @@ pub fn parse_properties(body: &str) -> Option<UserProperties> {
|
||||
});
|
||||
Some(UserProperties {
|
||||
servers_allowed,
|
||||
friends_enabled,
|
||||
accept_friend_invites,
|
||||
multiplayer_ban,
|
||||
})
|
||||
}
|
||||
@@ -275,6 +290,24 @@ mod tests {
|
||||
assert!(clean.servers_allowed && clean.multiplayer_ban.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn friends_preferences_read_their_two_flags() {
|
||||
let body = r#"{"privileges":{"multiplayerServer":{"enabled":true}},
|
||||
"friendsPreferences":{"friends":"ENABLED","acceptInvites":"DISABLED"}}"#;
|
||||
let p = parse_properties(body).unwrap();
|
||||
assert!(p.friends_enabled);
|
||||
assert!(!p.accept_friend_invites);
|
||||
let other = parse_properties(
|
||||
r#"{"friendsPreferences":{"friends":"DISABLED","acceptInvites":"ENABLED"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!other.friends_enabled);
|
||||
assert!(other.accept_friend_invites);
|
||||
// The offline fallback has neither flag, as `OFFLINE_PROPERTIES` does.
|
||||
assert!(!UserProperties::default().friends_enabled);
|
||||
assert!(!UserProperties::default().accept_friend_invites);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_profile_actions() {
|
||||
let a = parse_profile_actions(
|
||||
@@ -297,6 +330,7 @@ mod tests {
|
||||
properties: UserProperties {
|
||||
servers_allowed: true,
|
||||
multiplayer_ban: Some(ban.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
actions: ProfileActions {
|
||||
forced_name_change: true,
|
||||
@@ -317,7 +351,7 @@ mod tests {
|
||||
let no_servers = AccountStatus {
|
||||
properties: UserProperties {
|
||||
servers_allowed: false,
|
||||
multiplayer_ban: None,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -112,6 +112,9 @@ pub struct BiomeInfo {
|
||||
/// `BiomeGenerationSettings.features`: the placed feature ids of each
|
||||
/// `GenerationStep.Decoration` step, in order.
|
||||
pub features: Vec<Vec<String>>,
|
||||
/// The biome's environment attributes (`Biome.getAttributes`): its layer in the
|
||||
/// environment attribute stack.
|
||||
pub attributes: horizon_world::environment_attributes::AttributeMap,
|
||||
}
|
||||
impl BiomeInfo {
|
||||
fn ambient_particles(v: &Value) -> Vec<(ParticleType, f32)> {
|
||||
@@ -277,6 +280,11 @@ impl BiomeInfo {
|
||||
.collect()
|
||||
})
|
||||
.collect(),
|
||||
attributes: horizon_world::environment_attributes::AttributeMap::parse(attributes)
|
||||
.unwrap_or_else(|e| {
|
||||
log::warn!("biome attributes: {e}");
|
||||
Default::default()
|
||||
}),
|
||||
name,
|
||||
}
|
||||
}
|
||||
@@ -393,6 +401,17 @@ impl Biomes {
|
||||
pub fn get(&self, id: u16) -> &BiomeInfo {
|
||||
&self.list[usize::from(id).min(self.list.len() - 1)]
|
||||
}
|
||||
/// The name of biome `id`, plains (as for an unknown name) when the id is
|
||||
/// past this table: a column a server sent under its earlier, longer
|
||||
/// registry (a proxy's previous server) keeps its ids until the next
|
||||
/// login replaces it. `get` clamps such an id to the last entry instead,
|
||||
/// so for that stale column the colours and this name can come from
|
||||
/// different biomes until the login.
|
||||
pub fn name(&self, id: u16) -> &str {
|
||||
self.list
|
||||
.get(usize::from(id))
|
||||
.map_or("minecraft:plains", |b| b.name.as_str())
|
||||
}
|
||||
pub fn grass(&self, id: u16, x: f64, z: f64) -> u32 {
|
||||
let b = self.get(id);
|
||||
match b.modifier {
|
||||
@@ -624,6 +643,49 @@ mod pack_tests {
|
||||
assert_eq!(other.get(2).water, 0x3F76E4);
|
||||
assert!(!other.has_packs());
|
||||
}
|
||||
|
||||
/// A column keeps the ids of the table it was decoded with. When a
|
||||
/// proxy's next server lacks a biome the last one added, that id is past
|
||||
/// the next table, and it names plains instead of indexing past the end
|
||||
/// (#12148: "the len is 67 but the index is 67").
|
||||
#[test]
|
||||
fn an_id_past_the_table_names_plains() {
|
||||
let next = builtin(&["minecraft:meadow", "minecraft:plains", "minecraft:river"]);
|
||||
let last = next.with_packs([("limbo:void", &json!({}))]);
|
||||
let stale = last.id("limbo:void");
|
||||
assert_eq!((stale, last.name(stale)), (3, "limbo:void"));
|
||||
assert_eq!(next.name(stale), "minecraft:plains");
|
||||
assert_eq!(next.name(u16::MAX), "minecraft:plains");
|
||||
assert_eq!(next.name(2), "minecraft:river");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Biomes {
|
||||
/// Biomes from their files' JSON, by name, in id order: for tests elsewhere.
|
||||
pub(crate) fn from_jsons(entries: &[(&str, Value)]) -> Self {
|
||||
let maps = Colormaps {
|
||||
grass: Vec::new(),
|
||||
foliage: Vec::new(),
|
||||
dry: Vec::new(),
|
||||
};
|
||||
let list: Vec<_> = entries
|
||||
.iter()
|
||||
.map(|(name, v)| BiomeInfo::from_json((*name).to_owned(), v, &maps))
|
||||
.collect();
|
||||
Self {
|
||||
builtin: Arc::new(list.clone()),
|
||||
index: list
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, b)| (b.name.clone(), i as u16))
|
||||
.collect(),
|
||||
list,
|
||||
info_noise: SimplexNoise::with_skipped_offsets(&mut LegacyRandom::new(2345), true),
|
||||
colormaps: Arc::new(maps),
|
||||
packs: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -37,6 +37,37 @@ pub fn potion_name_key(item: &str, potion: Option<&str>) -> String {
|
||||
let suffix = potion.and_then(self::potion).map_or("empty", |p| p.name);
|
||||
format!("item.minecraft.{prefix}.effect.{suffix}")
|
||||
}
|
||||
/// `PotionItem.getName` and `TippedArrowItem.getName`: a potion or a tipped
|
||||
/// arrow is named by its contents (`PotionContents.getName`: their own
|
||||
/// `custom_name`, else the potion's name, else `empty`), whatever its
|
||||
/// `item_name`. None for other items, and for a stack whose contents were
|
||||
/// taken off (`!minecraft:potion_contents`), which takes the item's name.
|
||||
pub fn contents_name_key(stack: &ItemStack) -> Option<String> {
|
||||
use horizon_world::nbt::Tag;
|
||||
let item = &*stack.item;
|
||||
let named = matches!(
|
||||
item,
|
||||
"minecraft:potion"
|
||||
| "minecraft:splash_potion"
|
||||
| "minecraft:lingering_potion"
|
||||
| "minecraft:tipped_arrow"
|
||||
);
|
||||
let data = stack.data();
|
||||
if !named || data.is_some_and(|d| d.other("!minecraft:potion_contents").is_some()) {
|
||||
return None;
|
||||
}
|
||||
let prefix = item.trim_start_matches("minecraft:");
|
||||
let own = data
|
||||
.and_then(|d| d.potion_extra.iter().find(|(k, _)| k == "custom_name"))
|
||||
.and_then(|(_, name)| match name {
|
||||
Tag::String(s) => Some(s.to_string_lossy()),
|
||||
_ => None,
|
||||
});
|
||||
Some(match own {
|
||||
Some(suffix) => format!("item.minecraft.{prefix}.effect.{suffix}"),
|
||||
None => potion_name_key(item, stack.potion()),
|
||||
})
|
||||
}
|
||||
/// `PotionContents.getAllEffects`: the potion's effects, then the stack's
|
||||
/// `custom_effects` (id, duration, amplifier). Unknown effect ids are dropped.
|
||||
pub fn contents_effects(stack: &ItemStack) -> Vec<(&'static str, i32, i32)> {
|
||||
@@ -469,6 +500,40 @@ mod tests {
|
||||
potion_name_key("minecraft:splash_potion", Some("minecraft:long_swiftness")),
|
||||
"item.minecraft.splash_potion.effect.swiftness"
|
||||
);
|
||||
// A potion or a tipped arrow is named by its contents, over its
|
||||
// item_name; one whose contents were taken off is not.
|
||||
use horizon_world::nbt::Tag;
|
||||
let mut swift =
|
||||
ItemStack::new("minecraft:potion", 1).with_potion("minecraft:long_swiftness");
|
||||
swift
|
||||
.data_mut()
|
||||
.set_other("minecraft:item_name", Some(Tag::String("Bottle".into())));
|
||||
assert_eq!(
|
||||
contents_name_key(&swift).as_deref(),
|
||||
Some("item.minecraft.potion.effect.swiftness")
|
||||
);
|
||||
assert_eq!(
|
||||
contents_name_key(&ItemStack::new("minecraft:splash_potion", 1)).as_deref(),
|
||||
Some("item.minecraft.splash_potion.effect.empty")
|
||||
);
|
||||
let mut own = ItemStack::new("minecraft:tipped_arrow", 1).with_potion("minecraft:poison");
|
||||
own.data_mut()
|
||||
.potion_extra
|
||||
.push(("custom_name".into(), Tag::String("luck".into())));
|
||||
assert_eq!(
|
||||
contents_name_key(&own).as_deref(),
|
||||
Some("item.minecraft.tipped_arrow.effect.luck")
|
||||
);
|
||||
let mut taken = ItemStack::new("minecraft:potion", 1);
|
||||
taken.data_mut().set_other(
|
||||
"!minecraft:potion_contents",
|
||||
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
|
||||
);
|
||||
assert_eq!(contents_name_key(&taken), None);
|
||||
assert_eq!(
|
||||
contents_name_key(&ItemStack::new("minecraft:stone", 1)),
|
||||
None
|
||||
);
|
||||
assert_eq!(format_duration(9600), "08:00");
|
||||
assert_eq!(format_duration(450), "00:22");
|
||||
assert_eq!(format_duration(140), "00:07");
|
||||
|
||||
@@ -357,6 +357,24 @@ impl Light {
|
||||
}
|
||||
/// The smallest form of packed light per block.
|
||||
pub fn packed(light: Box<[u8; 4096]>) -> Self {
|
||||
match Self::classify(&light) {
|
||||
(false, false, first) => Self::Uniform(first),
|
||||
(true, false, first) => Self::Sky(Self::pack_layer(&light, 4), first & 15),
|
||||
(false, true, first) => Self::Block(Self::pack_layer(&light, 0), first >> 4),
|
||||
(true, true, _) => Self::Full(light),
|
||||
}
|
||||
}
|
||||
/// The smallest form of packed light per block, allocating a 4 KiB box only
|
||||
/// when both sky and block light vary within the section.
|
||||
pub fn from_slice(light: &[u8; 4096]) -> Self {
|
||||
match Self::classify(light) {
|
||||
(false, false, first) => Self::Uniform(first),
|
||||
(true, false, first) => Self::Sky(Self::pack_layer(light, 4), first & 15),
|
||||
(false, true, first) => Self::Block(Self::pack_layer(light, 0), first >> 4),
|
||||
(true, true, _) => Self::Full(Box::new(*light)),
|
||||
}
|
||||
}
|
||||
fn classify(light: &[u8; 4096]) -> (bool, bool, u8) {
|
||||
let first = light[0];
|
||||
let (mut sky_varies, mut block_varies) = (false, false);
|
||||
for &v in light.iter() {
|
||||
@@ -364,19 +382,14 @@ impl Light {
|
||||
sky_varies |= d & 0xF0 != 0;
|
||||
block_varies |= d & 15 != 0;
|
||||
}
|
||||
let layer = |shift: u32| {
|
||||
let mut layer = Box::new([0u8; 2048]);
|
||||
for (out, pair) in layer.iter_mut().zip(light.as_chunks::<2>().0) {
|
||||
*out = (pair[0] >> shift & 15) | (pair[1] >> shift & 15) << 4;
|
||||
}
|
||||
layer
|
||||
};
|
||||
match (sky_varies, block_varies) {
|
||||
(false, false) => Self::Uniform(first),
|
||||
(true, false) => Self::Sky(layer(4), first & 15),
|
||||
(false, true) => Self::Block(layer(0), first >> 4),
|
||||
(true, true) => Self::Full(light),
|
||||
(sky_varies, block_varies, first)
|
||||
}
|
||||
fn pack_layer(light: &[u8; 4096], shift: u32) -> Box<[u8; 2048]> {
|
||||
let mut layer = Box::new([0u8; 2048]);
|
||||
for (out, pair) in layer.iter_mut().zip(light.as_chunks::<2>().0) {
|
||||
*out = (pair[0] >> shift & 15) | (pair[1] >> shift & 15) << 4;
|
||||
}
|
||||
layer
|
||||
}
|
||||
/// The packed light of blocks `at..at + out.len()`, into `out`.
|
||||
#[inline]
|
||||
|
||||
@@ -515,6 +515,11 @@ pub struct Info {
|
||||
pub pos: [f64; 3],
|
||||
pub source: Option<Who>,
|
||||
pub owner: Option<Who>,
|
||||
/// `uuid`: the source's saved identity (`source`), taken when the event
|
||||
/// was posted. `source` is the live entity and comes back `None` on load.
|
||||
pub source_uuid: Option<[i32; 4]>,
|
||||
/// `projectileOwnerUuid` (`projectile_owner`), likewise beside `owner`.
|
||||
pub owner_uuid: Option<[i32; 4]>,
|
||||
}
|
||||
/// `VibrationSelector`: the closest, then highest-frequency candidate of a tick.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
|
||||
@@ -356,6 +356,38 @@ where
|
||||
)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
/// `Level.loadedAndEntityCanStandOn(pos, projectile)` with the projectile's
|
||||
/// feet at `entity_y` (`loadedAndEntityCanStandOnFace` with `UP`):
|
||||
/// `Block.isFaceFull(state.getCollisionShape(level, pos,
|
||||
/// CollisionContext.of(projectile)), UP)`. A projectile is not living and never
|
||||
/// falls (it moves by `setPos`), so powder snow and liquids hold nothing up
|
||||
/// and only scaffolding reads the height. An unloaded or out-of-world block
|
||||
/// reads as air.
|
||||
pub fn projectile_can_stand_on(
|
||||
world: &World,
|
||||
binding: &BlockCollisionRegistry<'_>,
|
||||
pos: BlockPos,
|
||||
entity_y: f64,
|
||||
) -> bool {
|
||||
use horizon_core::geometry::{Direction, ShapeOp};
|
||||
let mut scene = SceneCollisions {
|
||||
world,
|
||||
binding,
|
||||
subject: None,
|
||||
};
|
||||
let state = world.block(pos.x, pos.y, pos.z);
|
||||
let context = CollisionContext::position(entity_y);
|
||||
binding
|
||||
.collision_shape_with_context(&mut scene, state, pos, &context)
|
||||
.is_ok_and(|shape| {
|
||||
shape.is_block()
|
||||
|| shape.face_shape(Direction::Up).is_ok_and(|face| {
|
||||
VoxelShape::block()
|
||||
.join_is_not_empty(&face, ShapeOp::XOR)
|
||||
.is_ok_and(|differs| !differs)
|
||||
})
|
||||
})
|
||||
}
|
||||
fn no_collision_in<W, E>(world: &mut W, bounds: Aabb, fluids: bool) -> bool
|
||||
where
|
||||
W: BlockCollisionWorld<Error = E> + EntityMovementWorld<Error = E>,
|
||||
|
||||
@@ -33,7 +33,7 @@ struct Globals {
|
||||
hdr_windows: array<vec4<f32>, 2>,
|
||||
// HDR lighting: x how strongly the sky lights the world now (`Sky::sky_light_factor`),
|
||||
// y what the sky and fog get: the same, but none under water.
|
||||
// w: this entire frame writes light, so alpha is coverage rather than a mark.
|
||||
// w: apply the no-pack HDR gain; HDR_WRITE_LIGHT independently keeps float alpha as coverage.
|
||||
hdr_light: vec4<f32>,
|
||||
hdr_settings: vec4<f32>,
|
||||
hdr_shares: vec4<f32>,
|
||||
@@ -175,7 +175,11 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
|
||||
@vertex fn vs_entity(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
|
||||
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>,
|
||||
@location(5) normal: vec4<f32>, @location(6) glint_uv: vec2<f32>) -> Out {
|
||||
var out = entity_vertex(position, uv, overlay & 0xffffu, color, light, normal);
|
||||
// [world warp entity]
|
||||
// (a world warp's pipelines put the vertex through the mod's `world_warp`: gfx/world_warp.rs)
|
||||
let at = position;
|
||||
// [end world warp entity]
|
||||
var out = entity_vertex(at, uv, overlay & 0xffffu, color, light, normal);
|
||||
out.glint_uv = glint_uv;
|
||||
out.tier = (overlay >> 16u) & 0xffu;
|
||||
return out;
|
||||
@@ -279,10 +283,11 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
|
||||
}
|
||||
// CRUMBLING_UV_END
|
||||
@fragment fn fs_entity(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
return entity_written_color(in, front);
|
||||
return entity_written_color(in, entity_color(in, front));
|
||||
}
|
||||
fn entity_written_color(in: Out, front: bool) -> vec4<f32> {
|
||||
let color = entity_color(in, front);
|
||||
// Takes entity_color's result rather than calling it: FXC (DX12) fails with "argument pulled into
|
||||
// unrelated predicate" when entity_color's discards sit a function call below the entry point.
|
||||
fn entity_written_color(in: Out, color: vec4<f32>) -> vec4<f32> {
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 && in.overlay != 11u && in.overlay != 12u {
|
||||
var mark = hdr_entity_mark(in);
|
||||
if in.overlay == 4u { mark = hdr_scene_alpha(globals.hdr_light.y); }
|
||||
@@ -455,6 +460,7 @@ fn celestial_color(in: Out) -> vec4<f32> {
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
|
||||
return vec4(hdr_written(added, select(1.0, hdr_mark(tier, y), full), globals.hdr_settings, globals.hdr_shares), color.a);
|
||||
}
|
||||
if HDR_WRITE_LIGHT { return vec4(added, color.a); }
|
||||
if !full { return vec4(added, 0.0); }
|
||||
return vec4(added, 1.0 - hdr_mark(tier, y));
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -554,14 +554,22 @@ mod tests {
|
||||
});
|
||||
let view = target.create_view(&Default::default());
|
||||
// Independent scalar expectation: a sky mark of 206/255 gives this gamma gain.
|
||||
let gain = (1.0f32 + 3.0 * (1.0 - 206.0 / 255.0) / 0.24).powf(1.0 / 2.2);
|
||||
for (mode_index, mode, debug) in MODES
|
||||
let hdr_gain = (1.0f32 + 3.0 * (1.0 - 206.0 / 255.0) / 0.24).powf(1.0 / 2.2);
|
||||
for (mode_index, mode, debug, written) in MODES
|
||||
.iter()
|
||||
.copied()
|
||||
.enumerate()
|
||||
.map(|(i, m)| (i, m, false))
|
||||
.chain([(5, Blend::Screen, true)])
|
||||
.map(|(i, m)| (i, m, false, true))
|
||||
.chain([(5, Blend::Screen, true, true)])
|
||||
.chain(
|
||||
MODES
|
||||
.iter()
|
||||
.copied()
|
||||
.enumerate()
|
||||
.map(|(i, m)| (i, m, false, false)),
|
||||
)
|
||||
{
|
||||
let gain = if written { hdr_gain } else { 1.0 };
|
||||
let uniform = Uniform {
|
||||
clip_from_sky: Mat4::IDENTITY.to_cols_array_2d(),
|
||||
brightness: 0.7,
|
||||
@@ -569,7 +577,7 @@ mod tests {
|
||||
pad: [0; 2],
|
||||
hdr_settings: [5.0, 4.0, 1.0, f32::from(u8::from(debug))],
|
||||
hdr_shares: [0.2, 0.5, 0.6, 1.0],
|
||||
hdr_light: [0.8, 0.8, 1.0, 1.0],
|
||||
hdr_light: [0.8, 0.8, 1.0, f32::from(u8::from(written))],
|
||||
};
|
||||
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
@@ -704,6 +712,12 @@ mod tests {
|
||||
let data = readback.get_mapped_range(..).unwrap();
|
||||
let got = half::f16::from_le_bytes([data[0], data[1]]).to_f32();
|
||||
let (d, t, k) = (2.0, 0.8, 128.0 / 255.0 * 0.7);
|
||||
let alpha = half::f16::from_le_bytes([data[6], data[7]]).to_f32();
|
||||
let coverage = if mode == Blend::Alpha { k } else { 1.0 };
|
||||
assert!(
|
||||
(alpha - coverage).abs() < 0.001,
|
||||
"{mode:?}: alpha {alpha}, expected {coverage}"
|
||||
);
|
||||
let expected = if debug {
|
||||
let grey = (0.2126f32 * 0.8f32.powf(2.2)
|
||||
+ 0.7152 * 0.4f32.powf(2.2)
|
||||
@@ -728,6 +742,8 @@ mod tests {
|
||||
"{mode:?} debug {debug}: {got} expected {expected}"
|
||||
);
|
||||
}
|
||||
eprintln!("HDR custom sky GPU ran all nine modes and debug Screen over above-white light");
|
||||
eprintln!(
|
||||
"HDR custom sky GPU ran all nine modes with and without gain, plus debug Screen, checking RGB and coverage"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ use crate::mesher::TerrainVertex;
|
||||
|
||||
/// The solid quads the grid can draw a frame, and the sections they come from.
|
||||
const MAX_QUADS: u64 = 1 << 16;
|
||||
const MAX_SECTIONS: usize = 64;
|
||||
pub(crate) const MAX_SECTIONS: usize = 64;
|
||||
/// Bytes of one quad's corners.
|
||||
const QUAD_BYTES: u64 = 4 * std::mem::size_of::<TerrainVertex>() as u64;
|
||||
/// How far past the flat face the relief may reach, in blocks, for the view test.
|
||||
@@ -413,6 +413,8 @@ pub struct Displacement {
|
||||
pub shadow: Option<Picks>,
|
||||
/// Group 1's layout for the grid's pipelines.
|
||||
pub draw_layout: wgpu::BindGroupLayout,
|
||||
/// CPU-side section table reused between displacement preparation passes.
|
||||
table: Vec<Section>,
|
||||
/// Sections the pick ran over this frame (0: nothing to draw).
|
||||
pub drawn: usize,
|
||||
}
|
||||
@@ -598,6 +600,7 @@ impl Displacement {
|
||||
view,
|
||||
shadow,
|
||||
draw_layout,
|
||||
table: Vec::new(),
|
||||
drawn: 0,
|
||||
}
|
||||
}
|
||||
@@ -610,7 +613,8 @@ impl Displacement {
|
||||
near: &[Near<'_>],
|
||||
view_proj: glam::Mat4,
|
||||
) {
|
||||
let mut table = Vec::with_capacity(near.len().min(MAX_SECTIONS));
|
||||
self.table.clear();
|
||||
self.table.reserve(near.len().min(MAX_SECTIONS));
|
||||
let mut first = 0u64;
|
||||
let mut most = 0u32;
|
||||
for n in near.iter().take(MAX_SECTIONS) {
|
||||
@@ -626,7 +630,7 @@ impl Displacement {
|
||||
first * QUAD_BYTES,
|
||||
quads * QUAD_BYTES,
|
||||
);
|
||||
table.push(Section {
|
||||
self.table.push(Section {
|
||||
origin: [
|
||||
n.origin[0],
|
||||
n.origin[1],
|
||||
@@ -640,11 +644,11 @@ impl Displacement {
|
||||
first += quads;
|
||||
most = most.max(quads as u32);
|
||||
}
|
||||
self.drawn = table.len();
|
||||
if table.is_empty() {
|
||||
self.drawn = self.table.len();
|
||||
if self.table.is_empty() {
|
||||
return;
|
||||
}
|
||||
queue.write_buffer(&self.sections, 0, bytemuck::cast_slice(&table));
|
||||
queue.write_buffer(&self.sections, 0, bytemuck::cast_slice(&self.table));
|
||||
let lists = std::iter::once((&self.view, 0)).chain(self.shadow.iter().map(|s| (s, 1)));
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("Displacement pick"),
|
||||
@@ -655,7 +659,7 @@ impl Displacement {
|
||||
let params = Params {
|
||||
view_proj: view_proj.to_cols_array_2d(),
|
||||
distance: self.distance,
|
||||
sections: table.len() as u32,
|
||||
sections: self.table.len() as u32,
|
||||
margin: RELIEF_MARGIN,
|
||||
capacity: MAX_QUADS as u32,
|
||||
shadow,
|
||||
@@ -665,7 +669,7 @@ impl Displacement {
|
||||
let vertices = grid_vertices(picks.cells, picks.steps);
|
||||
queue.write_buffer(&picks.draw, 0, bytemuck::cast_slice(&[vertices, 0, 0, 0]));
|
||||
pass.set_bind_group(0, &picks.pick_group, &[]);
|
||||
pass.dispatch_workgroups(most.div_ceil(64), table.len() as u32, 1);
|
||||
pass.dispatch_workgroups(most.div_ceil(64), self.table.len() as u32, 1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Which compiler turns DirectX 12's shaders into bytecode: DXC on integrated
|
||||
//! GPUs, FXC on every other adapter (#8513).
|
||||
//!
|
||||
//! Measured on one laptop at render distance 32 (2560x1440, the same world and
|
||||
//! view): DXC took Intel Iris Xe from 44 to 65 FPS (22.1 to 14.7 ms of GPU
|
||||
//! time) and an NVIDIA RTX 3050 Ti from 159 down to 150 FPS. An RX 9070 gained
|
||||
//! about 9 % of GPU time at render distance 32 and lost 0.03 ms at 8. So
|
||||
//! discrete GPUs keep FXC (`d3dcompiler_47.dll`, part of Windows), and only
|
||||
//! integrated ones load DXC (`dxcompiler.dll`, which the Windows x64 zip puts
|
||||
//! beside the game: `tools/ci/fetch_dxc.py`).
|
||||
//!
|
||||
//! wgpu chooses the compiler for a whole instance, before any adapter is
|
||||
//! known. `open_backend` therefore asks for the adapter on an FXC instance and,
|
||||
//! when that adapter is an integrated GPU on DirectX 12, asks again on an
|
||||
//! instance with DXC. That happens once, when the device is opened; nothing
|
||||
//! here runs per frame. Without the DLL, or when it does not load, the game
|
||||
//! keeps the FXC instance and logs one line. `WGPU_DX12_COMPILER` (`fxc`,
|
||||
//! `dxc`, `auto`) overrides the choice: wgpu applies it to every instance.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// The DLL's name, as the Windows zip ships it beside `horizon-client.exe`.
|
||||
pub(super) const DLL: &str = "dxcompiler.dll";
|
||||
|
||||
/// Whether `info`, opened on an FXC instance, should be opened again with DXC:
|
||||
/// an integrated GPU on DirectX 12, unless `forced` (`WGPU_DX12_COMPILER` is
|
||||
/// set, so its compiler is already the one every instance uses).
|
||||
pub(super) fn wants_dxc(info: &wgpu::AdapterInfo, forced: bool) -> bool {
|
||||
!forced
|
||||
&& info.backend == wgpu::Backend::Dx12
|
||||
&& info.device_type == wgpu::DeviceType::IntegratedGpu
|
||||
}
|
||||
|
||||
/// Whether `WGPU_DX12_COMPILER` names the compiler.
|
||||
pub(super) fn forced() -> bool {
|
||||
std::env::var_os("WGPU_DX12_COMPILER").is_some()
|
||||
}
|
||||
|
||||
/// `dxcompiler.dll` beside the running executable, if it is there. An absolute
|
||||
/// path, so a copy elsewhere on `PATH` (an older Windows SDK's) is never the
|
||||
/// one loaded.
|
||||
pub(super) fn bundled_dll() -> Option<PathBuf> {
|
||||
let dll = std::env::current_exe().ok()?.parent()?.join(DLL);
|
||||
dll.is_file().then_some(dll)
|
||||
}
|
||||
|
||||
/// The compiler setting that loads `dll`, and fails the instance (rather
|
||||
/// than falling back to FXC on its own) when it doesn't load: `open_backend`
|
||||
/// then keeps the FXC instance it already has.
|
||||
pub(super) fn dxc(dll: &std::path::Path) -> wgpu::Dx12Compiler {
|
||||
wgpu::Dx12Compiler::DynamicDxc {
|
||||
dxc_path: dll.to_string_lossy().into_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the DXC instance offered the adapter the FXC instance had: on a
|
||||
/// laptop with two GPUs, the same request should pick the same one, and the
|
||||
/// game must not switch GPUs because the compiler changed.
|
||||
pub(super) fn same_adapter(fxc: &wgpu::AdapterInfo, dxc: &wgpu::AdapterInfo) -> bool {
|
||||
fxc.backend == dxc.backend
|
||||
&& fxc.vendor == dxc.vendor
|
||||
&& fxc.device == dxc.device
|
||||
&& fxc.device_type == dxc.device_type
|
||||
&& fxc.device_pci_bus_id == dxc.device_pci_bus_id
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn adapter(backend: wgpu::Backend, device_type: wgpu::DeviceType) -> wgpu::AdapterInfo {
|
||||
wgpu::AdapterInfo {
|
||||
name: "Intel(R) Iris(R) Xe Graphics".into(),
|
||||
vendor: 0x8086,
|
||||
device: 0x9a49,
|
||||
device_type,
|
||||
device_pci_bus_id: "0000:00:02.0".into(),
|
||||
driver: String::new(),
|
||||
driver_info: String::new(),
|
||||
backend,
|
||||
subgroup_min_size: 0,
|
||||
subgroup_max_size: 0,
|
||||
transient_saves_memory: None,
|
||||
limit_bucket: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_integrated_gpus_on_directx_12_get_dxc() {
|
||||
use wgpu::{Backend, DeviceType};
|
||||
assert!(wants_dxc(
|
||||
&adapter(Backend::Dx12, DeviceType::IntegratedGpu),
|
||||
false
|
||||
));
|
||||
for kind in [
|
||||
DeviceType::DiscreteGpu,
|
||||
DeviceType::VirtualGpu,
|
||||
DeviceType::Cpu,
|
||||
DeviceType::Other,
|
||||
] {
|
||||
assert!(!wants_dxc(&adapter(Backend::Dx12, kind), false), "{kind:?}");
|
||||
}
|
||||
// Other backends have no shader compiler to choose.
|
||||
for backend in [Backend::Vulkan, Backend::Gl, Backend::Metal] {
|
||||
assert!(
|
||||
!wants_dxc(&adapter(backend, DeviceType::IntegratedGpu), false),
|
||||
"{backend:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wgpu_dx12_compiler_overrides_the_choice() {
|
||||
let igpu = adapter(wgpu::Backend::Dx12, wgpu::DeviceType::IntegratedGpu);
|
||||
assert!(!wants_dxc(&igpu, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_dxc_instance_must_offer_the_same_gpu() {
|
||||
let igpu = adapter(wgpu::Backend::Dx12, wgpu::DeviceType::IntegratedGpu);
|
||||
assert!(same_adapter(&igpu, &igpu.clone()));
|
||||
let dgpu = wgpu::AdapterInfo {
|
||||
name: "NVIDIA GeForce RTX 3050 Ti Laptop GPU".into(),
|
||||
vendor: 0x10de,
|
||||
device: 0x25a0,
|
||||
device_type: wgpu::DeviceType::DiscreteGpu,
|
||||
device_pci_bus_id: "0000:01:00.0".into(),
|
||||
..igpu.clone()
|
||||
};
|
||||
assert!(!same_adapter(&igpu, &dgpu));
|
||||
}
|
||||
}
|
||||
@@ -174,11 +174,22 @@ pub fn wanted(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
|
||||
|
||||
/// Whether frames without a shader pack's terrain draw flat: where the backend
|
||||
/// emulates multi-draw (Metal, past `MIN_SECTIONS`), on Intel Gen11/Gen12's Vulkan
|
||||
/// driver, or with `HORIZON_FLAT_DRAWS=1`. Elsewhere per-section draws stay.
|
||||
/// driver, on Qualcomm's Adreno under DirectX 12, or with `HORIZON_FLAT_DRAWS=1`.
|
||||
/// Elsewhere per-section draws stay.
|
||||
pub fn for_vanilla(info: &wgpu::AdapterInfo) -> bool {
|
||||
std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1")
|
||||
|| info.backend == wgpu::Backend::Metal
|
||||
|| is_broken_intel_vulkan(info)
|
||||
|| is_adreno_dx12(info)
|
||||
}
|
||||
|
||||
/// Qualcomm's Adreno under DirectX 12 (Windows on Snapdragon, where Auto picks DirectX 12),
|
||||
/// where wgpu's indirect multi-draw is `ExecuteIndirect`: drawn flat, an X1-45 took 3-11%
|
||||
/// less GPU time a frame at render distance 12 and 3-13% less at 32 (perf_bench's four
|
||||
/// scenes, held still), from 389 sections in view up, the same picture. DXGI names
|
||||
/// Qualcomm `QCOM` (0x4D4F4351); Vulkan, 0x5143.
|
||||
pub fn is_adreno_dx12(info: &wgpu::AdapterInfo) -> bool {
|
||||
info.backend == wgpu::Backend::Dx12 && matches!(info.vendor, 0x4d4f_4351 | 0x5143)
|
||||
}
|
||||
|
||||
/// Intel Gen11 (Ice Lake) and Gen12 (Tiger Lake, Rocket Lake, Alder Lake,
|
||||
@@ -1044,6 +1055,30 @@ impl Flat {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn adreno_draws_flat_under_dx12_only() {
|
||||
let mut info = wgpu::AdapterInfo {
|
||||
name: "Qualcomm(R) Adreno(TM) X1-45 GPU".into(),
|
||||
vendor: 0x4d4f_4351,
|
||||
device: 0,
|
||||
device_type: wgpu::DeviceType::IntegratedGpu,
|
||||
device_pci_bus_id: String::new(),
|
||||
driver: "31.0.128.0".into(),
|
||||
driver_info: String::new(),
|
||||
backend: wgpu::Backend::Dx12,
|
||||
subgroup_min_size: 0,
|
||||
subgroup_max_size: 0,
|
||||
transient_saves_memory: None,
|
||||
limit_bucket: None,
|
||||
};
|
||||
assert!(is_adreno_dx12(&info));
|
||||
info.backend = wgpu::Backend::Vulkan;
|
||||
assert!(!is_adreno_dx12(&info));
|
||||
info.backend = wgpu::Backend::Dx12;
|
||||
info.vendor = 0x10de;
|
||||
assert!(!is_adreno_dx12(&info));
|
||||
}
|
||||
|
||||
/// Draws as `render` writes them: solid (page, quads at a base vertex)
|
||||
/// first, then translucent (page, sorted page, count, first sorted index,
|
||||
/// base vertex).
|
||||
|
||||
@@ -2735,8 +2735,9 @@ mod tests {
|
||||
|
||||
/// `test` on each device here that draws counts with first instances:
|
||||
/// Vulkan, and DirectX 12 with each of its compilers. Players' DirectX 12
|
||||
/// compiles with FXC, part of Windows: wgpu takes DXC only where
|
||||
/// `dxcompiler.dll` loads, and the game ships none.
|
||||
/// compiles with FXC, part of Windows, and with DXC (`dxcompiler.dll`,
|
||||
/// beside the game in the Windows x64 zip) on integrated GPUs
|
||||
/// (`gfx/dx12_compiler.rs`).
|
||||
fn on_each_device(test: impl Fn(&wgpu::Device, &Queue)) {
|
||||
let mut tested = false;
|
||||
for (backends, compiler, name) in [
|
||||
|
||||
@@ -72,32 +72,28 @@ 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 `resolve` (the
|
||||
/// world and the hand marked it) or `tonemap` found it.
|
||||
/// The world as `keep_world` kept it; also the tone curve's input scratch on a layered frame.
|
||||
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` (and `tonemap`), `keep_world` and
|
||||
/// `composite`.
|
||||
/// The layered frame's bindings for `tonemap`, `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 default world draws write light directly; marked fallback frames
|
||||
/// turn their marks into light after the world and the hand (`resolve`); the tone curve eases it into the peak after the post effects (`tonemap`); the
|
||||
/// "The HUD and menus"): the default world draws write light directly; the tone curve eases
|
||||
/// it into the peak after the post effects (`tonemap`); 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),
|
||||
/// Reads what `resolve` reads.
|
||||
tonemap: wgpu::RenderPipeline,
|
||||
tonemap: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
|
||||
keep: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
|
||||
composite: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
|
||||
/// What `resolve` and `composite` read, written before they run (`begin`).
|
||||
/// What `tonemap` and `composite` read, written before they run (`begin`).
|
||||
params: wgpu::Buffer,
|
||||
}
|
||||
|
||||
@@ -337,10 +333,8 @@ impl Hdr {
|
||||
let pipeline = pass(label, &layout, entry, format);
|
||||
(layout, pipeline)
|
||||
};
|
||||
let resolve = with("Horizon HDR marks to light", &[1], true, "fs_resolve");
|
||||
Layered {
|
||||
tonemap: pass("Horizon HDR tone curve", &resolve.0, "fs_tonemap", format),
|
||||
resolve,
|
||||
tonemap: with("Horizon HDR tone curve", &[1], true, "fs_tonemap"),
|
||||
keep: with("Horizon HDR HUD backdrop", &[0], false, "fs_keep"),
|
||||
composite: with("Horizon HDR composite", &[0, 4, 5], true, "fs_composite"),
|
||||
params: params(),
|
||||
@@ -492,7 +486,7 @@ impl Hdr {
|
||||
})
|
||||
};
|
||||
[
|
||||
group(&l.resolve.0, &[entry(1, &world), params.clone()]),
|
||||
group(&l.tonemap.0, &[entry(1, &world), params.clone()]),
|
||||
group(&l.keep.0, &[entry(0, &frame)]),
|
||||
group(
|
||||
&l.composite.0,
|
||||
@@ -529,7 +523,7 @@ impl Hdr {
|
||||
self.targets.as_ref()?.hud.as_ref()
|
||||
}
|
||||
|
||||
/// On the layered frame, before `resolve` and `composite`: what they read, for a frame that
|
||||
/// On the layered frame, before `tonemap` 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 {
|
||||
@@ -541,34 +535,12 @@ impl Hdr {
|
||||
}
|
||||
}
|
||||
|
||||
/// On the layered frame, after the world and the hand drew and marked it: the marks
|
||||
/// become light in the frame (`fs_resolve`).
|
||||
pub fn resolve(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
self.redraw(encoder, |l| &l.resolve.1, "HDR marks to light", timing);
|
||||
}
|
||||
|
||||
/// On the layered frame, after the post effects and before the camera overlays: the tone
|
||||
/// curve eases the light into the peak (`fs_tonemap`).
|
||||
pub fn tonemap(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
self.redraw(encoder, |l| &l.tonemap, "HDR tone curve", timing);
|
||||
}
|
||||
|
||||
/// The layered frame drawn again by `pipeline`, which reads it from a copy in the world
|
||||
/// target.
|
||||
fn redraw(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
pipeline: impl Fn(&Layered) -> &wgpu::RenderPipeline,
|
||||
label: &'static str,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
let (Some(l), Some(t)) = (&self.layered, &self.targets) else {
|
||||
return;
|
||||
@@ -582,7 +554,14 @@ impl Hdr {
|
||||
t.frame.size(),
|
||||
);
|
||||
let view = t.frame.create_view(&Default::default());
|
||||
Self::draw(encoder, pipeline(l), group, &view, label, timing);
|
||||
Self::draw(
|
||||
encoder,
|
||||
&l.tonemap.1,
|
||||
group,
|
||||
&view,
|
||||
"HDR tone curve",
|
||||
timing,
|
||||
);
|
||||
}
|
||||
|
||||
/// On the layered frame: the picture so far (`fs_composite`), as the frame stores light,
|
||||
@@ -981,6 +960,10 @@ var<private> params: ReferenceParams;
|
||||
let actual = hdr_mark_light(color, mark, shows, eased,
|
||||
vec4(params.peak, params.scene, params.world, params.debug), params.share);
|
||||
errors[i] = select(0u, 1u, any(abs(expected.light - actual.light) > vec3(1e-6)) || expected.lifted != actual.lifted);
|
||||
// Gain stays above the tone knee until the separate tone curve runs.
|
||||
let gain = 1.0 + 3.0 * (1.0 - 194.0 / 255.0) / 0.24;
|
||||
let white = hdr_written(vec3(1.0), 0.76, vec4(5.0, 4.0, 1.0, 0.0), params.share);
|
||||
errors[i] |= select(0u, 4u, any(abs(pow(white, vec3(2.2)) - vec3(gain)) > vec3(1e-5)));
|
||||
let boundary = (f32(i % 255u) + 0.5) / 255.0;
|
||||
for (var step = -1; step <= 1; step++) {
|
||||
let stored = unpack2x16float(pack2x16float(vec2(boundary + f32(step) / 65536.0))).x;
|
||||
@@ -1133,7 +1116,14 @@ var<private> params: ReferenceParams;
|
||||
pub(super) 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()
|
||||
eprintln!("HDR GPU {:?}", adapter.get_info());
|
||||
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
required_features: adapter.features()
|
||||
& (wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
|
||||
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES),
|
||||
..Default::default()
|
||||
}))
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Clears all of `texture` to `color`.
|
||||
@@ -1193,37 +1183,147 @@ var<private> params: ReferenceParams;
|
||||
std::array::from_fn(|i| half::f16::from_le_bytes([bytes[2 * i], bytes[2 * i + 1]]).to_f32())
|
||||
}
|
||||
|
||||
// Independent pre-refactor light arithmetic applied to a compatibility attachment.
|
||||
// Float targets in these tests only receive the resulting colour and coverage.
|
||||
pub(super) fn legacy_light(
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
hdr: &Hdr,
|
||||
source: &wgpu::Texture,
|
||||
target: &wgpu::Texture,
|
||||
) {
|
||||
use wgpu::util::DeviceExt;
|
||||
assert_eq!(source.format(), F::Rgba16Unorm);
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("legacy non-float light oracle"),
|
||||
source: wgpu::ShaderSource::Wgsl(
|
||||
(r#"
|
||||
struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: vec4<f32> }
|
||||
@group(0) @binding(0) var<uniform> params: ReferenceParams;
|
||||
@group(0) @binding(1) var source: texture_2d<f32>;
|
||||
"#
|
||||
.to_owned()
|
||||
+ include_str!("hdr_light_reference.txt")
|
||||
+ r#"
|
||||
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
|
||||
return vec4(vec2(f32((i << 1u) & 2u), f32(i & 2u)) * 2.0 - 1.0, 0.0, 1.0);
|
||||
}
|
||||
@fragment fn fs() -> @location(0) vec4<f32> {
|
||||
let c = textureLoad(source, vec2(0), 0);
|
||||
let mark = round(saturate(c.a) * 255.0) / 255.0;
|
||||
let light = reference_lit(c.rgb, mark, params.world > 0.5 && params.peak > 1.0, false).light;
|
||||
return vec4(pow(light, vec3(1.0 / 2.2)), 1.0);
|
||||
}
|
||||
"#)
|
||||
.into(),
|
||||
),
|
||||
});
|
||||
let pipeline = device.create_render_pipeline(&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: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(F::Rgba16Float.into())],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let params = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: bytemuck::cast_slice(&[settings, shares]),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let view = source.create_view(&Default::default());
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &pipeline.get_bind_group_layout(0),
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: params.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(&view),
|
||||
},
|
||||
],
|
||||
});
|
||||
Hdr::draw(
|
||||
encoder,
|
||||
&pipeline,
|
||||
&group,
|
||||
&target.create_view(&Default::default()),
|
||||
"legacy oracle",
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_layered_frame_shows_the_world_as_the_last_pass_lifted_it() {
|
||||
fn direct_light_matches_the_legacy_non_float_display_below_the_tone_knee() {
|
||||
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, then
|
||||
// pass the tone curve.
|
||||
if !device.features().contains(
|
||||
wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
|
||||
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES,
|
||||
) {
|
||||
eprintln!("legacy display GPU skipped: 16-bit UNORM unavailable");
|
||||
return;
|
||||
}
|
||||
// Compare the compatibility display lift with direct colour under the layered HUD.
|
||||
// The frozen oracle supplies the writer's light from a non-float attachment.
|
||||
let shown = |layered: bool, world: [f64; 4]| {
|
||||
let mut hdr =
|
||||
Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, layered, None);
|
||||
let mut hdr = Hdr::with_debug(
|
||||
&device,
|
||||
&queue,
|
||||
if layered {
|
||||
F::Rgba16Float
|
||||
} else {
|
||||
F::Rgba16Unorm
|
||||
},
|
||||
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 {
|
||||
let mut legacy =
|
||||
Hdr::with_debug(&device, &queue, F::Rgba16Unorm, Output::Pq, false, None);
|
||||
let marked = legacy.frame(&device, [1, 1]);
|
||||
fill(&mut encoder, &marked, world);
|
||||
if world[3] == 1.0 {
|
||||
fill(&mut encoder, &frame, world);
|
||||
} else {
|
||||
legacy_light(&device, &mut encoder, &hdr, &marked, &frame);
|
||||
}
|
||||
hdr.begin(&queue, true);
|
||||
hdr.resolve(&mut encoder, None);
|
||||
hdr.tonemap(&mut encoder, None);
|
||||
hdr.keep_world(&mut encoder, None);
|
||||
super::super::hud_coverage::clear(&mut encoder, &hdr.coverage_view().unwrap());
|
||||
} else {
|
||||
fill(&mut encoder, &frame, world);
|
||||
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: all up to
|
||||
// Unmarked, HDR lighting and light sources of each tier: all up to
|
||||
// the tone curve's knee, which the curve leaves as they are.
|
||||
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],
|
||||
@@ -1240,6 +1340,10 @@ var<private> params: ReferenceParams;
|
||||
assert!(layered[0] > shown(true, [world[0], world[1], world[2], 1.0])[0]);
|
||||
}
|
||||
}
|
||||
// Above-white unmarked colour belongs to float storage, not the UNORM oracle.
|
||||
let above = shown(true, [1.5, 1.2, 0.6, 1.0]);
|
||||
let white = shown(true, [1.0, 1.0, 0.6, 1.0]);
|
||||
assert!(above[0] > white[0] && above[1] > white[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1306,13 +1410,6 @@ var<private> params: ReferenceParams;
|
||||
hdr.peak = 150.0;
|
||||
let [r, g, b] = eased(&mut hdr, [1.5, 0.5, 0.2]);
|
||||
assert!(r == 1.0 && near(g, 0.5) && near(b, 0.2), "{r} {g} {b}");
|
||||
// HDR lighting's gain stays past the knee until the curve: the marks become light
|
||||
// without easing.
|
||||
hdr.peak = 1000.0;
|
||||
let resolve = |hdr: &Hdr, encoder: &mut wgpu::CommandEncoder| hdr.resolve(encoder, None);
|
||||
let [r, ..] = after(&mut hdr, [1.0, 1.0, 1.0, 0.76], &resolve);
|
||||
let gain = 1.0 + 3.0 * (1.0 - 194.0 / 255.0) / 0.24;
|
||||
assert!(near(r.powf(2.2), gain), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::{
|
||||
Hdr, Output,
|
||||
tests::{gpu, texel},
|
||||
tests::{gpu, legacy_light, texel},
|
||||
};
|
||||
use wgpu::TextureFormat as F;
|
||||
use wgpu::util::DeviceExt;
|
||||
@@ -46,6 +46,29 @@ override TEST_OVERLAY: u32 = 0u;
|
||||
}
|
||||
"#;
|
||||
|
||||
const TERRAIN_EXTRA_VERTICES: &str = r#"
|
||||
@vertex fn test_model(@builtin(vertex_index) i: u32) -> NativeModelOut {
|
||||
let p = vec2(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
var o: NativeModelOut;
|
||||
o.clip = vec4(p * 2.0 - 1.0, 0.0, 1.0);
|
||||
o.uv = vec2(0.5);
|
||||
o.color = vec4(0.8, 0.9, 0.7, 1.0);
|
||||
return o;
|
||||
}
|
||||
@vertex fn test_fluid(@builtin(vertex_index) i: u32) -> NativeFluidOut {
|
||||
let p = vec2(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
var o: NativeFluidOut;
|
||||
o.clip = vec4(p * 2.0 - 1.0, 0.0, 1.0);
|
||||
o.uv = vec2(0.5);
|
||||
o.color = vec4(0.8, 0.9, 0.7, 1.0);
|
||||
return o;
|
||||
}
|
||||
@vertex fn test_sunrise(@builtin(vertex_index) i: u32) -> SunriseOut {
|
||||
let p = vec2(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
return SunriseOut(vec4(p * 2.0 - 1.0, 0.0, 1.0), 0.5);
|
||||
}
|
||||
"#;
|
||||
|
||||
struct Draw<'a> {
|
||||
device: &'a wgpu::Device,
|
||||
queue: &'a wgpu::Queue,
|
||||
@@ -54,6 +77,9 @@ struct Draw<'a> {
|
||||
layout: wgpu::PipelineLayout,
|
||||
bindings: wgpu::BindGroupLayout,
|
||||
group: wgpu::BindGroup,
|
||||
raw: bool,
|
||||
hdr_lighting: bool,
|
||||
material_frame: Option<super::super::native_post::NativeFrame>,
|
||||
}
|
||||
impl<'a> Draw<'a> {
|
||||
fn render_oit(
|
||||
@@ -316,7 +342,7 @@ impl<'a> Draw<'a> {
|
||||
alpha: u8,
|
||||
) -> Self {
|
||||
let source = if terrain {
|
||||
super::super::terrain_shader() + TERRAIN_VERTEX
|
||||
super::super::terrain_shader() + TERRAIN_VERTEX + TERRAIN_EXTRA_VERTICES
|
||||
} else {
|
||||
super::super::entity_shader() + ENTITY_VERTEX
|
||||
};
|
||||
@@ -432,8 +458,43 @@ impl<'a> Draw<'a> {
|
||||
layout,
|
||||
bindings,
|
||||
group,
|
||||
raw: false,
|
||||
hdr_lighting: true,
|
||||
material_frame: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn material(&mut self, api: horizon_shader_api::Api) {
|
||||
let source = super::super::native_material::coverage_test_shader(api);
|
||||
self.shader = self
|
||||
.device
|
||||
.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("actual assembled material wrappers"),
|
||||
source: wgpu::ShaderSource::Wgsl(source.into()),
|
||||
});
|
||||
let frame = super::super::native_post::NativeFrame::new(
|
||||
self.device,
|
||||
None,
|
||||
Vec::new(),
|
||||
Default::default(),
|
||||
None,
|
||||
api,
|
||||
);
|
||||
self.queue.write_buffer(
|
||||
&frame.buffer,
|
||||
0,
|
||||
bytemuck::bytes_of(&horizon_shader_api::Frame::default()),
|
||||
);
|
||||
self.layout = self
|
||||
.device
|
||||
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: None,
|
||||
bind_group_layouts: &[Some(&self.bindings), None, None, Some(&frame.layout)],
|
||||
..Default::default()
|
||||
});
|
||||
self.material_frame = Some(frame);
|
||||
self.raw = true;
|
||||
}
|
||||
fn render(
|
||||
&self,
|
||||
hdr: &mut Hdr,
|
||||
@@ -443,11 +504,50 @@ impl<'a> Draw<'a> {
|
||||
overlay: u32,
|
||||
background: [f64; 4],
|
||||
) -> [f32; 4] {
|
||||
let constants = [
|
||||
("HDR_WRITE_LIGHT", f64::from(u8::from(written))),
|
||||
let mut constants = vec![
|
||||
(
|
||||
if self.material_frame.is_some() {
|
||||
"horizon_terrain_HDR_WRITE_LIGHT"
|
||||
} else {
|
||||
"HDR_WRITE_LIGHT"
|
||||
},
|
||||
f64::from(u8::from(written)),
|
||||
),
|
||||
("TEST_TIER", f64::from(tier)),
|
||||
("TEST_OVERLAY", f64::from(overlay)),
|
||||
];
|
||||
if self.terrain {
|
||||
constants.push((
|
||||
if self.material_frame.is_some() {
|
||||
"horizon_terrain_HDR_LIGHTING"
|
||||
} else {
|
||||
"HDR_LIGHTING"
|
||||
},
|
||||
f64::from(u8::from(self.hdr_lighting)),
|
||||
));
|
||||
}
|
||||
if self.material_frame.is_some() {
|
||||
constants.push((
|
||||
"horizon_displace_distance",
|
||||
if entry == "horizon_material_fs_displaced" {
|
||||
10.0
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
));
|
||||
}
|
||||
let vertex = match entry {
|
||||
"fs_sky" | "fs_sky_base" => "vs_sky",
|
||||
"fs_sunrise" => "test_sunrise",
|
||||
"fs_native_model" => "test_model",
|
||||
"fs_native_fluid" => "test_fluid",
|
||||
_ => "test_vertex",
|
||||
};
|
||||
let format = if !written && !self.raw {
|
||||
F::Rgba16Unorm
|
||||
} else {
|
||||
F::Rgba16Float
|
||||
};
|
||||
let pipeline = |entry, mark| {
|
||||
self.device
|
||||
.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
@@ -455,7 +555,7 @@ impl<'a> Draw<'a> {
|
||||
layout: Some(&self.layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &self.shader,
|
||||
entry_point: Some("test_vertex"),
|
||||
entry_point: Some(vertex),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &constants,
|
||||
..Default::default()
|
||||
@@ -473,12 +573,14 @@ impl<'a> Draw<'a> {
|
||||
..Default::default()
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: F::Rgba16Float,
|
||||
blend: Some(if mark || (self.terrain && entry != "fs_translucent") {
|
||||
wgpu::BlendState::REPLACE
|
||||
} else {
|
||||
wgpu::BlendState::ALPHA_BLENDING
|
||||
}),
|
||||
format,
|
||||
blend: Some(
|
||||
if self.raw || mark || (self.terrain && entry != "fs_translucent") {
|
||||
wgpu::BlendState::REPLACE
|
||||
} else {
|
||||
wgpu::BlendState::ALPHA_BLENDING
|
||||
},
|
||||
),
|
||||
write_mask: if mark {
|
||||
wgpu::ColorWrites::ALPHA
|
||||
} else {
|
||||
@@ -491,7 +593,7 @@ impl<'a> Draw<'a> {
|
||||
})
|
||||
};
|
||||
let color = pipeline(entry, false);
|
||||
let mark = (!self.terrain && !written && entry == "fs_entity").then(|| {
|
||||
let mark = (!self.raw && !self.terrain && !written && entry == "fs_entity").then(|| {
|
||||
pipeline(
|
||||
if overlay == 4 {
|
||||
"fs_hdr_cloud_mark"
|
||||
@@ -501,7 +603,22 @@ impl<'a> Draw<'a> {
|
||||
true,
|
||||
)
|
||||
});
|
||||
let frame = hdr.frame(self.device, [1, 1]);
|
||||
let frame = self.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,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = frame.create_view(&Default::default());
|
||||
let mut encoder = self.device.create_command_encoder(&Default::default());
|
||||
{
|
||||
@@ -519,6 +636,9 @@ impl<'a> Draw<'a> {
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
if let Some(frame) = &self.material_frame {
|
||||
pass.set_bind_group(3, &frame.group, &[]);
|
||||
}
|
||||
pass.set_pipeline(&color);
|
||||
pass.draw(0..3, 0..1);
|
||||
if let Some(mark) = &mark {
|
||||
@@ -526,20 +646,266 @@ impl<'a> Draw<'a> {
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
}
|
||||
if !written {
|
||||
hdr.begin(self.queue, true);
|
||||
hdr.resolve(&mut encoder, None);
|
||||
if !written && !self.raw {
|
||||
let light = hdr.frame(self.device, [1, 1]);
|
||||
legacy_light(self.device, &mut encoder, hdr, &frame, &light);
|
||||
return texel(self.device, self.queue, encoder, &light);
|
||||
}
|
||||
texel(self.device, self.queue, encoder, &frame)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_opaque_writers_match_marks_then_resolve() {
|
||||
fn float_plain_fragments_keep_rgb_and_write_only_coverage() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
eprintln!("plain float GPU unavailable");
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let mut cases = 0;
|
||||
for hdr_on in [false, true] {
|
||||
let globals = super::super::Globals {
|
||||
inverse_view: glam::Mat4::IDENTITY.to_cols_array_2d(),
|
||||
fog_color: [0.2, 0.3, 0.4, 1.0],
|
||||
sky_color: [0.7, 0.8, 0.9, 1.0],
|
||||
sunrise: [1.2, 0.7, 0.4, 0.6],
|
||||
fog: [0.0, 100.0, 0.0, 100.0],
|
||||
fog_ends: [100.0, 100.0, 0.0, 0.0],
|
||||
misc: [0.0, 1.0, 0.0, 0.0],
|
||||
filtering: [0.0, 1.0, 0.0, 0.0],
|
||||
hdr_windows: hdr.windows(),
|
||||
hdr_light: [0.8, 0.8, f32::from(u8::from(hdr_on)), 0.0],
|
||||
hdr_settings: settings,
|
||||
hdr_shares: shares,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
for terrain in [true, false] {
|
||||
let mut draw = Draw::new(&device, &queue, terrain, &globals, 180);
|
||||
draw.raw = true;
|
||||
let coverage = 180.0 / 255.0;
|
||||
let entries = if terrain {
|
||||
vec![
|
||||
("fs_opaque", 1.0),
|
||||
("fs_solid", 1.0),
|
||||
("fs_solid_mirror", 1.0),
|
||||
("fs_translucent", coverage),
|
||||
("fs_native_model", 1.0),
|
||||
("fs_native_fluid", coverage),
|
||||
("fs_sky", 1.0),
|
||||
("fs_sky_base", 1.0),
|
||||
("fs_sunrise", 0.3),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
("fs_entity", coverage),
|
||||
("fs_celestial", coverage),
|
||||
("fs_sky_celestial", coverage),
|
||||
]
|
||||
};
|
||||
for plain in [false, true] {
|
||||
draw.hdr_lighting = !plain;
|
||||
for &(entry, alpha) in &entries {
|
||||
for tier in [0, 3, 4] {
|
||||
for overlay in if terrain { vec![0, 1] } else { vec![0, 6] } {
|
||||
let old = draw.render(&mut hdr, false, entry, tier, overlay, [0.0; 4]);
|
||||
let float = draw.render(&mut hdr, true, entry, tier, overlay, [0.0; 4]);
|
||||
assert_eq!(
|
||||
&float[..3],
|
||||
&old[..3],
|
||||
"{hdr_on} {entry} {tier} {overlay} {plain}"
|
||||
);
|
||||
assert!(
|
||||
(float[3] - alpha).abs() < 0.001,
|
||||
"{entry}: {float:?}, expected alpha {alpha}"
|
||||
);
|
||||
assert!(
|
||||
float[..3].iter().any(|&c| c > 0.0),
|
||||
"empty fixture: {entry}"
|
||||
);
|
||||
cases += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!("plain float GPU ran {cases} colour/coverage comparisons");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_stars_keep_the_original_rgb_with_brightness_as_coverage() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
// Frozen f030d9c80 fragment: the original written branch and its marked fallback.
|
||||
let source = include_str!("../hdr_light.wgsl").to_owned()
|
||||
+ include_str!("stars.wgsl")
|
||||
+ r#"
|
||||
@vertex fn fixture(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
|
||||
return vec4(vec2(f32((i << 1u) & 2u), f32(i & 2u)) * 2.0 - 1.0, 0.0, 1.0);
|
||||
}
|
||||
@fragment fn reference() -> @location(0) vec4<f32> {
|
||||
let b = stars.color.a;
|
||||
if HDR_WRITE_LIGHT && stars.hdr_light.w != 0.0 {
|
||||
return vec4(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_shares), b);
|
||||
}
|
||||
return vec4(stars.color.rgb * b, 0.175);
|
||||
}
|
||||
"#;
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("actual stars with original RGB reference"),
|
||||
source: wgpu::ShaderSource::Wgsl(source.into()),
|
||||
});
|
||||
for gain in [0.0, 1.0] {
|
||||
let mut data = [0.0_f32; 32];
|
||||
data[16..20].copy_from_slice(&[0.7, 0.8, 0.9, 0.35]);
|
||||
data[20..24].copy_from_slice(&settings);
|
||||
data[24..28].copy_from_slice(&shares);
|
||||
data[31] = gain;
|
||||
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: bytemuck::cast_slice(&data),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let mut draw = |entry| {
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("fixture"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some(entry),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &[("HDR_WRITE_LIGHT", 1.0)],
|
||||
..Default::default()
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: F::Rgba16Float,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &pipeline.get_bind_group_layout(0),
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: uniform.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
let frame = hdr.frame(&device, [1, 1]);
|
||||
let view = frame.create_view(&Default::default());
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
texel(&device, &queue, encoder, &frame)
|
||||
};
|
||||
let old = draw("reference");
|
||||
let float = draw("fs_stars");
|
||||
assert_eq!(&float[..3], &old[..3]);
|
||||
assert!((float[3] - 0.35).abs() < 0.001, "{float:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_material_wrappers_keep_rgb_and_coverage_including_displacement() {
|
||||
let instance = super::super::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
// The real material layout binds the terrain arrays and the pack's frame together.
|
||||
if adapter.limits().max_sampled_textures_per_shader_stage < 19 {
|
||||
return;
|
||||
}
|
||||
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
required_limits: wgpu::Limits {
|
||||
max_sampled_textures_per_shader_stage: 19,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})) else {
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let globals = super::super::Globals {
|
||||
filtering: [0.0, 1.0, 0.0, 0.0],
|
||||
hdr_windows: hdr.windows(),
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
for api in [horizon_shader_api::Api::V0_1, horizon_shader_api::Api::V0_2] {
|
||||
let mut draw = Draw::new(&device, &queue, true, &globals, 255);
|
||||
draw.material(api);
|
||||
for entry in [
|
||||
"horizon_material_fs_solid",
|
||||
"horizon_material_fs_displaced",
|
||||
"horizon_material_fs_translucent",
|
||||
] {
|
||||
let old = draw.render(&mut hdr, false, entry, 3, 0, [0.0; 4]);
|
||||
let float = draw.render(&mut hdr, true, entry, 3, 0, [0.0; 4]);
|
||||
assert_eq!(&float[..3], &old[..3], "{api} {entry}");
|
||||
let alpha = if entry.ends_with("translucent") {
|
||||
0.7
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
assert!((float[3] - alpha).abs() < 0.001, "{api} {entry}: {float:?}");
|
||||
assert!(
|
||||
float[0] > 1.0,
|
||||
"{api} {entry}: clipping or an empty fixture: {float:?}"
|
||||
);
|
||||
if alpha == 1.0 {
|
||||
assert_ne!(
|
||||
old[3], float[3],
|
||||
"the legacy source mark must distinguish the paths"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!("material GPU ran both API lines through solid, displaced and translucent wrappers");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_opaque_writers_match_the_legacy_non_float_oracle() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
eprintln!("HDR draw GPU unavailable");
|
||||
return;
|
||||
};
|
||||
if !device.features().contains(
|
||||
wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
|
||||
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES,
|
||||
) {
|
||||
eprintln!("opaque oracle GPU skipped: 16-bit UNORM unavailable");
|
||||
return;
|
||||
}
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
(hdr.paper_white, hdr.peak) = (203.0, 1000.0);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
@@ -576,8 +942,8 @@ fn actual_opaque_writers_match_marks_then_resolve() {
|
||||
draw.render(&mut hdr, false, entry, tier, overlay, [0.0; 4]);
|
||||
let written =
|
||||
draw.render(&mut hdr, true, entry, tier, overlay, [0.0; 4]);
|
||||
// The marked path stores half floats before conversion; the written
|
||||
// one stores after it. Allow two half-float ULPs in gamma light.
|
||||
// The oracle stores UNORM before conversion; the direct writer
|
||||
// stores half floats after it. Allow two half-float ULPs in gamma light.
|
||||
for i in 0..3 {
|
||||
assert!(
|
||||
(marked[i] - written[i]).abs() <= 0.002 * marked[i].max(1.0),
|
||||
@@ -630,6 +996,10 @@ fn actual_translucent_writers_blend_their_own_light() {
|
||||
let dst = [2.5, 1.8, 0.9, 1.0];
|
||||
let got = draw.render(&mut hdr, true, entry, tier, overlay, dst);
|
||||
let alpha = f32::from(texel_alpha) / 255.0 * tint_alpha;
|
||||
assert!(
|
||||
(src[3] - alpha).abs() < 0.001,
|
||||
"{entry}: {src:?}, coverage {alpha}"
|
||||
);
|
||||
if terrain {
|
||||
// Full block light leaves mark 194/255 whatever the texel's alpha, even when
|
||||
// vertex coverage is halved.
|
||||
@@ -657,6 +1027,13 @@ fn unmarked_entity_users_keep_their_source_gain() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
return;
|
||||
};
|
||||
if !device.features().contains(
|
||||
wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
|
||||
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES,
|
||||
) {
|
||||
eprintln!("legacy oracle GPU skipped: 16-bit UNORM unavailable");
|
||||
return;
|
||||
}
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let globals = super::super::Globals {
|
||||
@@ -694,6 +1071,13 @@ fn additive_texels_below_the_mark_cutoff_keep_their_gain() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
return;
|
||||
};
|
||||
if !device.features().contains(
|
||||
wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
|
||||
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES,
|
||||
) {
|
||||
eprintln!("legacy oracle GPU skipped: 16-bit UNORM unavailable");
|
||||
return;
|
||||
}
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let globals = super::super::Globals {
|
||||
|
||||
@@ -3,17 +3,11 @@
|
||||
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
|
||||
attachments >= 2
|
||||
&& flags.contains(wgpu::DownlevelFlags::INDEPENDENT_BLEND)
|
||||
&& format
|
||||
.allowed_usages
|
||||
@@ -345,37 +339,17 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn coverage_requires_the_no_pack_float_hdr_lane_and_independent_r8_blending() {
|
||||
fn coverage_requires_two_attachments_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(2, flags, format));
|
||||
assert!(!supported(1, flags, format));
|
||||
assert!(!supported(2, wgpu::DownlevelFlags::empty(), format));
|
||||
assert!(!supported(
|
||||
lane,
|
||||
true,
|
||||
2,
|
||||
wgpu::DownlevelFlags::empty(),
|
||||
format
|
||||
));
|
||||
assert!(!supported(
|
||||
lane,
|
||||
true,
|
||||
2,
|
||||
flags,
|
||||
wgpu::TextureFormatFeatures {
|
||||
@@ -384,8 +358,6 @@ mod tests {
|
||||
}
|
||||
));
|
||||
assert!(!supported(
|
||||
lane,
|
||||
true,
|
||||
2,
|
||||
flags,
|
||||
wgpu::TextureFormatFeatures {
|
||||
@@ -393,6 +365,14 @@ mod tests {
|
||||
..format
|
||||
}
|
||||
));
|
||||
assert!(!supported(
|
||||
2,
|
||||
flags,
|
||||
wgpu::TextureFormatFeatures {
|
||||
allowed_usages: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
..format
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -149,6 +149,21 @@ fn composed() -> Vec<(&'static str, Vec<&'static str>, String)> {
|
||||
vec!["terrain.wgsl", "visbuffer.wgsl"],
|
||||
super::terrain_shader() + include_str!("../visbuffer.wgsl"),
|
||||
),
|
||||
// A native mod's world warp (gfx/world_warp.rs): its copies with the plainest warp.
|
||||
(
|
||||
"world warp terrain",
|
||||
vec![
|
||||
"terrain.wgsl",
|
||||
"world_warp.wgsl",
|
||||
"world_warp_fragment.wgsl",
|
||||
],
|
||||
super::world_warp::terrain_shader(super::world_warp::IDENTITY).unwrap(),
|
||||
),
|
||||
(
|
||||
"world warp entities",
|
||||
vec!["entity.wgsl", "static_entity.wgsl", "world_warp.wgsl"],
|
||||
super::world_warp::entity_shader(super::world_warp::IDENTITY).unwrap(),
|
||||
),
|
||||
(
|
||||
"improved transparency world border",
|
||||
vec!["border.wgsl", "oit.wgsl", "oit_border.wgsl"],
|
||||
|
||||
@@ -50,8 +50,10 @@ impl Pending {
|
||||
}
|
||||
}
|
||||
|
||||
/// Small, its meshes boxed: `draw_frame` walks every tile each frame (thousands at long far
|
||||
/// distances), and that walk is bound by the memory it reads.
|
||||
pub(super) struct Tile {
|
||||
pub mesh: SectionGpu,
|
||||
pub mesh: Box<SectionGpu>,
|
||||
bounds: Bounds,
|
||||
/// When it began to fade in; none while the rest of its job uploads.
|
||||
shown: Option<Instant>,
|
||||
@@ -62,7 +64,7 @@ pub(super) struct Tile {
|
||||
/// Its mesh before near chunks took some of its columns, drawn in its
|
||||
/// place until they have faded in (from the job's end, the instant), so
|
||||
/// no hole shows through them.
|
||||
previous: Option<(SectionGpu, Option<Instant>)>,
|
||||
previous: Option<Box<(SectionGpu, Option<Instant>)>>,
|
||||
/// Its slot in occlusion culling's results (`cull::TILE`), kept while
|
||||
/// meshes of the same tile replace one another.
|
||||
pub cull_slot: u32,
|
||||
@@ -155,10 +157,12 @@ pub(super) struct Visible {
|
||||
impl Tile {
|
||||
/// The mesh drawn this frame: the previous one while it stands in.
|
||||
pub fn drawn(&self) -> &SectionGpu {
|
||||
self.previous.as_ref().map_or(&self.mesh, |(mesh, _)| mesh)
|
||||
self.previous
|
||||
.as_deref()
|
||||
.map_or(&self.mesh, |(mesh, _)| mesh)
|
||||
}
|
||||
pub fn drawn_mut(&mut self) -> &mut SectionGpu {
|
||||
match &mut self.previous {
|
||||
match self.previous.as_deref_mut() {
|
||||
Some((mesh, _)) => mesh,
|
||||
None => &mut self.mesh,
|
||||
}
|
||||
@@ -177,7 +181,7 @@ impl Tile {
|
||||
}
|
||||
/// Both meshes it holds in the arena: its own and the stand-in, if any.
|
||||
pub fn meshes_mut(&mut self) -> impl Iterator<Item = &mut SectionGpu> {
|
||||
std::iter::once(&mut self.mesh).chain(self.previous.as_mut().map(|(mesh, _)| mesh))
|
||||
std::iter::once(&mut *self.mesh).chain(self.previous.as_deref_mut().map(|(mesh, _)| mesh))
|
||||
}
|
||||
pub fn visible(
|
||||
&self,
|
||||
@@ -214,12 +218,12 @@ impl Renderer {
|
||||
let (shown, previous) = match self.lods.remove(&key) {
|
||||
Some(old) if old.dying.is_none() => match old.previous {
|
||||
Some(previous) => {
|
||||
self.release_mesh(old.mesh);
|
||||
self.release_mesh(*old.mesh);
|
||||
(old.shown, Some(previous))
|
||||
}
|
||||
None if keep => (old.shown, Some((old.mesh, None))),
|
||||
None if keep => (old.shown, Some(Box::new((*old.mesh, None)))),
|
||||
None => {
|
||||
self.release_mesh(old.mesh);
|
||||
self.release_mesh(*old.mesh);
|
||||
(old.shown, None)
|
||||
}
|
||||
},
|
||||
@@ -240,7 +244,7 @@ impl Renderer {
|
||||
self.lods.insert(
|
||||
key,
|
||||
Tile {
|
||||
mesh,
|
||||
mesh: Box::new(mesh),
|
||||
bounds,
|
||||
shown,
|
||||
dying: None,
|
||||
@@ -251,7 +255,7 @@ impl Renderer {
|
||||
);
|
||||
}
|
||||
None => {
|
||||
if let Some((previous, _)) = previous {
|
||||
if let Some((previous, _)) = previous.map(|p| *p) {
|
||||
self.release_mesh(previous);
|
||||
}
|
||||
self.lod_cull_free.extend(old_slot);
|
||||
@@ -260,8 +264,8 @@ impl Renderer {
|
||||
}
|
||||
|
||||
fn release_tile(&mut self, tile: Tile) {
|
||||
self.release_mesh(tile.mesh);
|
||||
if let Some((previous, _)) = tile.previous {
|
||||
self.release_mesh(*tile.mesh);
|
||||
if let Some((previous, _)) = tile.previous.map(|p| *p) {
|
||||
self.release_mesh(previous);
|
||||
}
|
||||
}
|
||||
@@ -337,8 +341,10 @@ impl Renderer {
|
||||
let stood_in: Vec<_> = self
|
||||
.lods
|
||||
.values_mut()
|
||||
.filter_map(|tile| match tile.previous {
|
||||
Some((_, Some(since))) if start - since >= FADE => tile.previous.take(),
|
||||
.filter_map(|tile| match tile.previous.as_deref() {
|
||||
Some((_, Some(since))) if start - *since >= FADE => {
|
||||
tile.previous.take().map(|p| *p)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
@@ -440,7 +446,7 @@ impl Renderer {
|
||||
if tile.shown.is_none() {
|
||||
tile.shown = Some(if over_replaced(key) { start } else { whole });
|
||||
}
|
||||
if let Some((_, since)) = &mut tile.previous {
|
||||
if let Some((_, since)) = tile.previous.as_deref_mut() {
|
||||
since.get_or_insert(start);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +195,7 @@ 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 horizon_terrain_HDR_WRITE_LIGHT { return 1.0; }
|
||||
let code = in.hdr_code & 15u;
|
||||
if code > 0u {
|
||||
let whole = code > 4u;
|
||||
@@ -432,6 +433,10 @@ impl NativeMaterial {
|
||||
];
|
||||
constants.extend(sprite_names.into_iter().zip(shape.sprites));
|
||||
constants.push(("horizon_material_fog", f64::from(u8::from(fog))));
|
||||
constants.push((
|
||||
"horizon_terrain_HDR_WRITE_LIGHT",
|
||||
f64::from(u8::from(shape.format == super::LINEAR_FRAME)),
|
||||
));
|
||||
constants.push((
|
||||
"horizon_displace_distance",
|
||||
displaced.map_or(0.0, |d| f64::from(d.distance)),
|
||||
@@ -580,6 +585,39 @@ impl NativeMaterial {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn coverage_test_shader(api: horizon_shader_api::Api) -> String {
|
||||
use horizon_shader_api::{MANIFEST, Pack, StageKind, check};
|
||||
let manifest = format!(
|
||||
"schema = 1\nid = \"coverage\"\nname = \"Coverage\"\nversion = \"1\"\napi = \"{api}\"\n[material]\nfile = \"material.wgsl\"\n[displacement]\n"
|
||||
);
|
||||
let material = "fn material(input: MaterialInput) -> Surface { return Surface(vec4<f32>(1.4, 0.3, 0.1, 0.7)); }\nfn displacement(input: DisplacementInput) -> f32 { return 0.0; }\n";
|
||||
let files = [(MANIFEST, manifest.as_str()), ("material.wgsl", material)]
|
||||
.map(|(n, text)| (n.to_owned(), text.as_bytes().to_vec()))
|
||||
.into();
|
||||
let report = check(&Pack::from_files(files).unwrap()).unwrap();
|
||||
let stage = report
|
||||
.stages
|
||||
.iter()
|
||||
.find(|s| s.kind == StageKind::Material)
|
||||
.unwrap();
|
||||
shader_source(&stage.source, &[], &[], (false, true), api)
|
||||
+ r#"
|
||||
override TEST_TIER: u32 = 0u;
|
||||
override TEST_OVERLAY: u32 = 0u;
|
||||
@vertex fn test_vertex(@builtin(vertex_index) i: u32) -> HorizonMaterialOut {
|
||||
var o: HorizonMaterialOut;
|
||||
o.clip = vec4(vec2(f32((i << 1u) & 2u), f32(i & 2u)) * 2.0 - 1.0, 0.0, 1.0);
|
||||
o.uv = vec2(0.5);
|
||||
o.tint = vec4(1.0);
|
||||
o.lightmap = vec3(1.0);
|
||||
o.hdr_code = TEST_TIER;
|
||||
o.base = vec3(1.0);
|
||||
return o;
|
||||
}
|
||||
"#
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -7,14 +7,16 @@ use super::Vertex;
|
||||
use super::pack_draw::EntitySpan;
|
||||
use glam::{DVec3, Mat4};
|
||||
use std::sync::{
|
||||
OnceLock,
|
||||
Arc, OnceLock,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
|
||||
mod generated {
|
||||
include!(concat!(env!("OUT_DIR"), "/native_render_generated.rs"));
|
||||
}
|
||||
pub use generated::{HandDrawnEvent, RenderHooks, RenderNeedsEvent, WorldDrawnEvent};
|
||||
pub use generated::{
|
||||
HandDrawnEvent, RenderHooks, RenderNeedsEvent, WorldDrawnEvent, WorldWarpEvent,
|
||||
};
|
||||
pub use wgpu;
|
||||
|
||||
static RENDERERS: OnceLock<Box<[&'static dyn RenderHooks]>> = OnceLock::new();
|
||||
@@ -178,6 +180,38 @@ pub fn render_needs() -> RenderNeeds {
|
||||
needs
|
||||
}
|
||||
|
||||
/// A world warp ([`native_world_warp`]): WGSL that every terrain vertex (far terrain's tiles too)
|
||||
/// and entity vertex goes through, so a mod can bend the drawn world itself: its real geometry,
|
||||
/// light and depth, seen from any side. docs/native-mods.md, "World warps", has the WGSL contract.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub struct WorldWarp {
|
||||
/// WGSL with `fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped`, and optionally,
|
||||
/// after a line `// [fragments]`, `fn world_fragment(f: Fragment) -> Painted` for every
|
||||
/// pixel of the terrain. `None`: no warp.
|
||||
pub source: Option<Arc<str>>,
|
||||
/// Whether this frame draws with the warp. A source given while this is false is still
|
||||
/// built, so a mod can pay for its pipelines before the frame that needs them.
|
||||
pub on: bool,
|
||||
/// What the warp reads as `warp.params`: four `vec4<f32>`.
|
||||
pub params: [f32; 16],
|
||||
}
|
||||
|
||||
/// Asked once a frame, before the world is drawn (not on the title screen's panorama): set
|
||||
/// [`WorldWarp`] to have the frame drawn through your warp. Mods are asked in order and one warp
|
||||
/// draws: the last one set. Keep it cheap: compare and reuse your `Arc<str>`.
|
||||
#[inline(never)]
|
||||
pub fn native_world_warp(warp: &mut WorldWarp) {
|
||||
generated::dispatch_world_warp(RENDERERS.get().map_or(&[], |renderers| renderers), warp);
|
||||
}
|
||||
|
||||
/// This frame's world warp ([`native_world_warp`]).
|
||||
pub fn world_warp() -> WorldWarp {
|
||||
let mut warp = WorldWarp::default();
|
||||
native_world_warp(&mut warp);
|
||||
warp
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -378,6 +412,33 @@ mod tests {
|
||||
assert_eq!(texels, expected);
|
||||
}
|
||||
|
||||
struct Bend(&'static str);
|
||||
impl RenderHooks for Bend {
|
||||
fn world_warp(&self, event: &mut WorldWarpEvent<'_>) {
|
||||
event.warp.source = Some(self.0.into());
|
||||
event.warp.on = true;
|
||||
event.warp.params[0] += 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_last_mod_to_set_a_warp_draws() {
|
||||
let mut warp = WorldWarp::default();
|
||||
generated::dispatch_world_warp(&[&Depth, &Bend("a"), &Bend("b")], &mut warp);
|
||||
assert_eq!(warp.source.as_deref(), Some("b"));
|
||||
assert!(warp.on);
|
||||
// each saw what the one before left
|
||||
assert_eq!(warp.params[0], 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_mods_there_is_no_warp() {
|
||||
let mut warp = WorldWarp::default();
|
||||
generated::dispatch_world_warp(&[], &mut warp);
|
||||
assert_eq!(warp, WorldWarp::default());
|
||||
assert!(warp.source.is_none() && !warp.on);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_accumulate_in_mod_order() {
|
||||
let mut needs = RenderNeeds::default();
|
||||
|
||||
@@ -49,12 +49,12 @@ fn terrain_fade_reaches_pack_pixels_through_real_instances() {
|
||||
std::env::temp_dir().join(format!("horizon-terrain-fade-{}", std::process::id())),
|
||||
);
|
||||
std::fs::create_dir_all(folder.0.join("shaders")).unwrap();
|
||||
// The guard, initializer, varying and integer output are independent probe code.
|
||||
// The initializer, varying and integer output are independent probe code. The program reads
|
||||
// `mc_chunkFade` unguarded: Horizon defines no `IRIS_FEATURE_*` (spec OQ-2).
|
||||
let vertex = "#version 330\nout vec4 observation;\n\
|
||||
#ifdef IRIS_FEATURE_FADE_VARIABLE\nfloat first = mc_chunkFade;\n#endif\n\
|
||||
void main() {\n#ifdef IRIS_FEATURE_FADE_VARIABLE\n\
|
||||
float first = mc_chunkFade;\n\
|
||||
void main() {\n\
|
||||
observation = vec4(mc_chunkFade, first, gl_Color.a, gl_MultiTexCoord0.x);\n\
|
||||
#else\nobservation = vec4(1.0, 1.0, gl_Color.a, gl_MultiTexCoord0.x);\n#endif\n\
|
||||
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);\n}";
|
||||
let fragment = "#version 330\nin vec4 observation;\nlayout(location = 0) out uvec4 pixel;\n\
|
||||
void main() { pixel = floatBitsToUint(observation); }";
|
||||
@@ -159,7 +159,8 @@ fn terrain_fade_reaches_pack_pixels_through_real_instances() {
|
||||
origin,
|
||||
0x80ffffff,
|
||||
);
|
||||
let fade = if requested { expected } else { 1.0 };
|
||||
// The fade reaches the program whether the pack requested it or not.
|
||||
let fade = expected;
|
||||
let expected = [
|
||||
fade,
|
||||
if geometry { 0.8125 } else { fade },
|
||||
|
||||
@@ -2464,6 +2464,12 @@ impl PackPost {
|
||||
let textures = pack_textures::upload(device, queue, post.textures, &mut notes);
|
||||
let shadow_config = post.config.shadow.clone();
|
||||
let noise_size = post.config.noise_resolution.unwrap_or(256);
|
||||
let half_lives = post.config.half_lives;
|
||||
for (name, ticks) in horizon_shaders::HALF_LIVES.iter().zip(half_lives) {
|
||||
if let Some(t) = ticks {
|
||||
notes.push(format!("`{name}` is {t} ticks"));
|
||||
}
|
||||
}
|
||||
// shadowcolor2 to 7 only for a pack that names `HIGHER_SHADOWCOLOR`.
|
||||
let shadow_colors = (1u16 << post.shadow_colors) - 1;
|
||||
let mut custom = post.custom;
|
||||
@@ -3443,6 +3449,7 @@ impl PackPost {
|
||||
let mut u = FrameUniforms::default();
|
||||
u.atlas = world.atlas.map_or([0; 2], |a| a.1);
|
||||
u.shadow = shadow_config;
|
||||
u.half_lives = half_lives;
|
||||
u
|
||||
},
|
||||
shadow_cache: Default::default(),
|
||||
|
||||
@@ -405,6 +405,8 @@ pub struct FrameUniforms {
|
||||
center_depth: Option<f32>,
|
||||
/// The pack's shadow settings (7.1).
|
||||
pub shadow: Shadow,
|
||||
/// The pack's smoothing half-lives in ticks (`horizon_shaders::HALF_LIVES`).
|
||||
pub half_lives: [Option<f32>; 4],
|
||||
}
|
||||
|
||||
impl Default for FrameUniforms {
|
||||
@@ -422,6 +424,7 @@ impl Default for FrameUniforms {
|
||||
center_depth_raw: None,
|
||||
center_depth: None,
|
||||
shadow: Shadow::default(),
|
||||
half_lives: [None; 4],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -734,6 +737,7 @@ impl FrameUniforms {
|
||||
*self = Self {
|
||||
atlas: self.atlas,
|
||||
shadow: std::mem::take(&mut self.shadow),
|
||||
half_lives: self.half_lives,
|
||||
..Self::default()
|
||||
};
|
||||
}
|
||||
@@ -798,10 +802,13 @@ impl FrameUniforms {
|
||||
// a tenth of a second a tick: Iris 1.11.7 smooths that slowly (measured frame by frame
|
||||
// with a probe pack: wetness 60 s rising, 20 s falling; eyeBrightnessSmooth 1 s;
|
||||
// centerDepthSmooth 0.1 s).
|
||||
// The pack's `wetnessHalflife`/`drynessHalflife` when it sets them (OptiFine's defaults
|
||||
// otherwise), as for `eyeBrightnessHalflife` and `centerDepthHalflife` below.
|
||||
let [wet, dry, eye_half, depth_half] = self.half_lives;
|
||||
let half_life = if inputs.rain > self.wetness {
|
||||
600.0
|
||||
wet.unwrap_or(600.0)
|
||||
} else {
|
||||
200.0
|
||||
dry.unwrap_or(200.0)
|
||||
};
|
||||
self.wetness = smooth(self.wetness, inputs.rain, half_life * SECONDS_A_TICK, dt);
|
||||
let eye = inputs.eye_light.map(|l| (l * 16) as f32);
|
||||
@@ -810,13 +817,18 @@ impl FrameUniforms {
|
||||
self.eye_smooth = eye;
|
||||
}
|
||||
for (smoothed, target) in self.eye_smooth.iter_mut().zip(eye) {
|
||||
*smoothed = smooth(*smoothed, target, 10.0 * SECONDS_A_TICK, dt);
|
||||
*smoothed = smooth(
|
||||
*smoothed,
|
||||
target,
|
||||
eye_half.unwrap_or(10.0) * SECONDS_A_TICK,
|
||||
dt,
|
||||
);
|
||||
}
|
||||
// `centerDepthHalflife`'s default, 1 tick (OptiFine's shaders.txt); the first value
|
||||
// read back is taken as it is.
|
||||
if let Some(raw) = self.center_depth_raw {
|
||||
self.center_depth = Some(match self.center_depth {
|
||||
Some(v) => smooth(v, raw, SECONDS_A_TICK, dt),
|
||||
Some(v) => smooth(v, raw, depth_half.unwrap_or(1.0) * SECONDS_A_TICK, dt),
|
||||
None => raw,
|
||||
});
|
||||
}
|
||||
@@ -1260,10 +1272,12 @@ mod tests {
|
||||
format!(r#"{{"attributes": {attributes}}}"#),
|
||||
)
|
||||
.unwrap();
|
||||
let environment =
|
||||
crate::environment::Environment::load_dimension(&root, name, false).unwrap();
|
||||
let environment = crate::environment::Environment::load_dimension(&root, name).unwrap();
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
environment.sample(6000, None, None)
|
||||
environment.sample(&crate::environment::Sample {
|
||||
day_time: 6000,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2218,22 +2232,33 @@ pub(super) mod initial_history_tests {
|
||||
let hud = HudBatch::new([800, 450]);
|
||||
let f = frame(&hud);
|
||||
let mut u = FrameUniforms::default();
|
||||
// The next frame comes `seconds` after the last (and a few microseconds of the test).
|
||||
let after = |u: &mut FrameUniforms, seconds: f32| {
|
||||
u.last = Some(std::time::Instant::now() - std::time::Duration::from_secs_f32(seconds));
|
||||
// A scripted clock: the next frame comes exactly `seconds` after the last, however long
|
||||
// the test itself takes (a wall-clock gap of a millisecond moved centerDepthSmooth by
|
||||
// more than the tolerance on a busy CI runner).
|
||||
let mut counter = 1;
|
||||
let mut elapsed = 0.0;
|
||||
let mut after = |input: &mut PackInputs, seconds: f32| {
|
||||
counter += 1;
|
||||
elapsed += seconds;
|
||||
input.frame_timing = Some(FrameTiming {
|
||||
counter,
|
||||
elapsed,
|
||||
delta: seconds,
|
||||
});
|
||||
};
|
||||
let mut input = PackInputs {
|
||||
eye_light: [0, 15],
|
||||
frame_timing: Some(FrameTiming::nth(1, 0.0)),
|
||||
..Default::default()
|
||||
};
|
||||
u.center_depth_raw = Some(1.0);
|
||||
u.update(&f, &input, [800, 450]);
|
||||
input.eye_light = [0, 0];
|
||||
u.center_depth_raw = Some(0.0);
|
||||
after(&mut u, 0.1);
|
||||
after(&mut input, 0.1);
|
||||
u.update(&f, &input, [800, 450]);
|
||||
assert!((float(&u, "centerDepthSmooth")[0] - 0.5).abs() < 1e-3);
|
||||
after(&mut u, 0.6);
|
||||
after(&mut input, 0.6);
|
||||
u.update(&f, &input, [800, 450]);
|
||||
// 240 falling toward 0 for 0.7 s: 147.8, read as 147.
|
||||
match u.values[index("eyeBrightnessSmooth").unwrap()] {
|
||||
@@ -2241,11 +2266,46 @@ pub(super) mod initial_history_tests {
|
||||
_ => panic!("integer uniform expected"),
|
||||
}
|
||||
input.rain = 1.0;
|
||||
after(&mut u, 60.0);
|
||||
after(&mut input, 60.0);
|
||||
u.update(&f, &input, [800, 450]);
|
||||
assert!((float(&u, "wetness")[0] - 0.5).abs() < 1e-3);
|
||||
input.rain = 0.0;
|
||||
after(&mut u, 20.0);
|
||||
after(&mut input, 20.0);
|
||||
u.update(&f, &input, [800, 450]);
|
||||
assert!((float(&u, "wetness")[0] - 0.25).abs() < 1e-3);
|
||||
}
|
||||
|
||||
/// A pack's `const float wetnessHalflife = 30.0;` (and its dryness) replace the defaults:
|
||||
/// wetness reaches half the rain in 3 s instead of 60 s.
|
||||
#[test]
|
||||
fn a_pack_s_half_lives_replace_the_defaults() {
|
||||
let hud = HudBatch::new([800, 450]);
|
||||
let f = frame(&hud);
|
||||
let mut u = FrameUniforms {
|
||||
half_lives: [Some(30.0), Some(10.0), None, None],
|
||||
..Default::default()
|
||||
};
|
||||
// A scripted clock, as in `smoothed_uniforms_take_iris_half_lives`: the wall clock's gap
|
||||
// between two updates is not exact on a busy runner.
|
||||
let mut input = PackInputs {
|
||||
frame_timing: Some(FrameTiming::nth(1, 0.0)),
|
||||
..Default::default()
|
||||
};
|
||||
u.update(&f, &input, [800, 450]);
|
||||
input.rain = 1.0;
|
||||
input.frame_timing = Some(FrameTiming {
|
||||
counter: 2,
|
||||
elapsed: 3.0,
|
||||
delta: 3.0,
|
||||
});
|
||||
u.update(&f, &input, [800, 450]);
|
||||
assert!((float(&u, "wetness")[0] - 0.5).abs() < 1e-3);
|
||||
input.rain = 0.0;
|
||||
input.frame_timing = Some(FrameTiming {
|
||||
counter: 3,
|
||||
elapsed: 4.0,
|
||||
delta: 1.0,
|
||||
});
|
||||
u.update(&f, &input, [800, 450]);
|
||||
assert!((float(&u, "wetness")[0] - 0.25).abs() < 1e-3);
|
||||
}
|
||||
|
||||
@@ -274,7 +274,13 @@ impl Panorama {
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_panorama"),
|
||||
compilation_options: Default::default(),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &[(
|
||||
"HDR_WRITE_LIGHT",
|
||||
f64::from(u8::from(format == super::LINEAR_FRAME)),
|
||||
)],
|
||||
..Default::default()
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: None,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// CubeMap's position-only cube and panorama.vsh/fsh: sample its untransformed direction.
|
||||
override HDR_WRITE_LIGHT: bool = false;
|
||||
struct Projection { view_proj: mat4x4<f32> }
|
||||
@group(0) @binding(0) var<uniform> projection: Projection;
|
||||
@group(0) @binding(1) var panorama: texture_cube<f32>;
|
||||
@@ -26,5 +27,6 @@ struct Out {
|
||||
return out;
|
||||
}
|
||||
@fragment fn fs_panorama(in: Out) -> @location(0) vec4<f32> {
|
||||
return textureSample(panorama, panorama_sampler, in.direction);
|
||||
let color = textureSample(panorama, panorama_sampler, in.direction);
|
||||
return vec4(color.rgb, select(color.a, 1.0, HDR_WRITE_LIGHT));
|
||||
}
|
||||
@@ -439,17 +439,18 @@ impl<T> Resident<T> {
|
||||
if list.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let min = origin((column.x, column.low, column.z), self.min_y, camera).as_vec3();
|
||||
let top = origin((column.x, column.high, column.z), self.min_y, camera).as_vec3();
|
||||
let max = top + Vec3::splat(16.0);
|
||||
let inside = frustum.contains(min, max);
|
||||
if !inside && !frustum.intersects(min, max) {
|
||||
continue;
|
||||
}
|
||||
let ox = f64::from(column.x * 16) - camera.x;
|
||||
let oz = f64::from(column.z * 16) - camera.z;
|
||||
let min_x = ox as f32;
|
||||
let min_z = oz as f32;
|
||||
let low_oy = (f64::from(self.min_y + column.low * 16) - camera.y) as f32;
|
||||
let high_oy = (f64::from(self.min_y + column.high * 16) - camera.y) as f32;
|
||||
let min = Vec3::new(min_x, low_oy, min_z);
|
||||
let max = Vec3::new(min_x + 16.0, high_oy + 16.0, min_z + 16.0);
|
||||
let inside = frustum.contains(min, max);
|
||||
if !inside && !frustum.intersects(min, max) {
|
||||
continue;
|
||||
}
|
||||
let dx8 = ox + 8.0;
|
||||
let dz8 = oz + 8.0;
|
||||
let xz_sq = dx8 * dx8 + dz8 * dz8;
|
||||
|
||||
@@ -22,5 +22,6 @@ struct Stars {
|
||||
if HDR_WRITE_LIGHT && stars.hdr_light.w != 0.0 {
|
||||
return vec4(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_shares), b);
|
||||
}
|
||||
if HDR_WRITE_LIGHT { return vec4(stars.color.rgb * b, b); }
|
||||
return vec4(stars.color.rgb * b, 0.175);
|
||||
}
|
||||
@@ -79,7 +79,7 @@ pub struct Post {
|
||||
/// The last TAA frame's unjittered `view_proj` and camera, `None` once a frame ran without it.
|
||||
taa_previous: Option<(DMat4, DVec3)>,
|
||||
taa_this_frame: TaaUniform,
|
||||
light_key: Option<([f32; 4], [f32; 4], [[f32; 4]; 2])>,
|
||||
light_key: Option<(bool, [f32; 4], [f32; 4], [[f32; 4]; 2])>,
|
||||
}
|
||||
|
||||
impl Post {
|
||||
@@ -263,12 +263,21 @@ impl Post {
|
||||
}
|
||||
}
|
||||
|
||||
// History stores the baked gain as well as the picture. Reject it before jitter records
|
||||
// the next frame whenever that gain or the frame's representation changes.
|
||||
pub(super) fn light_representation(
|
||||
// History stores baked light, so a change must reject it before jitter records the next frame.
|
||||
pub(super) fn baked_light(
|
||||
&mut self,
|
||||
key: Option<([f32; 4], [f32; 4], [[f32; 4]; 2])>,
|
||||
gain: bool,
|
||||
settings: [f32; 4],
|
||||
shares: [f32; 4],
|
||||
windows: [[f32; 4]; 2],
|
||||
) {
|
||||
// These uniforms do not change baked colour without gain. Keep compatibility
|
||||
// history stable when only its final display settings change.
|
||||
let key = Some(if gain {
|
||||
(true, settings, shares, windows)
|
||||
} else {
|
||||
(false, [0.0; 4], [0.0; 4], [[0.0; 4]; 2])
|
||||
});
|
||||
if self.light_key != key {
|
||||
self.taa_previous = None;
|
||||
self.light_key = key;
|
||||
@@ -745,13 +754,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn written_light_changes_reject_history_before_the_next_jitter() {
|
||||
fn baked_light_changes_and_returning_from_a_pack_reject_taa_history() {
|
||||
let Some((device, _)) = test_device() else {
|
||||
eprintln!("TAA baked-light GPU skipped: no test device");
|
||||
return;
|
||||
};
|
||||
let mut post = Post::new(&device, wgpu::TextureFormat::Rgba16Float);
|
||||
post.taa = true;
|
||||
if !post.supports_taa() {
|
||||
eprintln!("TAA baked-light GPU skipped: TAA unsupported");
|
||||
return;
|
||||
}
|
||||
let key = (
|
||||
@@ -759,11 +770,11 @@ mod tests {
|
||||
[0.2, 0.5, 0.6, 1.0],
|
||||
[[0.1; 4], [0.4; 4]],
|
||||
);
|
||||
for key in [Some(key), None, Some(key)] {
|
||||
post.light_representation(key);
|
||||
for gain in [true, false, true] {
|
||||
post.baked_light(gain, key.0, key.1, key.2);
|
||||
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
|
||||
assert_eq!(post.taa_this_frame.jitter[2], 1.0);
|
||||
post.light_representation(key);
|
||||
post.baked_light(gain, key.0, key.1, key.2);
|
||||
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
|
||||
assert_eq!(post.taa_this_frame.jitter[2], 0.0);
|
||||
}
|
||||
@@ -774,10 +785,26 @@ mod tests {
|
||||
4..=7 => changed.1[field - 4] += 0.01,
|
||||
_ => changed.2[(field - 8) / 4][(field - 8) % 4] += 0.01,
|
||||
}
|
||||
post.light_representation(Some(changed));
|
||||
post.baked_light(true, changed.0, changed.1, changed.2);
|
||||
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
|
||||
assert_eq!(post.taa_this_frame.jitter[2], 1.0);
|
||||
}
|
||||
post.baked_light(false, key.0, key.1, key.2);
|
||||
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
|
||||
assert_eq!(post.taa_this_frame.jitter[2], 1.0);
|
||||
post.baked_light(false, [0.0; 4], [0.0; 4], [[0.0; 4]; 2]);
|
||||
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
|
||||
assert_eq!(post.taa_this_frame.jitter[2], 0.0);
|
||||
// A pack disables TAA even when the light settings stay the same. Its first
|
||||
// world frame after the pack is dropped must not reuse the pre-pack history.
|
||||
post.jitter([32, 32], false, Mat4::IDENTITY, DVec3::ZERO);
|
||||
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
|
||||
assert_eq!(post.taa_this_frame.jitter[2], 1.0);
|
||||
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
|
||||
assert_eq!(post.taa_this_frame.jitter[2], 0.0);
|
||||
eprintln!(
|
||||
"TAA baked-light GPU checked gain, all settings/shares/windows and the first frame after a pack"
|
||||
);
|
||||
}
|
||||
|
||||
fn test_device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
|
||||
@@ -0,0 +1,697 @@
|
||||
//! A native mod's world warp ([`native_world_warp`](super::native_render::native_world_warp),
|
||||
//! docs/native-mods.md "World warps"): WGSL that every terrain vertex, far terrain's tiles
|
||||
//! included, and entity vertex goes through, so a mod can bend the drawn world itself (its real
|
||||
//! blocks, light and depth, seen from any side) rather than a picture of it.
|
||||
//!
|
||||
//! The stock shaders hold only comment markers where a warp goes in (terrain.wgsl's
|
||||
//! `[world warp ...]`, entity.wgsl's and static_entity.wgsl's): a frame without a warp draws
|
||||
//! with exactly the shaders it always did. For a warp, copies of them get the markers filled,
|
||||
//! the declarations of `world_warp.wgsl`, the mod's WGSL and, for the terrain, the pixel stage of
|
||||
//! `world_warp_fragment.wgsl` with the mod's `world_fragment` or [`DEFAULT_FRAGMENT`]; the
|
||||
//! pipelines built from them read the warp's uniform at group 2.
|
||||
//!
|
||||
//! Those pipelines don't cull back faces (a warp may turn a face over), and a frame drawn with
|
||||
//! them leaves nothing out by where it was (`Renderer::render`): what is out of view, hidden or
|
||||
//! facing away may be brought into view. It also keeps to the per-section draws, which these
|
||||
//! pipelines are built for.
|
||||
use super::native_render::WorldWarp;
|
||||
use super::{SolidPipeline, TERRAIN_ATTRIBUTES, TerrainVertex, Vertex};
|
||||
use glam::DVec3;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The line in a warp's source after which the terrain's pixel stage begins.
|
||||
const FRAGMENTS: &str = "// [fragments]";
|
||||
/// The pixel stage of a warp without one: the terrain as the game draws it, less faces it cut.
|
||||
pub const DEFAULT_FRAGMENT: &str = "fn world_fragment(f: Fragment) -> Painted {
|
||||
if f.cut > 0.0 { return Painted(vec4(0.0, 0.0, 0.0, -1.0), 1.0); }
|
||||
return Painted(sprite(f.uv, f.layer) * f.color, 1.0);
|
||||
}
|
||||
";
|
||||
/// What terrain.wgsl's markers are filled with: the warp's two values in `TerrainOut`, and how
|
||||
/// they cross to the pixel stage in `TerrainWire`, after its five locations.
|
||||
const VARYINGS: &str = "warp_home: vec4<f32>,
|
||||
warp_at: vec3<f32>,";
|
||||
const WIRE: &str = "@location(5) warp_home: vec4<f32>,
|
||||
@location(6) warp_at: vec3<f32>,";
|
||||
const TO_WIRE: &str = "w.warp_home = o.warp_home;
|
||||
w.warp_at = o.warp_at;";
|
||||
const FROM_WIRE: &str = "o.warp_home = w.warp_home;
|
||||
o.warp_at = w.warp_at;";
|
||||
const VERTEX: &str = "let warp_home = position + origin;
|
||||
let warped = world_warp(warp_home, origin);
|
||||
let pos = warped.pos;";
|
||||
const VERTEX_OUT: &str = "out.color = out.color * vec4(max(warped.tint, vec3(0.0)), 1.0);
|
||||
out.warp_home = vec4(warp_home, select(0.0, 1.0, warped.tint.r < 0.0));
|
||||
out.warp_at = pos;";
|
||||
/// And entity.wgsl's and static_entity.wgsl's: an entity's vertex is its own `section`; a block
|
||||
/// entity's is its section's corner.
|
||||
const ENTITY: &str = "let at = world_warp(position, position).pos;";
|
||||
const STATIC_ENTITY: &str =
|
||||
"let section_corner = vec3<f32>(block_delta) - static_entity.camera_fraction.xyz;
|
||||
let camera_relative = world_warp(section_corner + position, section_corner).pos;";
|
||||
|
||||
/// The plainest warp, for the tests of the shaders it makes.
|
||||
#[cfg(test)]
|
||||
pub(super) const IDENTITY: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped { return Warped(pos, vec3(1.0)); }\n";
|
||||
|
||||
/// `shader` with what stands between the markers `// [name]` and `// [end name]` replaced.
|
||||
fn fill(shader: &str, name: &str, with: &str) -> Result<String, String> {
|
||||
let (from, to) = (format!("// [{name}]"), format!("// [end {name}]"));
|
||||
let a = shader
|
||||
.find(&from)
|
||||
.ok_or_else(|| format!("no `{from}` in the shader"))?;
|
||||
let b = shader[a..]
|
||||
.find(&to)
|
||||
.ok_or_else(|| format!("no `{to}` in the shader"))?
|
||||
+ a;
|
||||
Ok(format!("{}{with}{}", &shader[..a], &shader[b + to.len()..]))
|
||||
}
|
||||
|
||||
/// A warp's source split into what every warped shader takes and the terrain's pixel stage.
|
||||
fn split(source: &str) -> (&str, Option<&str>) {
|
||||
match source.find(FRAGMENTS) {
|
||||
Some(at) => (&source[..at], Some(&source[at + FRAGMENTS.len()..])),
|
||||
None => (source, None),
|
||||
}
|
||||
}
|
||||
|
||||
/// The warp's copy of terrain.wgsl.
|
||||
pub(super) fn terrain_shader(source: &str) -> Result<String, String> {
|
||||
let (vertex, fragment) = split(source);
|
||||
let fragment = fragment
|
||||
.filter(|f| f.contains("fn world_fragment"))
|
||||
.unwrap_or(DEFAULT_FRAGMENT);
|
||||
let shader = fill(&super::terrain_shader(), "world warp varyings", VARYINGS)?;
|
||||
let shader = fill(&shader, "world warp wire", WIRE)?;
|
||||
let shader = fill(&shader, "world warp to wire", TO_WIRE)?;
|
||||
let shader = fill(&shader, "world warp from wire", FROM_WIRE)?;
|
||||
let shader = fill(&shader, "world warp vertex", VERTEX)?;
|
||||
let shader = fill(&shader, "world warp out", VERTEX_OUT)?;
|
||||
Ok(format!(
|
||||
"{shader}\n{}\n{vertex}\n{}\n{fragment}",
|
||||
include_str!("world_warp.wgsl"),
|
||||
include_str!("world_warp_fragment.wgsl"),
|
||||
))
|
||||
}
|
||||
|
||||
/// The warp's copy of entity.wgsl with static_entity.wgsl, for every entity pipeline it draws.
|
||||
pub(super) fn entity_shader(source: &str) -> Result<String, String> {
|
||||
let (vertex, _) = split(source);
|
||||
let shader = fill(&super::static_entity_shader(), "world warp entity", ENTITY)?;
|
||||
let shader = fill(&shader, "world warp static entity", STATIC_ENTITY)?;
|
||||
Ok(format!(
|
||||
"{shader}\n{}\n{vertex}",
|
||||
include_str!("world_warp.wgsl")
|
||||
))
|
||||
}
|
||||
|
||||
fn validate(source: &str) -> Result<(), String> {
|
||||
let module = naga::front::wgsl::parse_str(source).map_err(|e| e.emit_to_string(source))?;
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.map_err(|e| e.emit_to_string(source))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The warp's uniform (`world_warp.wgsl`'s `WorldWarpUniform`): the mod's sixteen floats, then
|
||||
/// the camera split into f32 and what f32 left out of it, with the frame's partial tick.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct Uniform {
|
||||
params: [[f32; 4]; 4],
|
||||
camera: [f32; 4],
|
||||
camera_rest: [f32; 4],
|
||||
}
|
||||
|
||||
fn uniform(warp: &WorldWarp, camera: DVec3, partial_tick: f32) -> Uniform {
|
||||
let (params, _) = warp.params.as_chunks::<4>();
|
||||
let params: [[f32; 4]; 4] = params.try_into().expect("sixteen floats");
|
||||
let at = camera.as_vec3();
|
||||
let rest = camera - at.as_dvec3();
|
||||
Uniform {
|
||||
params,
|
||||
camera: [at.x, at.y, at.z, partial_tick],
|
||||
camera_rest: [rest.x as f32, rest.y as f32, rest.z as f32, 0.0],
|
||||
}
|
||||
}
|
||||
|
||||
/// What the warp's pipelines are built with: the renderer's own layouts and formats.
|
||||
pub(super) struct Kit {
|
||||
/// terrain.wgsl's group 0, and with flat draws the per-section draws' page group 1
|
||||
/// (`vs_section` reads its vertices from it).
|
||||
pub terrain: wgpu::BindGroupLayout,
|
||||
pub pages: Option<wgpu::BindGroupLayout>,
|
||||
/// entity.wgsl's group 0 and static_entity.wgsl's group 1.
|
||||
pub entity: wgpu::BindGroupLayout,
|
||||
pub static_entity: wgpu::BindGroupLayout,
|
||||
pub format: wgpu::TextureFormat,
|
||||
pub sprite_constants: [(&'static str, f64); 2],
|
||||
/// Whether the display offers HDR output: the solid terrain is built lit by it too.
|
||||
pub hdr_output: bool,
|
||||
}
|
||||
|
||||
/// The warp's pipelines, in place of the renderer's while it is on.
|
||||
pub(super) struct Pipelines {
|
||||
pub solid: SolidPipeline,
|
||||
pub translucent: wgpu::RenderPipeline,
|
||||
pub entities: wgpu::RenderPipeline,
|
||||
pub static_entities: wgpu::RenderPipeline,
|
||||
pub translucent_items: wgpu::RenderPipeline,
|
||||
pub text: wgpu::RenderPipeline,
|
||||
pub entity_shadows: wgpu::RenderPipeline,
|
||||
pub hdr_mark: wgpu::RenderPipeline,
|
||||
pub hdr_entity_mark: wgpu::RenderPipeline,
|
||||
}
|
||||
|
||||
/// The warp's uniform, and what binds it.
|
||||
struct Bound {
|
||||
layout: wgpu::BindGroupLayout,
|
||||
buffer: wgpu::Buffer,
|
||||
group: wgpu::BindGroup,
|
||||
}
|
||||
|
||||
/// The renderer's world warp: nothing made until a mod gives one.
|
||||
pub(super) struct WorldWarps {
|
||||
kit: Kit,
|
||||
bound: Option<Bound>,
|
||||
built: Option<(Arc<str>, Pipelines)>,
|
||||
/// The last source that didn't compile, so it is tried once.
|
||||
refused: Option<Arc<str>>,
|
||||
/// Whether the log said a warp went undrawn under a shader pack drawing the terrain.
|
||||
told_undrawable: bool,
|
||||
}
|
||||
|
||||
impl WorldWarps {
|
||||
pub fn new(kit: Kit) -> Self {
|
||||
Self {
|
||||
kit,
|
||||
bound: None,
|
||||
built: None,
|
||||
refused: None,
|
||||
told_undrawable: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Readies this frame's warp: builds its pipelines when its source is new and writes its
|
||||
/// uniform. Whether the frame draws with it: not when it is off, didn't compile, or the
|
||||
/// frame can't take it (`drawable`: a shader pack draws the terrain).
|
||||
pub fn frame(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
uploads: &mut super::upload::Uploads,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
warp: &WorldWarp,
|
||||
camera: DVec3,
|
||||
partial_tick: f32,
|
||||
drawable: bool,
|
||||
) -> bool {
|
||||
let Some(source) = &warp.source else {
|
||||
return false;
|
||||
};
|
||||
let same = |s: &Arc<str>| Arc::ptr_eq(s, source) || **s == **source;
|
||||
if !self.built.as_ref().is_some_and(|(s, _)| same(s)) {
|
||||
if self.refused.as_ref().is_some_and(same) {
|
||||
return false;
|
||||
}
|
||||
match self.build(device, source) {
|
||||
Ok(pipelines) => self.built = Some((source.clone(), pipelines)),
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: a mod's world warp did not compile: {e}");
|
||||
self.refused = Some(source.clone());
|
||||
self.built = None;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !warp.on {
|
||||
return false;
|
||||
}
|
||||
if !drawable {
|
||||
if !self.told_undrawable {
|
||||
log::info!(
|
||||
"Horizon: a mod's world warp isn't drawn while a shader pack draws the terrain"
|
||||
);
|
||||
self.told_undrawable = true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
let bound = self.bound(device);
|
||||
uploads.buffer(
|
||||
encoder,
|
||||
&bound.buffer,
|
||||
0,
|
||||
bytemuck::bytes_of(&uniform(warp, camera, partial_tick)),
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
/// The pipelines of the warp `frame` readied.
|
||||
pub fn pipelines(&self) -> Option<&Pipelines> {
|
||||
self.built.as_ref().map(|(_, p)| p)
|
||||
}
|
||||
|
||||
/// The warp's group 2, once a frame has drawn with it.
|
||||
pub fn group(&self) -> Option<&wgpu::BindGroup> {
|
||||
self.bound.as_ref().map(|b| &b.group)
|
||||
}
|
||||
|
||||
fn bound(&mut self, device: &wgpu::Device) -> &Bound {
|
||||
self.bound.get_or_insert_with(|| bind(device))
|
||||
}
|
||||
|
||||
fn build(&mut self, device: &wgpu::Device, source: &str) -> Result<Pipelines, String> {
|
||||
let started = std::time::Instant::now();
|
||||
let terrain = terrain_shader(source)?;
|
||||
let entity = entity_shader(source)?;
|
||||
validate(&terrain)?;
|
||||
validate(&entity)?;
|
||||
// Validation failures above are the mod's; past here wgpu's are the game's.
|
||||
let warp = &self.bound.get_or_insert_with(|| bind(device)).layout;
|
||||
let kit = &self.kit;
|
||||
let layout = |label, groups: &[Option<&wgpu::BindGroupLayout>]| {
|
||||
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some(label),
|
||||
bind_group_layouts: groups,
|
||||
..Default::default()
|
||||
})
|
||||
};
|
||||
let terrain_layout = layout(
|
||||
"Warped terrain layout",
|
||||
&[Some(&kit.terrain), None, Some(warp)],
|
||||
);
|
||||
let pages_layout = kit.pages.as_ref().map(|pages| {
|
||||
layout(
|
||||
"Warped terrain layout (pulled)",
|
||||
&[Some(&kit.terrain), Some(pages), Some(warp)],
|
||||
)
|
||||
});
|
||||
let entity_layout = layout(
|
||||
"Warped entity layout",
|
||||
&[Some(&kit.entity), None, Some(warp)],
|
||||
);
|
||||
let static_layout = layout(
|
||||
"Warped static block-entity layout",
|
||||
&[Some(&kit.entity), Some(&kit.static_entity), Some(warp)],
|
||||
);
|
||||
let module = |label, source: String| {
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some(label),
|
||||
source: wgpu::ShaderSource::Wgsl(source.into()),
|
||||
})
|
||||
};
|
||||
let terrain = module("Warped terrain", terrain);
|
||||
let entity = module("Warped entities", entity);
|
||||
|
||||
let instance_attributes = wgpu::vertex_attr_array![5=>Float32x4];
|
||||
let instances = wgpu::VertexBufferLayout {
|
||||
array_stride: 16,
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: &instance_attributes,
|
||||
};
|
||||
let vertices = wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<TerrainVertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &TERRAIN_ATTRIBUTES,
|
||||
};
|
||||
let buffered = [Some(vertices), Some(instances.clone())];
|
||||
let pulled = [None, Some(instances)];
|
||||
// As gfx.rs's: a float frame takes light where others take marks.
|
||||
let write_light = (
|
||||
"HDR_WRITE_LIGHT",
|
||||
f64::from(u8::from(kit.format == super::LINEAR_FRAME)),
|
||||
);
|
||||
let lit = [
|
||||
kit.sprite_constants[0],
|
||||
kit.sprite_constants[1],
|
||||
write_light,
|
||||
];
|
||||
let plain = [
|
||||
kit.sprite_constants[0],
|
||||
kit.sprite_constants[1],
|
||||
("HDR_LIGHTING", 0.0),
|
||||
write_light,
|
||||
];
|
||||
let depth = |write| wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(write),
|
||||
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
};
|
||||
// As gfx.rs's terrain pipelines, without back-face culling.
|
||||
let terrain_pipeline = |label,
|
||||
layout: &wgpu::PipelineLayout,
|
||||
vertex,
|
||||
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
|
||||
fragment,
|
||||
constants: &[(&str, f64)],
|
||||
blend| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout: Some(layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &terrain,
|
||||
entry_point: Some(vertex),
|
||||
compilation_options: Default::default(),
|
||||
buffers,
|
||||
},
|
||||
primitive: wgpu::PrimitiveState::default(),
|
||||
depth_stencil: Some(depth(true)),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &terrain,
|
||||
entry_point: Some(fragment),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants,
|
||||
..Default::default()
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: kit.format,
|
||||
blend,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
// The per-section solid draws read the arena page with flat draws (`vs_section`), and
|
||||
// vertex buffers without.
|
||||
let (solid_layout, solid_vertex, solid_buffers): (_, _, &[_]) = match &pages_layout {
|
||||
Some(pages) => (pages, "vs_section", &pulled),
|
||||
None => (&terrain_layout, "vs_terrain", &buffered),
|
||||
};
|
||||
let solid = |constants: &[(&str, f64)]| {
|
||||
terrain_pipeline(
|
||||
"Warped terrain solid",
|
||||
solid_layout,
|
||||
solid_vertex,
|
||||
solid_buffers,
|
||||
"fs_warp_solid",
|
||||
constants,
|
||||
None,
|
||||
)
|
||||
};
|
||||
let solid = SolidPipeline {
|
||||
plain: solid(&plain),
|
||||
hdr: kit.hdr_output.then(|| solid(&lit)),
|
||||
};
|
||||
let translucent = terrain_pipeline(
|
||||
"Warped terrain translucent",
|
||||
&terrain_layout,
|
||||
"vs_terrain",
|
||||
&buffered,
|
||||
"fs_warp_translucent",
|
||||
&lit,
|
||||
Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
);
|
||||
|
||||
let entity_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
|
||||
let entity_vertices = wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &entity_attributes,
|
||||
};
|
||||
let origin_attributes = wgpu::vertex_attr_array![7=>Sint32x3];
|
||||
let static_buffers = [
|
||||
Some(entity_vertices.clone()),
|
||||
Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<[i32; 3]>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: &origin_attributes,
|
||||
}),
|
||||
];
|
||||
let entity_buffers = [Some(entity_vertices)];
|
||||
// As gfx.rs's `entity_pipeline_with`, for the pipelines a warped frame swaps.
|
||||
let entity_pipeline = |look: &EntityLook| {
|
||||
let (layout, vertex, buffers): (_, _, &[_]) = if look.static_mesh {
|
||||
(&static_layout, "vs_static_entity", &static_buffers)
|
||||
} else {
|
||||
(&entity_layout, "vs_entity", &entity_buffers)
|
||||
};
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(look.label),
|
||||
layout: Some(layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &entity,
|
||||
entry_point: Some(vertex),
|
||||
compilation_options: Default::default(),
|
||||
buffers,
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: look.cull,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
bias: look.bias,
|
||||
..depth(look.depth_write)
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &entity,
|
||||
entry_point: Some(look.fragment),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &[write_light],
|
||||
..Default::default()
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: kit.format,
|
||||
blend: look.blend,
|
||||
write_mask: look.write_mask,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let pipelines = Pipelines {
|
||||
solid,
|
||||
translucent,
|
||||
entities: entity_pipeline(&ENTITIES),
|
||||
static_entities: entity_pipeline(&STATIC_ENTITIES),
|
||||
translucent_items: entity_pipeline(&TRANSLUCENT_ITEMS),
|
||||
text: entity_pipeline(&TEXT),
|
||||
entity_shadows: entity_pipeline(&ENTITY_SHADOWS),
|
||||
hdr_mark: entity_pipeline(&HDR_MARK),
|
||||
hdr_entity_mark: entity_pipeline(&HDR_ENTITY_MARK),
|
||||
};
|
||||
log::info!(
|
||||
"Horizon: a mod's world warp built in {} ms",
|
||||
started.elapsed().as_millis()
|
||||
);
|
||||
Ok(pipelines)
|
||||
}
|
||||
}
|
||||
|
||||
fn bind(device: &wgpu::Device) -> Bound {
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("World warp"),
|
||||
entries: &[wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
}],
|
||||
});
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("World warp"),
|
||||
size: std::mem::size_of::<Uniform>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("World warp"),
|
||||
layout: &layout,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: buffer.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
Bound {
|
||||
layout,
|
||||
buffer,
|
||||
group,
|
||||
}
|
||||
}
|
||||
|
||||
/// How one of the entity pipelines a warped frame swaps draws: as gfx.rs builds the stock one
|
||||
/// of the same name.
|
||||
struct EntityLook {
|
||||
label: &'static str,
|
||||
static_mesh: bool,
|
||||
cull: Option<wgpu::Face>,
|
||||
depth_write: bool,
|
||||
bias: wgpu::DepthBiasState,
|
||||
blend: Option<wgpu::BlendState>,
|
||||
fragment: &'static str,
|
||||
write_mask: wgpu::ColorWrites,
|
||||
}
|
||||
const NO_BIAS: wgpu::DepthBiasState = wgpu::DepthBiasState {
|
||||
constant: 0,
|
||||
slope_scale: 0.0,
|
||||
clamp: 0.0,
|
||||
};
|
||||
const ENTITIES: EntityLook = EntityLook {
|
||||
label: "Warped entities",
|
||||
static_mesh: false,
|
||||
cull: None,
|
||||
depth_write: true,
|
||||
bias: wgpu::DepthBiasState {
|
||||
constant: 128,
|
||||
slope_scale: 0.0,
|
||||
clamp: 0.0,
|
||||
},
|
||||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
fragment: "fs_entity",
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
};
|
||||
const STATIC_ENTITIES: EntityLook = EntityLook {
|
||||
label: "Warped static block entities",
|
||||
static_mesh: true,
|
||||
bias: NO_BIAS,
|
||||
blend: None,
|
||||
fragment: "fs_entity_unmarked",
|
||||
..ENTITIES
|
||||
};
|
||||
const TRANSLUCENT_ITEMS: EntityLook = EntityLook {
|
||||
label: "Warped translucent items",
|
||||
bias: NO_BIAS,
|
||||
..ENTITIES
|
||||
};
|
||||
const TEXT: EntityLook = EntityLook {
|
||||
label: "Warped polygon offset text",
|
||||
cull: Some(wgpu::Face::Back),
|
||||
bias: wgpu::DepthBiasState {
|
||||
constant: 10,
|
||||
slope_scale: 1.0,
|
||||
clamp: 0.0,
|
||||
},
|
||||
fragment: "fs_entity_unmarked",
|
||||
..ENTITIES
|
||||
};
|
||||
const ENTITY_SHADOWS: EntityLook = EntityLook {
|
||||
label: "Warped entity shadows",
|
||||
cull: Some(wgpu::Face::Back),
|
||||
depth_write: false,
|
||||
bias: wgpu::DepthBiasState {
|
||||
constant: 128,
|
||||
slope_scale: 0.015,
|
||||
clamp: 0.0,
|
||||
},
|
||||
fragment: "fs_entity_shadow",
|
||||
..ENTITIES
|
||||
};
|
||||
const HDR_MARK: EntityLook = EntityLook {
|
||||
label: "Warped HDR particle mark",
|
||||
depth_write: false,
|
||||
bias: NO_BIAS,
|
||||
blend: Some(wgpu::BlendState::REPLACE),
|
||||
fragment: "fs_hdr_mark",
|
||||
write_mask: wgpu::ColorWrites::ALPHA,
|
||||
..ENTITIES
|
||||
};
|
||||
/// `HDR_MARK` at the entities' depth bias, for what they drew.
|
||||
const HDR_ENTITY_MARK: EntityLook = EntityLook {
|
||||
label: "Warped HDR entity mark",
|
||||
bias: ENTITIES.bias,
|
||||
..HDR_MARK
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const BEND: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
|
||||
let lift = sin(pos.x * 0.1 + warp.params[0].x) * warp.params[0].y + section.y * 0.0;
|
||||
return Warped(pos + vec3(0.0, lift, 0.0), vec3(1.0));
|
||||
}
|
||||
";
|
||||
|
||||
#[test]
|
||||
fn a_warp_without_fragments_draws_the_terrain_as_the_game_does() {
|
||||
let terrain = terrain_shader(BEND).unwrap();
|
||||
validate(&terrain).unwrap();
|
||||
assert!(terrain.contains(DEFAULT_FRAGMENT));
|
||||
let entity = entity_shader(BEND).unwrap();
|
||||
validate(&entity).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_warps_own_fragments_replace_the_default_and_stay_out_of_the_entities() {
|
||||
let source = format!(
|
||||
"{BEND}{FRAGMENTS}
|
||||
fn world_fragment(f: Fragment) -> Painted {{
|
||||
if !f.front || f.light.x < 1.0 {{ return Painted(vec4(0.0, 0.0, 0.0, -1.0), 1.0); }}
|
||||
let far = length(f.home - f.at) + f.cut;
|
||||
return Painted(sprite(f.uv * 2.0, f.layer) * f.color, 1.0 - min(far, 1.0));
|
||||
}}
|
||||
"
|
||||
);
|
||||
let terrain = terrain_shader(&source).unwrap();
|
||||
validate(&terrain).unwrap();
|
||||
assert!(!terrain.contains(DEFAULT_FRAGMENT));
|
||||
let entity = entity_shader(&source).unwrap();
|
||||
validate(&entity).unwrap();
|
||||
assert!(!entity.contains("world_fragment"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_stock_shaders_hold_nothing_of_a_warp() {
|
||||
for (name, shader) in [
|
||||
("terrain", super::super::terrain_shader()),
|
||||
("entity", super::super::static_entity_shader()),
|
||||
] {
|
||||
validate(&shader).unwrap();
|
||||
for word in ["world_warp(", "warp_home", "Warped", "group(2)"] {
|
||||
assert!(!shader.contains(word), "{name}.wgsl holds {word}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_marker_is_found() {
|
||||
let terrain = terrain_shader(BEND).unwrap();
|
||||
let entity = entity_shader(BEND).unwrap();
|
||||
for shader in [&terrain, &entity] {
|
||||
assert!(!shader.contains("// [world warp"), "a marker left unfilled");
|
||||
}
|
||||
assert!(terrain.contains("let warped = world_warp(warp_home, origin);"));
|
||||
assert!(entity.contains("let at = world_warp(position, position).pos;"));
|
||||
assert!(entity.contains("world_warp(section_corner + position, section_corner)"));
|
||||
assert_eq!(
|
||||
fill("a // [x] b // [end x] c", "x", "B"),
|
||||
Ok("a B c".to_owned())
|
||||
);
|
||||
assert!(fill("a // [x] b", "x", "B").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_warp_that_does_not_compile_is_refused_with_naga_s_reason() {
|
||||
let terrain =
|
||||
terrain_shader("fn world_warp(pos: vec3<f32>) -> Warped { return 1; }").unwrap();
|
||||
let e = validate(&terrain).unwrap_err();
|
||||
assert!(e.contains("world_warp"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_uniform_keeps_the_camera_exact_far_out() {
|
||||
let mut warp = WorldWarp::default();
|
||||
warp.params[5] = 2.0;
|
||||
let camera = DVec3::new(29_999_984.3, 70.25, -12_345_678.9);
|
||||
let u = uniform(&warp, camera, 0.25);
|
||||
assert_eq!(u.params[1][1], 2.0);
|
||||
assert_eq!(u.camera[3], 0.25);
|
||||
for i in 0..3 {
|
||||
let whole = f64::from(u.camera[i]) + f64::from(u.camera_rest[i]);
|
||||
assert!((whole - camera[i]).abs() < 1e-6, "{whole} {}", camera[i]);
|
||||
}
|
||||
assert_eq!(std::mem::size_of::<Uniform>(), 96);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// A native mod's world warp (gfx/world_warp.rs, docs/native-mods.md "World warps"): what the
|
||||
// game declares around the mod's WGSL in the warp's own copies of terrain.wgsl and entity.wgsl.
|
||||
// None of it is in the shaders a frame without a warp draws with.
|
||||
|
||||
// What `world_warp` makes of a vertex: where to draw it, from the camera, and what to multiply
|
||||
// its colour by (terrain only; red under 0 marks its faces cut: `Fragment.cut`).
|
||||
struct Warped {
|
||||
pos: vec3<f32>,
|
||||
tint: vec3<f32>,
|
||||
}
|
||||
struct WorldWarpUniform {
|
||||
// `WorldWarp::params`.
|
||||
params: array<vec4<f32>, 4>,
|
||||
// xyz: the camera in the world as f32; `camera_rest.xyz` what f32 left out of it, so
|
||||
// `(p - warp.camera.xyz) - warp.camera_rest.xyz` is the world's point `p` from the camera,
|
||||
// exact far from the world's origin. w: the frame's partial tick, 0 to 1, for motion
|
||||
// smoother than the ticks `params` are set in.
|
||||
camera: vec4<f32>,
|
||||
camera_rest: vec4<f32>,
|
||||
}
|
||||
@group(2) @binding(0) var<uniform> warp: WorldWarpUniform;
|
||||
@@ -0,0 +1,70 @@
|
||||
// A world warp's terrain pixels (gfx/world_warp.rs): added to the warp's copy of terrain.wgsl
|
||||
// only. `world_fragment` is the mod's if its source has one after `// [fragments]`, else
|
||||
// `DEFAULT_FRAGMENT` in gfx/world_warp.rs (the terrain as the game draws it).
|
||||
|
||||
// A pixel of the land as `world_fragment` gets it.
|
||||
struct Fragment {
|
||||
// Where this point of the world really is, from the camera: `world_warp`'s `pos`.
|
||||
home: vec3<f32>,
|
||||
// Where it is drawn, from the camera.
|
||||
at: vec3<f32>,
|
||||
// `sprite(uv, layer)` is the block's texture here.
|
||||
uv: vec2<f32>,
|
||||
layer: u32,
|
||||
// The vertices' colour: biome tint, light, ambient occlusion and the warp's tint.
|
||||
color: vec4<f32>,
|
||||
// Sky and block light, 0 to 15.
|
||||
light: vec2<f32>,
|
||||
// Over 0 where a vertex of the face had a tint with red under 0.
|
||||
cut: f32,
|
||||
// False on a face's back: the warp's pipelines don't cull, so both sides draw.
|
||||
front: bool,
|
||||
}
|
||||
// What `world_fragment` makes of it: a colour (alpha under 0 leaves the pixel out) and how much
|
||||
// of the game's fog it takes, 0 to 1.
|
||||
struct Painted {
|
||||
color: vec4<f32>,
|
||||
fog: f32,
|
||||
}
|
||||
fn warp_fragment(in: TerrainOut, front: bool) -> Fragment {
|
||||
return Fragment(in.warp_home.xyz, in.warp_at, in.uv, in.layer & 0x7fffu, in.color,
|
||||
in.scene.yx, in.warp_home.w, front);
|
||||
}
|
||||
// As `solid_color`, with the mod's colour in place of the texture's.
|
||||
@fragment fn fs_warp_solid(wire: TerrainWire, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
let in = from_wire(wire);
|
||||
let dx = dpdx(in.local);
|
||||
let dy = dpdy(in.local);
|
||||
if HDR_LIGHTING && globals.hdr_light.z != 0.0 {
|
||||
hdr_normal = normalize(cross(dy, dx));
|
||||
}
|
||||
// (far terrain's tiles and the sections replacing them still cross-fade)
|
||||
if faded(in.fade, in.local, dx, dy) { discard; }
|
||||
let p = world_fragment(warp_fragment(in, front));
|
||||
if p.color.a < 0.0 { discard; }
|
||||
let color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, p.color.a), p.color, in.visibility);
|
||||
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
|
||||
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
|
||||
let finished = solid_finish_at(in, color, color, dpdx(in.uv), dpdy(in.uv));
|
||||
return solid_light(vec4(mix(color.rgb, finished.rgb, p.fog), finished.a));
|
||||
}
|
||||
// As `fs_translucent`.
|
||||
@fragment fn fs_warp_translucent(wire: TerrainWire, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
let in = from_wire(wire);
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
|
||||
hdr_normal = normalize(cross(dpdy(in.local), dpdx(in.local)));
|
||||
}
|
||||
let p = world_fragment(warp_fragment(in, front));
|
||||
if p.color.a < 0.0 { discard; }
|
||||
let color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, p.color.a), p.color, in.visibility);
|
||||
if color.a < 0.01 { discard; }
|
||||
let fogged = apply_fog(color, in.spherical, in.cylindrical);
|
||||
let finished = vec4(mix(color.rgb, fogged.rgb, p.fog), color.a);
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
|
||||
let fog = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
|
||||
let mark = hdr_scene_alpha(mix(hdr_scene(in.scene.x, in.scene.y, hdr_facing()), globals.hdr_light.y, fog));
|
||||
return vec4(hdr_written(finished.rgb, mark, globals.hdr_settings, globals.hdr_shares), finished.a);
|
||||
}
|
||||
return finished;
|
||||
}
|
||||
@@ -2394,6 +2394,19 @@ mod tests {
|
||||
assert_eq!(s.horizon.entity_culling, EntityCullingMode::Off);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_and_right_step_a_focused_choice_back_and_on() {
|
||||
let mut s = Settings::default();
|
||||
let mut ui = ui();
|
||||
row(&mut ui, &s, Item::Horizon(H::EntityCulling));
|
||||
focus_on(&mut ui, Item::Horizon(H::EntityCulling));
|
||||
assert_eq!(key(&mut ui, &mut s, K::ArrowLeft), [CLICK, CHANGED]);
|
||||
assert_eq!(s.horizon.entity_culling, EntityCullingMode::Asynchronous);
|
||||
assert_eq!(key(&mut ui, &mut s, K::ArrowRight), [CLICK, CHANGED]);
|
||||
assert_eq!(s.horizon.entity_culling, EntityCullingMode::Off);
|
||||
assert_eq!(ui.focused_row(), Some(Item::Horizon(H::EntityCulling)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_culling_precheck_slider_is_only_live_for_async() {
|
||||
let item = Item::Horizon(H::EntityCullingPrecheck);
|
||||
|
||||
@@ -34,7 +34,7 @@ struct 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
|
||||
// directly from its writers; 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>;
|
||||
@@ -211,16 +211,6 @@ fn display(light: vec3<f32>) -> vec4<f32> {
|
||||
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, past the
|
||||
// peak if they lift it there (the tone curve eases it in). 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, false).light, vec3(1.0 / 2.2)), 1.0);
|
||||
}
|
||||
|
||||
// The tone curve's knee: up to it light stays as it is. Where HDR lighting eased into the peak
|
||||
// before the curve (`lit`), 60% of it, so bright textures keep their detail; at least paper white.
|
||||
fn tone_knee(headroom: f32) -> f32 {
|
||||
|
||||
@@ -1355,7 +1355,15 @@ impl HudAtlas {
|
||||
.filter(|&size| size != (w, h));
|
||||
let flags = mcmeta.as_deref().and_then(TextureFlags::from_mcmeta);
|
||||
let animated = frames && animation.is_some();
|
||||
let (gw, gh) = gui_size.unwrap_or((w, h));
|
||||
// The cap below keeps GUI textures small; a particle sprite isn't capped, as
|
||||
// vanilla's particle atlas stitches any size (packs draw light shafts and fog
|
||||
// banks from 1024-wide textures).
|
||||
let particle = name.starts_with("particle/") || name.contains(":particle/");
|
||||
let (gw, gh) = if particle {
|
||||
(0, 0)
|
||||
} else {
|
||||
gui_size.unwrap_or((w, h))
|
||||
};
|
||||
let maps = read_maps
|
||||
.then(|| {
|
||||
let stem = path.file_stem()?.to_str()?;
|
||||
@@ -3427,6 +3435,32 @@ mod tests {
|
||||
assert!(!atlas.sprites.contains_key("gems:item/ruby"));
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
/// A pack's particle sprite of any size packs whole, as vanilla's particle atlas stitches
|
||||
/// it (a light shaft drawn from a 1024-wide texture); the cap on GUI textures stays.
|
||||
#[test]
|
||||
fn large_particle_sprites_pack_and_large_gui_textures_dont() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-atlas-big-particle-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let rays = root.join("assets/mypack/textures/particle/light_rays");
|
||||
fs::create_dir_all(&rays).unwrap();
|
||||
write_strip(
|
||||
&rays.join("02.png"),
|
||||
1024,
|
||||
1024,
|
||||
&[[250, 240, 200, 255]],
|
||||
None,
|
||||
);
|
||||
let gui = root.join("assets/minecraft/textures/gui");
|
||||
fs::create_dir_all(&gui).unwrap();
|
||||
write_strip(&gui.join("huge.png"), 2048, 64, &[[1, 2, 3, 255]], None);
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let rect = atlas.sprites["mypack:particle/light_rays/02"];
|
||||
assert_eq!(rect.texels((atlas.width, atlas.height)), (1024, 1024));
|
||||
assert_eq!(texel_at(&atlas, rect, 512, 512), [250, 240, 200, 255]);
|
||||
assert!(!atlas.sprites.contains_key("gui/huge"));
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
/// OptiFine random entity variants outside `textures/` pack under the names
|
||||
/// `RandomTextures` gives them; one beside its texture packs once, under its own.
|
||||
#[test]
|
||||
|
||||
@@ -12,8 +12,6 @@ impl Gpu {
|
||||
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),
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
//! The HUD kit: small immediate-mode pieces Horizon's own overlays share, so they fit around
|
||||
//! vanilla's HUD and look alike (`docs/ui-kit.md`). Nothing here reaches gameplay; the game
|
||||
//! builds the inputs (`Game::hud_room`) and the overlays draw with them.
|
||||
//!
|
||||
//! - `room`: what of a view an overlay may use, clear of the boss bars.
|
||||
//! - `plate`: the box behind a notice's text.
|
||||
//!
|
||||
//! Vanilla's own display lists (`hud_layout`, `screen_draw`) never use the kit: they stay
|
||||
//! exactly vanilla's.
|
||||
pub mod plate;
|
||||
pub mod room;
|
||||
@@ -0,0 +1,64 @@
|
||||
//! Plates: the boxes Horizon's HUD overlays draw behind their text, one place for each look so
|
||||
//! overlays that mean the same thing look the same.
|
||||
//!
|
||||
//! A plate is drawn first, then the overlay's text and glyphs over it. Only the kinds the
|
||||
//! overlays use today are here; each keeps the colours its overlay always had. Today that is
|
||||
//! one, shared by the controller notice and splitscreen's prompts.
|
||||
//!
|
||||
//! ```
|
||||
//! use horizon_client_render::hud_kit::plate::Plate;
|
||||
//!
|
||||
//! // A notice's box: a dark fill and a faint 1 px edge inside its area.
|
||||
//! assert_eq!(Plate::Notice.fill(), 0xD200_0000);
|
||||
//! assert_eq!(Plate::Notice.edge(), Some(0x5AFF_FFFF));
|
||||
//! ```
|
||||
//!
|
||||
//! In a draw: `plate::draw(batch, atlas, Plate::Notice, [x, y, w, h])`, the area in GUI px.
|
||||
|
||||
use crate::hud::{HudAtlas, HudBatch, rgba};
|
||||
|
||||
/// A plate's look.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Plate {
|
||||
/// Something to read now: controller notices and splitscreen's prompts. A dark fill with a
|
||||
/// faint edge.
|
||||
Notice,
|
||||
}
|
||||
|
||||
impl Plate {
|
||||
/// The fill colour as `0xAARRGGBB`.
|
||||
pub fn fill(self) -> u32 {
|
||||
match self {
|
||||
Self::Notice => 0xD200_0000,
|
||||
}
|
||||
}
|
||||
|
||||
/// The 1 GUI px edge's colour as `0xAARRGGBB`, drawn inside the area; `None` for none.
|
||||
pub fn edge(self) -> Option<u32> {
|
||||
match self {
|
||||
Self::Notice => Some(0x5AFF_FFFF),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `[a, r, g, b]` of `0xAARRGGBB` as the batch's colour.
|
||||
fn colour(argb: u32) -> u32 {
|
||||
let [a, r, g, b] = argb.to_be_bytes();
|
||||
rgba(r, g, b, a)
|
||||
}
|
||||
|
||||
/// Draws `plate` over `[x, y, w, h]` (GUI px): its fill, then its edge inside the area.
|
||||
pub fn draw(batch: &mut HudBatch, atlas: &HudAtlas, plate: Plate, [x, y, w, h]: [f32; 4]) {
|
||||
batch.fill(atlas, x, y, w, h, colour(plate.fill()));
|
||||
if let Some(edge) = plate.edge() {
|
||||
let edge = colour(edge);
|
||||
for [ex, ey, ew, eh] in [
|
||||
[x, y, w, 1.0],
|
||||
[x, y + h - 1.0, w, 1.0],
|
||||
[x, y, 1.0, h],
|
||||
[x + w - 1.0, y, 1.0, h],
|
||||
] {
|
||||
batch.fill(atlas, ex, ey, ew, eh, edge);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
//! Room: what of a view Horizon's own HUD overlays may use. Vanilla's HUD elements are
|
||||
//! keep-outs; an overlay asks the room where it fits instead of assuming a fixed spot, so it
|
||||
//! doesn't cover them. Today the only keep-out is the boss bars at the top centre.
|
||||
//!
|
||||
//! The room is built once a frame from what the HUD shows (`Game::hud_room` in gameplay). With
|
||||
//! nothing showing it is empty, and every overlay stays exactly where it always was.
|
||||
//!
|
||||
//! All sizes are GUI px, from the view's top left. An area is `[x, y, w, h]`.
|
||||
//!
|
||||
//! ```
|
||||
//! use horizon_client_render::hud_kit::room::{Keep, Room};
|
||||
//! use horizon_client_render::hud_overlays::boss_bars_bottom;
|
||||
//!
|
||||
//! // A centred notice wants y = 18.
|
||||
//! let empty = Room::new(320.0, 240.0);
|
||||
//! assert_eq!(empty.below_top(18.0), 18.0);
|
||||
//! // One boss bar ends at y 17: no overlap, so the notice stays.
|
||||
//! let one = Room::new(320.0, 240.0).with(Keep::BossBars, [69.0, 0.0, 182.0, 17.0]);
|
||||
//! assert_eq!(one.below_top(18.0), 18.0);
|
||||
//! // Two end at y 36: the notice goes below them, a gap past.
|
||||
//! let bars = boss_bars_bottom(240, 2) as f32;
|
||||
//! let two = Room::new(320.0, 240.0).with(Keep::BossBars, [69.0, 0.0, 182.0, bars]);
|
||||
//! assert_eq!(two.below_top(18.0), bars + 2.0);
|
||||
//! ```
|
||||
|
||||
/// An area in GUI px: `[x, y, w, h]` from the view's top left.
|
||||
pub type Area = [f32; 4];
|
||||
|
||||
/// The gap, in GUI px, an overlay leaves below a keep-out it moved past.
|
||||
pub const GAP: f32 = 2.0;
|
||||
|
||||
/// A HUD element Horizon's overlays keep clear of.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Keep {
|
||||
/// The boss bars and their names at the top centre (`hud_overlays::boss_bars_bottom`).
|
||||
BossBars,
|
||||
}
|
||||
|
||||
/// A view and the keep-outs its HUD shows this frame.
|
||||
///
|
||||
/// Invariants: the view's size is what the overlays draw in (the `HudBatch`'s size), and an
|
||||
/// empty keep-out (no width or height) is never stored, so a room built from hidden elements
|
||||
/// equals `Room::new`.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Room {
|
||||
width: f32,
|
||||
height: f32,
|
||||
keeps: Vec<(Keep, Area)>,
|
||||
}
|
||||
|
||||
impl Room {
|
||||
/// A `width` x `height` GUI px view with nothing to keep clear of.
|
||||
pub fn new(width: f32, height: f32) -> Self {
|
||||
Self {
|
||||
width,
|
||||
height,
|
||||
keeps: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// This room with `area` kept clear of as `keep`; an empty area is ignored.
|
||||
pub fn with(mut self, keep: Keep, area: Area) -> Self {
|
||||
self.add(keep, area);
|
||||
self
|
||||
}
|
||||
|
||||
/// Keeps clear of `area` as `keep`; an empty area is ignored.
|
||||
pub fn add(&mut self, keep: Keep, area: Area) {
|
||||
if area[2] > 0.0 && area[3] > 0.0 {
|
||||
self.keeps.push((keep, area));
|
||||
}
|
||||
}
|
||||
|
||||
/// The view's width in GUI px.
|
||||
pub fn width(&self) -> f32 {
|
||||
self.width
|
||||
}
|
||||
|
||||
/// The view's height in GUI px.
|
||||
pub fn height(&self) -> f32 {
|
||||
self.height
|
||||
}
|
||||
|
||||
/// The keep-outs, in the order they were added.
|
||||
pub fn keeps(&self) -> impl Iterator<Item = (Keep, Area)> + '_ {
|
||||
self.keeps.iter().copied()
|
||||
}
|
||||
|
||||
/// How far, in GUI px from the view's top, the keep-outs at the top reach down: the boss
|
||||
/// bars' bottom, or 0 with none.
|
||||
pub fn top(&self) -> f32 {
|
||||
self.keeps()
|
||||
.map(|(Keep::BossBars, [_, y, _, h])| y + h)
|
||||
.fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
/// Where a centred overlay that wants its top at `wanted` (GUI px) goes: at `wanted` unless
|
||||
/// a keep-out reaches past it (`top() > wanted`, a real overlap; touching isn't one), else
|
||||
/// `GAP` below that keep-out.
|
||||
pub fn below_top(&self, wanted: f32) -> f32 {
|
||||
let top = self.top();
|
||||
if top > wanted { top + GAP } else { wanted }
|
||||
}
|
||||
|
||||
/// The view below the top keep-outs, with `GAP` past them: where a centred overlay may go.
|
||||
/// The whole view when nothing is kept clear of.
|
||||
pub fn usable(&self) -> Area {
|
||||
let top = self.top();
|
||||
let top = if top > 0.0 { top + GAP } else { 0.0 };
|
||||
[0.0, top, self.width, (self.height - top).max(0.0)]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_empty_room_is_the_whole_view_and_moves_nothing() {
|
||||
let room = Room::new(320.0, 240.0).with(Keep::BossBars, [69.0, 0.0, 182.0, 0.0]);
|
||||
assert_eq!(room, Room::new(320.0, 240.0));
|
||||
assert_eq!(room.usable(), [0.0, 0.0, 320.0, 240.0]);
|
||||
assert_eq!(room.below_top(18.0), 18.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_overlay_moves_only_when_the_bars_reach_past_it() {
|
||||
let bars =
|
||||
|bottom: f32| Room::new(500.0, 300.0).with(Keep::BossBars, [159.0, 0.0, 182.0, bottom]);
|
||||
// Ending above or at its top: no overlap.
|
||||
assert_eq!(bars(17.0).below_top(18.0), 18.0);
|
||||
assert_eq!(bars(18.0).below_top(18.0), 18.0);
|
||||
// Past it: below, a gap away.
|
||||
assert_eq!(bars(36.0).below_top(18.0), 38.0);
|
||||
assert_eq!(bars(36.0).usable(), [0.0, 38.0, 500.0, 262.0]);
|
||||
}
|
||||
}
|
||||
@@ -50,10 +50,14 @@ pub fn text_with_backdrop(
|
||||
text(out, t, x, y, color, true);
|
||||
}
|
||||
|
||||
/// `Arrays.sort(T[], Comparator)` for the short arrays the HUD sorts: Java's
|
||||
/// TimSort below `MIN_MERGE` (a natural run, then binary insertion), which
|
||||
/// matters when the comparator is not a total order. Longer arrays fall back
|
||||
/// to a stable sort.
|
||||
/// `Arrays.sort(T[], Comparator)` for the short arrays the HUD sorts. Its Javadoc
|
||||
/// says it is adapted from Tim Peters's list sort for Python, and for fewer than 32
|
||||
/// elements that sort is one run plus binary insertion, as Tim Peters describes it
|
||||
/// in `listsort.txt` (CPython's `Objects/listsort.txt`): take the run at the start,
|
||||
/// reversed in place when it is strictly descending, then insert each remaining
|
||||
/// element by binary search after the elements equal to it, so the sort is stable.
|
||||
/// The order of the comparisons matters because vanilla's comparator here is not a
|
||||
/// total order. Longer arrays fall back to a stable sort.
|
||||
pub fn java_sort<T>(a: &mut [T], cmp: impl Fn(&T, &T) -> Ordering) {
|
||||
let n = a.len();
|
||||
if n < 2 {
|
||||
@@ -63,7 +67,7 @@ pub fn java_sort<T>(a: &mut [T], cmp: impl Fn(&T, &T) -> Ordering) {
|
||||
a.sort_by(cmp);
|
||||
return;
|
||||
}
|
||||
// countRunAndMakeAscending.
|
||||
// The run at the start: strictly descending (then reversed) or non-descending.
|
||||
let mut run = 2;
|
||||
if cmp(&a[1], &a[0]) == Ordering::Less {
|
||||
while run < n && cmp(&a[run], &a[run - 1]) == Ordering::Less {
|
||||
@@ -75,7 +79,7 @@ pub fn java_sort<T>(a: &mut [T], cmp: impl Fn(&T, &T) -> Ordering) {
|
||||
run += 1;
|
||||
}
|
||||
}
|
||||
// binarySort.
|
||||
// Binary insertion of the rest, each element after the last one not greater.
|
||||
for start in run..n {
|
||||
let (mut left, mut right) = (0, start);
|
||||
while left < right {
|
||||
@@ -571,6 +575,41 @@ pub fn boss_overlay(w: i32, h: i32, bars: &[BossBar], font: &Font) -> Vec<GuiDra
|
||||
out
|
||||
}
|
||||
|
||||
/// `BossHealthOverlay`'s first bar's top edge, in GUI px (its name sits 9 px above).
|
||||
pub const BOSS_BAR_TOP: i32 = 12;
|
||||
/// `BossHealthOverlay`'s step from one bar to the next, in GUI px (`10 + 9`).
|
||||
pub const BOSS_BAR_STEP: i32 = 10 + 9;
|
||||
/// A boss bar's sprite height, in GUI px.
|
||||
pub const BOSS_BAR_HEIGHT: i32 = 5;
|
||||
/// A boss bar's sprite width, in GUI px; it is centred on the view.
|
||||
pub const BOSS_BAR_WIDTH: i32 = 182;
|
||||
|
||||
/// How many of `n` boss bars `BossHealthOverlay.extractRenderState` draws on a view `h` GUI px
|
||||
/// tall: it stops once the next bar's top reaches a third of the height (integer division,
|
||||
/// as vanilla's `guiHeight() / 3`), but always draws the first.
|
||||
pub fn boss_bars_drawn(h: i32, n: usize) -> usize {
|
||||
let mut drawn = 0;
|
||||
let mut y = BOSS_BAR_TOP;
|
||||
while drawn < n {
|
||||
drawn += 1;
|
||||
y += BOSS_BAR_STEP;
|
||||
if y >= h / 3 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
drawn
|
||||
}
|
||||
|
||||
/// The bottom edge, in GUI px from the view's top, of the last of `n` boss bars a view `h` GUI
|
||||
/// px tall shows (`boss_overlay`'s layout); 0 when there are none. Names sit above their bar,
|
||||
/// so nothing of the overlay reaches below this.
|
||||
pub fn boss_bars_bottom(h: i32, n: usize) -> i32 {
|
||||
match boss_bars_drawn(h, n) {
|
||||
0 => 0,
|
||||
k => BOSS_BAR_TOP + BOSS_BAR_STEP * (k as i32 - 1) + BOSS_BAR_HEIGHT,
|
||||
}
|
||||
}
|
||||
|
||||
/// `JumpableVehicleBar.extractBackground`: `cooldown` is the mount's jump
|
||||
/// cooldown and `scale` the player's `getJumpRidingScale()`.
|
||||
pub fn jump_bar(w: i32, h: i32, cooldown: i32, scale: f32) -> Vec<GuiDraw> {
|
||||
@@ -1381,3 +1420,64 @@ mod text_position_tests {
|
||||
assert_eq!(relative, Some(-4));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod boss_bar_room_tests {
|
||||
use super::*;
|
||||
|
||||
/// `BossHealthOverlay.extractRenderState` worked by hand: bars at y 12, 31, 50, 69, 88...,
|
||||
/// each 5 tall, until the next top reaches `guiHeight() / 3`.
|
||||
#[test]
|
||||
fn boss_bars_end_where_vanillas_overlay_stops() {
|
||||
assert_eq!(boss_bars_bottom(300, 0), 0);
|
||||
assert_eq!(boss_bars_bottom(300, 1), 17);
|
||||
assert_eq!(boss_bars_bottom(300, 2), 36);
|
||||
assert_eq!(boss_bars_bottom(300, 3), 55);
|
||||
// A third of 300 is 100: the fifth bar is the last (its next top would be 107).
|
||||
assert_eq!(boss_bars_drawn(300, 9), 5);
|
||||
assert_eq!(boss_bars_bottom(300, 9), 93);
|
||||
// A short view still draws the first.
|
||||
assert_eq!(boss_bars_bottom(60, 3), 17);
|
||||
// `guiHeight() / 3` is integer division: 152 / 3 = 50 stops at two, 153 / 3 = 51 not.
|
||||
assert_eq!(boss_bars_drawn(152, 3), 2);
|
||||
assert_eq!(boss_bars_drawn(153, 3), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boss_bars_bottom_is_below_every_drawn_bar() {
|
||||
// The missing-glyph metrics: no game assets needed.
|
||||
let font = Font::load(
|
||||
std::path::Path::new("/nonexistent-horizon-hud-test-resources"),
|
||||
&std::collections::HashMap::new(),
|
||||
&[],
|
||||
(1, 1),
|
||||
(0, 0),
|
||||
crate::hud::Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 1,
|
||||
h: 1,
|
||||
},
|
||||
);
|
||||
let bars: Vec<BossBar> = (0..6)
|
||||
.map(|_| BossBar {
|
||||
name: Text::literal(""),
|
||||
progress: 1.0,
|
||||
color: 0,
|
||||
overlay: 0,
|
||||
})
|
||||
.collect();
|
||||
for h in [60, 120, 152, 153, 240, 300, 600] {
|
||||
let bottom = boss_overlay(400, h, &bars, &font)
|
||||
.iter()
|
||||
.filter_map(|d| match d {
|
||||
GuiDraw::Sprite { y, h, .. } => Some(y + h),
|
||||
_ => None,
|
||||
})
|
||||
.max();
|
||||
assert_eq!(bottom, Some(boss_bars_bottom(h, bars.len())), "height {h}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,7 @@ pub mod graphics_style;
|
||||
pub mod graphics_ui;
|
||||
pub mod hdr_calibration;
|
||||
pub mod hud;
|
||||
pub mod hud_kit;
|
||||
pub mod hud_layout;
|
||||
pub mod hud_overlays;
|
||||
pub mod inventory_effects;
|
||||
@@ -109,6 +110,7 @@ pub mod shader_pack_list;
|
||||
pub mod shader_ui;
|
||||
pub mod signs;
|
||||
pub mod skins;
|
||||
pub mod social;
|
||||
pub mod sound_engine;
|
||||
pub mod sound_events;
|
||||
pub mod sound_preview;
|
||||
|
||||
@@ -653,15 +653,17 @@ fn pack_lighting(
|
||||
sky_light: bool,
|
||||
) -> Vec<Light> {
|
||||
let open = if sky_light { 0xF0 } else { 0x00 };
|
||||
let mut data = [open; 4096];
|
||||
(0..sections)
|
||||
.map(|si| {
|
||||
if si * 16 >= h {
|
||||
return Light::Uniform(open);
|
||||
}
|
||||
let mut data = Box::new([open; 4096]);
|
||||
for ly in 0..16 {
|
||||
let y = si * 16 + ly;
|
||||
if y >= h {
|
||||
let out_row = ly << 8;
|
||||
data[out_row..out_row + 256].fill(open);
|
||||
continue;
|
||||
}
|
||||
let y_offset = y * (W * W);
|
||||
@@ -674,7 +676,7 @@ fn pack_lighting(
|
||||
}
|
||||
}
|
||||
}
|
||||
Light::packed(data)
|
||||
Light::from_slice(&data)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -1281,7 +1283,7 @@ fn relight_layer(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use crate::chunk::{HEIGHT, MIN_Y, SECTIONS};
|
||||
|
||||
@@ -1310,13 +1312,13 @@ mod tests {
|
||||
assert_eq!(dirty(&[(0, 0, 0)]).len(), 8);
|
||||
}
|
||||
|
||||
const AIR: u32 = 0;
|
||||
pub(crate) const AIR: u32 = 0;
|
||||
const STONE: u32 = 1;
|
||||
const TORCH: u32 = 2;
|
||||
pub(crate) const TORCH: u32 = 2;
|
||||
const SLAB: u32 = 3;
|
||||
/// Air, stone, torch and a bottom slab (full bottom face, half sides,
|
||||
/// empty top), with a third face shape for the half sides.
|
||||
fn tables() -> LightTables {
|
||||
pub(crate) fn tables() -> LightTables {
|
||||
let cell = |dampening, emission, faces: [u16; 6]| Cell {
|
||||
dampening,
|
||||
emission,
|
||||
@@ -1335,7 +1337,7 @@ mod tests {
|
||||
3,
|
||||
)
|
||||
}
|
||||
fn world(edit: impl Fn(&mut Chunk)) -> HashMap<(i32, i32), Arc<Chunk>> {
|
||||
pub(crate) fn world(edit: impl Fn(&mut Chunk)) -> HashMap<(i32, i32), Arc<Chunk>> {
|
||||
world_of(crate::chunk::ColumnLayout::VANILLA, edit)
|
||||
}
|
||||
/// Lit chunks around (0, 0) of `layout`, stone up to y 0.
|
||||
|
||||
@@ -10,6 +10,7 @@ use crate::{
|
||||
pub mod cache;
|
||||
mod mesh;
|
||||
pub mod packed;
|
||||
mod samples;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
@@ -957,7 +958,7 @@ type Reduced = FxHashMap<(i32, i32), Arc<ReducedColumns>>;
|
||||
/// every tile's inputs from it (gathering on the render thread stalled the
|
||||
/// frame, tens of milliseconds for a large job on a 4-thread machine).
|
||||
struct Snapshot {
|
||||
samples: FxHashMap<TileKey, Arc<Vec<Column>>>,
|
||||
samples: samples::Samples,
|
||||
saved: Saved,
|
||||
reduced: Reduced,
|
||||
/// The plan's tiles, to tell terrain not drawn yet from terrain out of
|
||||
@@ -1267,6 +1268,8 @@ pub struct Lod {
|
||||
/// Stale tiles `refresh`ed again while sampled: that sample may be older.
|
||||
restale: FxHashSet<TileKey>,
|
||||
active: FxHashSet<TileKey>,
|
||||
/// The active tiles' `ready` samples, as mesh jobs read them (`sync_sample`).
|
||||
mesh_samples: samples::Samples,
|
||||
/// Tiles needing a mesh, with the revision their job must carry.
|
||||
dirty: FxHashMap<TileKey, u64>,
|
||||
/// The newest revision of each active tile's mesh.
|
||||
@@ -1657,6 +1660,7 @@ impl Lod {
|
||||
ready: FxHashMap::default(),
|
||||
complete: FxHashMap::default(),
|
||||
active: FxHashSet::default(),
|
||||
mesh_samples: samples::Samples::default(),
|
||||
dirty: FxHashMap::default(),
|
||||
mesh_version: FxHashMap::default(),
|
||||
shown_version: FxHashMap::default(),
|
||||
@@ -1935,9 +1939,11 @@ impl Lod {
|
||||
if active != was_active {
|
||||
if active {
|
||||
self.active.insert(node);
|
||||
self.sync_sample(node);
|
||||
self.removed_pending.remove(&node);
|
||||
} else {
|
||||
self.active.remove(&node);
|
||||
self.sync_sample(node);
|
||||
self.removed_pending.insert(node);
|
||||
self.cancel_mesh(node);
|
||||
self.mesh_version.remove(&node);
|
||||
@@ -2210,6 +2216,7 @@ impl Lod {
|
||||
}
|
||||
}
|
||||
for key in modified {
|
||||
self.sync_sample(key);
|
||||
self.mark_data_changed(key);
|
||||
}
|
||||
}
|
||||
@@ -2271,6 +2278,7 @@ impl Lod {
|
||||
return;
|
||||
}
|
||||
let existed = self.ready.insert(key, Arc::new(samples)).is_some();
|
||||
self.sync_sample(key);
|
||||
self.refresh_ready(key, existed);
|
||||
}
|
||||
}
|
||||
@@ -2505,6 +2513,8 @@ impl Lod {
|
||||
.collect();
|
||||
self.plan = plan;
|
||||
self.ready.retain(|key, _| self.plan.nodes.contains(key));
|
||||
let nodes = &self.plan.nodes;
|
||||
self.mesh_samples.retain(|key| nodes.contains(key));
|
||||
// Queued tiles the view left are not sampled (a running one is
|
||||
// dropped when it finishes, `finish_sample`).
|
||||
drop_queued_samples(&self.shared, &mut self.pending, |key| {
|
||||
@@ -2572,6 +2582,7 @@ impl Lod {
|
||||
.retain(|key| !self.active.contains(key));
|
||||
let changed: Vec<_> = old.symmetric_difference(&self.active).copied().collect();
|
||||
for &key in &changed {
|
||||
self.sync_sample(key);
|
||||
if old.contains(&key) && !self.active.contains(&key) {
|
||||
self.cancel_mesh(key);
|
||||
self.removed_pending.insert(key);
|
||||
@@ -2857,12 +2868,10 @@ impl Lod {
|
||||
if keys.is_empty() {
|
||||
return;
|
||||
}
|
||||
#[cfg(debug_assertions)]
|
||||
self.check_mesh_samples();
|
||||
let snapshot = Arc::new(Snapshot {
|
||||
samples: self
|
||||
.active
|
||||
.iter()
|
||||
.filter_map(|key| self.ready.get(key).map(|columns| (*key, columns.clone())))
|
||||
.collect(),
|
||||
samples: self.mesh_samples.clone(),
|
||||
saved: self.saved.clone(),
|
||||
reduced: self.reduced.clone(),
|
||||
nodes: self.plan.nodes.clone(),
|
||||
@@ -2895,6 +2904,31 @@ impl Lod {
|
||||
self.outstanding_meshes += count;
|
||||
self.shared.mesh_wake.notify_all();
|
||||
}
|
||||
/// Brings `mesh_samples` up to date for `key` after its `active` or `ready` changed.
|
||||
fn sync_sample(&mut self, key: TileKey) {
|
||||
let samples = self.ready.get(&key).filter(|_| self.active.contains(&key));
|
||||
self.mesh_samples.set(key, samples);
|
||||
}
|
||||
/// `mesh_samples` holds every active tile's ready samples and nothing else (debug builds).
|
||||
#[cfg(debug_assertions)]
|
||||
fn check_mesh_samples(&self) {
|
||||
let kept = self.mesh_samples.iter().count();
|
||||
let mut want = 0;
|
||||
for key in &self.active {
|
||||
if let Some(columns) = self.ready.get(key) {
|
||||
want += 1;
|
||||
let got = self.mesh_samples.get(key);
|
||||
assert!(
|
||||
got.is_some_and(|got| Arc::ptr_eq(got, columns)),
|
||||
"mesh samples of {key:?} out of date"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
kept, want,
|
||||
"mesh samples keep tiles no longer active or ready"
|
||||
);
|
||||
}
|
||||
fn finer_of(&self, key: TileKey) -> Vec<TileKey> {
|
||||
if !self.plan.split.contains(&key) {
|
||||
return Vec::new();
|
||||
@@ -4852,6 +4886,7 @@ mod tests {
|
||||
lod.active.insert(key);
|
||||
lod.ready
|
||||
.insert(key, Arc::new(vec![Column::default(); CELLS * CELLS]));
|
||||
lod.sync_sample(key);
|
||||
lod.mark_mesh(key);
|
||||
lod.set_near_loading(true);
|
||||
lod.dispatch_meshes([0, 0]);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
//! The samples mesh jobs read (`Snapshot::samples`): the active tiles' ready columns, kept
|
||||
//! as the tiles change rather than gathered again for every batch of jobs. Kept in shards of
|
||||
//! nearby tiles behind `Arc`s: a batch's copy (`clone`) copies the shards' handles, and a
|
||||
//! change copies only its shard, and only while a job still reads it.
|
||||
use super::{Column, TileKey};
|
||||
use crate::fast_hash::FxHashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
type Shard = FxHashMap<TileKey, Arc<Vec<Column>>>;
|
||||
|
||||
/// 8 by 8 tiles of one level a shard.
|
||||
fn shard_of(key: TileKey) -> (i32, i32, u8) {
|
||||
(key.x >> 3, key.z >> 3, key.level)
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub(super) struct Samples {
|
||||
shards: FxHashMap<(i32, i32, u8), Arc<Shard>>,
|
||||
}
|
||||
|
||||
impl Samples {
|
||||
pub fn get(&self, key: &TileKey) -> Option<&Arc<Vec<Column>>> {
|
||||
self.shards.get(&shard_of(*key))?.get(key)
|
||||
}
|
||||
/// Sets `key`'s samples (`None`: not active or not ready), copying its shard only when
|
||||
/// they change.
|
||||
pub fn set(&mut self, key: TileKey, samples: Option<&Arc<Vec<Column>>>) {
|
||||
let at = shard_of(key);
|
||||
match samples {
|
||||
Some(samples) => {
|
||||
let shard = self.shards.entry(at).or_default();
|
||||
if shard.get(&key).is_none_or(|s| !Arc::ptr_eq(s, samples)) {
|
||||
Arc::make_mut(shard).insert(key, samples.clone());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let Some(shard) = self.shards.get_mut(&at) else {
|
||||
return;
|
||||
};
|
||||
if shard.contains_key(&key) {
|
||||
Arc::make_mut(shard).remove(&key);
|
||||
if shard.is_empty() {
|
||||
self.shards.remove(&at);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Keeps the tiles `keep` accepts.
|
||||
pub fn retain(&mut self, mut keep: impl FnMut(&TileKey) -> bool) {
|
||||
self.shards.retain(|_, shard| {
|
||||
if !shard.keys().all(&mut keep) {
|
||||
Arc::make_mut(shard).retain(|key, _| keep(key));
|
||||
}
|
||||
!shard.is_empty()
|
||||
});
|
||||
}
|
||||
#[cfg(any(test, debug_assertions))]
|
||||
pub fn iter(&self) -> impl Iterator<Item = (&TileKey, &Arc<Vec<Column>>)> {
|
||||
self.shards.values().flat_map(|shard| shard.iter())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromIterator<(TileKey, Arc<Vec<Column>>)> for Samples {
|
||||
fn from_iter<I: IntoIterator<Item = (TileKey, Arc<Vec<Column>>)>>(iter: I) -> Self {
|
||||
let mut samples = Self::default();
|
||||
for (key, columns) in iter {
|
||||
samples.set(key, Some(&columns));
|
||||
}
|
||||
samples
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn key(x: i32, z: i32, level: u8) -> TileKey {
|
||||
TileKey { x, z, level }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_change_leaves_a_batchs_copy_as_it_was() {
|
||||
let a = Arc::new(Vec::new());
|
||||
let b = Arc::new(Vec::new());
|
||||
let mut live: Samples = [(key(0, 0, 0), a.clone()), (key(40, -3, 1), a.clone())]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let batch = live.clone();
|
||||
live.set(key(0, 0, 0), Some(&b));
|
||||
live.set(key(40, -3, 1), None);
|
||||
live.set(key(-9, 2, 0), Some(&b));
|
||||
assert!(Arc::ptr_eq(batch.get(&key(0, 0, 0)).unwrap(), &a));
|
||||
assert!(batch.get(&key(40, -3, 1)).is_some());
|
||||
assert!(batch.get(&key(-9, 2, 0)).is_none());
|
||||
assert!(Arc::ptr_eq(live.get(&key(0, 0, 0)).unwrap(), &b));
|
||||
assert!(live.get(&key(40, -3, 1)).is_none());
|
||||
live.retain(|k| k.x < 0);
|
||||
assert_eq!(
|
||||
live.iter().map(|(k, _)| *k).collect::<Vec<_>>(),
|
||||
[key(-9, 2, 0)]
|
||||
);
|
||||
assert_eq!(batch.iter().count(), 2);
|
||||
}
|
||||
}
|
||||
@@ -450,7 +450,7 @@ impl SectionMesh {
|
||||
self.packed.clear();
|
||||
self.packed_far = false;
|
||||
if packed::packing() && quads > 0 && self.solid_tints.len() == quads {
|
||||
self.packed = packed::layout(&self.solid, &self.solid_tints);
|
||||
packed::layout_into(&self.solid, &self.solid_tints, &mut self.packed);
|
||||
}
|
||||
self.solid_tints.clear();
|
||||
}
|
||||
@@ -644,6 +644,8 @@ struct Info {
|
||||
block_entity: bool,
|
||||
tints: [Tint; 2],
|
||||
tint_count: u8,
|
||||
/// A resource pack's Polytone colormap over its tints (an index in `MeshTables::colormaps`).
|
||||
colormap: Option<u16>,
|
||||
/// `OffsetType` as (max horizontal, max vertical): vertical 0 for XZ.
|
||||
offset: Option<(f32, f32)>,
|
||||
/// `isSolid()` (legacy), for fluid corner heights.
|
||||
@@ -700,6 +702,9 @@ pub struct MeshTables {
|
||||
lava_still: u16,
|
||||
lava_flow: u16,
|
||||
animated_layers: Vec<bool>,
|
||||
/// Resource packs' Polytone block and fluid colormaps (#11486), and the water's.
|
||||
colormaps: Vec<Arc<crate::environment::Colormap>>,
|
||||
water_colormap: Option<u16>,
|
||||
}
|
||||
fn stem_color(age: u8) -> u32 {
|
||||
let a = u32::from(age);
|
||||
@@ -842,6 +847,7 @@ impl MeshTables {
|
||||
block_entity: s.block_entity,
|
||||
tints,
|
||||
tint_count,
|
||||
colormap: None,
|
||||
offset,
|
||||
legacy_solid: s.legacy_solid,
|
||||
overlay: s.has_class("HalfTransparentBlock") || s.has_class("LeavesBlock"),
|
||||
@@ -875,8 +881,52 @@ impl MeshTables {
|
||||
lava_still: layer("minecraft:block/lava_still")?,
|
||||
lava_flow: layer("minecraft:block/lava_flow")?,
|
||||
animated_layers,
|
||||
colormaps: Vec::new(),
|
||||
water_colormap: None,
|
||||
})
|
||||
}
|
||||
/// These tables with the colormaps of the Polytone block and fluid modifiers of the packs
|
||||
/// merged at `pack_root` (wiki "Colormaps"): a tinted block they target takes its colour
|
||||
/// from the colormap, and its own tint where the colormap's pixel is transparent.
|
||||
#[must_use]
|
||||
pub fn with_polytone_colormaps(
|
||||
mut self,
|
||||
pack_root: &std::path::Path,
|
||||
registry: &horizon_world::registry::BlockRegistry,
|
||||
) -> Self {
|
||||
let modifiers = crate::environment::polytone_colormap_modifiers(pack_root);
|
||||
if modifiers.is_empty() {
|
||||
return self;
|
||||
}
|
||||
let mut loaded: std::collections::HashMap<String, Option<u16>> = Default::default();
|
||||
let mut index = |id: &str, colormaps: &mut Vec<Arc<crate::environment::Colormap>>| {
|
||||
*loaded.entry(id.to_owned()).or_insert_with(|| {
|
||||
let colormap = crate::environment::Colormap::load(pack_root, id)?;
|
||||
colormaps.push(Arc::new(colormap));
|
||||
Some((colormaps.len() - 1) as u16)
|
||||
})
|
||||
};
|
||||
for (fluid, targets, id) in &modifiers {
|
||||
if *fluid {
|
||||
if targets.matches("minecraft:water", |_| false) {
|
||||
self.water_colormap = index(id, &mut self.colormaps);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some(i) = index(id, &mut self.colormaps) else {
|
||||
continue;
|
||||
};
|
||||
for (state, info) in self.info.iter_mut().enumerate() {
|
||||
let Some(block) = registry.state(BlockStateId(state as u32)) else {
|
||||
continue;
|
||||
};
|
||||
if info.tint_count > 0 && targets.matches(&block.name, |_| false) {
|
||||
info.colormap = Some(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
/// Whether a terrain sprite layer has an animated clip.
|
||||
pub fn is_animated(&self, layer: u16) -> bool {
|
||||
self.animated_layers
|
||||
@@ -971,7 +1021,7 @@ impl MeshTables {
|
||||
}
|
||||
let [x, y, z] = pos;
|
||||
let origin = [x >> 4, z >> 4, y & !15];
|
||||
let mut tints = Tints::new(region, biomes, zoom, blend_radius, origin);
|
||||
let mut tints = Tints::new(region, biomes, zoom, blend_radius, origin).with_colormaps(self);
|
||||
Mesher::tint(info, 0, &mut tints, [x & 15, y & 15, z & 15])
|
||||
}
|
||||
/// `BlockColors` for each of the state's tint indices at `pos`, as the
|
||||
@@ -989,7 +1039,8 @@ impl MeshTables {
|
||||
let sources = self.info.get(state.0 as usize).map(|info| {
|
||||
let [x, y, z] = pos;
|
||||
let origin = [x >> 4, z >> 4, y & !15];
|
||||
let mut tints = Tints::new(region, biomes, zoom, blend_radius, origin);
|
||||
let mut tints =
|
||||
Tints::new(region, biomes, zoom, blend_radius, origin).with_colormaps(self);
|
||||
let local = [x & 15, y & 15, z & 15];
|
||||
let colors = [0, 1].map(|i| Mesher::tint(info, i, &mut tints, local));
|
||||
(colors, i32::from(info.tint_count))
|
||||
@@ -1382,6 +1433,11 @@ struct Tints<'a> {
|
||||
layers: [Option<Box<Layer>>; 16],
|
||||
/// `uniform_biome`, once computed.
|
||||
uniform: Option<Option<u16>>,
|
||||
/// The packs' colormaps (`MeshTables::colormaps`), the water's, and their colours so far
|
||||
/// by colormap and section-local block.
|
||||
colormaps: &'a [Arc<crate::environment::Colormap>],
|
||||
water_colormap: Option<u16>,
|
||||
sampled: std::collections::HashMap<(u16, [i32; 3]), Option<u32>>,
|
||||
}
|
||||
impl<'a> Tints<'a> {
|
||||
fn new(
|
||||
@@ -1400,8 +1456,41 @@ impl<'a> Tints<'a> {
|
||||
origin,
|
||||
layers: Default::default(),
|
||||
uniform: None,
|
||||
colormaps: &[],
|
||||
water_colormap: None,
|
||||
sampled: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
/// With the packs' colormaps of `tables`.
|
||||
fn with_colormaps(mut self, tables: &'a MeshTables) -> Self {
|
||||
self.colormaps = &tables.colormaps;
|
||||
self.water_colormap = tables.water_colormap;
|
||||
self
|
||||
}
|
||||
/// Colormap `index`'s colour at block `(x, y, z)` (section-local), `None` where its pixel
|
||||
/// is transparent.
|
||||
fn colormap(&mut self, index: u16, x: i32, y: i32, z: i32) -> Option<u32> {
|
||||
if let Some(&known) = self.sampled.get(&(index, [x, y, z])) {
|
||||
return known;
|
||||
}
|
||||
let colormap = self.colormaps.get(usize::from(index))?;
|
||||
let id = Self::biome_id(&self.zoom, self.region, self.plains, self.origin, [x, y, z]);
|
||||
let [cx, cz, base_y] = self.origin;
|
||||
let at = [cx * 16 + x, base_y + y, cz * 16 + z];
|
||||
let mut host = crate::environment::BlockHost::new(at, self.biomes.get(id));
|
||||
let colour = colormap.sample(&mut host).map(|c| c & 0xFF_FFFF);
|
||||
self.sampled.insert((index, [x, y, z]), colour);
|
||||
colour
|
||||
}
|
||||
/// The water's tint at block `(x, y, z)`: the packs' water colormap, else the biome's.
|
||||
fn water(&mut self, x: i32, y: i32, z: i32) -> u32 {
|
||||
if let Some(index) = self.water_colormap
|
||||
&& let Some(colour) = self.colormap(index, x, y, z)
|
||||
{
|
||||
return colour;
|
||||
}
|
||||
self.get(Resolver::Water, x, y, z)
|
||||
}
|
||||
/// The biome of block `(x, y, z)` (section-local x/z, y in 0..16), as `BiomeManager.getBiome`
|
||||
/// picks it.
|
||||
fn biome(&self, x: i32, y: i32, z: i32) -> &'a str {
|
||||
@@ -1645,7 +1734,8 @@ impl Mesher<'_> {
|
||||
self.zoom,
|
||||
self.blend_radius,
|
||||
[center.x, center.z, base_y],
|
||||
);
|
||||
)
|
||||
.with_colormaps(self.tables);
|
||||
let info = &self.tables.info;
|
||||
let full_faces = &self.tables.full_faces;
|
||||
let catalog = &self.render.model_catalog;
|
||||
@@ -1674,7 +1764,7 @@ impl Mesher<'_> {
|
||||
continue;
|
||||
}
|
||||
if state.fluid.kind() != FluidKind::Empty {
|
||||
let water = tints.get(Resolver::Water, x, y, z);
|
||||
let water = tints.water(x, y, z);
|
||||
let shade = (
|
||||
self.nether_shading,
|
||||
self.shader_pack_lighting,
|
||||
@@ -1808,13 +1898,18 @@ impl Mesher<'_> {
|
||||
if index < 0 || index >= i32::from(state.tint_count) {
|
||||
return None;
|
||||
}
|
||||
if let Some(colormap) = state.colormap
|
||||
&& let Some(colour) = tints.colormap(colormap, x, y, z)
|
||||
{
|
||||
return Some(colour);
|
||||
}
|
||||
Some(match state.tints[(index as usize).min(1)] {
|
||||
Tint::None => return None,
|
||||
Tint::Grass | Tint::GrassBlock | Tint::SugarCane => tints.get(Resolver::Grass, x, y, z),
|
||||
Tint::TallGrassUpper => tints.get(Resolver::Grass, x, y, z),
|
||||
Tint::Foliage => tints.get(Resolver::Foliage, x, y, z),
|
||||
Tint::DryFoliage => tints.get(Resolver::Dry, x, y, z),
|
||||
Tint::Water => tints.get(Resolver::Water, x, y, z),
|
||||
Tint::Water => tints.water(x, y, z),
|
||||
Tint::Constant(c) => c,
|
||||
Tint::LilyPad => 0x20_8030,
|
||||
Tint::Redstone(p) => redstone_color(p),
|
||||
|
||||
@@ -340,12 +340,11 @@ pub(super) fn tesselate(
|
||||
} else {
|
||||
(tables.lava_still, tables.lava_flow, None)
|
||||
};
|
||||
let target = if water {
|
||||
&mut out.translucent
|
||||
let (target, sprite_layers) = if water {
|
||||
(&mut out.translucent, &mut out.sprite_layers)
|
||||
} else {
|
||||
&mut out.solid
|
||||
(&mut out.solid, &mut out.sprite_layers)
|
||||
};
|
||||
let mut sprite_layers = Vec::new();
|
||||
let mut quad = |v: [([f32; 3], [f32; 2]); 4],
|
||||
layer: u16,
|
||||
color: u32,
|
||||
@@ -497,7 +496,6 @@ pub(super) fn tesselate(
|
||||
d as usize,
|
||||
);
|
||||
}
|
||||
out.sprite_layers.extend(sprite_layers);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -335,6 +335,7 @@ mod tests {
|
||||
block_entity: false,
|
||||
tints: [super::super::Tint::None; 2],
|
||||
tint_count: 0,
|
||||
colormap: None,
|
||||
offset: None,
|
||||
legacy_solid: false,
|
||||
overlay: leaves,
|
||||
|
||||
@@ -199,16 +199,29 @@ pub fn packed_vertex(words: &[u32; 6], corner: usize) -> TerrainVertex {
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static RECORDS_SCRATCH: std::cell::RefCell<Vec<Option<[u32; 6]>>> =
|
||||
const { std::cell::RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// The compact layout of sorted solid quads with their tints (`SectionMesh::packed`), or
|
||||
/// nothing when it would be no smaller than their vertices.
|
||||
pub fn layout(solid: &[TerrainVertex], tints: &[u32]) -> Vec<[u32; 6]> {
|
||||
let mut out = Vec::new();
|
||||
layout_into(solid, tints, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Writes the compact layout of sorted solid quads with their tints into `out`,
|
||||
/// reusing `out`'s allocation and a thread-local record scratch buffer.
|
||||
pub fn layout_into(solid: &[TerrainVertex], tints: &[u32], out: &mut Vec<[u32; 6]>) {
|
||||
let quads = solid.as_chunks::<4>().0;
|
||||
let records: Vec<Option<[u32; 6]>> = quads
|
||||
.iter()
|
||||
.zip(tints)
|
||||
.map(|(q, &t)| pack_quad(q, t))
|
||||
.collect();
|
||||
place(&records, quads)
|
||||
RECORDS_SCRATCH.with(|cell| {
|
||||
let mut records = cell.borrow_mut();
|
||||
records.clear();
|
||||
records.extend(quads.iter().zip(tints).map(|(q, &t)| pack_quad(q, t)));
|
||||
place_into(&records, quads, out);
|
||||
});
|
||||
}
|
||||
|
||||
/// A mesh's solid range from a record per quad, `None` where no record fits:
|
||||
@@ -220,6 +233,17 @@ pub fn layout(solid: &[TerrainVertex], tints: &[u32]) -> Vec<[u32; 6]> {
|
||||
/// Shared with `lod::packed`, whose records hold a far tile's quads instead but
|
||||
/// spill and pad the same way.
|
||||
pub fn place(records: &[Option<[u32; 6]>], quads: &[[TerrainVertex; 4]]) -> Vec<[u32; 6]> {
|
||||
let mut out = Vec::new();
|
||||
place_into(records, quads, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
pub fn place_into(
|
||||
records: &[Option<[u32; 6]>],
|
||||
quads: &[[TerrainVertex; 4]],
|
||||
out: &mut Vec<[u32; 6]>,
|
||||
) {
|
||||
out.clear();
|
||||
let n = quads.len();
|
||||
let packed = records.iter().filter(|r| r.is_some()).count();
|
||||
let full = n - packed;
|
||||
@@ -229,9 +253,9 @@ pub fn place(records: &[Option<[u32; 6]>], quads: &[[TerrainVertex; 4]]) -> Vec<
|
||||
0
|
||||
};
|
||||
if n + pad + 4 * full >= 4 * n {
|
||||
return Vec::new();
|
||||
return;
|
||||
}
|
||||
let mut out = Vec::with_capacity(n + pad + 4 * full);
|
||||
out.reserve(n + pad + 4 * full);
|
||||
let mut next = n + pad;
|
||||
for (i, record) in records.iter().enumerate() {
|
||||
out.push(record.unwrap_or_else(|| {
|
||||
@@ -249,7 +273,6 @@ pub fn place(records: &[Option<[u32; 6]>], quads: &[[TerrainVertex; 4]]) -> Vec<
|
||||
);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Quads `layout` gave a record of their own.
|
||||
|
||||
@@ -112,3 +112,72 @@ fn one_biome_shortcut_blends_like_the_full_square() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A pack root holding `files` (path, bytes), for colormap tests.
|
||||
fn pack(files: &[(&str, Vec<u8>)]) -> std::path::PathBuf {
|
||||
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"horizon-mesher-colormap-{}-{n}",
|
||||
std::process::id()
|
||||
));
|
||||
for (path, bytes) in files {
|
||||
let path = root.join(path);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
}
|
||||
root
|
||||
}
|
||||
|
||||
/// A one-row PNG of RGBA pixels.
|
||||
fn png_row(pixels: &[[u8; 4]]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let mut encoder = png::Encoder::new(&mut out, pixels.len() as u32, 1);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header().unwrap();
|
||||
writer.write_image_data(&pixels.concat()).unwrap();
|
||||
writer.finish().unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_polytone_colormap_tints_by_biome_and_keeps_the_biome_colour_where_transparent() {
|
||||
let biomes = Biomes::from_jsons(&[
|
||||
(
|
||||
"minecraft:plains",
|
||||
serde_json::json!({"effects": {"water_color": 0x3F76E4}}),
|
||||
),
|
||||
(
|
||||
"minecraft:desert",
|
||||
serde_json::json!({"effects": {"water_color": 0x32A598}}),
|
||||
),
|
||||
]);
|
||||
// Left to right: transparent, red. Desert maps to column 1, plains (unnamed) to column 0.
|
||||
let root = pack(&[
|
||||
(
|
||||
"assets/mypack/polytone/colormaps/water.png",
|
||||
png_row(&[[0, 0, 0, 0], [255, 0, 0, 255]]),
|
||||
),
|
||||
(
|
||||
"assets/mypack/polytone/colormaps/water.json",
|
||||
br#"{"x_axis": "biome_id", "y_axis": 0, "biome_id_mapper": {"minecraft:desert": 1, "texture_size": 2}}"#
|
||||
.to_vec(),
|
||||
),
|
||||
]);
|
||||
let colormaps = vec![Arc::new(
|
||||
crate::environment::Colormap::load(&root, "mypack:water").unwrap(),
|
||||
)];
|
||||
for (biome, expected) in [(1, 0xFF_0000), (0, 0x3F_76E4)] {
|
||||
let chunks: Vec<Vec<Chunk>> = (0..3)
|
||||
.map(|rz| (0..3).map(|rx| filled(rx - 1, rz - 1, biome)).collect())
|
||||
.collect();
|
||||
let region: [[Option<&Chunk>; 3]; 3] =
|
||||
std::array::from_fn(|rz| std::array::from_fn(|rx| Some(&chunks[rz][rx])));
|
||||
let mut tints = Tints::new(®ion, &biomes, BiomeZoom::new(0), 0, [0, 0, 64]);
|
||||
tints.colormaps = &colormaps;
|
||||
tints.water_colormap = Some(0);
|
||||
assert_eq!(tints.water(3, 4, 5), expected, "biome {biome}");
|
||||
}
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
@@ -84,6 +84,7 @@ fn info(solid: bool) -> Info {
|
||||
block_entity: false,
|
||||
tints: [Tint::None; 2],
|
||||
tint_count: 0,
|
||||
colormap: None,
|
||||
offset: None,
|
||||
legacy_solid: false,
|
||||
overlay: false,
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
return oit_transmittance_fragment(entity_color(in, front).a, in.clip);
|
||||
}
|
||||
@fragment fn fs_oit_entity_accumulate(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
return oit_accumulate_fragment(entity_written_color(in, front), in.clip);
|
||||
return oit_accumulate_fragment(entity_written_color(in, entity_color(in, front)), in.clip);
|
||||
}
|
||||
// Text keeps its source gain, as fs_entity_unmarked does in the forward pass.
|
||||
@fragment fn fs_oit_text_accumulate(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
|
||||
@@ -34,8 +34,11 @@ pub struct Pack {
|
||||
pub description_style: Style,
|
||||
pub compatible: bool,
|
||||
pub compatibility: crate::pack_screen::Compat,
|
||||
/// Overlay directories active for this game version, in `pack.mcmeta` order.
|
||||
/// Overlay directories active for this game version, in `pack.mcmeta` order: also those a
|
||||
/// Polytone `polytone_condition` turns on.
|
||||
pub overlays: Vec<String>,
|
||||
/// The values of the pack's Polytone config entries.
|
||||
pub polytone_configs: horizon_polytone::pack::Configs,
|
||||
/// `filter.block` entries (namespace and path patterns) hiding resources
|
||||
/// of the packs below this one.
|
||||
pub filter: Vec<Filter>,
|
||||
@@ -416,12 +419,12 @@ fn overlay_applies(entry: &serde_json::Value) -> bool {
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
fn overlays_of(meta: &serde_json::Value) -> Vec<String> {
|
||||
fn overlays_of(meta: &serde_json::Value, configs: &horizon_polytone::pack::Configs) -> Vec<String> {
|
||||
meta["overlays"]["entries"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|e| overlay_applies(e))
|
||||
.filter(|e| horizon_polytone::pack::overlay_active(e, configs, overlay_applies(e)))
|
||||
.filter_map(|e| e["directory"].as_str().map(str::to_owned))
|
||||
// OverlayMetadataSection's DIR_VALIDATOR: one folder of the pack, `[-_a-zA-Z0-9.]+`.
|
||||
.filter(|d| {
|
||||
@@ -468,12 +471,66 @@ pub(crate) fn parse_meta(bytes: &[u8]) -> Option<serde_json::Value> {
|
||||
.next()?
|
||||
.ok()
|
||||
}
|
||||
/// A pack's Polytone config entries (`assets/<namespace>/polytone/config_entries/<name>.json`,
|
||||
/// in the pack's base, not its overlays) at their default values.
|
||||
pub fn polytone_configs(source: &Source) -> horizon_polytone::pack::Configs {
|
||||
let mut configs = horizon_polytone::pack::Configs::default();
|
||||
let id_of = |name: &str| -> Option<String> {
|
||||
let rest = name.strip_prefix("assets/")?;
|
||||
let (namespace, rest) = rest.split_once('/')?;
|
||||
let entry = rest
|
||||
.strip_prefix("polytone/config_entries/")?
|
||||
.strip_suffix(".json")?;
|
||||
Some(format!("{namespace}:{entry}"))
|
||||
};
|
||||
match source {
|
||||
Source::Dir(d) => {
|
||||
let Ok(namespaces) = fs::read_dir(d.join("assets")) else {
|
||||
return configs;
|
||||
};
|
||||
for namespace in namespaces.flatten() {
|
||||
let folder = namespace.path().join("polytone/config_entries");
|
||||
for file in fs::read_dir(&folder).into_iter().flatten().flatten() {
|
||||
let name = format!(
|
||||
"assets/{}/polytone/config_entries/{}",
|
||||
namespace.file_name().to_string_lossy(),
|
||||
file.file_name().to_string_lossy()
|
||||
);
|
||||
if let (Some(id), Ok(bytes)) = (id_of(&name), fs::read(file.path()))
|
||||
&& let Ok(json) = serde_json::from_slice(&bytes)
|
||||
{
|
||||
configs.add_entry(id, &json);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Source::Zip(z) => {
|
||||
let Ok(zip) = Zip::open(z) else {
|
||||
return configs;
|
||||
};
|
||||
let names: Vec<String> = zip
|
||||
.names()
|
||||
.filter(|n| id_of(n).is_some())
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
for name in names {
|
||||
if let (Some(id), Ok(bytes)) = (id_of(&name), zip.read(&name))
|
||||
&& let Ok(json) = serde_json::from_slice(&bytes)
|
||||
{
|
||||
configs.add_entry(id, &json);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
configs
|
||||
}
|
||||
fn pack(id: String, title: Text, source: Source) -> Option<Pack> {
|
||||
pack_for(id, title, source, RESOURCE_FORMAT)
|
||||
}
|
||||
/// A pack whose compatibility is against `format`.
|
||||
fn pack_for(id: String, title: Text, source: Source, format: (i64, i64)) -> Option<Pack> {
|
||||
let meta = read_meta(&source)?;
|
||||
let polytone_configs = polytone_configs(&source);
|
||||
let (raw_description, description_style) = component_root(&meta["pack"]["description"]);
|
||||
Some(Pack {
|
||||
id,
|
||||
@@ -483,7 +540,8 @@ fn pack_for(id: String, title: Text, source: Source, format: (i64, i64)) -> Opti
|
||||
description_style,
|
||||
compatible: compatible(&meta),
|
||||
compatibility: compatibility_for(&meta, format),
|
||||
overlays: overlays_of(&meta),
|
||||
overlays: overlays_of(&meta, &polytone_configs),
|
||||
polytone_configs,
|
||||
filter: filter_of(&meta),
|
||||
source,
|
||||
})
|
||||
@@ -1930,6 +1988,36 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
#[test]
|
||||
fn polytone_conditions_turn_overlays_on_from_the_packs_configs() {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("horizon-packs-polytone-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
let configs = dir.join("assets/mypack/polytone/config_entries");
|
||||
fs::create_dir_all(&configs).unwrap();
|
||||
fs::write(configs.join("fog.json"), r#"{"default_value": "ON"}"#).unwrap();
|
||||
fs::write(
|
||||
configs.join("birds.json"),
|
||||
r#"{"default_value": 0, "max": 300}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.join("pack.mcmeta"),
|
||||
r#"{"pack":{"description":"p","pack_format":97},"overlays":{"entries":[
|
||||
{"directory":"opt_fog","formats":[0,0],"polytone_condition":"config('mypack:fog') == 'ON'"},
|
||||
{"directory":"opt_birds","formats":[0,0],"polytone_condition":"config('mypack:birds') > 0"},
|
||||
{"directory":"opt_plain","formats":[0,0]}]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let pack = pack(
|
||||
"file/p".into(),
|
||||
Text::literal("p"),
|
||||
Source::Dir(dir.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(pack.overlays, ["opt_fog"]);
|
||||
let _ = fs::remove_dir_all(dir);
|
||||
}
|
||||
#[test]
|
||||
fn overlays_and_filters() {
|
||||
let v = |s: &str| serde_json::from_str::<serde_json::Value>(s).unwrap();
|
||||
let meta = v(r#"{"overlays":{"entries":[
|
||||
@@ -1937,7 +2025,7 @@ mod tests {
|
||||
{"formats":{"min_inclusive":97,"max_inclusive":97},"directory":"new"},
|
||||
{"min_format":97,"max_format":[97,5],"directory":"also"},
|
||||
{"formats":50,"directory":"no"}]}}"#);
|
||||
assert_eq!(overlays_of(&meta), ["new", "also"]);
|
||||
assert_eq!(overlays_of(&meta, &Default::default()), ["new", "also"]);
|
||||
// Only one plain folder of the pack, as vanilla's DIR_VALIDATOR has it.
|
||||
let meta = v(r#"{"overlays":{"entries":[
|
||||
{"formats":97,"directory":".."},
|
||||
@@ -1946,7 +2034,7 @@ mod tests {
|
||||
{"formats":97,"directory":"a/b"},
|
||||
{"formats":97,"directory":"C:"},
|
||||
{"formats":97,"directory":"ok_1.21-x"}]}}"#);
|
||||
assert_eq!(overlays_of(&meta), ["ok_1.21-x"]);
|
||||
assert_eq!(overlays_of(&meta, &Default::default()), ["ok_1.21-x"]);
|
||||
let base = std::env::temp_dir().join(format!("horizon-packs-ov-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&base);
|
||||
let vanilla = base.join("vanilla");
|
||||
|
||||
@@ -28,6 +28,8 @@ use std::{
|
||||
pub use horizon_client_core::particle_type::ParticleType;
|
||||
pub use horizon_items::firework::{DYE_FIREWORK_COLORS, FireworkExplosion, FireworkShape};
|
||||
mod native;
|
||||
pub mod polytone;
|
||||
mod polytone_gpu;
|
||||
pub use native::{ParticleController, ParticleUpdate};
|
||||
|
||||
/// `ParticleOptions` with the parameters of the option classes used here.
|
||||
@@ -64,6 +66,8 @@ pub struct ParticleOptions {
|
||||
/// `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,
|
||||
/// What the Polytone emitter that spawned this custom particle sets on it.
|
||||
pub polytone_seed: Option<std::sync::Arc<polytone::Seed>>,
|
||||
}
|
||||
impl ParticleOptions {
|
||||
pub fn simple(ty: ParticleType) -> Self {
|
||||
@@ -80,6 +84,7 @@ impl ParticleOptions {
|
||||
mod_particle: None,
|
||||
legacy_entity_effect: None,
|
||||
legacy_trail_lifetime: false,
|
||||
polytone_seed: None,
|
||||
}
|
||||
}
|
||||
/// A native mod's `SimpleParticleType`: the sprite set of
|
||||
@@ -522,6 +527,12 @@ pub struct Particle {
|
||||
/// `Particle.random`: the particle's own generator (`RandomSource.create()`).
|
||||
random: LegacyRandom,
|
||||
controller: Option<Box<dyn native::Controller>>,
|
||||
/// A resource pack's Polytone custom particle type, for its `limit`.
|
||||
polytone_type: Option<std::sync::Arc<str>>,
|
||||
/// How a Polytone custom particle's quad turns (`rotation_mode`).
|
||||
orientation: polytone_gpu::Orientation,
|
||||
/// Blended additively (a Polytone `additive_translucent` particle).
|
||||
additive: bool,
|
||||
}
|
||||
fn aabb_at(p: [f64; 3], w: f32, h: f32) -> Aabb {
|
||||
let w = f64::from(w) / 2.0;
|
||||
@@ -883,6 +894,9 @@ impl Particle {
|
||||
frame_ticks: 0,
|
||||
random: LegacyRandom::new(0),
|
||||
controller: None,
|
||||
polytone_type: None,
|
||||
orientation: polytone_gpu::Orientation::Camera,
|
||||
additive: false,
|
||||
}
|
||||
}
|
||||
/// The normal age/removal test and previous pose, without vanilla motion.
|
||||
@@ -1277,6 +1291,13 @@ pub struct ParticleEngine {
|
||||
/// A native mod's sprite sets, by namespaced particle name.
|
||||
mod_sets: HashMap<String, (u32, Option<ModBehaviour>)>,
|
||||
set_sprites: Vec<Vec<u32>>,
|
||||
/// Resource packs' Polytone custom particle types (#11486).
|
||||
polytone: Option<std::sync::Arc<polytone::Registry>>,
|
||||
/// The packs' GPU particles' records, one store per type, and the ticks they count in.
|
||||
polytone_gpu: Vec<polytone_gpu::Store>,
|
||||
polytone_ticks: u64,
|
||||
/// The last frame's additively blended quads (`take_additive`).
|
||||
additive: std::sync::Mutex<Vec<Vertex>>,
|
||||
/// `TrackingEmitter`s: particle type, entity feet position, width, height, ticks left.
|
||||
emitters: Vec<(ParticleOptions, [f64; 3], f64, f64, u32)>,
|
||||
/// Camera position for the 32-block spawn limiter.
|
||||
@@ -1469,6 +1490,10 @@ impl ParticleEngine {
|
||||
sets: HashMap::new(),
|
||||
mod_sets: HashMap::new(),
|
||||
set_sprites: Vec::new(),
|
||||
polytone: None,
|
||||
polytone_gpu: Vec::new(),
|
||||
polytone_ticks: 0,
|
||||
additive: std::sync::Mutex::default(),
|
||||
emitters: Vec::new(),
|
||||
starters: Vec::new(),
|
||||
tracked_explosions: Vec::new(),
|
||||
@@ -1689,6 +1714,28 @@ impl ParticleEngine {
|
||||
particle.controller = Some(Box::new(controller));
|
||||
Some(particle)
|
||||
}
|
||||
/// Sets (or clears) the resource packs' Polytone custom particle types.
|
||||
pub fn set_polytone(&mut self, registry: Option<std::sync::Arc<polytone::Registry>>) {
|
||||
let registry = registry.filter(|r| !r.is_empty());
|
||||
let same = match (&self.polytone, ®istry) {
|
||||
(Some(a), Some(b)) => std::sync::Arc::ptr_eq(a, b),
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
};
|
||||
if !same {
|
||||
self.polytone_gpu.clear();
|
||||
}
|
||||
self.polytone = registry;
|
||||
}
|
||||
/// The quads the last render blends additively (`RenderPipelines` additive, src alpha and
|
||||
/// one), for the frame's additive pass.
|
||||
pub fn take_additive(&self) -> Vec<Vertex> {
|
||||
std::mem::take(&mut *self.additive.lock().unwrap_or_else(|e| e.into_inner()))
|
||||
}
|
||||
/// Live GPU particle records (spawns, before `area` spreads them), for tests and status.
|
||||
pub fn polytone_gpu_records(&self) -> usize {
|
||||
self.polytone_gpu.iter().map(polytone_gpu::Store::len).sum()
|
||||
}
|
||||
/// Choose a frame from this particle's declared sprite set.
|
||||
pub fn set_sprite_frame(&self, particle: &mut Particle, frame: usize) {
|
||||
if let Some(sprites) = particle.set.and_then(|s| self.set_sprites.get(s as usize))
|
||||
@@ -2092,6 +2139,27 @@ impl ParticleEngine {
|
||||
let ty = o.ty;
|
||||
// A native mod's SimpleParticleType: vanilla's TextureSheetParticle with
|
||||
// the sprite set its particles/<name>.json names, no tint, 20..40 ticks.
|
||||
if let Some(name) = &o.mod_particle
|
||||
&& let Some(registry) = &self.polytone
|
||||
&& let Some(def) = registry.get(name)
|
||||
{
|
||||
let (registry, def) = (registry.clone(), def.clone());
|
||||
let particle = polytone::make(self, ®istry, name, &def, o, p, v, seed)?;
|
||||
if def.gpu.is_some() {
|
||||
// A GPU particle is only a record: it never becomes an engine particle.
|
||||
let now = self.polytone_ticks;
|
||||
let store = match self.polytone_gpu.iter().position(|s| s.is(&def)) {
|
||||
Some(i) => &mut self.polytone_gpu[i],
|
||||
None => {
|
||||
self.polytone_gpu.push(polytone_gpu::Store::new(&def)?);
|
||||
self.polytone_gpu.last_mut()?
|
||||
}
|
||||
};
|
||||
store.spawn(&particle, now, seed as u64);
|
||||
return None;
|
||||
}
|
||||
return Some(particle);
|
||||
}
|
||||
if let Some(name) = &o.mod_particle {
|
||||
let (set, behaviour) = *self.mod_sets.get(name)?;
|
||||
let mut r = LegacyRandom::new(seed);
|
||||
@@ -3726,6 +3794,14 @@ impl ParticleEngine {
|
||||
}
|
||||
}
|
||||
pub fn tick(&mut self, level: &ParticleLevel) {
|
||||
self.polytone_ticks += 1;
|
||||
for store in &mut self.polytone_gpu {
|
||||
store.expire(self.polytone_ticks);
|
||||
}
|
||||
// The sounds the packs' custom particles played last tick.
|
||||
if let Some(registry) = &self.polytone {
|
||||
self.sounds.extend(registry.take_sounds());
|
||||
}
|
||||
self.tick_starters();
|
||||
self.tick_explosions(level);
|
||||
for (options, feet, width, height, life) in std::mem::take(&mut self.emitters) {
|
||||
@@ -4718,6 +4794,8 @@ impl ParticleEngine {
|
||||
let base = Quat::from_rotation_y(std::f32::consts::PI - yaw.to_radians())
|
||||
* Quat::from_rotation_x(-pitch.to_radians());
|
||||
let mut light_cache = ParticleLightCache::default();
|
||||
let mut additive = self.additive.lock().unwrap_or_else(|e| e.into_inner());
|
||||
additive.clear();
|
||||
let mut section_cache =
|
||||
(CULL && frustum.is_some()).then(ParticleSectionFrustumCache::default);
|
||||
for p in &self.particles {
|
||||
@@ -4789,7 +4867,15 @@ impl ParticleEngine {
|
||||
) - camera;
|
||||
(pos.as_vec3(), p.quad_size(partial))
|
||||
});
|
||||
let rotation = if p.roll != 0.0 {
|
||||
let rotation = if p.orientation != polytone_gpu::Orientation::Camera {
|
||||
let velocity = FVec3::new(
|
||||
p.velocity[0] as f32,
|
||||
p.velocity[1] as f32,
|
||||
p.velocity[2] as f32,
|
||||
);
|
||||
let roll = p.o_roll + (p.roll - p.o_roll) * partial;
|
||||
polytone_gpu::orient(p.orientation, center, velocity, roll, base)
|
||||
} else if p.roll != 0.0 {
|
||||
base * Quat::from_rotation_z(p.o_roll + (p.roll - p.o_roll) * partial)
|
||||
} else {
|
||||
base
|
||||
@@ -4871,6 +4957,7 @@ impl ParticleEngine {
|
||||
r.u0 + du * p.uv[2],
|
||||
r.v0 + dv * p.uv[3],
|
||||
);
|
||||
let target: &mut Vec<Vertex> = if p.additive { &mut additive } else { &mut *out };
|
||||
for rotation in rotations.into_iter().flatten() {
|
||||
for (n, uv) in [
|
||||
((1.0, -1.0), [u1, v1]),
|
||||
@@ -4879,7 +4966,7 @@ impl ParticleEngine {
|
||||
((-1.0, -1.0), [u0, v1]),
|
||||
] {
|
||||
let v = center + rotation * FVec3::new(n.0, n.1, 0.0) * size;
|
||||
out.push(Vertex {
|
||||
target.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: v.to_array(),
|
||||
uv,
|
||||
@@ -4896,6 +4983,19 @@ impl ParticleEngine {
|
||||
}
|
||||
}
|
||||
}
|
||||
for store in &self.polytone_gpu {
|
||||
store.render(
|
||||
&self.atlas,
|
||||
camera,
|
||||
base,
|
||||
self.polytone_ticks,
|
||||
partial,
|
||||
&mut light_cache,
|
||||
light_at,
|
||||
out,
|
||||
&mut additive,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// How brightly particle `ty` shines in HDR output, as `mesher::hdr_tier`: flames,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,408 @@
|
||||
//! How resource packs' Polytone particles are drawn (#11486): the rotation modes of custom
|
||||
//! particles, and GPU particles (wiki "GPU Particles"). A GPU particle never ticks: its spawn is a
|
||||
//! record (position, velocity, spawn time, lifetime, seed, colour, size, roll), and where it is,
|
||||
//! how big, what colour and which frame are a function of that record and the time. Here the
|
||||
//! function runs when the frame's particle quads are built, beside the engine's own particles.
|
||||
use super::{ParticleLevel, ParticleLightCache};
|
||||
use crate::hud::Rect;
|
||||
use crate::mesher::Vertex;
|
||||
use glam::{DVec3, Mat3, Quat, Vec3 as FVec3};
|
||||
use horizon_polytone::particles::{CustomParticle, Gpu, RenderType};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// How a particle quad turns (`rotation_mode`).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(super) enum Orientation {
|
||||
/// `look_at_xyz`: faces the camera (what every other mode falls back to).
|
||||
#[default]
|
||||
Camera,
|
||||
/// `look_at_y`: turns around Y only.
|
||||
AroundY,
|
||||
/// `look_up`: lies flat, facing up.
|
||||
Flat,
|
||||
/// `movement_aligned`: stands along its velocity, facing the camera around it.
|
||||
Movement,
|
||||
}
|
||||
|
||||
impl Orientation {
|
||||
pub(super) fn parse(mode: &str) -> Self {
|
||||
match mode {
|
||||
"look_at_y" => Self::AroundY,
|
||||
"look_up" => Self::Flat,
|
||||
"movement_aligned" => Self::Movement,
|
||||
_ => Self::Camera,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The rotation of a quad (corners in its XY plane, facing +Z) at `center` (relative to the
|
||||
/// camera) moving along `velocity`, rolled by `roll`; `camera` is the camera-facing rotation.
|
||||
pub(super) fn orient(
|
||||
mode: Orientation,
|
||||
center: FVec3,
|
||||
velocity: FVec3,
|
||||
roll: f32,
|
||||
camera: Quat,
|
||||
) -> Quat {
|
||||
let rolled = |q: Quat| q * Quat::from_rotation_z(roll);
|
||||
match mode {
|
||||
Orientation::Camera => rolled(camera),
|
||||
Orientation::AroundY => rolled(Quat::from_rotation_y((-center.x).atan2(-center.z))),
|
||||
Orientation::Flat => rolled(Quat::from_rotation_x(-std::f32::consts::FRAC_PI_2)),
|
||||
Orientation::Movement => {
|
||||
let up = velocity.normalize_or_zero();
|
||||
let right = up.cross((-center).normalize_or_zero()).normalize_or_zero();
|
||||
if right == FVec3::ZERO {
|
||||
return rolled(camera);
|
||||
}
|
||||
Quat::from_mat3(&Mat3::from_cols(right, up, right.cross(up)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One GPU particle spawn.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Record {
|
||||
position: [f64; 3],
|
||||
velocity: [f64; 3],
|
||||
/// The engine's tick it spawned on, and its life in ticks.
|
||||
spawn: u64,
|
||||
lifetime: f32,
|
||||
seed: u64,
|
||||
rgba: [f32; 4],
|
||||
size: f32,
|
||||
roll: f32,
|
||||
}
|
||||
|
||||
/// A GPU particle type's records: a ring of `limit` slots, the oldest overwritten.
|
||||
pub(super) struct Store {
|
||||
def: Arc<CustomParticle>,
|
||||
gpu: Gpu,
|
||||
orientation: Orientation,
|
||||
/// The texture's sprite name in the particle atlas (`ns:particle/path`).
|
||||
sprite: String,
|
||||
records: Vec<Record>,
|
||||
next: usize,
|
||||
limit: usize,
|
||||
}
|
||||
|
||||
/// The atlas name of a full texture path: `ns:textures/particle/a/b.png` is `ns:particle/a/b`
|
||||
/// (`particle/a/b` in `minecraft`).
|
||||
fn sprite_name(texture: &str) -> String {
|
||||
let (namespace, path) = texture.split_once(':').unwrap_or(("minecraft", texture));
|
||||
let path = path
|
||||
.trim_start_matches("textures/")
|
||||
.trim_end_matches(".png");
|
||||
if namespace == "minecraft" {
|
||||
path.to_owned()
|
||||
} else {
|
||||
format!("{namespace}:{path}")
|
||||
}
|
||||
}
|
||||
|
||||
/// SplitMix64: a well-mixed number from `seed` and `i`, for each quad's own randoms.
|
||||
fn mix(seed: u64, i: u64) -> u64 {
|
||||
let mut z = seed ^ i.wrapping_mul(0x9E37_79B9_7F4A_7C15);
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
|
||||
/// A number in 0..1 from `mix`.
|
||||
fn unit(seed: u64, i: u64) -> f64 {
|
||||
(mix(seed, i) >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
|
||||
/// Where a record's particle is after `t` ticks, and how fast it moves: gravity pulls down,
|
||||
/// friction (`friction` kept per tick, as a continuous drag) slows it toward a terminal velocity.
|
||||
fn motion(gpu: &Gpu, position: [f64; 3], velocity: [f64; 3], t: f64) -> ([f64; 3], [f64; 3]) {
|
||||
let drag = if gpu.friction > 0.0 && gpu.friction < 1.0 {
|
||||
-gpu.friction.ln()
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let gravity = [0.0, -gpu.gravity, 0.0];
|
||||
let mut at = [0.0; 3];
|
||||
let mut moving = [0.0; 3];
|
||||
for i in 0..3 {
|
||||
let (v, g) = (velocity[i], gravity[i]);
|
||||
if drag > 1.0e-9 {
|
||||
let e = (-drag * t).exp();
|
||||
at[i] = position[i] + v * (1.0 - e) / drag + g * (t / drag - (1.0 - e) / (drag * drag));
|
||||
moving[i] = v * e + g * (1.0 - e) / drag;
|
||||
} else {
|
||||
at[i] = position[i] + v * t + 0.5 * g * t * t;
|
||||
moving[i] = v + g * t;
|
||||
}
|
||||
}
|
||||
(at, moving)
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub(super) fn new(def: &Arc<CustomParticle>) -> Option<Self> {
|
||||
let gpu = def.gpu.clone()?;
|
||||
Some(Self {
|
||||
def: def.clone(),
|
||||
orientation: Orientation::parse(&def.rotation_mode),
|
||||
sprite: sprite_name(&gpu.texture),
|
||||
gpu,
|
||||
records: Vec::new(),
|
||||
next: 0,
|
||||
// The wiki's default ring capacity.
|
||||
limit: def.limit.map_or(16_384, |l| l.max(1) as usize),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn is(&self, def: &Arc<CustomParticle>) -> bool {
|
||||
Arc::ptr_eq(&self.def, def)
|
||||
}
|
||||
|
||||
/// Records a spawn from a particle its initializer set up (position, motion, life, look).
|
||||
pub(super) fn spawn(&mut self, p: &super::Particle, now: u64, seed: u64) {
|
||||
if p.lifetime <= 0 {
|
||||
return;
|
||||
}
|
||||
let record = Record {
|
||||
position: p.position,
|
||||
velocity: p.velocity,
|
||||
spawn: now,
|
||||
lifetime: p.lifetime as f32,
|
||||
seed,
|
||||
rgba: p.rgba,
|
||||
size: p.size,
|
||||
roll: p.roll,
|
||||
};
|
||||
if self.records.len() < self.limit {
|
||||
self.records.push(record);
|
||||
} else {
|
||||
self.records[self.next] = record;
|
||||
self.next = (self.next + 1) % self.limit;
|
||||
}
|
||||
}
|
||||
|
||||
/// Forgets the records whose life is over.
|
||||
pub(super) fn expire(&mut self, now: u64) {
|
||||
let before = self.records.len();
|
||||
self.records
|
||||
.retain(|r| (now.saturating_sub(r.spawn) as f32) < r.lifetime);
|
||||
if self.records.len() != before {
|
||||
self.next = 0;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn len(&self) -> usize {
|
||||
self.records.len()
|
||||
}
|
||||
|
||||
/// The quads of every live record at `now + partial` ticks.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn render(
|
||||
&self,
|
||||
atlas: &HashMap<String, (Rect, bool)>,
|
||||
camera: DVec3,
|
||||
facing: Quat,
|
||||
now: u64,
|
||||
partial: f32,
|
||||
light_cache: &mut ParticleLightCache,
|
||||
light_at: &dyn Fn([i32; 3]) -> u8,
|
||||
out: &mut Vec<Vertex>,
|
||||
additive: &mut Vec<Vertex>,
|
||||
) {
|
||||
if self.def.render_type == RenderType::Invisible {
|
||||
return;
|
||||
}
|
||||
let Some(&(rect, _)) = atlas.get(&self.sprite) else {
|
||||
return;
|
||||
};
|
||||
let gpu = &self.gpu;
|
||||
let translucent = matches!(
|
||||
self.def.render_type,
|
||||
RenderType::Translucent | RenderType::AdditiveTranslucent
|
||||
);
|
||||
let blend_add = self.def.render_type == RenderType::AdditiveTranslucent;
|
||||
let target = if blend_add { additive } else { out };
|
||||
let layer = if translucent && !blend_add {
|
||||
crate::gfx::TRANSLUCENT_PARTICLE_OVERLAY
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let (count, area) = gpu.area.map_or((1, [0.0; 3]), |(size, n)| (n, size));
|
||||
let frames = gpu.frames.max(1);
|
||||
for r in &self.records {
|
||||
let t = (now.saturating_sub(r.spawn)) as f32 + partial;
|
||||
if t >= r.lifetime {
|
||||
continue;
|
||||
}
|
||||
let life = (t / r.lifetime).clamp(0.0, 1.0);
|
||||
let (at, moving) = motion(gpu, r.position, r.velocity, f64::from(t));
|
||||
// Fading in and out, and the colour and size toward their ends.
|
||||
let [fade_in, fade_out] = gpu.fade.map(|f| f as f32);
|
||||
let mut alpha = r.rgba[3];
|
||||
if fade_in > 0.0 {
|
||||
alpha *= (life / fade_in).min(1.0);
|
||||
}
|
||||
if fade_out > 0.0 {
|
||||
alpha *= ((1.0 - life) / fade_out).min(1.0);
|
||||
}
|
||||
let mut rgb = [r.rgba[0], r.rgba[1], r.rgba[2]];
|
||||
if let Some(end) = gpu.color_end {
|
||||
let channel = |shift: u32| ((end >> shift) & 0xff) as f32 / 255.0;
|
||||
let to = [channel(16), channel(8), channel(0)];
|
||||
for i in 0..3 {
|
||||
rgb[i] += (to[i] - rgb[i]) * life;
|
||||
}
|
||||
alpha += (alpha * channel(24) - alpha) * life;
|
||||
}
|
||||
let size = gpu
|
||||
.size_end
|
||||
.map_or(r.size, |end| r.size + (end as f32 - r.size) * life);
|
||||
let c = |v: f32| (v.clamp(0.0, 1.0) * 255.0) as u32;
|
||||
let color = c(alpha) << 24 | c(rgb[2]) << 16 | c(rgb[1]) << 8 | c(rgb[0]);
|
||||
let frame = if self.def.random_sprite {
|
||||
(mix(r.seed, 0) % u64::from(frames)) as u32
|
||||
} else {
|
||||
((life * frames as f32) as u32).min(frames - 1)
|
||||
};
|
||||
let (dv, v0) = ((rect.v1 - rect.v0) / frames as f32, rect.v0);
|
||||
let (u0, u1) = (rect.u0, rect.u1);
|
||||
let (v0, v1) = (v0 + dv * frame as f32, v0 + dv * (frame + 1) as f32);
|
||||
let velocity = FVec3::new(moving[0] as f32, moving[1] as f32, moving[2] as f32);
|
||||
for quad in 0..u64::from(count) {
|
||||
// Each quad of an area has its own place in the box, sway phase and roll.
|
||||
let offset: [f64; 3] = if count > 1 {
|
||||
std::array::from_fn(|i| (unit(r.seed, quad * 8 + i as u64 + 1) - 0.5) * area[i])
|
||||
} else {
|
||||
[0.0; 3]
|
||||
};
|
||||
let phase = unit(r.seed, quad * 8 + 4) * std::f64::consts::TAU;
|
||||
let sway = gpu.sway * (f64::from(t) * 0.1 + phase).sin();
|
||||
let sway_z = gpu.sway * (f64::from(t) * 0.07 + phase).cos();
|
||||
let world = [
|
||||
at[0] + offset[0] + sway,
|
||||
at[1] + offset[1],
|
||||
at[2] + offset[2] + sway_z,
|
||||
];
|
||||
let block = ParticleLevel::block_at(world);
|
||||
let packed = light_cache.get(block, light_at);
|
||||
// kill_below_heightmap: under the surface, where the sky doesn't reach.
|
||||
if gpu.kill_below_heightmap && packed >> 4 < 15 {
|
||||
continue;
|
||||
}
|
||||
let block_light = u32::from((packed & 15).max(self.def.light_level));
|
||||
let light = (u32::from(packed >> 4) * 16) | ((block_light * 16) << 8);
|
||||
let center = (DVec3::from_array(world) - camera).as_vec3();
|
||||
let roll = r.roll
|
||||
+ gpu.spin as f32 * t
|
||||
+ if count > 1 {
|
||||
(unit(r.seed, quad * 8 + 5) * std::f64::consts::TAU) as f32
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let rotation = orient(self.orientation, center, velocity, roll, facing);
|
||||
let half = FVec3::new(size, size * gpu.aspect as f32, 0.0);
|
||||
for (n, uv) in [
|
||||
((1.0, -1.0), [u1, v1]),
|
||||
((1.0, 1.0), [u1, v0]),
|
||||
((-1.0, 1.0), [u0, v0]),
|
||||
((-1.0, -1.0), [u0, v1]),
|
||||
] {
|
||||
let corner = FVec3::new(n.0 * half.x, n.1 * half.y, 0.0);
|
||||
target.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: (center + rotation * corner).to_array(),
|
||||
uv,
|
||||
layer,
|
||||
color,
|
||||
light,
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn gpu(gravity: f64, friction: f64) -> Gpu {
|
||||
Gpu {
|
||||
texture: String::new(),
|
||||
frames: 1,
|
||||
gravity,
|
||||
friction,
|
||||
size_end: None,
|
||||
color_end: None,
|
||||
fade: [0.0; 2],
|
||||
sway: 0.0,
|
||||
spin: 0.0,
|
||||
aspect: 1.0,
|
||||
area: None,
|
||||
kill_below_heightmap: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texture_paths_name_their_atlas_sprites() {
|
||||
assert_eq!(
|
||||
sprite_name("davidparticles:textures/particle/weather/rain.png"),
|
||||
"davidparticles:particle/weather/rain"
|
||||
);
|
||||
assert_eq!(
|
||||
sprite_name("minecraft:textures/particle/generic_7.png"),
|
||||
"particle/generic_7"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_follows_gravity_and_drag() {
|
||||
// No drag: a thrown body, x = v t, y = -g t^2 / 2.
|
||||
let (at, v) = motion(&gpu(0.1, 1.0), [0.0; 3], [1.0, 0.0, 0.0], 10.0);
|
||||
assert_eq!(at, [10.0, -5.0, 0.0]);
|
||||
assert!((v[1] + 1.0).abs() < 1e-12);
|
||||
// Drag: the fall speed tends to g / drag (terminal velocity).
|
||||
let friction = 0.9;
|
||||
let (_, v) = motion(&gpu(0.1, friction), [0.0; 3], [0.0; 3], 1000.0);
|
||||
assert!((v[1] + 0.1 / -f64::ln(friction)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orientations_turn_the_quad_normal() {
|
||||
let normal = |q: Quat| q * FVec3::Z;
|
||||
let close = |a: FVec3, b: FVec3| (a - b).length() < 1e-5;
|
||||
// Lying flat: facing up.
|
||||
assert!(close(
|
||||
normal(orient(
|
||||
Orientation::Flat,
|
||||
FVec3::ONE,
|
||||
FVec3::ZERO,
|
||||
0.0,
|
||||
Quat::IDENTITY
|
||||
)),
|
||||
FVec3::Y
|
||||
));
|
||||
// Around Y: facing the camera horizontally (the camera at the origin, the quad east).
|
||||
assert!(close(
|
||||
normal(orient(
|
||||
Orientation::AroundY,
|
||||
FVec3::new(5.0, 3.0, 0.0),
|
||||
FVec3::ZERO,
|
||||
0.0,
|
||||
Quat::IDENTITY
|
||||
)),
|
||||
-FVec3::X
|
||||
));
|
||||
// Along its motion: its up is the velocity, its face toward the camera.
|
||||
let q = orient(
|
||||
Orientation::Movement,
|
||||
FVec3::new(0.0, 0.0, -4.0),
|
||||
FVec3::new(0.0, -2.0, 0.0),
|
||||
0.0,
|
||||
Quat::IDENTITY,
|
||||
);
|
||||
assert!(close(q * FVec3::Y, -FVec3::Y));
|
||||
assert!(close(normal(q), FVec3::Z));
|
||||
}
|
||||
}
|
||||
@@ -155,6 +155,10 @@ pub struct Context {
|
||||
pub source: Option<Who>,
|
||||
/// The projectile's owner, when the source is a projectile.
|
||||
pub owner: Option<Who>,
|
||||
/// The saved UUIDs of `source` and `owner` (`VibrationInfo`'s `uuid` and
|
||||
/// `projectileOwnerUuid`), when the game knows them for that entity.
|
||||
pub source_uuid: Option<[i32; 4]>,
|
||||
pub owner_uuid: Option<[i32; 4]>,
|
||||
/// `affectedState` is in `dampens_vibrations`.
|
||||
pub dampening_state: bool,
|
||||
/// The source is a spectator.
|
||||
@@ -309,14 +313,29 @@ impl Data {
|
||||
}
|
||||
}
|
||||
fn info_to_nbt(info: &Info) -> Tag {
|
||||
Tag::compound(vec![
|
||||
let mut fields = vec![
|
||||
("game_event", Tag::String(info.event.into())),
|
||||
("distance", Tag::Float(info.distance.to_bits())),
|
||||
(
|
||||
"pos",
|
||||
Tag::List(info.pos.iter().map(|v| Tag::Double(v.to_bits())).collect()),
|
||||
),
|
||||
])
|
||||
];
|
||||
// `VibrationInfo.CODEC`: each UUID an int array of four, absent for none.
|
||||
if let Some(u) = info.source_uuid {
|
||||
fields.push(("source", Tag::IntArray(u.to_vec())));
|
||||
}
|
||||
if let Some(u) = info.owner_uuid {
|
||||
fields.push(("projectile_owner", Tag::IntArray(u.to_vec())));
|
||||
}
|
||||
Tag::compound(fields)
|
||||
}
|
||||
/// A UUID in its saved int array form; anything else is no UUID.
|
||||
fn uuid_field(tag: &Tag, key: &str) -> Option<[i32; 4]> {
|
||||
match tag.get(key) {
|
||||
Some(Tag::IntArray(v)) => <[i32; 4]>::try_from(v.as_slice()).ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn info_from_nbt(tag: &Tag) -> Option<Info> {
|
||||
let event = intern(&horizon_client_core::nbt_value::text(
|
||||
@@ -342,8 +361,12 @@ fn info_from_nbt(tag: &Tag) -> Option<Info> {
|
||||
event,
|
||||
distance,
|
||||
pos,
|
||||
// The live entities are found again by UUID only when needed; a
|
||||
// loaded vibration names none of them.
|
||||
source: None,
|
||||
owner: None,
|
||||
source_uuid: uuid_field(tag, "source"),
|
||||
owner_uuid: uuid_field(tag, "projectile_owner"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -623,6 +646,8 @@ mod tests {
|
||||
pos: [0.0; 3],
|
||||
source: None,
|
||||
owner: None,
|
||||
source_uuid: None,
|
||||
owner_uuid: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -748,6 +773,106 @@ mod tests {
|
||||
assert_eq!(back.selector.current.map(|(_, t)| t), Some(41));
|
||||
}
|
||||
|
||||
fn identified(event: &'static str, distance: f32) -> Info {
|
||||
Info {
|
||||
source: Some(Who::Mob(7)),
|
||||
owner: Some(Who::Mob(8)),
|
||||
source_uuid: Some([100, 0, 0, 1]),
|
||||
owner_uuid: Some([100, 0, 0, 2]),
|
||||
..info(event, distance)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vibration_uuids_save_as_source_and_projectile_owner() {
|
||||
let tag = info_to_nbt(&identified("minecraft:step", 3.0));
|
||||
assert_eq!(tag.get("source"), Some(&Tag::IntArray(vec![100, 0, 0, 1])));
|
||||
assert_eq!(
|
||||
tag.get("projectile_owner"),
|
||||
Some(&Tag::IntArray(vec![100, 0, 0, 2]))
|
||||
);
|
||||
// Read back, the identities stay; the live entities do not.
|
||||
let back = info_from_nbt(&tag).unwrap();
|
||||
assert_eq!(back.source_uuid, Some([100, 0, 0, 1]));
|
||||
assert_eq!(back.owner_uuid, Some([100, 0, 0, 2]));
|
||||
assert_eq!((back.source, back.owner), (None, None));
|
||||
// A live entity with no saved identity writes nothing for it.
|
||||
let unnamed = Info {
|
||||
source: Some(Who::Mob(7)),
|
||||
..info("minecraft:step", 3.0)
|
||||
};
|
||||
let tag = info_to_nbt(&unnamed);
|
||||
assert!(tag.get("source").is_none() && tag.get("projectile_owner").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_loaded_vibration_keeps_only_well_formed_uuids() {
|
||||
let base = |extra: Vec<(&'static str, Tag)>| {
|
||||
let mut fields = vec![
|
||||
("game_event", Tag::String("minecraft:step".into())),
|
||||
("distance", Tag::Float(3.0f32.to_bits())),
|
||||
("pos", Tag::List(vec![Tag::Double(0f64.to_bits()); 3])),
|
||||
];
|
||||
fields.extend(extra);
|
||||
info_from_nbt(&Tag::compound(fields)).unwrap()
|
||||
};
|
||||
let absent = base(vec![]);
|
||||
assert_eq!((absent.source_uuid, absent.owner_uuid), (None, None));
|
||||
let malformed = base(vec![
|
||||
("source", Tag::IntArray(vec![1, 2, 3])),
|
||||
(
|
||||
"projectile_owner",
|
||||
Tag::String("00000064-0000-0000-0000-000000000002".into()),
|
||||
),
|
||||
]);
|
||||
assert_eq!((malformed.source_uuid, malformed.owner_uuid), (None, None));
|
||||
// Only the codec's fields are kept: an unknown key is not saved again.
|
||||
let extra = base(vec![("horizon:raw", Tag::Int(1))]);
|
||||
assert!(info_to_nbt(&extra).get("horizon:raw").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reloaded_listener_drops_stale_uuids() {
|
||||
let mut d = Data::default();
|
||||
d.schedule(identified("minecraft:step", 2.0), 40);
|
||||
d.tick(41, true, true, |_| 5);
|
||||
assert_eq!(d.current.and_then(|i| i.source_uuid), Some([100, 0, 0, 1]));
|
||||
// A listener saved with no identity replaces the old one, not merges.
|
||||
d = Data::from_nbt(
|
||||
&Data {
|
||||
current: Some(info("minecraft:step", 2.0)),
|
||||
..Data::default()
|
||||
}
|
||||
.to_nbt(),
|
||||
);
|
||||
assert_eq!(
|
||||
d.current.map(|i| (i.source_uuid, i.owner_uuid)),
|
||||
Some((None, None))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_chosen_candidate_carries_its_uuids_into_the_travelling_vibration() {
|
||||
let mut d = Data::default();
|
||||
d.schedule(identified("minecraft:step", 2.0), 40);
|
||||
let saved = Data::from_nbt(&d.to_nbt());
|
||||
assert_eq!(
|
||||
saved.selector.current.map(|(i, _)| i.source_uuid),
|
||||
Some(Some([100, 0, 0, 1]))
|
||||
);
|
||||
// The candidate read back is the one that starts travelling.
|
||||
let mut d = saved;
|
||||
let t = d.tick(41, true, true, |_| 5);
|
||||
let travelling = t.started.map(|(i, _)| i).unwrap();
|
||||
assert_eq!(travelling.source_uuid, Some([100, 0, 0, 1]));
|
||||
assert_eq!(travelling.owner_uuid, Some([100, 0, 0, 2]));
|
||||
let saved = Data::from_nbt(&d.to_nbt()).current.unwrap();
|
||||
assert_eq!(
|
||||
(saved.source_uuid, saved.owner_uuid),
|
||||
(Some([100, 0, 0, 1]), Some([100, 0, 0, 2]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wool_on_the_line_occludes() {
|
||||
let origin = [0.5, 0.5, 0.5];
|
||||
|
||||
@@ -390,6 +390,10 @@ impl Settings {
|
||||
// Horizon's own key bindings live in horizon-extras.txt, not in options.txt.
|
||||
let emote = settings.extras.emote_key.clone();
|
||||
settings.keys.set("key.emote", &emote);
|
||||
// `Options.load`: `sharePresence` decides what the friends service posts.
|
||||
crate::social::set_share_presence(crate::social::SharePresence::of(
|
||||
settings.file.int("sharePresence").clamp(0, 2) as u8,
|
||||
));
|
||||
settings.describe_for_crashes();
|
||||
// Options.load: the backend asked for at startup, what a restart is measured from.
|
||||
Self {
|
||||
|
||||
@@ -751,7 +751,8 @@ const SCROLL_RATE: f32 = 18.0;
|
||||
pub(crate) enum Value {
|
||||
/// A tick box.
|
||||
Switch(bool),
|
||||
/// A value stepped between `<` `>`, as it reads now.
|
||||
/// A value stepped between `<` `>`, as it reads now: a press steps it on, a Shift press back,
|
||||
/// and focused, Left and Right do the same.
|
||||
Choice(String),
|
||||
/// A slider: left and right step it (`steps`: its stops, else a pixel of the track), a press
|
||||
/// on the track sets it (a controller's focus does the same).
|
||||
@@ -992,7 +993,7 @@ pub(crate) trait Hosted {
|
||||
/// The one way a list scrolls: the wheel, a right stick and a D-pad past the view's edge.
|
||||
fn scroll(&mut self, s: &Settings, size: (f32, f32), amount: f32);
|
||||
/// Typing in the search field, Tab and the arrows between controls, Left and Right on a
|
||||
/// slider, Enter or Space to press, Escape to leave.
|
||||
/// slider or a choice, Enter or Space to press, Escape to leave.
|
||||
fn key(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
@@ -1415,6 +1416,9 @@ impl<S: Screen> Shell<S> {
|
||||
key: matches!(row.value, Value::Key(..)).then_some(waits),
|
||||
};
|
||||
self::row(batch, atlas, cols, y, &view);
|
||||
if row.live && matches!(row.value, Value::Choice(_)) {
|
||||
crate::widgets::steps_value(cols.row(y));
|
||||
}
|
||||
crate::widgets::last_narratable(|n| n.hint = Some(row.described().to_owned()));
|
||||
}
|
||||
/// The strip under the plate: the hovered or focused row's name and what it does; with none,
|
||||
@@ -1738,6 +1742,14 @@ impl<S: Screen + Default> Hosted for Shell<S> {
|
||||
self.did(&[DRAGGED]);
|
||||
return vec![DRAGGED];
|
||||
}
|
||||
// A choice's `<` `>`: Left steps it back and Right on, as a Shift press and a press.
|
||||
if matches!(key, K::ArrowLeft | K::ArrowRight)
|
||||
&& let Some(row) = focused
|
||||
&& row.live
|
||||
&& matches!(row.value, Value::Choice(_))
|
||||
{
|
||||
return self.activate(s, row, key == K::ArrowLeft, None);
|
||||
}
|
||||
if matches!(
|
||||
key,
|
||||
K::ArrowUp | K::ArrowDown | K::ArrowLeft | K::ArrowRight
|
||||
@@ -2303,6 +2315,43 @@ mod tests {
|
||||
assert_eq!(key(&mut shell, K::Escape), vec![Output::Done]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_and_right_step_a_focused_choice_and_leave_other_rows() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let mut key = |shell: &mut Shell<Probe>, k| shell.key(&mut s, k, None, false, SIZE);
|
||||
shell.set_focus(Some(Target::Row(CHOICE)));
|
||||
// Right is a press, Left a Shift press: the screen's own way back, and the focus stays.
|
||||
assert_eq!(key(&mut shell, K::ArrowRight), vec![CLICK, CHANGED]);
|
||||
assert_eq!(key(&mut shell, K::ArrowLeft), vec![CLICK, CHANGED]);
|
||||
assert_eq!(shell.screen.pressed, [(CHOICE, false), (CHOICE, true)]);
|
||||
assert_eq!(shell.focus, Some(Target::Row(CHOICE)));
|
||||
assert!(shell.changed);
|
||||
// A row that isn't a choice: Left goes to the page list, as before.
|
||||
shell.set_focus(Some(Target::Row(FIRST_FILLER)));
|
||||
assert!(key(&mut shell, K::ArrowLeft).is_empty());
|
||||
assert!(matches!(shell.focus, Some(Target::Page(_))));
|
||||
assert_eq!(shell.screen.pressed.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drawn_choice_tells_the_pad_its_arrows_step_it() {
|
||||
let atlas = atlas();
|
||||
let s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let mut batch = HudBatch::new([1708, 960]);
|
||||
shell.draw(&s, &mut batch, &atlas, (0.0, 0.0), false, None);
|
||||
crate::widgets::end_frame(u64::MAX);
|
||||
let view = shell.view(&s, (batch.width, batch.height));
|
||||
let rect_of = |id| {
|
||||
let mut placed = shell.placed(&view);
|
||||
view.cols.row(placed.find(|(r, _)| r.id == id).unwrap().1)
|
||||
};
|
||||
assert!(crate::widgets::last_steps_value(rect_of(CHOICE)));
|
||||
assert!(!crate::widgets::last_steps_value(rect_of(SLIDER)));
|
||||
assert!(!crate::widgets::last_steps_value(rect_of(FIRST_FILLER)));
|
||||
}
|
||||
|
||||
/// A controller presses with a click, then moves its own focus: only keys show this one.
|
||||
#[test]
|
||||
fn leaving_the_search_field_gives_the_keys_back() {
|
||||
|
||||
@@ -24,12 +24,43 @@ pub struct Textures {
|
||||
pub signed: bool,
|
||||
}
|
||||
|
||||
/// The `getTexture` endpoint's valid URIs from Minecraft services discovery.
|
||||
/// The `getTexture` endpoint's valid URIs in production's services discovery
|
||||
/// document (`MinecraftServicesEnvironment.PROD`).
|
||||
pub const TEXTURE_URIS: [&str; 2] = [
|
||||
"https://textures.minecraft.net/texture/{textureId}",
|
||||
"http://textures.minecraft.net/texture/{textureId}",
|
||||
];
|
||||
|
||||
/// `MinecraftServicesDiscoveryService`'s valid texture URIs: the discovery
|
||||
/// document's `profiles` / `getTexture` `validUris`, which
|
||||
/// `MinecraftServicesSessionService.isAllowedTextureDomain` checks texture URLs
|
||||
/// against. Production's document lists [`TEXTURE_URIS`], so they are used
|
||||
/// without asking it; another environment (`HORIZON_API_DISCOVERY`) is asked
|
||||
/// once per game, and keeps [`TEXTURE_URIS`] when its document lists none or
|
||||
/// can't be fetched.
|
||||
pub fn texture_uris() -> &'static [String] {
|
||||
static URIS: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
|
||||
URIS.get_or_init(|| {
|
||||
crate::auth::discovery_overridden()
|
||||
.then(|| crate::auth::discovery().ok())
|
||||
.flatten()
|
||||
.and_then(|doc| discovery_texture_uris(&doc))
|
||||
.unwrap_or_else(|| TEXTURE_URIS.map(str::to_owned).to_vec())
|
||||
})
|
||||
}
|
||||
|
||||
/// The `profiles` / `getTexture` endpoint's `validUris` in a discovery
|
||||
/// document, when it lists any.
|
||||
fn discovery_texture_uris(doc: &serde_json::Value) -> Option<Vec<String>> {
|
||||
let uris: Vec<String> = doc
|
||||
.pointer("/discovery/profiles/endpoints/getTexture/validUris")?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.filter_map(|u| u.as_str().map(str::to_owned))
|
||||
.collect();
|
||||
(!uris.is_empty()).then_some(uris)
|
||||
}
|
||||
|
||||
/// `java.util.Base64.getDecoder().decode`.
|
||||
pub fn base64_decode(text: &str) -> Option<Vec<u8>> {
|
||||
use base64::{
|
||||
@@ -44,7 +75,7 @@ pub fn base64_decode(text: &str) -> Option<Vec<u8>> {
|
||||
|
||||
/// `unpackTextures(property)`: the textures of a `textures` property's
|
||||
/// base64 JSON payload.
|
||||
pub fn unpack(value: &str, signature: Signature, valid_uris: &[&str]) -> Textures {
|
||||
pub fn unpack(value: &str, signature: Signature, valid_uris: &[impl AsRef<str>]) -> Textures {
|
||||
let empty = Textures::default();
|
||||
let Some(json) = base64_decode(value)
|
||||
.map(|b| String::from_utf8_lossy(&b).into_owned())
|
||||
@@ -68,7 +99,7 @@ pub fn unpack(value: &str, signature: Signature, valid_uris: &[&str]) -> Texture
|
||||
};
|
||||
let allowed = valid_uris
|
||||
.iter()
|
||||
.any(|u| url.starts_with(&u.replace("{textureId}", "")));
|
||||
.any(|u| url.starts_with(&u.as_ref().replace("{textureId}", "")));
|
||||
if !allowed {
|
||||
return empty;
|
||||
}
|
||||
@@ -95,7 +126,7 @@ pub fn unpack(value: &str, signature: Signature, valid_uris: &[&str]) -> Texture
|
||||
/// those textures be unsigned (`getInsecureSkinInformation`).
|
||||
pub fn profile_skin_url(profile: &serde_json::Value) -> Option<String> {
|
||||
let (value, signature) = parse_profile(profile).textures?;
|
||||
unpack(&value, signature, &TEXTURE_URIS).skin
|
||||
unpack(&value, signature, texture_uris()).skin
|
||||
}
|
||||
|
||||
/// The textures of a Minecraft services `/minecraft/profile` answer: the
|
||||
@@ -114,7 +145,7 @@ pub fn services_profile_textures(profile: &serde_json::Value) -> Textures {
|
||||
.and_then(|t| t.get("url"))
|
||||
.and_then(|u| u.as_str())
|
||||
.filter(|url| {
|
||||
TEXTURE_URIS
|
||||
texture_uris()
|
||||
.iter()
|
||||
.any(|u| url.starts_with(&u.replace("{textureId}", "")))
|
||||
})
|
||||
@@ -127,7 +158,7 @@ pub fn services_profile_textures(profile: &serde_json::Value) -> Textures {
|
||||
.and_then(|t| t.get("url"))
|
||||
.and_then(|u| u.as_str())
|
||||
.filter(|url| {
|
||||
TEXTURE_URIS
|
||||
texture_uris()
|
||||
.iter()
|
||||
.any(|u| url.starts_with(&u.replace("{textureId}", "")))
|
||||
})
|
||||
@@ -155,7 +186,7 @@ pub fn profile_skin_url_nbt(profile: &horizon_world::nbt::Tag) -> Option<String>
|
||||
.then(|| nbt_text(p.get("value")))
|
||||
.flatten()
|
||||
})?;
|
||||
unpack(&value, Signature::Unsigned, &TEXTURE_URIS).skin
|
||||
unpack(&value, Signature::Unsigned, texture_uris()).skin
|
||||
}
|
||||
|
||||
/// `ResolvableProfile.skinPatch().body()` of a skull's `profile` NBT: the
|
||||
@@ -478,7 +509,7 @@ struct HeadProfile {
|
||||
fn prepare_head(profile: &serde_json::Value) -> HeadProfile {
|
||||
let info = parse_profile(profile);
|
||||
let skin = info.textures.as_ref().and_then(|(value, signature)| {
|
||||
let url = unpack(value, *signature, &TEXTURE_URIS).skin?;
|
||||
let url = unpack(value, *signature, texture_uris()).skin?;
|
||||
let location = skin_location(&texture_hash(&url)?);
|
||||
Some((url, location))
|
||||
});
|
||||
@@ -1362,6 +1393,33 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `MinecraftServicesDiscoveryService`: another environment's document
|
||||
/// names its own texture host; one that lists none names nothing.
|
||||
#[test]
|
||||
fn texture_uris_come_from_the_discovery_document() {
|
||||
use super::{Signature, discovery_texture_uris, unpack};
|
||||
let doc = serde_json::json!({"discovery": {"profiles": {"endpoints": {"getTexture": {
|
||||
"validUris": ["https://skins.example.org/t/{textureId}"]
|
||||
}}}}});
|
||||
let uris = discovery_texture_uris(&doc).unwrap();
|
||||
assert_eq!(uris, ["https://skins.example.org/t/{textureId}"]);
|
||||
// {"textures":{"SKIN":{"url":"https://skins.example.org/t/abc"}}}
|
||||
let ours =
|
||||
"eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHBzOi8vc2tpbnMuZXhhbXBsZS5vcmcvdC9hYmMifX19";
|
||||
assert_eq!(
|
||||
unpack(ours, Signature::Signed, &uris).skin.as_deref(),
|
||||
Some("https://skins.example.org/t/abc")
|
||||
);
|
||||
assert_eq!(
|
||||
unpack(ours, Signature::Signed, &super::TEXTURE_URIS).skin,
|
||||
None
|
||||
);
|
||||
let none = serde_json::json!({"discovery": {"profiles": {"endpoints": {}}}});
|
||||
assert_eq!(discovery_texture_uris(&none), None);
|
||||
let empty = serde_json::json!({"discovery": {"profiles": {"endpoints": {"getTexture": {"validUris": []}}}}});
|
||||
assert_eq!(discovery_texture_uris(&empty), None);
|
||||
}
|
||||
|
||||
/// `MinecraftProfileTexture.getMetadata("model")`: `slim` marks a slim
|
||||
/// skin; no metadata is the wide (classic) model.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,860 @@
|
||||
//! Vanilla's friends machinery without its screens: authlib's
|
||||
//! `MinecraftServicesFriendsService` (`getFriends`, `updateFriends`,
|
||||
//! `updateAttributes`, `updatePresence`), `RemoteFriendListUpdateHandler`'s worker and
|
||||
//! state, `PlayerSocialManager`'s flags, and `PresenceHandler`'s posting.
|
||||
//!
|
||||
//! `title::poll_account` starts this the way `Minecraft`'s constructor does: the
|
||||
//! account's `friendsPreferences` (`UserFlag.FRIENDS_ENABLED`,
|
||||
//! `UserFlag.ACCEPT_FRIEND_INVITES`) decide whether the worker reads the lists. No
|
||||
//! screen shows them yet; the overlay and the Online options are later parts.
|
||||
//!
|
||||
//! The requests keep authlib's cache rules: `If-None-Match` with the last `ETag`, a 304
|
||||
//! or an empty answer keeps the cached lists, `Retry-After` sets the poll interval, and
|
||||
//! `getFriendData` waits out a ten-second cooldown between reads. The presence statuses
|
||||
//! are `PresenceHandler.getPresenceStatus`'s, supplied by the client each tick.
|
||||
use crate::auth::{self, AuthSession};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
sync::{
|
||||
Arc, Mutex, OnceLock,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
/// authlib's `FriendsService.ResultCode`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ResultCode {
|
||||
Success,
|
||||
Error,
|
||||
ServiceNotAvailable,
|
||||
TooManyRequests,
|
||||
Forbidden,
|
||||
UpgradeNeeded,
|
||||
ConnectionIssue,
|
||||
TemporaryUnavailable,
|
||||
UnknownProfile,
|
||||
Unauthorized,
|
||||
GenericError,
|
||||
}
|
||||
|
||||
/// `FriendDto`: one friend or one pending request.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Friend {
|
||||
pub id: u128,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// authlib's `FriendData`: the three lists the service answers.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct FriendData {
|
||||
pub friends: Vec<Friend>,
|
||||
pub incoming: Vec<Friend>,
|
||||
pub outgoing: Vec<Friend>,
|
||||
}
|
||||
|
||||
/// authlib's `PresenceStatus`, as `updatePresence` stores it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PresenceStatus {
|
||||
Online,
|
||||
PlayingOffline,
|
||||
PlayingRealms,
|
||||
PlayingServer,
|
||||
PlayingHostedServer,
|
||||
Offline,
|
||||
}
|
||||
impl PresenceStatus {
|
||||
/// `PresenceStatus.name()`: the string `updatePresence` sends and
|
||||
/// `PresenceStatusDto.status` carries.
|
||||
fn wire(self) -> &'static str {
|
||||
match self {
|
||||
Self::Online => "ONLINE",
|
||||
Self::PlayingOffline => "PLAYING_OFFLINE",
|
||||
Self::PlayingRealms => "PLAYING_REALMS",
|
||||
Self::PlayingServer => "PLAYING_SERVER",
|
||||
Self::PlayingHostedServer => "PLAYING_HOSTED_SERVER",
|
||||
Self::Offline => "OFFLINE",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `PresenceStatusDto`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Presence {
|
||||
pub id: u128,
|
||||
pub status: PresenceStatus,
|
||||
}
|
||||
|
||||
/// `RemoteFriendListUpdateHandler.State`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum State {
|
||||
Loading,
|
||||
UpgradeNeeded,
|
||||
ConnectionIssue,
|
||||
UserMayLackActiveProfile,
|
||||
Unauthorized,
|
||||
TemporaryUnavailable,
|
||||
GenericError,
|
||||
Success,
|
||||
}
|
||||
impl State {
|
||||
/// `RemoteFriendListUpdateHandler.mapResultCodeToState`.
|
||||
fn of(code: ResultCode) -> Self {
|
||||
match code {
|
||||
ResultCode::Success => Self::Success,
|
||||
ResultCode::UpgradeNeeded => Self::UpgradeNeeded,
|
||||
ResultCode::ConnectionIssue => Self::ConnectionIssue,
|
||||
ResultCode::UnknownProfile => Self::UserMayLackActiveProfile,
|
||||
ResultCode::Unauthorized => Self::Unauthorized,
|
||||
ResultCode::TemporaryUnavailable
|
||||
| ResultCode::Forbidden
|
||||
| ResultCode::ServiceNotAvailable
|
||||
| ResultCode::TooManyRequests => Self::TemporaryUnavailable,
|
||||
ResultCode::Error | ResultCode::GenericError => Self::GenericError,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Options.sharePresence` (`PresenceSharing`): how much of the player the service sees.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SharePresence {
|
||||
None,
|
||||
Limited,
|
||||
All,
|
||||
}
|
||||
|
||||
impl SharePresence {
|
||||
/// `options.txt`'s `sharePresence` (0 none, 1 limited, 2 all; `Options` defaults to
|
||||
/// all).
|
||||
pub fn of(setting: u8) -> Self {
|
||||
match setting {
|
||||
0 => Self::None,
|
||||
1 => Self::Limited,
|
||||
_ => Self::All,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `MinecraftServicesFriendsService`: the HTTP client and its caches.
|
||||
#[derive(Default)]
|
||||
struct Service {
|
||||
friends_etag: Option<String>,
|
||||
presence_etag: Option<String>,
|
||||
friends_cache: FriendData,
|
||||
presence_cache: Vec<Presence>,
|
||||
/// `getFriendsPollInterval`/`getPresencePollInterval`: the service's `Retry-After`.
|
||||
friends_poll: Option<Duration>,
|
||||
presence_poll: Option<Duration>,
|
||||
/// `requestCooldown`: ten seconds between `getFriendData` requests.
|
||||
cooldown: Option<Instant>,
|
||||
/// `requestPending`: one list request at a time.
|
||||
pending: bool,
|
||||
}
|
||||
|
||||
impl Service {
|
||||
/// `canMakeRequest`.
|
||||
fn can_make_request(&self) -> bool {
|
||||
self.cooldown.is_none_or(|at| Instant::now() > at)
|
||||
}
|
||||
}
|
||||
|
||||
/// `RemoteFriendListUpdateHandler`.
|
||||
struct Handler {
|
||||
enabled: bool,
|
||||
state: State,
|
||||
latest: FriendData,
|
||||
/// `lastUpdateNanos`: a read at that moment is due; `None` reads at once.
|
||||
last_update: Option<Instant>,
|
||||
/// A forced read (`forceUpdate`) is due, whatever the interval.
|
||||
forced: bool,
|
||||
/// `getUpdateIntervalNanos`'s foreground: the friends overlay is open.
|
||||
foreground: bool,
|
||||
}
|
||||
|
||||
impl Default for Handler {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
state: State::Loading,
|
||||
latest: FriendData::default(),
|
||||
last_update: None,
|
||||
forced: false,
|
||||
foreground: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Handler {
|
||||
/// `getUpdateIntervalNanos`: the service's interval (a minute when it named none),
|
||||
/// five times as long in the background.
|
||||
fn interval(&self, service: &Service) -> Duration {
|
||||
let foreground = service.friends_poll.unwrap_or(Duration::from_secs(60));
|
||||
if self.foreground {
|
||||
foreground
|
||||
} else {
|
||||
foreground * 5
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `PresenceHandler`.
|
||||
struct PresenceHandler {
|
||||
latest: Vec<Presence>,
|
||||
last_post: Instant,
|
||||
/// `updatePresence`: post at the next tick.
|
||||
update: bool,
|
||||
/// `PresenceHandler.getPresenceStatus`'s answer, from the client's state.
|
||||
playing: PresenceStatus,
|
||||
}
|
||||
|
||||
impl Default for PresenceHandler {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
latest: Vec::new(),
|
||||
// `lastPresencePost = Instant.now()`: the first post waits an interval.
|
||||
last_post: Instant::now(),
|
||||
// Its constructor sets `updatePresence = true`.
|
||||
update: true,
|
||||
playing: PresenceStatus::Online,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `PlayerSocialManager`'s flags.
|
||||
#[derive(Default)]
|
||||
struct Manager {
|
||||
friend_list_enabled: bool,
|
||||
allow_friend_requests: bool,
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
service: Service,
|
||||
handler: Handler,
|
||||
presence: PresenceHandler,
|
||||
manager: Manager,
|
||||
share: SharePresence,
|
||||
started: bool,
|
||||
}
|
||||
|
||||
impl Default for Inner {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
service: Service::default(),
|
||||
handler: Handler::default(),
|
||||
presence: PresenceHandler::default(),
|
||||
manager: Manager::default(),
|
||||
share: SharePresence::All,
|
||||
started: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn inner() -> &'static Arc<Mutex<Inner>> {
|
||||
static INNER: OnceLock<Arc<Mutex<Inner>>> = OnceLock::new();
|
||||
INNER.get_or_init(|| Arc::new(Mutex::new(Inner::default())))
|
||||
}
|
||||
|
||||
fn lock() -> std::sync::MutexGuard<'static, Inner> {
|
||||
inner().lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// `Minecraft`'s constructor: make the social manager, whose `friendListEnabled` comes
|
||||
/// from the account's `friendsPreferences`, and start the updater when it is on.
|
||||
/// Called once; later calls leave the running manager alone.
|
||||
pub fn init(friends_enabled: bool, allow_requests: bool) {
|
||||
{
|
||||
let mut inner = lock();
|
||||
if inner.started {
|
||||
return;
|
||||
}
|
||||
inner.started = true;
|
||||
inner.manager.friend_list_enabled = friends_enabled;
|
||||
inner.manager.allow_friend_requests = allow_requests;
|
||||
inner.handler.enabled = friends_enabled;
|
||||
}
|
||||
spawn_updater();
|
||||
}
|
||||
|
||||
/// `PlayerSocialManager.setFriendListEnabled`: the Online options switch.
|
||||
pub fn set_friend_list_enabled(enabled: bool) {
|
||||
let mut inner = lock();
|
||||
inner.manager.friend_list_enabled = enabled;
|
||||
inner.handler.enabled = enabled;
|
||||
if enabled {
|
||||
inner.handler.last_update = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// `PlayerSocialManager.isFriendListEnabled`.
|
||||
pub fn is_friend_list_enabled() -> bool {
|
||||
lock().manager.friend_list_enabled
|
||||
}
|
||||
|
||||
/// `PlayerSocialManager.isAllowFriendRequests`.
|
||||
pub fn allow_friend_requests() -> bool {
|
||||
lock().manager.allow_friend_requests
|
||||
}
|
||||
|
||||
/// `PlayerSocialManager.setAllowFriendRequests`.
|
||||
pub fn set_allow_friend_requests(allow: bool) {
|
||||
lock().manager.allow_friend_requests = allow;
|
||||
}
|
||||
|
||||
/// `PlayerSocialManager.getFriends`.
|
||||
pub fn friends() -> Vec<Friend> {
|
||||
lock().handler.latest.friends.clone()
|
||||
}
|
||||
|
||||
/// `PlayerSocialManager.getIncomingRequests`.
|
||||
pub fn incoming_requests() -> Vec<Friend> {
|
||||
lock().handler.latest.incoming.clone()
|
||||
}
|
||||
|
||||
/// `PlayerSocialManager.getOutgoingRequests`.
|
||||
pub fn outgoing_requests() -> Vec<Friend> {
|
||||
lock().handler.latest.outgoing.clone()
|
||||
}
|
||||
|
||||
/// `PlayerSocialManager.getFriendListState`.
|
||||
pub fn friend_list_state() -> State {
|
||||
lock().handler.state
|
||||
}
|
||||
|
||||
/// `PresenceHandler.getLatestPresence`.
|
||||
pub fn latest_presence() -> Vec<Presence> {
|
||||
lock().presence.latest.clone()
|
||||
}
|
||||
|
||||
/// `RemoteFriendListUpdateHandler`'s foreground interval: the friends overlay is open.
|
||||
pub fn set_foreground(open: bool) {
|
||||
lock().handler.foreground = open;
|
||||
}
|
||||
|
||||
/// `Options.sharePresence`: what presence the handler posts.
|
||||
pub fn set_share_presence(share: SharePresence) {
|
||||
lock().share = share;
|
||||
}
|
||||
|
||||
/// `PresenceHandler.getPresenceStatus`, supplied by the client from its world state.
|
||||
pub fn set_presence_status(status: PresenceStatus) {
|
||||
lock().presence.playing = status;
|
||||
}
|
||||
|
||||
/// `Minecraft.runTick`: `PresenceHandler.tick`, once per client tick.
|
||||
pub fn tick() {
|
||||
let due = {
|
||||
let inner = lock();
|
||||
if !inner.manager.friend_list_enabled || inner.handler.latest.friends.is_empty() {
|
||||
return;
|
||||
}
|
||||
let interval = inner
|
||||
.service
|
||||
.presence_poll
|
||||
.unwrap_or(Duration::from_secs(60));
|
||||
let since = inner.presence.last_post.elapsed();
|
||||
(inner.presence.update && since >= interval) || since >= interval * 5
|
||||
};
|
||||
if due {
|
||||
update_presence();
|
||||
}
|
||||
}
|
||||
|
||||
/// `PresenceHandler.updatePresence`.
|
||||
fn update_presence() {
|
||||
let status = {
|
||||
let mut inner = lock();
|
||||
inner.presence.update = false;
|
||||
inner.presence.last_post = Instant::now();
|
||||
match inner.share {
|
||||
SharePresence::None => PresenceStatus::Offline,
|
||||
SharePresence::Limited => PresenceStatus::Online,
|
||||
SharePresence::All => inner.presence.playing,
|
||||
}
|
||||
};
|
||||
let _ = thread::Builder::new()
|
||||
.name("Friends presence".into())
|
||||
.spawn(move || {
|
||||
if let Some(presence) = post_presence(status) {
|
||||
lock().presence.latest = presence;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// `PresenceHandler.tryUpdatePresence`: the world was entered (or the overlay opened).
|
||||
pub fn try_update_presence() {
|
||||
lock().presence.update = true;
|
||||
}
|
||||
|
||||
/// `PresenceHandler.sendOfflinePresence`: the game is closing.
|
||||
pub fn send_offline_presence() {
|
||||
static SENT: AtomicBool = AtomicBool::new(false);
|
||||
{
|
||||
let inner = lock();
|
||||
if !inner.manager.friend_list_enabled || inner.handler.latest.friends.is_empty() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// `Minecraft.close` happens once; a second quit path must not post twice.
|
||||
if SENT.swap(true, Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let _ = thread::Builder::new()
|
||||
.name("Friends offline".into())
|
||||
.spawn(|| {
|
||||
let _ = post_presence(PresenceStatus::Offline);
|
||||
});
|
||||
}
|
||||
|
||||
/// `FriendsService.sendFriendRequest(name)`.
|
||||
pub fn send_friend_request(name: &str) -> ResultCode {
|
||||
friend_action(json!({"name": name, "updateType": "ADD"}))
|
||||
}
|
||||
|
||||
/// `FriendsService.removeFriend`/`declineIncomingFriendRequest`/
|
||||
/// `revokeOutgoingFriendRequest` (`updateType` REMOVE).
|
||||
pub fn remove_friend(id: u128) -> ResultCode {
|
||||
friend_action(json!({"profileId": uuid(id), "updateType": "REMOVE"}))
|
||||
}
|
||||
|
||||
/// `FriendsService.acceptIncomingFriendRequest`: ADD by profile id.
|
||||
pub fn accept_incoming_friend_request(id: u128) -> ResultCode {
|
||||
friend_action(json!({"profileId": uuid(id), "updateType": "ADD"}))
|
||||
}
|
||||
|
||||
/// `FriendsService.updateFriendSettings`: the account's flags, server-side.
|
||||
pub fn update_friend_settings(friends_enabled: bool, allow_requests: bool) -> ResultCode {
|
||||
let Some(session) = session() else {
|
||||
return ResultCode::Unauthorized;
|
||||
};
|
||||
let body = json!({
|
||||
"friendsPreferences": {
|
||||
"friends": toggle(friends_enabled),
|
||||
"acceptInvites": toggle(allow_requests),
|
||||
}
|
||||
});
|
||||
let Some(url) = url("updateAttributes") else {
|
||||
return ResultCode::ServiceNotAvailable;
|
||||
};
|
||||
match send_json(&session, "POST", &url, &body.to_string(), None) {
|
||||
Ok(_) => ResultCode::Success,
|
||||
Err(failure) => failure.code,
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle(on: bool) -> &'static str {
|
||||
if on { "ENABLED" } else { "DISABLED" }
|
||||
}
|
||||
|
||||
/// `MinecraftServicesFriendsService.putFriendAction`: the answer already is the new
|
||||
/// lists, as authlib caches it, and the handler reads again.
|
||||
fn friend_action(body: Value) -> ResultCode {
|
||||
let Some(session) = session() else {
|
||||
return ResultCode::Unauthorized;
|
||||
};
|
||||
let Some(url) = url("updateFriends") else {
|
||||
return ResultCode::ServiceNotAvailable;
|
||||
};
|
||||
let answer = match send_json(&session, "PUT", &url, &body.to_string(), None) {
|
||||
Ok(answer) => answer,
|
||||
Err(failure) => return failure.code,
|
||||
};
|
||||
let code = if answer.body.is_empty() {
|
||||
ResultCode::Success
|
||||
} else {
|
||||
match parse_friends(&answer.body) {
|
||||
Some(data) => {
|
||||
let mut inner = lock();
|
||||
inner.service.friends_cache = data;
|
||||
inner.service.friends_etag = None;
|
||||
inner.service.cooldown = Some(Instant::now() + Duration::from_secs(10));
|
||||
ResultCode::Success
|
||||
}
|
||||
None => ResultCode::Error,
|
||||
}
|
||||
};
|
||||
if code == ResultCode::Success {
|
||||
// `PlayerSocialManager.runAction` forces a read after a successful action.
|
||||
lock().handler.forced = true;
|
||||
}
|
||||
code
|
||||
}
|
||||
|
||||
/// `RemoteFriendListUpdateHandler.start`'s worker: one read per interval while the list
|
||||
/// is enabled, the first one as soon as it starts.
|
||||
fn spawn_updater() {
|
||||
static STARTED: AtomicBool = AtomicBool::new(false);
|
||||
if STARTED.swap(true, Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let _ = thread::Builder::new()
|
||||
.name("Friends List".into())
|
||||
.spawn(|| {
|
||||
loop {
|
||||
thread::sleep(Duration::from_secs(1));
|
||||
let due = {
|
||||
let inner = lock();
|
||||
if !inner.handler.enabled {
|
||||
continue;
|
||||
}
|
||||
inner.handler.forced
|
||||
|| inner
|
||||
.handler
|
||||
.last_update
|
||||
.is_none_or(|at| at.elapsed() >= inner.handler.interval(&inner.service))
|
||||
};
|
||||
if due {
|
||||
update_friend_data();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// `RemoteFriendListUpdateHandler.runUpdateFriendDataInternal`.
|
||||
fn update_friend_data() {
|
||||
let etag = {
|
||||
let mut inner = lock();
|
||||
if inner.service.pending {
|
||||
// Another read is in flight; its answer is the cache's.
|
||||
return;
|
||||
}
|
||||
if !inner.service.can_make_request() {
|
||||
inner.handler.forced = false;
|
||||
inner.handler.state = State::Success;
|
||||
inner.handler.latest = inner.service.friends_cache.clone();
|
||||
inner.handler.last_update = Some(Instant::now());
|
||||
return;
|
||||
}
|
||||
inner.service.pending = true;
|
||||
inner.service.cooldown = Some(Instant::now() + Duration::from_secs(10));
|
||||
inner.service.friends_etag.clone()
|
||||
};
|
||||
log::debug!("Performing Friends List update");
|
||||
let result = match session() {
|
||||
Some(session) => fetch_friends(&session, etag.as_deref()),
|
||||
None => Err(Failure {
|
||||
code: ResultCode::Unauthorized,
|
||||
}),
|
||||
};
|
||||
let mut inner = lock();
|
||||
inner.service.pending = false;
|
||||
inner.handler.forced = false;
|
||||
inner.handler.last_update = Some(Instant::now());
|
||||
match result {
|
||||
Ok(answer) => {
|
||||
if let Some(retry) = answer.retry_after {
|
||||
inner.service.friends_poll = Some(retry);
|
||||
}
|
||||
if let Some(etag) = answer.etag {
|
||||
inner.service.friends_etag = Some(etag);
|
||||
}
|
||||
if answer.status != 304 && !answer.body.is_empty() {
|
||||
match parse_friends(&answer.body) {
|
||||
Some(data) => inner.service.friends_cache = data,
|
||||
None => {
|
||||
// authlib's parse failure is an ERROR result: the handler keeps
|
||||
// the cached lists and shows the generic error.
|
||||
inner.handler.latest = inner.service.friends_cache.clone();
|
||||
inner.handler.state = State::GenericError;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
inner.handler.latest = inner.service.friends_cache.clone();
|
||||
inner.handler.state = State::Success;
|
||||
}
|
||||
Err(failure) => {
|
||||
inner.handler.latest = inner.service.friends_cache.clone();
|
||||
inner.handler.state = State::of(failure.code);
|
||||
log::warn!(
|
||||
"Friends List update failed with result code: {:?}",
|
||||
failure.code
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `MinecraftServicesFriendsService.presence`: post one status, cache the answer.
|
||||
/// `None` means the answer said nothing new (304, an empty body or a failure); the
|
||||
/// handler keeps its last presence then.
|
||||
fn post_presence(status: PresenceStatus) -> Option<Vec<Presence>> {
|
||||
let session = session()?;
|
||||
let url = url("updatePresence")?;
|
||||
let body = json!({"status": status.wire()}).to_string();
|
||||
let etag = lock().service.presence_etag.clone();
|
||||
let answer = send_json(&session, "POST", &url, &body, etag.as_deref()).ok()?;
|
||||
let mut inner = lock();
|
||||
if let Some(retry) = answer.retry_after {
|
||||
inner.service.presence_poll = Some(retry);
|
||||
}
|
||||
if let Some(etag) = answer.etag {
|
||||
inner.service.presence_etag = Some(etag);
|
||||
}
|
||||
if answer.status == 304 || answer.body.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// `PresenceResponse`: a missing `presence` field is an empty list, replacing the
|
||||
// cached one; malformed JSON keeps the cache, as authlib's exception path does.
|
||||
if let Ok(value) = serde_json::from_str::<Value>(&answer.body) {
|
||||
let presence = presence_of(&value);
|
||||
inner.service.presence_cache = presence.clone();
|
||||
return Some(presence);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `MinecraftServicesFriendsService.getFriendData`'s request.
|
||||
fn fetch_friends(session: &AuthSession, etag: Option<&str>) -> Result<Answer, Failure> {
|
||||
let url = url("getFriends").ok_or(Failure {
|
||||
code: ResultCode::ServiceNotAvailable,
|
||||
})?;
|
||||
send_json(session, "GET", &url, "", etag)
|
||||
}
|
||||
|
||||
/// One HTTP answer: its status, cache headers and body.
|
||||
struct Answer {
|
||||
status: u16,
|
||||
etag: Option<String>,
|
||||
retry_after: Option<Duration>,
|
||||
body: String,
|
||||
}
|
||||
|
||||
struct Failure {
|
||||
code: ResultCode,
|
||||
}
|
||||
|
||||
/// The discovery document's `player` service endpoint.
|
||||
fn session() -> Option<Arc<AuthSession>> {
|
||||
auth::current().filter(|s| !s.expired())
|
||||
}
|
||||
|
||||
fn uuid(id: u128) -> String {
|
||||
let hex = format!("{id:032x}");
|
||||
format!(
|
||||
"{}-{}-{}-{}-{}",
|
||||
&hex[0..8],
|
||||
&hex[8..12],
|
||||
&hex[12..16],
|
||||
&hex[16..20],
|
||||
&hex[20..32]
|
||||
)
|
||||
}
|
||||
|
||||
fn url(name: &str) -> Option<String> {
|
||||
let doc = auth::discovery().ok()?;
|
||||
auth::endpoint(&doc, "player", name)
|
||||
}
|
||||
|
||||
/// authlib's `MinecraftClient`: the bearer token, `If-None-Match` when a cache has an
|
||||
/// `ETag`, and `Retry-After` back to the caller.
|
||||
fn send_json(
|
||||
session: &AuthSession,
|
||||
method: &str,
|
||||
url: &str,
|
||||
body: &str,
|
||||
etag: Option<&str>,
|
||||
) -> Result<Answer, Failure> {
|
||||
let bearer = format!("Bearer {}", session.access_token());
|
||||
let agent = auth::agent();
|
||||
let mut request = match method {
|
||||
"GET" => agent.get(url),
|
||||
"POST" => agent.post(url),
|
||||
"PUT" => agent.put(url),
|
||||
_ => {
|
||||
return Err(Failure {
|
||||
code: ResultCode::Error,
|
||||
});
|
||||
}
|
||||
};
|
||||
request = request.set("Authorization", &bearer);
|
||||
if let Some(etag) = etag {
|
||||
request = request.set("If-None-Match", etag);
|
||||
}
|
||||
let response = if method == "GET" {
|
||||
request.call()
|
||||
} else {
|
||||
request
|
||||
.set("Content-Type", "application/json; charset=utf-8")
|
||||
.send_string(body)
|
||||
}
|
||||
.map_err(http_error)?;
|
||||
let status = response.status();
|
||||
let etag = response.header("ETag").map(str::to_owned);
|
||||
let retry_after = response.header("Retry-After").and_then(parse_retry_after);
|
||||
if status == 304 {
|
||||
return Ok(Answer {
|
||||
status,
|
||||
etag,
|
||||
retry_after,
|
||||
body: String::new(),
|
||||
});
|
||||
}
|
||||
let body = response.into_string().map_err(|_| Failure {
|
||||
code: ResultCode::Error,
|
||||
})?;
|
||||
Ok(Answer {
|
||||
status,
|
||||
etag,
|
||||
retry_after,
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
/// authlib's `MinecraftClient.parseRetryAfter`: whole seconds above zero.
|
||||
fn parse_retry_after(value: &str) -> Option<Duration> {
|
||||
value
|
||||
.trim()
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.filter(|seconds| *seconds > 0)
|
||||
.map(Duration::from_secs)
|
||||
}
|
||||
|
||||
/// `MinecraftServicesFriendsService.handleHttpError`.
|
||||
fn http_error(error: ureq::Error) -> Failure {
|
||||
let code = match error {
|
||||
ureq::Error::Status(status, _) => match status {
|
||||
429 => ResultCode::TooManyRequests,
|
||||
500.. => ResultCode::ServiceNotAvailable,
|
||||
403 => ResultCode::Forbidden,
|
||||
400 => ResultCode::UnknownProfile,
|
||||
401 => ResultCode::Unauthorized,
|
||||
_ => ResultCode::Error,
|
||||
},
|
||||
ureq::Error::Transport(_) => ResultCode::ConnectionIssue,
|
||||
};
|
||||
Failure { code }
|
||||
}
|
||||
|
||||
/// authlib's `FriendsListResponse` (`getFriends`/`updateFriends` body).
|
||||
pub fn parse_friends(body: &str) -> Option<FriendData> {
|
||||
let v: Value = serde_json::from_str(body).ok()?;
|
||||
Some(FriendData {
|
||||
friends: parse_friend_list(v.get("friends")),
|
||||
incoming: parse_friend_list(v.get("incomingRequests")),
|
||||
outgoing: parse_friend_list(v.get("outgoingRequests")),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_friend_list(value: Option<&Value>) -> Vec<Friend> {
|
||||
value
|
||||
.and_then(|v| v.as_array())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|friend| {
|
||||
Some(Friend {
|
||||
id: auth::parse_uuid(friend.get("profileId")?.as_str()?)?,
|
||||
name: friend.get("name")?.as_str()?.to_owned(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// authlib's `PresenceResponse`.
|
||||
pub fn parse_presence(body: &str) -> Option<Vec<Presence>> {
|
||||
let v: Value = serde_json::from_str(body).ok()?;
|
||||
Some(presence_of(&v))
|
||||
}
|
||||
|
||||
/// `PresenceResponse.presence`: a missing list reads as empty, as Gson's null does.
|
||||
fn presence_of(v: &Value) -> Vec<Presence> {
|
||||
v.get("presence")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|entry| {
|
||||
let status = entry.get("status")?.as_str()?;
|
||||
let status = match status {
|
||||
"ONLINE" => PresenceStatus::Online,
|
||||
"PLAYING_OFFLINE" => PresenceStatus::PlayingOffline,
|
||||
"PLAYING_REALMS" => PresenceStatus::PlayingRealms,
|
||||
"PLAYING_SERVER" => PresenceStatus::PlayingServer,
|
||||
"PLAYING_HOSTED_SERVER" => PresenceStatus::PlayingHostedServer,
|
||||
_ => PresenceStatus::Offline,
|
||||
};
|
||||
Some(Presence {
|
||||
id: auth::parse_uuid(entry.get("profileId")?.as_str()?)?,
|
||||
status,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_friends_answer_reads_its_three_lists() {
|
||||
let data = parse_friends(
|
||||
r#"{"friends":[{"profileId":"0123456789abcdef0123456789abcdef","name":"Alex"}],
|
||||
"incomingRequests":[],"outgoingRequests":[{"profileId":"fedcba9876543210fedcba9876543210","name":"Steve"}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
data.friends,
|
||||
vec![Friend {
|
||||
id: 0x0123_4567_89ab_cdef_0123_4567_89ab_cdef,
|
||||
name: "Alex".into()
|
||||
}]
|
||||
);
|
||||
assert!(data.incoming.is_empty());
|
||||
assert_eq!(data.outgoing[0].name, "Steve");
|
||||
// The service's answer to an account with no friends, as the live check
|
||||
// captured it: the three lists plus an `empty` flag the parser ignores.
|
||||
let empty = parse_friends(
|
||||
r#"{"empty":true,"friends":[],"incomingRequests":[],"outgoingRequests":[]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(empty, FriendData::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_presence_answer_reads_each_status() {
|
||||
let presence = parse_presence(
|
||||
r#"{"presence":[
|
||||
{"profileId":"0123456789abcdef0123456789abcdef","pmid":"00000000000000000000000000000000","status":"PLAYING_REALMS","lastUpdated":"2026-11-08T12:00:00Z"},
|
||||
{"profileId":"fedcba9876543210fedcba9876543210","pmid":"00000000000000000000000000000000","status":"OFFLINE","lastUpdated":"2026-11-08T12:00:00Z"}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(presence[0].status, PresenceStatus::PlayingRealms);
|
||||
assert_eq!(presence[1].status, PresenceStatus::Offline);
|
||||
// `PresenceResponse` without its list is empty, not missing.
|
||||
assert_eq!(parse_presence("{}"), Some(Vec::new()));
|
||||
assert_eq!(parse_presence("not json"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_codes_map_to_the_handlers_states() {
|
||||
assert_eq!(State::of(ResultCode::Success), State::Success);
|
||||
assert_eq!(
|
||||
State::of(ResultCode::TooManyRequests),
|
||||
State::TemporaryUnavailable
|
||||
);
|
||||
assert_eq!(
|
||||
State::of(ResultCode::UnknownProfile),
|
||||
State::UserMayLackActiveProfile
|
||||
);
|
||||
assert_eq!(State::of(ResultCode::UpgradeNeeded), State::UpgradeNeeded);
|
||||
assert_eq!(
|
||||
State::of(ResultCode::ConnectionIssue),
|
||||
State::ConnectionIssue
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_share_presence_option_maps_to_its_levels() {
|
||||
assert_eq!(SharePresence::of(0), SharePresence::None);
|
||||
assert_eq!(SharePresence::of(1), SharePresence::Limited);
|
||||
assert_eq!(SharePresence::of(2), SharePresence::All);
|
||||
assert_eq!(SharePresence::of(9), SharePresence::All);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_after_reads_whole_positive_seconds() {
|
||||
assert_eq!(parse_retry_after("30"), Some(Duration::from_secs(30)));
|
||||
assert_eq!(parse_retry_after(" 5 "), Some(Duration::from_secs(5)));
|
||||
assert_eq!(parse_retry_after("0"), None);
|
||||
assert_eq!(parse_retry_after("later"), None);
|
||||
}
|
||||
}
|
||||
@@ -721,7 +721,7 @@ impl Sprites {
|
||||
/// A PNG's width, height and pixels as RGBA. From the prepared data file
|
||||
/// when it holds the texture: no file opened per texture, nor for its absent
|
||||
/// LabPBR maps.
|
||||
fn read_rgba(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
|
||||
pub(crate) fn read_rgba(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
|
||||
let bytes = horizon_core::prepared_files::read(path)?;
|
||||
let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
|
||||
decoder.ignore_checksums(true);
|
||||
|
||||
@@ -12,7 +12,10 @@ struct StaticEntityUniform {
|
||||
@location(5) normal: vec4<f32>, @location(6) glint_uv: vec2<f32>,
|
||||
@location(7) section_origin: vec3<i32>) -> Out {
|
||||
let block_delta = section_origin - static_entity.camera_block.xyz;
|
||||
// [world warp static entity]
|
||||
// (a world warp's pipelines put the vertex through the mod's `world_warp`: gfx/world_warp.rs)
|
||||
let camera_relative = vec3<f32>(block_delta) + position - static_entity.camera_fraction.xyz;
|
||||
// [end world warp static entity]
|
||||
var out = entity_vertex(camera_relative,
|
||||
uv, overlay & 0xffffu, color, light, normal);
|
||||
out.glint_uv = glint_uv;
|
||||
|
||||
@@ -286,7 +286,7 @@ enum Job {
|
||||
Decorate(i32, i32, Box<[Proto; 9]>, Vec<(usize, Chunk)>),
|
||||
Finish(Proto),
|
||||
Light(i32, i32, Box<Region>, u64, bool),
|
||||
Mesh(i32, i32, i32, Box<Region>, u64, MeshSettings),
|
||||
Mesh(i32, i32, i32, Arc<Region>, u64, MeshSettings),
|
||||
/// Proto chunks checked out to be saved (`ChunkGenerator::save_protos`),
|
||||
/// off the main thread like vanilla's IOWorker.
|
||||
Save(Vec<((i32, i32), Proto)>),
|
||||
@@ -1185,13 +1185,12 @@ impl Streaming {
|
||||
/// The resident sections `LevelExtractor.allChanged` queues again. An
|
||||
/// unlit column will queue itself when its light job completes.
|
||||
fn mark_loaded_sections_dirty(world: &mut World, lit: &FxHashSet<(i32, i32)>) {
|
||||
let mask = SectionMask::below(world.layout().sections());
|
||||
for &(x, z) in world.chunks.keys() {
|
||||
if !lit.contains(&(x, z)) {
|
||||
continue;
|
||||
}
|
||||
for sy in 0..world.layout().sections() as i32 {
|
||||
world.dirty_sections.insert((x, sy, z));
|
||||
}
|
||||
world.dirty_sections.insert_column_mask((x, z), mask);
|
||||
}
|
||||
}
|
||||
/// A column received from a server, with the light it sent
|
||||
@@ -1204,10 +1203,12 @@ impl Streaming {
|
||||
world.chunks.insert(key, Arc::new(chunk));
|
||||
self.lit.insert(key);
|
||||
self.generated_total += 1;
|
||||
let mask = SectionMask::below(world.layout().sections());
|
||||
for dz in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
for sy in 0..world.layout().sections() as i32 {
|
||||
world.dirty_sections.insert((key.0 + dx, sy, key.1 + dz));
|
||||
let n = (key.0 + dx, key.1 + dz);
|
||||
if n == key || world.chunks.contains_key(&n) {
|
||||
world.dirty_sections.insert_column_mask(n, mask);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1525,7 +1526,7 @@ impl Streaming {
|
||||
z,
|
||||
)
|
||||
}
|
||||
fn region(world: &World, x: i32, z: i32) -> Option<Box<Region>> {
|
||||
fn region(world: &World, x: i32, z: i32) -> Option<Arc<Region>> {
|
||||
let mut region: Region = Default::default();
|
||||
for dz in 0..3 {
|
||||
for dx in 0..3 {
|
||||
@@ -1537,7 +1538,7 @@ impl Streaming {
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(Box::new(region))
|
||||
Some(Arc::new(region))
|
||||
}
|
||||
pub fn is_lit(&self, x: i32, z: i32) -> bool {
|
||||
self.lit.contains(&(x, z))
|
||||
@@ -1937,8 +1938,8 @@ impl Streaming {
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
let keys: Vec<(i32, i32)> =
|
||||
(0..9).map(|i| (x + (i % 3) - 1, z + (i / 3) - 1)).collect();
|
||||
let keys: [(i32, i32); 9] =
|
||||
std::array::from_fn(|i| (x + (i as i32 % 3) - 1, z + (i as i32 / 3) - 1));
|
||||
// A final neighbour cannot be generated again: a generator that
|
||||
// can decorates beside it (a chunk of an older world), writing
|
||||
// into it as vanilla's features do; the others skip decoration
|
||||
@@ -1947,40 +1948,33 @@ impl Streaming {
|
||||
if keys.iter().any(|k| self.decorating_finals.contains(k)) {
|
||||
continue;
|
||||
}
|
||||
let has_final = keys.iter().any(|k| world.chunks.contains_key(k));
|
||||
if has_final && !self.saves_beside_final {
|
||||
self.decorated.insert((x, z));
|
||||
waiting.borrow_mut().insert((x, z), false);
|
||||
continue;
|
||||
}
|
||||
if !keys.iter().all(|k| {
|
||||
world.chunks.contains_key(k) || matches!(self.protos.get(k), Some(Some(_)))
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
let finals: Vec<(usize, Chunk)> = keys
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, k)| Some((i, (**world.chunks.get(k)?).clone())))
|
||||
.collect();
|
||||
if !finals.is_empty() && !self.saves_beside_final {
|
||||
self.decorated.insert((x, z));
|
||||
waiting.borrow_mut().insert((x, z), false);
|
||||
continue;
|
||||
}
|
||||
if !keys.iter().enumerate().all(|(i, k)| {
|
||||
finals.iter().any(|(f, _)| *f == i)
|
||||
|| matches!(self.protos.get(k), Some(Some(_)))
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
let region: Vec<Proto> = keys
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, k)| {
|
||||
if finals.iter().any(|(f, _)| *f == i) {
|
||||
return Box::new(()) as Proto;
|
||||
}
|
||||
self.light_views.remove(k);
|
||||
self.protos
|
||||
.insert(*k, None)
|
||||
.flatten()
|
||||
.expect("present proto")
|
||||
})
|
||||
.collect();
|
||||
let region: Box<[Proto; 9]> = match region.try_into() {
|
||||
Ok(r) => Box::new(r),
|
||||
Err(_) => continue,
|
||||
};
|
||||
let region: Box<[Proto; 9]> = Box::new(std::array::from_fn(|i| {
|
||||
let k = &keys[i];
|
||||
if finals.iter().any(|(f, _)| *f == i) {
|
||||
return Box::new(()) as Proto;
|
||||
}
|
||||
self.light_views.remove(k);
|
||||
self.protos
|
||||
.insert(*k, None)
|
||||
.flatten()
|
||||
.expect("present proto")
|
||||
}));
|
||||
self.decorating += 1;
|
||||
self.decorating_finals
|
||||
.extend(finals.iter().map(|(i, _)| keys[*i]));
|
||||
@@ -2264,10 +2258,14 @@ impl Streaming {
|
||||
world.dirty_light.extend(columns);
|
||||
}
|
||||
/// Sections whose meshes sample a changed light value (faces and corners
|
||||
/// read light from neighbouring blocks).
|
||||
/// read light from neighbouring blocks). Every column whose stored light
|
||||
/// changed is saved again (`ChunkHolder.sectionLightChanged` marks the
|
||||
/// chunk unsaved): an edit's light reaches up to 15 blocks into columns
|
||||
/// the edit itself did not mark, which may already have been saved.
|
||||
fn mark_light_changes(world: &mut World, changed: &LightChanges) {
|
||||
if let Some(log) = &mut world.light_log {
|
||||
for (x, sy, z) in changed.sections() {
|
||||
for (x, sy, z) in changed.sections() {
|
||||
world.modified.insert((x, z));
|
||||
if let Some(log) = &mut world.light_log {
|
||||
log.entry((x, z)).or_default().insert(sy as usize);
|
||||
}
|
||||
}
|
||||
@@ -4369,6 +4367,38 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// An edit's light change in a neighbouring column marks that column
|
||||
/// unsaved too, or a column saved before the edit keeps its old light on
|
||||
/// disk.
|
||||
#[test]
|
||||
fn light_spilling_into_a_saved_neighbour_marks_it_unsaved() {
|
||||
use crate::light::tests::{AIR, TORCH, tables, world};
|
||||
let tables = tables();
|
||||
let mut w = World::new(horizon_world::registry::BlockStateId(0), 0);
|
||||
for (key, chunk) in world(|c| {
|
||||
c.set(14, 0, 8, TORCH);
|
||||
}) {
|
||||
w.chunks.insert(key, chunk);
|
||||
}
|
||||
assert!(w.modified.is_empty());
|
||||
Arc::make_mut(w.chunks.get_mut(&(0, 0)).unwrap()).set(14, 0, 8, AIR);
|
||||
let mut changed = crate::light::LightChanges::default();
|
||||
crate::light::relight(
|
||||
&mut w.chunks,
|
||||
&tables,
|
||||
true,
|
||||
&[(14, 0, 8)],
|
||||
false,
|
||||
&mut changed,
|
||||
);
|
||||
assert_eq!(w.chunks[&(1, 0)].light(0, 0, 8) & 15, 0); // was 12
|
||||
Streaming::mark_light_changes(&mut w, &changed);
|
||||
assert!(w.modified.contains(&(1, 0)));
|
||||
assert!(w.modified.contains(&(0, 0)));
|
||||
// The torch never reached x -1: that column's light is unchanged.
|
||||
assert!(!w.modified.contains(&(-1, 0)));
|
||||
}
|
||||
|
||||
fn resident_column(world: &mut World, light: u8) -> Chunk {
|
||||
let mut chunk = Chunk::empty(2, -3);
|
||||
chunk.set(1, 10, 1, 1);
|
||||
|
||||
@@ -40,7 +40,7 @@ struct Globals {
|
||||
hdr_windows: array<vec4<f32>, 2>,
|
||||
// HDR lighting: x how strongly the sky lights the world now (`Sky::sky_light_factor`),
|
||||
// y what the sky and fog get: the same, but none under water; z 1 with HDR output on.
|
||||
// w: this entire frame writes light, so alpha is coverage rather than a mark.
|
||||
// w: apply the no-pack HDR gain; HDR_WRITE_LIGHT independently keeps float alpha as coverage.
|
||||
hdr_light: vec4<f32>,
|
||||
hdr_settings: vec4<f32>,
|
||||
hdr_shares: vec4<f32>,
|
||||
@@ -224,6 +224,10 @@ struct TerrainOut {
|
||||
// The camera-relative height, for the water mirror's cut at the plane.
|
||||
height: f32,
|
||||
emissive_layer: u32,
|
||||
// [world warp varyings]
|
||||
// (a native mod's world warp adds its own here, and in `TerrainWire`, `to_wire` and
|
||||
// `from_wire`: gfx/world_warp.rs)
|
||||
// [end world warp varyings]
|
||||
}
|
||||
// `TerrainOut` in 5 interpolant locations instead of 12. A GPU's vertex stage pays for each
|
||||
// location it exports (AMD's parameter cache gives one a whole slot, however few of its four
|
||||
@@ -242,6 +246,8 @@ struct TerrainWire {
|
||||
@location(3) d: vec4<f32>,
|
||||
// `layer`, `emission`, `fade` and `emissive_layer`.
|
||||
@location(4) @interpolate(flat) e: vec4<u32>,
|
||||
// [world warp wire]
|
||||
// [end world warp wire]
|
||||
}
|
||||
fn to_wire(o: TerrainOut) -> TerrainWire {
|
||||
var w: TerrainWire;
|
||||
@@ -251,6 +257,8 @@ fn to_wire(o: TerrainOut) -> TerrainWire {
|
||||
w.c = vec4(o.uv, o.scene);
|
||||
w.d = vec4(o.spherical, o.cylindrical, o.height, 0.0);
|
||||
w.e = vec4(o.layer, o.emission, o.fade, o.emissive_layer);
|
||||
// [world warp to wire]
|
||||
// [end world warp to wire]
|
||||
return w;
|
||||
}
|
||||
fn from_wire(w: TerrainWire) -> TerrainOut {
|
||||
@@ -268,6 +276,8 @@ fn from_wire(w: TerrainWire) -> TerrainOut {
|
||||
o.emission = w.e.y;
|
||||
o.fade = w.e.z;
|
||||
o.emissive_layer = w.e.w;
|
||||
// [world warp from wire]
|
||||
// [end world warp from wire]
|
||||
return o;
|
||||
}
|
||||
// Whether a fading tile or section leaves this pixel out (gfx/lod.rs's
|
||||
@@ -662,7 +672,10 @@ fn hdr_facing() -> f32 {
|
||||
}
|
||||
fn terrain_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
|
||||
light: vec2<u32>, origin: vec3<f32>, w: f32) -> TerrainOut {
|
||||
// [world warp vertex]
|
||||
// (a world warp's pipelines put the point through the mod's `world_warp`: gfx/world_warp.rs)
|
||||
let pos = position + origin;
|
||||
// [end world warp vertex]
|
||||
var out: TerrainOut;
|
||||
out.clip = fade_depth(globals.view_proj * vec4(pos, 1.0), w);
|
||||
out.local = position;
|
||||
@@ -695,6 +708,8 @@ fn terrain_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f3
|
||||
const CHUNK_FULLY_VISIBLE_RANGE: f32 = 16.0;
|
||||
out.visibility = mix(1.0, instance_visibility(w),
|
||||
clamp((length(pos) - CHUNK_FULLY_VISIBLE_RANGE) / CHUNK_FULLY_VISIBLE_RANGE, 0.0, 1.0));
|
||||
// [world warp out]
|
||||
// [end world warp out]
|
||||
return out;
|
||||
}
|
||||
fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
|
||||
@@ -762,11 +777,14 @@ fn solid_color(in: TerrainOut) -> vec4<f32> {
|
||||
fn solid_finish(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>) -> vec4<f32> {
|
||||
return solid_light(solid_finish_at(in, texel, color, dpdx(in.uv), dpdy(in.uv)));
|
||||
}
|
||||
// A finished solid pixel as the frame keeps it: its mark in alpha, or on the written lane its
|
||||
// light, alpha 1.
|
||||
// A finished solid pixel as the frame keeps it: on the float frame its colour, alpha 1, lit by
|
||||
// HDR's gain where it applies (`hdr_light.w`); else its mark in alpha.
|
||||
fn solid_light(finished: vec4<f32>) -> vec4<f32> {
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
|
||||
return vec4(hdr_written(finished.rgb, finished.a, globals.hdr_settings, globals.hdr_shares), 1.0);
|
||||
if HDR_WRITE_LIGHT {
|
||||
if globals.hdr_light.w != 0.0 {
|
||||
return vec4(hdr_written(finished.rgb, finished.a, globals.hdr_settings, globals.hdr_shares), 1.0);
|
||||
}
|
||||
return vec4(finished.rgb, 1.0);
|
||||
}
|
||||
return finished;
|
||||
}
|
||||
@@ -915,7 +933,7 @@ struct NativeModelOut {
|
||||
color = vec4(color.rgb * shade, color.a);
|
||||
}
|
||||
let finished = apply_fog(color, in.spherical, in.cylindrical);
|
||||
return vec4(finished.rgb, select(finished.a, 1.0, HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0));
|
||||
return vec4(finished.rgb, select(finished.a, 1.0, HDR_WRITE_LIGHT));
|
||||
}
|
||||
|
||||
// Sky (SkyRenderer): the 10-vertex sky disc fan at y = 16 (radius 512) in
|
||||
@@ -1050,8 +1068,11 @@ fn sky_along_ray_base(d: vec3<f32>) -> vec3<f32> {
|
||||
}
|
||||
fn sky_finished(color: vec3<f32>) -> vec4<f32> {
|
||||
let mark = 1.0 - 0.24 * globals.hdr_light.y;
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
|
||||
return vec4(hdr_written(color, mark, globals.hdr_settings, globals.hdr_shares), 1.0);
|
||||
if HDR_WRITE_LIGHT {
|
||||
if globals.hdr_light.w != 0.0 {
|
||||
return vec4(hdr_written(color, mark, globals.hdr_settings, globals.hdr_shares), 1.0);
|
||||
}
|
||||
return vec4(color, 1.0);
|
||||
}
|
||||
return vec4(color, mark);
|
||||
}
|
||||
|
||||
@@ -1057,9 +1057,9 @@ pub fn tooltip(batch: &mut HudBatch, atlas: &HudAtlas, lines: &[Text], cursor: (
|
||||
}
|
||||
tooltip_box(batch, atlas, lines, x, y, w, h);
|
||||
}
|
||||
/// `WidgetTooltipHolder.createTooltipPositioner`'s focused branch
|
||||
/// (`MenuTooltipPositioner`): a widget with the keyboard focus keeps the
|
||||
/// pointer where it is, and its tooltip stays against the widget at `rect`.
|
||||
/// `WidgetTooltipHolder.createTooltipPositioner` for a hovered widget
|
||||
/// (`MenuTooltipPositioner`): the tooltip follows the pointer's x and keeps
|
||||
/// 3 to 5 px from the widget at `rect`.
|
||||
pub fn tooltip_menu(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
@@ -1132,14 +1132,16 @@ pub fn menu_tooltip_position(
|
||||
}
|
||||
pos
|
||||
}
|
||||
/// `MenuTooltipPositioner.getOffset`: how far the tooltip keeps from the
|
||||
/// widget's top (or bottom) edge, from `MAX_DISTANCE_TO_WIDGET` 5 to
|
||||
/// `MAX_OVERLAP_WITH_WIDGET` 3 as the pointer nears the edge.
|
||||
/// The distance over the height is an int division in vanilla, so the lerp
|
||||
/// is `height - 3` until the pointer is a whole height away, then 5.
|
||||
/// `MenuTooltipPositioner.getOffset`: how far the tooltip goes past the
|
||||
/// widget's top (or bottom) edge, `height - 3` (`MAX_OVERLAP_WITH_WIDGET`)
|
||||
/// with the pointer on the edge, down to `MAX_DISTANCE_TO_WIDGET` 5 a whole
|
||||
/// height away. The distance over the height is a float division (`i2f`,
|
||||
/// `fdiv` in 26.3's bytecode), so the offset moves smoothly with the pointer;
|
||||
/// then `Mth.lerp` and Java's float rounding.
|
||||
fn widget_offset(at: i32, edge: i32, height: i32) -> i32 {
|
||||
let d = ((at - edge).unsigned_abs().min(height as u32) / height as u32) as f32;
|
||||
((height as f32 - 3.0) + d * (5.0 - (height as f32 - 3.0))).round() as i32
|
||||
let distance = (at - edge).unsigned_abs().min(height as u32) as f32;
|
||||
let start = (height - 3) as f32;
|
||||
horizon_core::jmath::round_f32(start + distance / height as f32 * (5.0 - start))
|
||||
}
|
||||
fn tooltip_size(atlas: &HudAtlas, lines: &[Text]) -> (f32, f32) {
|
||||
let w = lines
|
||||
@@ -2487,16 +2489,21 @@ mod tests {
|
||||
let screen = (427, 240);
|
||||
let widget = [100, 100, 150, 20];
|
||||
let size = (80, 10);
|
||||
// Pointer at the widget's top edge: the tooltip drops 3 px, then
|
||||
// `getOffset`'s int division gives `height - 3` = 17 more.
|
||||
// Pointer at the widget's top edge: the tooltip drops 3 px to 103,
|
||||
// then `getOffset` adds round(lerp(3 / 20, 17, 5)) = round(15.2) = 15.
|
||||
assert_eq!(
|
||||
menu_tooltip_position(screen, (100, 100), widget, size),
|
||||
(112, 120)
|
||||
(112, 118)
|
||||
);
|
||||
// Near the right edge its left side mirrors to the pointer's left.
|
||||
assert_eq!(
|
||||
menu_tooltip_position(screen, (400, 100), widget, size),
|
||||
(308, 120)
|
||||
(308, 118)
|
||||
);
|
||||
// Halfway down the offset follows the pointer: 113 + round(9.2).
|
||||
assert_eq!(
|
||||
menu_tooltip_position(screen, (100, 110), widget, size),
|
||||
(112, 122)
|
||||
);
|
||||
// A whole widget height from the edge, the offset is 5.
|
||||
assert_eq!(
|
||||
@@ -2504,10 +2511,10 @@ mod tests {
|
||||
(112, 125)
|
||||
);
|
||||
// A widget too close to the bottom: the tooltip goes above it,
|
||||
// 203 - (10 + 6) - 17.
|
||||
// 203 - (10 + 6) - round(lerp(17 / 20, 17, 5)) = 203 - 16 - 7.
|
||||
assert_eq!(
|
||||
menu_tooltip_position(screen, (100, 200), [100, 200, 150, 20], size),
|
||||
(112, 170)
|
||||
(112, 180)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,9 @@ thread_local! {
|
||||
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())) };
|
||||
/// The targets whose value left and right step ([`steps_value`]) since `end_frame`, and the
|
||||
/// last frame's.
|
||||
static STEPPERS: RefCell<(Vec<[f32; 4]>, Vec<[f32; 4]>)> = const { RefCell::new((Vec::new(), Vec::new())) };
|
||||
}
|
||||
|
||||
/// Splitscreen: one player's buttons and focus targets, kept aside while another player's game
|
||||
@@ -120,6 +123,7 @@ pub struct Saved(
|
||||
(u64, u64),
|
||||
(Vec<ScrollArea>, Vec<ScrollArea>),
|
||||
(Vec<FocusLink>, Vec<FocusLink>),
|
||||
(Vec<[f32; 4]>, Vec<[f32; 4]>),
|
||||
);
|
||||
|
||||
/// This thread's buttons and targets, leaving none.
|
||||
@@ -132,6 +136,7 @@ pub fn take() -> Saved {
|
||||
ACTION_SCREENS.take(),
|
||||
SCROLL_AREAS.take(),
|
||||
FOCUS_LINKS.take(),
|
||||
STEPPERS.take(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -141,6 +146,7 @@ pub fn put(saved: Saved) {
|
||||
TARGETS.set(saved.1);
|
||||
SCROLL_AREAS.set(saved.5);
|
||||
FOCUS_LINKS.set(saved.6);
|
||||
STEPPERS.set(saved.7);
|
||||
ACTIONS.set(saved.2);
|
||||
ACTION_GLYPHS.set(saved.3);
|
||||
ACTION_SCREENS.set(saved.4);
|
||||
@@ -288,6 +294,7 @@ pub fn cover_frame() {
|
||||
ACTIONS.with_borrow_mut(|(current, _)| current.clear());
|
||||
SCROLL_AREAS.with_borrow_mut(|(current, _)| current.clear());
|
||||
FOCUS_LINKS.with_borrow_mut(|(current, _)| current.clear());
|
||||
STEPPERS.with_borrow_mut(|(current, _)| current.clear());
|
||||
}
|
||||
|
||||
/// This frame drew a list with something to scroll, in `rect`, scrolled `amount`
|
||||
@@ -353,6 +360,18 @@ pub fn linked_step(focus: [f32; 4], down: bool) -> Option<[f32; 4]> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Left and right on the target at `rect` step its value, back and on, rather than move the focus
|
||||
/// (the settings shell's choices, whose `<` `>` say so): Right as a press, Left as a Shift press.
|
||||
pub fn steps_value(rect: [f32; 4]) {
|
||||
STEPPERS.with_borrow_mut(|(current, _)| current.push(rect));
|
||||
}
|
||||
|
||||
/// Whether the last drawn frame said left and right step the value of the target at `focus`.
|
||||
pub fn last_steps_value(focus: [f32; 4]) -> bool {
|
||||
let same = |rect: &[f32; 4]| rect.iter().zip(focus).all(|(a, b)| (a - b).abs() < 0.5);
|
||||
STEPPERS.with_borrow(|(_, last)| last.iter().any(same))
|
||||
}
|
||||
|
||||
/// The focus targets of the last drawn frame.
|
||||
pub fn last_targets() -> Vec<Target> {
|
||||
TARGETS.with_borrow(|(_, last, _)| last.clone())
|
||||
@@ -433,6 +452,9 @@ pub fn end_frame(frames: u64) {
|
||||
FOCUS_LINKS.with_borrow_mut(|(current, last)| {
|
||||
*last = std::mem::take(current);
|
||||
});
|
||||
STEPPERS.with_borrow_mut(|(current, last)| {
|
||||
*last = std::mem::take(current);
|
||||
});
|
||||
NARRATION.with_borrow_mut(|(current, last)| {
|
||||
*last = std::mem::replace(current, Narration::EMPTY);
|
||||
});
|
||||
|
||||
@@ -9,6 +9,9 @@ use horizon_core::random::LegacyRandom;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::sync::Arc;
|
||||
|
||||
mod modified;
|
||||
pub use modified::ModifiedChunks;
|
||||
|
||||
/// Sections (x, section index, z) waiting to be remeshed, kept by column
|
||||
/// (a bit per section index), so the mesh scheduler looks at each column once
|
||||
/// rather than at every section of it.
|
||||
@@ -21,6 +24,10 @@ impl DirtySections {
|
||||
debug_assert!((0..SectionMask::CAPACITY as i32).contains(&sy));
|
||||
self.columns.entry((x, z)).or_default().insert(sy as usize);
|
||||
}
|
||||
/// Mark all sections in `mask` dirty for column `(x, z)`.
|
||||
pub fn insert_column_mask(&mut self, (x, z): (i32, i32), mask: SectionMask) {
|
||||
*self.columns.entry((x, z)).or_default() |= mask;
|
||||
}
|
||||
pub fn contains(&self, &(x, sy, z): &(i32, i32, i32)) -> bool {
|
||||
(0..SectionMask::CAPACITY as i32).contains(&sy)
|
||||
&& self
|
||||
@@ -83,7 +90,7 @@ pub struct World {
|
||||
/// was generated rather than read from the save: the game tells native mods.
|
||||
pub loaded_chunks: Vec<(i32, i32, bool)>,
|
||||
/// Chunks changed since the last save.
|
||||
pub modified: FxHashSet<(i32, i32)>,
|
||||
pub modified: ModifiedChunks,
|
||||
/// Block entities, and the order they tick in.
|
||||
pub block_entities: crate::block_entities::BlockEntities,
|
||||
/// Each chest's `ContainerOpenersCounter`: opener count and largest
|
||||
@@ -220,7 +227,7 @@ impl World {
|
||||
loaded_entities: Vec::new(),
|
||||
entities_pending: FxHashSet::default(),
|
||||
loaded_chunks: Vec::new(),
|
||||
modified: FxHashSet::default(),
|
||||
modified: ModifiedChunks::default(),
|
||||
attachments: Default::default(),
|
||||
attachment_syncs: FxHashSet::default(),
|
||||
block_entities: Default::default(),
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
//! `LevelChunk.markUnsaved` and `ChunkMap`'s insertion-ordered eager queue.
|
||||
use crate::fast_hash::{FxHashMap, FxHashSet};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
ops::Deref,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
type Key = (i32, i32);
|
||||
const COOLDOWN: Duration = Duration::from_millis(10_000);
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ModifiedChunks {
|
||||
dirty: FxHashSet<Key>,
|
||||
// Ordered independently of the dirty set: an autosave leaves queue entries
|
||||
// in place until the eager loop visits them, including cooled-down ones.
|
||||
order: BTreeMap<u64, Key>,
|
||||
queued: FxHashMap<Key, u64>,
|
||||
sequence: u64,
|
||||
next_save: FxHashMap<Key, Instant>,
|
||||
}
|
||||
impl Deref for ModifiedChunks {
|
||||
type Target = FxHashSet<Key>;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.dirty
|
||||
}
|
||||
}
|
||||
impl IntoIterator for ModifiedChunks {
|
||||
type Item = Key;
|
||||
type IntoIter = std::collections::hash_set::IntoIter<Key>;
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.dirty.into_iter()
|
||||
}
|
||||
}
|
||||
impl ModifiedChunks {
|
||||
pub fn insert(&mut self, key: Key) -> bool {
|
||||
let changed = self.dirty.insert(key);
|
||||
if changed && !self.queued.contains_key(&key) {
|
||||
self.sequence += 1;
|
||||
self.order.insert(self.sequence, key);
|
||||
self.queued.insert(key, self.sequence);
|
||||
}
|
||||
changed
|
||||
}
|
||||
pub fn extend(&mut self, keys: impl IntoIterator<Item = Key>) {
|
||||
for key in keys {
|
||||
self.insert(key);
|
||||
}
|
||||
}
|
||||
pub fn remove(&mut self, key: &Key) -> bool {
|
||||
self.dirty.remove(key)
|
||||
}
|
||||
pub fn drain(&mut self) -> impl Iterator<Item = Key> + '_ {
|
||||
self.dirty.drain()
|
||||
}
|
||||
/// Like `drain`: queue entries of chunks no longer dirty go at the next eager pass.
|
||||
pub fn clear(&mut self) {
|
||||
self.dirty.clear();
|
||||
}
|
||||
/// `ChunkMap.scheduleUnload`: forget the cooldown, including clean chunks.
|
||||
pub fn unloaded(&mut self, key: &Key) {
|
||||
self.next_save.remove(key);
|
||||
}
|
||||
/// `ChunkMap.saveAllChunks(false)`: clear cooldowns, then save all dirty
|
||||
/// full chunks without a limit, starting a fresh cooldown for each write.
|
||||
pub fn autosaved(&mut self, keys: &[Key], now: Instant) {
|
||||
self.next_save.clear();
|
||||
self.next_save
|
||||
.extend(keys.iter().map(|&key| (key, now + COOLDOWN)));
|
||||
}
|
||||
/// `ChunkMap.saveChunksEagerly` / `saveChunkIfNeeded`. `available` checks
|
||||
/// both haveTime and activeChunkWrites before EACH candidate; `save` takes
|
||||
/// an immutable snapshot and queues it. Failed submissions stay dirty.
|
||||
pub fn save_eagerly(
|
||||
&mut self,
|
||||
now: Instant,
|
||||
mut available: impl FnMut() -> bool,
|
||||
mut loaded: impl FnMut(Key) -> bool,
|
||||
mut save: impl FnMut(Key) -> bool,
|
||||
) {
|
||||
let mut saved = 0;
|
||||
let mut remove = Vec::new();
|
||||
for (&sequence, &key) in &self.order {
|
||||
if saved == 20 || !available() {
|
||||
break;
|
||||
}
|
||||
if !loaded(key) || !self.dirty.contains(&key) {
|
||||
remove.push((sequence, key));
|
||||
} else if self.next_save.get(&key).is_none_or(|&at| now >= at) && save(key) {
|
||||
self.dirty.remove(&key);
|
||||
self.next_save.insert(key, now + COOLDOWN);
|
||||
remove.push((sequence, key));
|
||||
saved += 1;
|
||||
}
|
||||
}
|
||||
for (sequence, key) in remove {
|
||||
self.order.remove(&sequence);
|
||||
self.queued.remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,9 @@ use zip::{CompressionMethod, ZipArchive};
|
||||
/// The most one entry inflates to: far above any real pack file, but a zip
|
||||
/// bomb stops here.
|
||||
const MAX_ENTRY: usize = 1 << 30;
|
||||
/// The most every entry inflates to together, once per archive: per-entry
|
||||
/// limits still let a bomb spend its budget one 1 GiB entry at a time.
|
||||
const MAX_TOTAL: usize = 4 << 30;
|
||||
|
||||
/// One archive with its entries indexed by name (`\` read as `/`), files
|
||||
/// and folders apart.
|
||||
@@ -27,6 +30,8 @@ pub struct Zip {
|
||||
folders: BTreeMap<String, usize>,
|
||||
/// The archive's length: no size an entry claims can be bigger.
|
||||
len: usize,
|
||||
/// The bytes left for every entry together, drawn down as they are read.
|
||||
budget: AtomicU64,
|
||||
}
|
||||
fn invalid(e: impl std::fmt::Display) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, e.to_string())
|
||||
@@ -147,6 +152,7 @@ impl Zip {
|
||||
entries,
|
||||
folders,
|
||||
len,
|
||||
budget: AtomicU64::new(MAX_TOTAL as u64),
|
||||
})
|
||||
}
|
||||
/// The central directory on, as read at opening: every entry's name, sizes
|
||||
@@ -172,9 +178,15 @@ impl Zip {
|
||||
self.index(name).is_some()
|
||||
}
|
||||
pub fn read(&self, name: &str) -> io::Result<Vec<u8>> {
|
||||
self.read_within(name, &self.budget)
|
||||
}
|
||||
/// `read`, drawing `budget` down as entries are returned: a per-entry cap
|
||||
/// is not a total one, and a bomb spends its budget one 1 GiB entry at a
|
||||
/// time otherwise.
|
||||
fn read_within(&self, name: &str, budget: &AtomicU64) -> io::Result<Vec<u8>> {
|
||||
let (method, raw) = self.raw(name)?;
|
||||
match method {
|
||||
CompressionMethod::Stored => Ok(raw),
|
||||
let bytes = match method {
|
||||
CompressionMethod::Stored => raw,
|
||||
CompressionMethod::Deflated => {
|
||||
let mut decoded = Vec::with_capacity(raw.len().min(MAX_ENTRY));
|
||||
inflate(&raw)
|
||||
@@ -184,10 +196,12 @@ impl Zip {
|
||||
if decoded.len() > MAX_ENTRY {
|
||||
return Err(invalid("entry inflates past the size limit"));
|
||||
}
|
||||
Ok(decoded)
|
||||
decoded
|
||||
}
|
||||
_ => Err(invalid("unsupported zip compression")),
|
||||
}
|
||||
_ => return Err(invalid("unsupported zip compression")),
|
||||
};
|
||||
take(budget, bytes.len() as u64)?;
|
||||
Ok(bytes)
|
||||
}
|
||||
/// 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
|
||||
@@ -450,6 +464,22 @@ mod tests {
|
||||
assert!(zip.read("missing").is_err());
|
||||
}
|
||||
|
||||
/// A per-entry cap is not a total one: an archive whose entries together
|
||||
/// inflate past the budget is refused, whatever each entry alone says.
|
||||
#[test]
|
||||
fn entries_share_one_total_inflated_budget() {
|
||||
let zip = Zip::from_bytes(archive()).unwrap();
|
||||
let tight = AtomicU64::new(16);
|
||||
assert!(zip.read_within("a/b.txt", &tight).is_err());
|
||||
let zip = Zip::from_bytes(archive()).unwrap();
|
||||
let tight = AtomicU64::new(12);
|
||||
assert_eq!(
|
||||
zip.read_within("pack.mcmeta", &tight).unwrap(),
|
||||
b"{\"pack\":{}}"
|
||||
);
|
||||
assert!(zip.read_within("a/b.txt", &tight).is_err());
|
||||
}
|
||||
|
||||
/// The offset of the central-directory header of `name`.
|
||||
fn central_header(bytes: &[u8], name: &str) -> usize {
|
||||
let mut at = 0;
|
||||
|
||||
@@ -92,8 +92,8 @@ dunce = "1.0.5"
|
||||
# Use the registered URI handler without routing links through Explorer/cmd.
|
||||
open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
|
||||
# DirectX 12, where Vulkan has no adapter or device, or by choice (`graphicsApi`, graphics.rs).
|
||||
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or DXC when dxcompiler.dll is
|
||||
# on the PATH.
|
||||
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or on integrated GPUs with DXC
|
||||
# (dxcompiler.dll beside the game, gfx/dx12_compiler.rs).
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! usage: environment_fixture RESOURCES INPUT OUTPUT
|
||||
use horizon_client::{
|
||||
biome::Biomes,
|
||||
environment::{Environment, apply_weather},
|
||||
environment::{Environment, Sample},
|
||||
};
|
||||
use std::{collections::HashMap, fmt::Write as _, fs, path::Path};
|
||||
|
||||
@@ -22,22 +22,22 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
for line in input.lines() {
|
||||
let f: Vec<&str> = line.split(' ').collect();
|
||||
let dimension = f[0];
|
||||
let environment =
|
||||
match environments.entry(dimension.to_owned()) {
|
||||
std::collections::hash_map::Entry::Occupied(e) => e.into_mut(),
|
||||
std::collections::hash_map::Entry::Vacant(e) => e.insert(
|
||||
Environment::load_dimension(root, dimension, dimension == "overworld")?,
|
||||
),
|
||||
};
|
||||
let environment = match environments.entry(dimension.to_owned()) {
|
||||
std::collections::hash_map::Entry::Occupied(e) => e.into_mut(),
|
||||
std::collections::hash_map::Entry::Vacant(e) => {
|
||||
e.insert(Environment::load_dimension(root, dimension)?)
|
||||
}
|
||||
};
|
||||
let info = biomes.get(biomes.id(&format!("minecraft:{}", f[4])));
|
||||
let mut sky = environment.sample(
|
||||
f[1].parse()?,
|
||||
info.sky.map(horizon_client::biome::rgb),
|
||||
info.fog.map(horizon_client::biome::rgb),
|
||||
);
|
||||
if dimension == "overworld" {
|
||||
apply_weather(&mut sky, f[2].parse()?, f[3].parse()?);
|
||||
}
|
||||
// The weather layers only exist where the dimension can have weather.
|
||||
let sky = environment.sample(&Sample {
|
||||
day_time: f[1].parse()?,
|
||||
rain: f[2].parse()?,
|
||||
thunder: f[3].parse()?,
|
||||
blend: None,
|
||||
biome: Some(&info.attributes),
|
||||
..Sample::default()
|
||||
});
|
||||
let v3 = |c: [f32; 3]| format!("[{:?},{:?},{:?}]", c[0], c[1], c[2]);
|
||||
let v4 = |c: [f32; 4]| format!("[{:?},{:?},{:?},{:?}]", c[0], c[1], c[2], c[3]);
|
||||
let mut row = format!("{{\"case\":\"{line}\"");
|
||||
@@ -72,6 +72,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
"visual/cloud_fog_end_distance",
|
||||
format!("{:?}", sky.cloud_fog_end),
|
||||
);
|
||||
put("visual/cloud_height", format!("{:?}", sky.cloud_height));
|
||||
out.push_str(&row);
|
||||
out.push_str("}\n");
|
||||
}
|
||||
|
||||
@@ -96,6 +96,8 @@ fn run(
|
||||
blend: blended.then_some(&blend),
|
||||
biome: &biome,
|
||||
clock: &|_| case["ticks"].as_i64().unwrap_or(0),
|
||||
post_blend: None,
|
||||
post_biome: None,
|
||||
};
|
||||
for attribute in ATTRIBUTES.iter().filter(|a| a.kind != Kind::Other) {
|
||||
let value = system.value(attribute, &conditions).ok_or("no value")?;
|
||||
|
||||
@@ -86,6 +86,7 @@ mod particle_providers_probe;
|
||||
mod pick_block_data_fixture;
|
||||
mod place_recipe_fixture;
|
||||
mod player_sync_fixture;
|
||||
mod portal_tickets_fixture;
|
||||
mod raid_fixture;
|
||||
mod random_tick_sections_probe;
|
||||
mod recipe_book_fixture;
|
||||
@@ -100,6 +101,7 @@ mod screen_layout_fixture;
|
||||
mod section_mesh_fixture;
|
||||
mod server_files_probe;
|
||||
mod sheep_dye_fixture;
|
||||
mod shulker_bullet_probe;
|
||||
mod situational_music_fixture;
|
||||
mod skin_image_fixture;
|
||||
mod sound_engine_fixture;
|
||||
@@ -202,6 +204,7 @@ probes! {
|
||||
pick_block_data_fixture,
|
||||
place_recipe_fixture,
|
||||
player_sync_fixture,
|
||||
portal_tickets_fixture,
|
||||
raid_fixture,
|
||||
random_tick_sections_probe,
|
||||
recipe_book_fixture,
|
||||
@@ -219,6 +222,7 @@ probes! {
|
||||
skin_image_fixture,
|
||||
late_entity_pair_fixture,
|
||||
sheep_dye_fixture,
|
||||
shulker_bullet_probe,
|
||||
allay_interaction_fixture,
|
||||
entity_sound_packet_fixture,
|
||||
sound_engine_fixture,
|
||||
|
||||
@@ -34,10 +34,13 @@ pub fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<_> = crate::args().collect();
|
||||
let input = fs::read_to_string(&args[1])?;
|
||||
let mut lines = input.lines();
|
||||
let root: Vec<&str> = lines.next().ok_or("empty input")?.split(' ').collect();
|
||||
let vanilla = PathBuf::from(root[1]);
|
||||
let ids: Vec<String> = root[3..].iter().map(|s| (*s).to_owned()).collect();
|
||||
let all = packs::discover(&vanilla, &PathBuf::from(root[2]));
|
||||
let root: Vec<String> = serde_json::from_str(lines.next().ok_or("empty input")?)?;
|
||||
if root.len() < 3 || root[0] != "root" {
|
||||
return Err("invalid resource-pack header".into());
|
||||
}
|
||||
let vanilla = PathBuf::from(&root[1]);
|
||||
let ids = root[3..].to_vec();
|
||||
let all = packs::discover(&vanilla, &PathBuf::from(&root[2]));
|
||||
let enabled = packs::enabled(&all, &ids);
|
||||
let overlay = PathBuf::from(&args[2]).with_extension("overlay");
|
||||
packs::build_overlay(&vanilla, &enabled, &overlay)?;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
//! A level's saved chunk tickets through `chunk_tickets.dat`, for VanillaPortalTicketsOracle:
|
||||
//! each case's file is loaded, its `PORTAL` tickets ticked, refreshed and reloaded over a
|
||||
//! restart, and every step prints what the file then holds and which of the case's chunks a
|
||||
//! `PORTAL` ticket holds now.
|
||||
use horizon_client::save::{LevelShape, SaveDir, read_dat, write_dat};
|
||||
use horizon_client::ticket_levels::ChunkTickets;
|
||||
use horizon_world::nbt::{Tag, parse_snbt};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashSet;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
fn chunk(pos: &Value) -> (i32, i32) {
|
||||
(
|
||||
pos[0].as_i64().unwrap() as i32,
|
||||
pos[1].as_i64().unwrap() as i32,
|
||||
)
|
||||
}
|
||||
|
||||
/// Save the level's tickets, then list each saved one (type, level, chunk and ticks_left,
|
||||
/// "-" where it is left out) and "held x z" for each of `chunks` a `PORTAL` ticket holds.
|
||||
fn snapshot(
|
||||
(save, file): (&SaveDir, &Path),
|
||||
forced: &HashSet<(i32, i32)>,
|
||||
tickets: &ChunkTickets,
|
||||
case: &Value,
|
||||
) -> Vec<String> {
|
||||
save.save_chunk_tickets(forced, tickets.portal_tickets())
|
||||
.unwrap();
|
||||
let data = read_dat(file).unwrap();
|
||||
let mut rows = Vec::new();
|
||||
if let Some(Tag::List(saved)) = data.get("data").and_then(|d| d.get("tickets")) {
|
||||
for t in saved {
|
||||
let (Some(Tag::String(kind)), Some(Tag::Int(level)), Some(Tag::IntArray(pos))) =
|
||||
(t.get("type"), t.get("level"), t.get("chunk_pos"))
|
||||
else {
|
||||
panic!("unexpected ticket {t:?}")
|
||||
};
|
||||
let left = match t.get("ticks_left") {
|
||||
Some(Tag::Long(n)) => n.to_string(),
|
||||
None => "-".into(),
|
||||
Some(other) => format!("{other:?}"),
|
||||
};
|
||||
let kind = kind.to_string_lossy();
|
||||
rows.push(format!("{kind} {level} {} {} {left}", pos[0], pos[1]));
|
||||
}
|
||||
}
|
||||
for (x, z) in case["chunks"].as_array().unwrap().iter().map(chunk) {
|
||||
if tickets.portal_tickets().iter().any(|t| t.chunk == (x, z)) {
|
||||
rows.push(format!("held {x} {z}"));
|
||||
}
|
||||
}
|
||||
rows.sort();
|
||||
rows
|
||||
}
|
||||
|
||||
pub fn main() {
|
||||
let args: Vec<_> = super::args().collect();
|
||||
let input = std::fs::read_to_string(&args[1]).unwrap();
|
||||
let mut out = std::fs::File::create(&args[2]).unwrap();
|
||||
let root = std::env::temp_dir().join(format!("horizon-portal-tickets-{}", std::process::id()));
|
||||
for line in input.lines() {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let case: Value = serde_json::from_str(line).unwrap();
|
||||
let (data, _) = parse_snbt(case["saved"].as_str().unwrap()).unwrap();
|
||||
let file = root.join("data/minecraft/chunk_tickets.dat");
|
||||
write_dat(&file, Tag::compound([("data", data)])).unwrap();
|
||||
// How the game opens a level (`world_loading::make_streaming`, `open_level`).
|
||||
let save = SaveDir::new(root.clone(), LevelShape::OVERWORLD);
|
||||
let mut forced = save.load_forced_chunks();
|
||||
let mut tickets = ChunkTickets::with_saved_portal(save.load_portal_tickets());
|
||||
let level = (&save, file.as_path());
|
||||
let mut trace = vec![snapshot(level, &forced, &tickets, &case)];
|
||||
for op in case["ops"].as_array().unwrap() {
|
||||
match op["op"].as_str().unwrap() {
|
||||
"tick" => {
|
||||
let loading: HashSet<_> = op["loading"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(chunk)
|
||||
.collect();
|
||||
for _ in 0..op["times"].as_u64().unwrap_or(1) {
|
||||
tickets.tick_portal(|t| !loading.contains(&t.chunk));
|
||||
}
|
||||
}
|
||||
"portal" => tickets.add_portal(chunk(&op["pos"])),
|
||||
// The last snapshot saved them: the level opens again from the file.
|
||||
"restart" => {
|
||||
forced = save.load_forced_chunks();
|
||||
tickets = ChunkTickets::with_saved_portal(save.load_portal_tickets());
|
||||
}
|
||||
other => panic!("unknown operation {other}"),
|
||||
}
|
||||
trace.push(snapshot(level, &forced, &tickets, &case));
|
||||
}
|
||||
writeln!(out, "{}", serde_json::to_string(&trace).unwrap()).unwrap();
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
//! Shulker bullets for tools/test_shulker_bullet_parity.py: where a shulker's
|
||||
//! bullet starts (`shulker::bullet_origin` through `Projectile::shulker_bullet`)
|
||||
//! and the block test that turns it in flight
|
||||
//! (`collisions::projectile_can_stand_on`), one line per fixture.
|
||||
#![forbid(unsafe_code)]
|
||||
use horizon_client::{
|
||||
Result, collisions,
|
||||
mobs::{Mob, MobTypes, Who, shulker},
|
||||
projectiles::{Projectile, ProjectileWorld},
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
position::BlockPos,
|
||||
};
|
||||
use horizon_world::{chunk::BlockState, registry::BlockStateId};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
/// The bullet's first move only asks which neighbours are empty: all are.
|
||||
struct Open(Vec<(Who, Aabb)>);
|
||||
impl ProjectileWorld for Open {
|
||||
fn clip(&self, _: Vec3, _: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> {
|
||||
None
|
||||
}
|
||||
fn in_water(&self, _: Aabb) -> bool {
|
||||
false
|
||||
}
|
||||
fn targets(&self) -> &[(Who, Aabb)] {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
fn vector(v: &Value) -> Vec3 {
|
||||
let n = |i: usize| v[i].as_f64().unwrap();
|
||||
Vec3::new(n(0), n(1), n(2))
|
||||
}
|
||||
|
||||
/// `"minecraft:oak_slab[type=top]"` as a registry state.
|
||||
fn parse_state(text: &str) -> BlockState {
|
||||
let (name, properties) = text.split_once('[').unwrap_or((text, ""));
|
||||
BlockState {
|
||||
name: name.into(),
|
||||
properties: properties
|
||||
.trim_end_matches(']')
|
||||
.split(',')
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(|p| {
|
||||
let (key, value) = p.split_once('=').unwrap();
|
||||
(key.to_string(), value.to_string())
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn main() -> Result<()> {
|
||||
let args: Vec<_> = crate::args().collect();
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
let registry = horizon_world::level::data::load_registry(&root)?;
|
||||
let binding = collisions::load(&root, ®istry)?;
|
||||
let types = MobTypes::load(&root)?;
|
||||
let kind = Arc::new(
|
||||
types
|
||||
.types
|
||||
.get("minecraft:shulker")
|
||||
.ok_or("no shulker")?
|
||||
.clone(),
|
||||
);
|
||||
let mut out = BufWriter::new(File::create(&args[2])?);
|
||||
for line in BufReader::new(File::open(&args[1])?).lines() {
|
||||
let f: Value = serde_json::from_str(&line?)?;
|
||||
if f["mode"] == "origin" {
|
||||
let mut m = Mob::new(1, kind.clone(), vector(&f["position"]), 0.0);
|
||||
m.extra.variant3 = f["face"].as_u64().unwrap() as u8;
|
||||
m.anim.v[shulker::PEEK] = f32::from_bits(f["peek"].as_u64().unwrap() as u32);
|
||||
m.extra.model_scale = f["scale"].as_f64().unwrap() as f32;
|
||||
let target = Aabb::new(Vec3::new(-0.5, 0.0, -0.5), Vec3::new(0.5, 1.0, 0.5))
|
||||
.move_by(vector(&f["target"]));
|
||||
let axis = match m.extra.variant3 {
|
||||
0 | 1 => 1,
|
||||
2 | 3 => 2,
|
||||
_ => 0,
|
||||
};
|
||||
let world = Open(vec![(Who::Mob(2), target)]);
|
||||
let at = shulker::bullet_origin(&m);
|
||||
let p = Projectile::shulker_bullet(&world, at, Who::Mob(1), Who::Mob(2), axis, 1);
|
||||
let b = p.position;
|
||||
writeln!(
|
||||
out,
|
||||
"{:016x} {:016x} {:016x}",
|
||||
b.x.to_bits(),
|
||||
b.y.to_bits(),
|
||||
b.z.to_bits()
|
||||
)?;
|
||||
} else {
|
||||
let pos = &f["pos"];
|
||||
let pos = BlockPos::new(
|
||||
pos[0].as_i64().unwrap() as i32,
|
||||
pos[1].as_i64().unwrap() as i32,
|
||||
pos[2].as_i64().unwrap() as i32,
|
||||
);
|
||||
let mut world = World::new(BlockStateId(0), 0);
|
||||
let chunk = (pos.x >> 4, pos.z >> 4);
|
||||
world.chunks.insert(
|
||||
chunk,
|
||||
Arc::new(horizon_client::chunk::Chunk::empty(chunk.0, chunk.1)),
|
||||
);
|
||||
let state = registry.resolve(&parse_state(f["block"].as_str().unwrap()))?;
|
||||
world.set(pos.x, pos.y, pos.z, state)?;
|
||||
let y = f["y"].as_f64().unwrap();
|
||||
writeln!(
|
||||
out,
|
||||
"{}",
|
||||
collisions::projectile_can_stand_on(&world, &binding, pos, y)
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -628,6 +628,11 @@ pub trait World {
|
||||
fn block_entity_nbt(&self, _pos: [i32; 3]) -> Option<horizon_world::nbt::Tag> {
|
||||
None
|
||||
}
|
||||
/// Read-only contents of a loaded named predicate, when this context
|
||||
/// supplies a predicate registry. Other contexts keep references absent.
|
||||
fn predicate(&self, _id: &str) -> Option<&Value> {
|
||||
None
|
||||
}
|
||||
}
|
||||
/// An item stack as predicates see it.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -642,9 +647,18 @@ pub struct ItemView {
|
||||
/// family unprojected and unsupported; an empty compound is actual absence.
|
||||
pub custom_data: Option<horizon_world::nbt::Tag>,
|
||||
}
|
||||
/// Query snapshot of supported known movement, in blocks/tick, and actual fall distance.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct MovementView {
|
||||
pub per_tick: [f64; 3],
|
||||
pub fall_distance: f64,
|
||||
}
|
||||
|
||||
/// An entity as predicates see it.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct EntityView {
|
||||
/// None is unprojected/unsupported, not a fabricated stationary entity.
|
||||
pub movement: Option<MovementView>,
|
||||
pub kind: String,
|
||||
pub position: [f64; 3],
|
||||
pub on_fire: bool,
|
||||
@@ -1010,6 +1024,51 @@ pub fn distance(v: &Value, a: [f64; 3], b: [f64; 3]) -> bool {
|
||||
(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt(),
|
||||
)
|
||||
}
|
||||
/// `MinMaxBounds.Doubles.matches` and `matchesSqr`, only for movement predicates.
|
||||
fn movement_range(v: &Value, value: f64, squared: bool) -> bool {
|
||||
let (mut min, mut max) = match v {
|
||||
Value::Null => (None, None),
|
||||
Value::Number(n) => {
|
||||
let Some(n) = n.as_f64() else { return false };
|
||||
(Some(n), Some(n))
|
||||
}
|
||||
Value::Object(o) => {
|
||||
let read = |key| match o.get(key) {
|
||||
None => Some(None),
|
||||
Some(n) => n.as_f64().map(Some),
|
||||
};
|
||||
let (Some(min), Some(max)) = (read("min"), read("max")) else {
|
||||
return false;
|
||||
};
|
||||
(min, max)
|
||||
}
|
||||
_ => return false,
|
||||
};
|
||||
if squared {
|
||||
min = min.map(|n| n * n);
|
||||
max = max.map(|n| n * n);
|
||||
}
|
||||
if min.is_some_and(|n| n > value) {
|
||||
return false;
|
||||
}
|
||||
max.is_none_or(|n| n >= value)
|
||||
}
|
||||
|
||||
/// `MovementPredicate.matches`: known movement is scaled by 20 before the ordered tests.
|
||||
fn movement(p: &Value, m: MovementView) -> bool {
|
||||
if !p.is_object() {
|
||||
return false;
|
||||
}
|
||||
let [x, y, z] = m.per_tick.map(|v| v * 20.0);
|
||||
movement_range(&p["x"], x, false)
|
||||
&& movement_range(&p["y"], y, false)
|
||||
&& movement_range(&p["z"], z, false)
|
||||
&& movement_range(&p["speed"], x * x + y * y + z * z, true)
|
||||
&& movement_range(&p["horizontal_speed"], x * x + z * z, true)
|
||||
&& movement_range(&p["vertical_speed"], y.abs(), false)
|
||||
&& movement_range(&p["fall_distance"], m.fall_distance, false)
|
||||
}
|
||||
|
||||
/// `EntityPredicate` (26.x keyed sub-predicates).
|
||||
pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> bool {
|
||||
if v.is_null() {
|
||||
@@ -1027,6 +1086,7 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
|
||||
location(p, [e.position[0], e.position[1] - 0.5, e.position[2]], w)
|
||||
}
|
||||
"minecraft:distance" => distance(p, e.position, origin),
|
||||
"minecraft:movement" => e.movement.is_some_and(|m| movement(p, m)),
|
||||
"minecraft:flags" => {
|
||||
let f = |k: &str, have: bool| p[k].as_bool().is_none_or(|want| want == have);
|
||||
f("is_on_fire", e.on_fire)
|
||||
@@ -1402,9 +1462,63 @@ pub fn recipe_ingredients(v: &Value, used: &[ItemView], w: &dyn World) -> bool {
|
||||
}
|
||||
|
||||
/// A `ContextAwarePredicate`: a list of loot conditions (or one condition).
|
||||
/// Existing callers have no random context and keep their leaf behavior.
|
||||
/// Invalid references are distinct from false: inversion must not accept them.
|
||||
pub fn conditions(v: &Value, ctx: &Entities, w: &dyn World) -> bool {
|
||||
conditions_with(v, ctx, &mut |v, ctx| condition_leaf(v, ctx, w))
|
||||
references_valid(v, w) && conditions_with(v, ctx, &mut |v, ctx| condition_leaf(v, ctx, w))
|
||||
}
|
||||
|
||||
/// Every named predicate `v` reaches is loaded, and none reaches itself.
|
||||
pub fn references_valid(v: &Value, w: &dyn World) -> bool {
|
||||
predicate_references(v, w, &mut BTreeSet::new()).is_ok()
|
||||
}
|
||||
|
||||
enum InvalidReference {
|
||||
Missing,
|
||||
Cycle,
|
||||
}
|
||||
|
||||
/// `Validatable.validateHolder`'s active-name rule, as a defensive check on
|
||||
/// this evaluation context. Check every typed reference before boolean
|
||||
/// short-circuiting; this does not implement registry/startup admission.
|
||||
fn predicate_references(
|
||||
v: &Value,
|
||||
w: &dyn World,
|
||||
active: &mut BTreeSet<String>,
|
||||
) -> Result<(), InvalidReference> {
|
||||
match v {
|
||||
Value::String(id) => {
|
||||
let id = full(id).into_owned();
|
||||
if !active.insert(id.clone()) {
|
||||
return Err(InvalidReference::Cycle);
|
||||
}
|
||||
let result = w
|
||||
.predicate(&id)
|
||||
.ok_or(InvalidReference::Missing)
|
||||
.and_then(|v| predicate_references(v, w, active));
|
||||
active.remove(&id);
|
||||
result
|
||||
}
|
||||
Value::Array(list) => list
|
||||
.iter()
|
||||
.try_for_each(|v| predicate_references(v, w, active)),
|
||||
Value::Object(o) => {
|
||||
let kind = o
|
||||
.get("condition")
|
||||
.or_else(|| o.get("type"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
match &*full(kind) {
|
||||
"minecraft:inverted" => predicate_references(&v["term"], w, active),
|
||||
"minecraft:all_of" | "minecraft:any_of" => v["terms"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.try_for_each(|v| predicate_references(v, w, active)),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The existing condition traversal with a caller-owned leaf context.
|
||||
@@ -1534,6 +1648,10 @@ fn condition_leaf(v: &Value, ctx: &Entities, w: &dyn World) -> bool {
|
||||
}
|
||||
// An inline entity predicate (old-style).
|
||||
Value::Object(_) => ctx.this.is_some_and(|e| entity(v, e, w, ctx.origin)),
|
||||
// A named predicate reference: its loaded definition, walked like any other.
|
||||
Value::String(id) => w
|
||||
.predicate(&full(id))
|
||||
.is_some_and(|v| conditions_with(v, ctx, &mut |v, ctx| condition_leaf(v, ctx, w))),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -3493,4 +3611,66 @@ mod tests {
|
||||
}
|
||||
assert_eq!(draws, 0);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod movement_model_tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn added_model_movement_units_exact_bounds_and_squared_bounds() {
|
||||
// Source-derived model proof; no moving Java/runtime claim.
|
||||
let m = MovementView {
|
||||
per_tick: [3.0 / 20.0, -4.0 / 20.0, 0.0],
|
||||
fall_distance: 7.25,
|
||||
};
|
||||
assert_eq!(m.per_tick.map(|n| n * 20.0), [3.0, -4.0, 0.0]);
|
||||
for p in [
|
||||
json!({"x":3.0,"y":-4.0,"z":0.0}),
|
||||
json!({"x":{"min":3.0,"max":3.0}}),
|
||||
json!({"speed":5.0,"horizontal_speed":3.0,"vertical_speed":4.0,"fall_distance":7.25}),
|
||||
json!({"speed":{"min":5.0,"max":5.0}}),
|
||||
// Doubles boundsSqr maps each bound through Mth.square, including its sign.
|
||||
json!({"speed":-5.0,"horizontal_speed":{"max":-3.0}}),
|
||||
json!({}),
|
||||
] {
|
||||
assert!(movement(&p, m), "{p}");
|
||||
}
|
||||
for p in [
|
||||
json!({"x":3.0000000001}),
|
||||
json!({"x":{"min":3.0000000001}}),
|
||||
json!({"y":{"max":-4.0000000001}}),
|
||||
json!({"speed":{"max":4.9999999999}}),
|
||||
json!({"horizontal_speed":{"min":-4.0}}),
|
||||
json!({"vertical_speed":-4.0}),
|
||||
json!({"fall_distance":{"max":7.2499999999}}),
|
||||
] {
|
||||
assert!(!movement(&p, m), "{p}");
|
||||
}
|
||||
assert!(!movement_range(&json!(3.0000000001), 3.0, false));
|
||||
assert!(
|
||||
range_f(&json!(3.0000000001), 3.0),
|
||||
"existing global tolerance is untouched"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn added_model_unprojected_is_distinct_from_actual_stationary() {
|
||||
let p = json!({"minecraft:movement":{}});
|
||||
let view = EntityView {
|
||||
kind: "minecraft:pig".into(),
|
||||
..Default::default()
|
||||
};
|
||||
// Unprojected is explicitly unsupported, not claimed Java-correct.
|
||||
assert!(!entity(&p, &view, &super::Flat, [0.0; 3]));
|
||||
let view = EntityView {
|
||||
movement: Some(MovementView {
|
||||
per_tick: [0.0; 3],
|
||||
fall_distance: 0.0,
|
||||
}),
|
||||
..view
|
||||
};
|
||||
assert!(entity(&p, &view, &super::Flat, [0.0; 3]));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use crate::{
|
||||
blocks::{BlockFacts, Fluid},
|
||||
command::args::GameMode,
|
||||
items::ItemStack,
|
||||
particles::{ParticleEngine, ParticleOptions, ParticleType as T, along_axis},
|
||||
particles::{ParticleEngine, ParticleLevel, ParticleOptions, ParticleType as T, along_axis},
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::{
|
||||
@@ -600,6 +600,14 @@ fn do_animate_tick(
|
||||
}
|
||||
let state = ctx.state(pos);
|
||||
block_animate_tick(tables, engine, ctx, state, pos, center);
|
||||
// Resource packs' Polytone block emitters (#11486): nothing without such a pack.
|
||||
let level = ParticleLevel {
|
||||
world: ctx.world,
|
||||
facts: ctx.facts,
|
||||
collision: ctx.collision,
|
||||
player: None,
|
||||
};
|
||||
engine.polytone_animate(&level, pos);
|
||||
let facts = ctx.facts.get(state);
|
||||
if facts.fluid != Fluid::None {
|
||||
fluid_animate_tick(
|
||||
|
||||
@@ -351,7 +351,7 @@ impl OwnerLookup for () {
|
||||
}
|
||||
}
|
||||
impl ArrowScene<'_, '_> {
|
||||
fn block(&self, p: BlockPos) -> BlockStateId {
|
||||
pub fn block(&self, p: BlockPos) -> BlockStateId {
|
||||
self.world.block(p.x, p.y, p.z)
|
||||
}
|
||||
fn is_air(&self, state: BlockStateId) -> bool {
|
||||
@@ -408,7 +408,7 @@ impl ArrowScene<'_, '_> {
|
||||
Some((hit.location, hit.block, hit.direction.normal()))
|
||||
}
|
||||
/// `CollisionGetter.noCollision(null, box)` over blocks.
|
||||
fn no_block_collision(&self, b: Aabb) -> bool {
|
||||
pub fn no_block_collision(&self, b: Aabb) -> bool {
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
for x in (lo.x.floor() as i32 - 1)..=(hi.x.floor() as i32 + 1) {
|
||||
for y in (lo.y.floor() as i32 - 1)..=(hi.y.floor() as i32 + 1) {
|
||||
|
||||
@@ -61,6 +61,12 @@ fn read_tag(root: &Path, name: &str, depth: u32) -> Vec<Arc<str>> {
|
||||
|
||||
impl Banners {
|
||||
pub fn load(root: &Path) -> Self {
|
||||
Self::load_with_defaults(root, None)
|
||||
}
|
||||
pub(crate) fn load_with_defaults(
|
||||
root: &Path,
|
||||
defaults: Option<&horizon_world::item_defaults::Defaults>,
|
||||
) -> Self {
|
||||
let mut banners = Self::default();
|
||||
let mut files: Vec<_> = fs::read_dir(root.join("data/minecraft/banner_pattern"))
|
||||
.into_iter()
|
||||
@@ -92,7 +98,15 @@ impl Banners {
|
||||
});
|
||||
}
|
||||
banners.no_item_required = read_tag(root, "no_item_required", 0);
|
||||
if let Ok(raw) = horizon_world::item_defaults::read(root) {
|
||||
let owned_defaults;
|
||||
let defaults = match defaults {
|
||||
Some(defaults) => Some(defaults),
|
||||
None => {
|
||||
owned_defaults = horizon_world::item_defaults::read(root).ok();
|
||||
owned_defaults.as_ref()
|
||||
}
|
||||
};
|
||||
if let Some(raw) = defaults {
|
||||
for (item, c) in raw {
|
||||
let id: Arc<str> = Arc::from(item.as_str());
|
||||
if let Some(dye) = c["minecraft:dye"].as_str() {
|
||||
|
||||
@@ -16,16 +16,16 @@ pub use horizon_client_render::{
|
||||
cit_armor, cit_assets, cit_glint, client_resources, clouds, collisions, command_block,
|
||||
containers, controller_ui, crash, custom_sky, datapack, dynamic_light, effects, environment,
|
||||
extras, fast_hash, fluid_render, fog, font, gfx, graphics, graphics_style, graphics_ui,
|
||||
hdr_calibration, hud, hud_layout, hud_overlays, inventory_effects, java_text, jigsaw_block,
|
||||
json_folder, 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,
|
||||
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, weather, widgets, world, worldborder, zip,
|
||||
hdr_calibration, hud, hud_kit, hud_layout, hud_overlays, inventory_effects, java_text,
|
||||
jigsaw_block, json_folder, 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, random_source, screen_draw, screen_effects, sculk, section_mask, server_terrain,
|
||||
settings, shader_options, shader_pack_list, signs, skins, social, 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, weather, widgets, world, worldborder, zip,
|
||||
};
|
||||
pub mod attack_strength;
|
||||
pub mod attribute_updates;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! reconnects, a solo game's controller coming and going) decides that for itself.
|
||||
use crate::gamepad::{Button, GlyphStyle};
|
||||
use crate::hud::{HudAtlas, HudBatch, rgba};
|
||||
use crate::hud_kit::plate::{self, Plate};
|
||||
use crate::pad_glyph;
|
||||
use crate::toasts::Slide;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -117,7 +118,8 @@ impl NoticeSlot {
|
||||
}
|
||||
!finished
|
||||
}
|
||||
/// The notice under `top` on a view: a strip in the middle, its words wrapped to the view (a
|
||||
/// The notice under `top` (GUI px; the caller keeps it below the boss bars with the HUD's
|
||||
/// `Room`) on a view: a strip in the middle, its words wrapped to the view (a
|
||||
/// toast is as wide as its longest line: off a narrow view's edge), sliding down from the
|
||||
/// view's edge.
|
||||
pub fn draw(&mut self, batch: &mut HudBatch, atlas: &HudAtlas, top: f32, shown_for: Duration) {
|
||||
@@ -142,7 +144,7 @@ impl NoticeSlot {
|
||||
let (pw, ph) = (inner.min(max) + 16.0, 20.0 + 10.0 * lines.len() as f32);
|
||||
let px = ((w - pw) / 2.0).floor();
|
||||
let py = (top - (1.0 - notice.slide.portion) * (top + ph)).floor();
|
||||
panel(batch, atlas, [px, py, pw, ph]);
|
||||
plate::draw(batch, atlas, Plate::Notice, [px, py, pw, ph]);
|
||||
let white = rgba(255, 255, 255, 255);
|
||||
let tx = (px + (pw - width(¬ice.title)) / 2.0).floor();
|
||||
batch.text(atlas, ¬ice.title, tx, py + 6.0, white, true);
|
||||
@@ -157,20 +159,6 @@ impl NoticeSlot {
|
||||
}
|
||||
}
|
||||
|
||||
/// The prompts' and notices' box: dark, a faint edge.
|
||||
pub fn panel(batch: &mut HudBatch, atlas: &HudAtlas, [px, py, pw, ph]: [f32; 4]) {
|
||||
batch.fill(atlas, px, py, pw, ph, rgba(0, 0, 0, 210));
|
||||
let edge = rgba(255, 255, 255, 90);
|
||||
for [x, y, fw, fh] in [
|
||||
[px, py, pw, 1.0],
|
||||
[px, py + ph - 1.0, pw, 1.0],
|
||||
[px, py, 1.0, ph],
|
||||
[px + pw - 1.0, py, 1.0, ph],
|
||||
] {
|
||||
batch.fill(atlas, x, y, fw, fh, edge);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -14,7 +14,7 @@ use crate::{
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Direction, Vec3, VoxelShape, border::BorderGeometry},
|
||||
position::BlockPos,
|
||||
random::RandomSource,
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
use horizon_world::{
|
||||
block_collision::BlockCollisionRegistry,
|
||||
@@ -1101,10 +1101,20 @@ fn lava_cauldron_contacts(
|
||||
pub struct ObstacleGrid {
|
||||
cells: crate::fast_hash::FxHashMap<(i32, i32, i32), Vec<u32>>,
|
||||
boxes: Vec<Aabb>,
|
||||
/// Each box's entity's `getRootVehicle` when it rides something; empty
|
||||
/// when none was given.
|
||||
roots: Vec<Option<u32>>,
|
||||
found: Vec<u32>,
|
||||
}
|
||||
impl ObstacleGrid {
|
||||
const CELL: f64 = 8.0;
|
||||
pub fn new(boxes: Vec<Aabb>) -> Self {
|
||||
Self::with_roots(boxes, Vec::new())
|
||||
}
|
||||
/// [`Self::new`] with the vehicle at the bottom of what each box's entity
|
||||
/// rides (`roots[i]` for `boxes[i]`), so [`Self::near_except`] can leave a
|
||||
/// mover's own riders out.
|
||||
pub fn with_roots(boxes: Vec<Aabb>, roots: Vec<Option<u32>>) -> Self {
|
||||
let mut cells: crate::fast_hash::FxHashMap<(i32, i32, i32), Vec<u32>> = Default::default();
|
||||
for (i, b) in boxes.iter().enumerate() {
|
||||
let (lo, hi) = Self::span(*b);
|
||||
@@ -1116,7 +1126,12 @@ impl ObstacleGrid {
|
||||
}
|
||||
}
|
||||
}
|
||||
Self { cells, boxes }
|
||||
Self {
|
||||
cells,
|
||||
boxes,
|
||||
roots,
|
||||
found: Vec::new(),
|
||||
}
|
||||
}
|
||||
fn span(b: Aabb) -> ((i32, i32, i32), (i32, i32, i32)) {
|
||||
let cell = |v: Vec3| {
|
||||
@@ -1132,30 +1147,54 @@ impl ObstacleGrid {
|
||||
self.boxes.is_empty()
|
||||
}
|
||||
/// The boxes meeting `area` (`getEntityCollisions`'s query box), in the order they were given.
|
||||
pub fn near(&self, area: Aabb, out: &mut Vec<Aabb>) {
|
||||
pub fn near(&mut self, area: Aabb, out: &mut Vec<Aabb>) {
|
||||
self.near_except(area, None, out);
|
||||
}
|
||||
/// [`Self::near`] without the boxes of what rides `vehicle` (given with
|
||||
/// [`Self::with_roots`]): `Entity.canCollideWith` leaves out
|
||||
/// `isPassengerOfSameVehicle`, so a stand's riders do not stop it.
|
||||
pub fn near_except(&mut self, area: Aabb, vehicle: Option<u32>, out: &mut Vec<Aabb>) {
|
||||
out.clear();
|
||||
if self.boxes.is_empty() {
|
||||
return;
|
||||
}
|
||||
let kept = |i: usize| vehicle.is_none() || self.roots.get(i).copied().flatten() != vehicle;
|
||||
let (lo, hi) = Self::span(area);
|
||||
let mut found: Vec<u32> = Vec::new();
|
||||
let side = |lo: i32, hi: i32| (i64::from(hi) - i64::from(lo) + 1).max(0);
|
||||
let cells = side(lo.0, hi.0)
|
||||
.saturating_mul(side(lo.1, hi.1))
|
||||
.saturating_mul(side(lo.2, hi.2));
|
||||
// An area over more cells than there are boxes (a stand loaded with a
|
||||
// huge Motion asks for one millions of blocks long): test each box
|
||||
// once rather than walk the cells.
|
||||
if cells > self.boxes.len() as i64 {
|
||||
out.extend(
|
||||
self.boxes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|&(i, b)| kept(i) && b.intersects(area))
|
||||
.map(|(_, b)| *b),
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.found.clear();
|
||||
for x in lo.0..=hi.0 {
|
||||
for y in lo.1..=hi.1 {
|
||||
for z in lo.2..=hi.2 {
|
||||
if let Some(list) = self.cells.get(&(x, y, z)) {
|
||||
found.extend(list);
|
||||
self.found.extend(list);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
found.sort_unstable();
|
||||
found.dedup();
|
||||
out.extend(
|
||||
found
|
||||
.into_iter()
|
||||
.map(|i| self.boxes[i as usize])
|
||||
.filter(|b| b.intersects(area)),
|
||||
);
|
||||
self.found.sort_unstable();
|
||||
self.found.dedup();
|
||||
for &i in &self.found {
|
||||
let b = self.boxes[i as usize];
|
||||
if kept(i as usize) && b.intersects(area) {
|
||||
out.push(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1315,6 +1354,9 @@ pub struct ExperienceOrb {
|
||||
pub health: i32,
|
||||
/// `Entity.noGravity`: `ExperienceOrb.tick`'s `applyGravity` does nothing.
|
||||
pub no_gravity: bool,
|
||||
/// `ExperienceOrb.followingPlayer`: the followed player's entity id, kept
|
||||
/// until they are gone or further than 8 blocks. Never saved.
|
||||
pub following: Option<u32>,
|
||||
}
|
||||
impl ExperienceOrb {
|
||||
/// `ExperienceOrb.hurtServer`: the damage comes off the health as an
|
||||
@@ -1466,7 +1508,9 @@ impl ExperienceOrb {
|
||||
) -> bool {
|
||||
self.ticks += 1;
|
||||
self.previous = self.position;
|
||||
self.update_fluids(world, fluids);
|
||||
// `Entity.baseTick`'s fluid step, whose `updateFluidOnEyes` answer the
|
||||
// water branch below reads: the orb's eye, not its feet, decides.
|
||||
let eye_in_water = self.update_fluids(world, fluids);
|
||||
let block = world.block(
|
||||
self.position.x.floor() as i32,
|
||||
self.position.y.floor() as i32,
|
||||
@@ -1481,7 +1525,10 @@ impl ExperienceOrb {
|
||||
// `boolean colliding = !level.noCollision(getBoundingBox())`: an orb
|
||||
// inside blocks takes no gravity until it is pushed out below.
|
||||
let colliding = !crate::collisions::no_collision_empty(&mut scene, bounds);
|
||||
if facts.get(block).fluid == Fluid::Water {
|
||||
// ExperienceOrb.tick: `isEyeInFluid(FluidTags.WATER)` floats the orb,
|
||||
// so an orb whose eye dips into the surface floats with its feet
|
||||
// still in the air block above it.
|
||||
if eye_in_water {
|
||||
self.set_underwater_movement();
|
||||
} else if !colliding {
|
||||
self.velocity.y -= self.get_gravity();
|
||||
@@ -1932,6 +1979,7 @@ pub struct ItemFrame {
|
||||
pub check_interval: u32,
|
||||
/// `Entity.getCustomName` (`CustomName`), copied onto the dropped frame.
|
||||
pub custom_name: Option<horizon_world::nbt::Tag>,
|
||||
pub random: LegacyRandom,
|
||||
}
|
||||
impl ItemFrame {
|
||||
pub fn step(&self) -> [i32; 3] {
|
||||
@@ -2106,7 +2154,7 @@ impl ArmorStand {
|
||||
in_water: bool,
|
||||
border: Option<&BorderGeometry>,
|
||||
) {
|
||||
if let Some(step) = self.tick_move(scene, facts, climbing, in_water, border) {
|
||||
if let Some(step) = self.tick_move(scene, facts, climbing, in_water, border, &[]) {
|
||||
let medium = if in_water {
|
||||
StandMedium::Water
|
||||
} else {
|
||||
@@ -2374,10 +2422,12 @@ impl ArmorStand {
|
||||
}
|
||||
self.health > 0.5
|
||||
}
|
||||
/// EntityType.ARMOR_STAND dimensions (0.5 x 1.975, half for small).
|
||||
/// EntityType.ARMOR_STAND dimensions (0.5 x 1.975F, half for small).
|
||||
pub fn bounds(&self) -> Aabb {
|
||||
let s = if self.small { 0.5 } else { 1.0 };
|
||||
let (w, h) = (0.25 * s, 1.975 * s);
|
||||
// EntityDimensions.makeBoundingBox adds the float height, widened
|
||||
// (halving it for the small stand is exact).
|
||||
let (w, h) = (0.25 * s, f64::from(1.975_f32) * s);
|
||||
let p = self.position;
|
||||
Aabb::new(
|
||||
Vec3::new(p.x - w, p.y, p.z - w),
|
||||
@@ -2402,7 +2452,10 @@ impl ArmorStand {
|
||||
/// The game runs the fluid push first (`Entity::push_by_fluid`), then
|
||||
/// this up to the blocks' push, then the bubble column
|
||||
/// (`Entity::push_by_bubble_column`), then [`ArmorStand::tick_settle`].
|
||||
/// None: a marker or a stand with `NoGravity` does not travel.
|
||||
/// `entities` are the boxes `EntityGetter.getEntityCollisions` gives the
|
||||
/// stand (a living shulker, a boat, a happy ghast held still), which stop
|
||||
/// its move as blocks do. None: a marker or a stand with `NoGravity` does
|
||||
/// not travel.
|
||||
pub fn tick_move(
|
||||
&mut self,
|
||||
scene: &mut crate::collisions::SceneCollisions,
|
||||
@@ -2410,6 +2463,7 @@ impl ArmorStand {
|
||||
climbing: &horizon_world::climbing::ClimbingRegistry<'_>,
|
||||
in_water: bool,
|
||||
border: Option<&BorderGeometry>,
|
||||
entities: &[Aabb],
|
||||
) -> Option<StandStep> {
|
||||
use crate::entity_rules::EntityRules;
|
||||
self.previous = self.position;
|
||||
@@ -2468,18 +2522,20 @@ impl ArmorStand {
|
||||
}
|
||||
let mut velocity = wanted;
|
||||
let mut horizontal_collision = false;
|
||||
// Entity.collide: the world border's walls stop the stand too.
|
||||
// Entity.collide: the world border's walls and the entities that
|
||||
// `canBeCollidedWith` the stand stop it too.
|
||||
let moved = SceneCollisions {
|
||||
world: scene.world,
|
||||
binding: scene.binding,
|
||||
subject: scene.subject,
|
||||
}
|
||||
.resolve_in_border(
|
||||
.resolve_in_border_with(
|
||||
self.bounds(),
|
||||
wanted,
|
||||
0.0,
|
||||
self.on_ground,
|
||||
border.map(|b| (b, [self.position.x, self.position.z])),
|
||||
entities,
|
||||
);
|
||||
if let Ok(moved) = moved {
|
||||
self.position = Vec3::new(
|
||||
@@ -2489,24 +2545,7 @@ impl ArmorStand {
|
||||
);
|
||||
self.on_ground = wanted.y < 0.0 && moved.y > wanted.y;
|
||||
// Entity.setOnGroundWithMovement: the block under the feet.
|
||||
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),
|
||||
},
|
||||
false,
|
||||
);
|
||||
let position = self.position;
|
||||
let _ =
|
||||
self.support
|
||||
.check_support(self.on_ground, self.bounds(), Some(wanted), |area| {
|
||||
horizon_world::ground_support::find_supporting_block(
|
||||
scene, context, position, area, 1_000_000,
|
||||
)
|
||||
});
|
||||
self.ground_support(scene, wanted);
|
||||
if (wanted.x - moved.x).abs() >= f64::from(1.0e-5_f32) {
|
||||
horizontal_collision = true;
|
||||
velocity.x = 0.0;
|
||||
@@ -2525,6 +2564,115 @@ impl ArmorStand {
|
||||
self.velocity = velocity;
|
||||
Some(StandStep { falling, friction })
|
||||
}
|
||||
/// `Entity.move(MoverType.SHULKER, delta)`: an opening shulker lid
|
||||
/// shoves the stand (one with physics) by `delta`. Blocks, the world
|
||||
/// border and `entities` (the boxes `getEntityCollisions` gives the stand)
|
||||
/// cut the move short, a move cut to almost nothing is not made, and an
|
||||
/// axis that hits something loses its speed
|
||||
/// (`restituteMovementAfterCollisions`: a stand does not bounce, so the
|
||||
/// speed becomes `-speed * 0`).
|
||||
///
|
||||
/// The stand has already travelled this tick (stands tick before mobs
|
||||
/// here). `travels_after` says vanilla ticks it after the shulker (it
|
||||
/// joined the level later, as a stand placed on the shell does), so
|
||||
/// there the push comes first and its own travel follows:
|
||||
/// - a push nothing stops leaves `on_ground` and the speed as that
|
||||
/// travel left them (it lands the stand again on the risen lid);
|
||||
/// - a push down that something stops: the travel that follows starts
|
||||
/// with no vertical speed, moves nothing vertically (`on_ground`
|
||||
/// false) and ends with gravity's speed only, which the stand's own
|
||||
/// travel, stopped by the same floor, already gave it;
|
||||
/// - a push up that a ceiling stops loses the vertical speed, so the
|
||||
/// stand stays pinned on the next tick as vanilla's does (vanilla
|
||||
/// shows gravity's speed by then, and once the lid stops, the stand
|
||||
/// falls through it one tick later than vanilla's).
|
||||
///
|
||||
/// Otherwise vanilla ticks it before the shulker, as here, and the push
|
||||
/// sets `on_ground` and the block under the feet itself. Left out: the
|
||||
/// block speed factor on the horizontal speed, the fall distance a push
|
||||
/// down through the air adds, and a slime or bed bounce.
|
||||
pub fn shove(
|
||||
&mut self,
|
||||
scene: SceneCollisions,
|
||||
delta: Vec3,
|
||||
border: Option<&BorderGeometry>,
|
||||
entities: &[Aabb],
|
||||
travels_after: bool,
|
||||
) {
|
||||
let mut scene = SceneCollisions {
|
||||
subject: self.feet_are_leather_boots().then_some(
|
||||
crate::collisions::PowderSnowSubject {
|
||||
leather_boots: true,
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
..scene
|
||||
};
|
||||
let Ok(moved) = SceneCollisions {
|
||||
world: scene.world,
|
||||
binding: scene.binding,
|
||||
subject: scene.subject,
|
||||
}
|
||||
.resolve_in_border_with(
|
||||
self.bounds(),
|
||||
delta,
|
||||
0.0,
|
||||
self.on_ground,
|
||||
border.map(|b| (b, [self.position.x, self.position.z])),
|
||||
entities,
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let length = moved.length_squared();
|
||||
if length > 1.0e-7 || delta.length_squared() - length < 1.0e-7 {
|
||||
self.position = self.position.add_vector(moved);
|
||||
}
|
||||
// Mth.equal on x and z; y only when the push had a vertical part.
|
||||
if (delta.x - moved.x).abs() >= f64::from(1.0e-5_f32) {
|
||||
self.velocity.x = -self.velocity.x * 0.0;
|
||||
}
|
||||
if (delta.z - moved.z).abs() >= f64::from(1.0e-5_f32) {
|
||||
self.velocity.z = -self.velocity.z * 0.0;
|
||||
}
|
||||
let vertical = moved.y != delta.y;
|
||||
if travels_after {
|
||||
if vertical {
|
||||
self.on_ground = false;
|
||||
if delta.y > 0.0 {
|
||||
self.velocity.y = -self.velocity.y * 0.0;
|
||||
}
|
||||
self.ground_support(&mut scene, moved);
|
||||
}
|
||||
} else {
|
||||
if vertical && delta.y != 0.0 {
|
||||
self.velocity.y = -self.velocity.y * 0.0;
|
||||
}
|
||||
self.on_ground = vertical && delta.y < 0.0;
|
||||
self.ground_support(&mut scene, moved);
|
||||
}
|
||||
}
|
||||
/// `Entity.checkSupportingBlock` after a move by `movement`: the block
|
||||
/// under the feet while `on_ground`, none otherwise.
|
||||
fn ground_support(&mut self, scene: &mut SceneCollisions, movement: Vec3) {
|
||||
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),
|
||||
},
|
||||
false,
|
||||
);
|
||||
let (position, bounds) = (self.position, self.bounds());
|
||||
let _ = self
|
||||
.support
|
||||
.check_support(self.on_ground, bounds, Some(movement), |area| {
|
||||
horizon_world::ground_support::find_supporting_block(
|
||||
scene, context, position, area, 1_000_000,
|
||||
)
|
||||
});
|
||||
}
|
||||
/// `HoneyBlock.entityInside`: side-slide movement after the stand's travel.
|
||||
/// `ArmorStand.hasPhysics` / `Entity.isAffectedByBlocks` exclude markers
|
||||
/// and NoGravity stands. Slide effects are separate from this movement.
|
||||
@@ -3338,6 +3486,7 @@ mod world_border_tests {
|
||||
count: 1,
|
||||
ticks: 0,
|
||||
health: 5,
|
||||
following: None,
|
||||
};
|
||||
orb.tick(
|
||||
&world,
|
||||
@@ -4010,7 +4159,7 @@ mod entity_collision_tests {
|
||||
Vec3::new(x + 1.375, 64.5625, 1.375),
|
||||
)
|
||||
};
|
||||
let grid = ObstacleGrid::new((0..1000).map(|i| boat(f64::from(i) * 100.0)).collect());
|
||||
let mut grid = ObstacleGrid::new((0..1000).map(|i| boat(f64::from(i) * 100.0)).collect());
|
||||
let mut out = Vec::new();
|
||||
// Around the 7th boat: only it.
|
||||
let area = Aabb::new(Vec3::new(699.0, 63.0, -1.0), Vec3::new(701.0, 66.0, 1.0));
|
||||
@@ -4022,12 +4171,46 @@ mod entity_collision_tests {
|
||||
assert!(out.is_empty());
|
||||
// A box spanning cells is found from any of them, once.
|
||||
let wide = Aabb::new(Vec3::new(0.0, 0.0, 0.0), Vec3::new(20.0, 1.0, 1.0));
|
||||
let grid = ObstacleGrid::new(vec![wide]);
|
||||
let mut grid = ObstacleGrid::new(vec![wide]);
|
||||
let area = Aabb::new(Vec3::new(17.0, 0.0, 0.0), Vec3::new(18.0, 1.0, 1.0));
|
||||
grid.near(area, &mut out);
|
||||
assert_eq!(out, vec![wide]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn obstacle_grid_leaves_out_a_movers_riders_and_answers_a_huge_area_at_once() {
|
||||
let unit = |x: f64| Aabb::new(Vec3::new(x, 64.0, 0.0), Vec3::new(x + 1.0, 65.0, 1.0));
|
||||
// The shulker at x = 2 rides stand 7; the one at x = 0 and the boat
|
||||
// at x = 4 ride nothing.
|
||||
let mut grid = ObstacleGrid::with_roots(
|
||||
vec![unit(0.0), unit(2.0), unit(4.0)],
|
||||
vec![None, Some(7), None],
|
||||
);
|
||||
let mut out = Vec::new();
|
||||
let area = Aabb::new(Vec3::new(-1.0, 63.0, -1.0), Vec3::new(6.0, 66.0, 2.0));
|
||||
grid.near_except(area, Some(7), &mut out);
|
||||
assert_eq!(out, vec![unit(0.0), unit(4.0)], "stand 7 ignores its rider");
|
||||
grid.near_except(area, Some(8), &mut out);
|
||||
assert_eq!(
|
||||
out,
|
||||
vec![unit(0.0), unit(2.0), unit(4.0)],
|
||||
"stand 8 does not"
|
||||
);
|
||||
// A stand loaded with Motion 1e10 asks for an area 1e10 blocks long
|
||||
// (about 1.25e9 cells): the boxes in it come back in order, at once.
|
||||
let huge = Aabb::new(Vec3::new(-1.0, 63.0, -1.0), Vec3::new(1.0e10, 66.0, 2.0));
|
||||
let start = std::time::Instant::now();
|
||||
grid.near(huge, &mut out);
|
||||
assert_eq!(out, vec![unit(0.0), unit(2.0), unit(4.0)]);
|
||||
let far = Aabb::new(
|
||||
Vec3::new(-1.0e300, 63.0, -1.0),
|
||||
Vec3::new(1.0e300, 66.0, 2.0),
|
||||
);
|
||||
grid.near(far, &mut out);
|
||||
assert_eq!(out.len(), 3);
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(1));
|
||||
}
|
||||
|
||||
/// A farm: 5000 drops and 1000 separated vehicles. Run with `-- --ignored --nocapture`.
|
||||
#[test]
|
||||
#[ignore = "timing"]
|
||||
@@ -4053,7 +4236,7 @@ mod entity_collision_tests {
|
||||
hits += boxes.iter().filter(|b| b.intersects(area)).count();
|
||||
}
|
||||
let scan = start.elapsed();
|
||||
let grid = ObstacleGrid::new(boxes);
|
||||
let mut grid = ObstacleGrid::new(boxes);
|
||||
let start = std::time::Instant::now();
|
||||
let (mut out, mut found) = (Vec::new(), 0);
|
||||
for item in &items {
|
||||
@@ -4333,6 +4516,70 @@ mod armor_stand_motion_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.collide` (#11877): the boxes `getEntityCollisions` gives a
|
||||
/// stand (a shulker's here, one block on the test scene's ground, whose
|
||||
/// top is at y = 4) stop it as blocks do. Dropped from y = 8 it rests on
|
||||
/// the box's top at y = 5; sliding east at 2 blocks a tick from x = 18 it
|
||||
/// stops with its east side on the box's west face, x = 20. With no boxes
|
||||
/// it falls to the ground and slides on.
|
||||
#[test]
|
||||
fn a_stand_lands_on_and_stops_at_the_entity_boxes_it_is_given() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let collision = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let world = World::test_scene(&assets, &render, 3).unwrap();
|
||||
let empty = Default::default();
|
||||
let climbing = horizon_world::climbing::ClimbingRegistry::new(
|
||||
collision.registry(),
|
||||
&empty,
|
||||
&empty,
|
||||
&empty,
|
||||
)
|
||||
.unwrap();
|
||||
let shulker = Aabb::new(Vec3::new(20.0, 4.0, 20.0), Vec3::new(21.0, 5.0, 21.0));
|
||||
let run = |stand: &mut ArmorStand, boxes: &[Aabb], ticks: usize| {
|
||||
for _ in 0..ticks {
|
||||
let mut scene = SceneCollisions {
|
||||
world: &world,
|
||||
binding: &collision,
|
||||
subject: None,
|
||||
};
|
||||
if let Some(step) =
|
||||
stand.tick_move(&mut scene, &facts, &climbing, false, None, boxes)
|
||||
{
|
||||
stand.tick_settle(StandMedium::Air, step);
|
||||
}
|
||||
}
|
||||
};
|
||||
for (boxes, floor) in [(&[shulker][..], 5.0), (&[][..], 4.0)] {
|
||||
let mut stand = ArmorStand::new(1, Vec3::new(20.5, 8.0, 20.5), 0.0);
|
||||
run(&mut stand, boxes, 20);
|
||||
let y = stand.position.y;
|
||||
assert!((y - floor).abs() < 1.0e-9, "rests at {floor}, got {y}");
|
||||
assert!(stand.on_ground, "on the ground at {floor}");
|
||||
}
|
||||
for (boxes, blocked) in [(&[shulker][..], true), (&[][..], false)] {
|
||||
let mut stand = ArmorStand::new(1, Vec3::new(18.0, 4.0, 20.5), 0.0);
|
||||
stand.on_ground = true;
|
||||
stand.velocity = Vec3::new(2.0, 0.0, 0.0);
|
||||
run(&mut stand, boxes, 1);
|
||||
let east = stand.bounds().max_position().x;
|
||||
if blocked {
|
||||
assert!((east - 20.0).abs() < 1.0e-7, "stops at the box, got {east}");
|
||||
assert_eq!(stand.velocity.x, 0.0, "the wall takes its speed");
|
||||
} else {
|
||||
assert!((east - 20.25).abs() < 1.0e-7, "goes on, got {east}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stand_in_lava_halves_its_motion_and_sinks_by_a_quarter_of_gravity() {
|
||||
// LivingEntity.travelInLava, measured on the real server: a stand at
|
||||
@@ -4642,6 +4889,7 @@ mod orb_underwater_tests {
|
||||
count: 1,
|
||||
ticks: 0,
|
||||
health: 5,
|
||||
following: None,
|
||||
};
|
||||
orb.set_underwater_movement();
|
||||
[
|
||||
@@ -4675,6 +4923,7 @@ mod render_light_tests {
|
||||
count: 1,
|
||||
ticks: 0,
|
||||
health: 5,
|
||||
following: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4871,6 +5120,7 @@ mod orb_lava_tests {
|
||||
count: 1,
|
||||
ticks: 0,
|
||||
health,
|
||||
following: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ pub fn update_entity_fluids(
|
||||
velocity: &mut Vec3,
|
||||
world: &World,
|
||||
fluids: &FluidResources,
|
||||
) -> (f64, f64) {
|
||||
) -> (f64, f64, bool) {
|
||||
let mut interaction = EntityFluidInteraction::new([WATER, LAVA]);
|
||||
let mut scene = EntityFluidWorld {
|
||||
bounds,
|
||||
@@ -68,10 +68,13 @@ pub fn update_entity_fluids(
|
||||
fluids,
|
||||
};
|
||||
if interaction.update(&mut scene, false, 4096).is_err() {
|
||||
return (0.0, 0.0);
|
||||
return (0.0, 0.0, false);
|
||||
}
|
||||
let water_height = interaction.fluid_height(&scene, WATER);
|
||||
let lava_height = interaction.fluid_height(&scene, LAVA);
|
||||
// `updateFluidOnEyes`'s answer, kept by the interaction's trackers:
|
||||
// whether the entity's eye is inside the water it scanned.
|
||||
let eye_in_water = interaction.is_eye_in_fluid(&FluidTags, WATER);
|
||||
let _ = interaction.apply_current(&mut scene, WATER, 0.014);
|
||||
let _ = interaction.apply_current(
|
||||
&mut scene,
|
||||
@@ -82,7 +85,7 @@ pub fn update_entity_fluids(
|
||||
0.0023333333333333335
|
||||
},
|
||||
);
|
||||
(water_height, lava_height)
|
||||
(water_height, lava_height, eye_in_water)
|
||||
}
|
||||
impl ItemEntity {
|
||||
/// `Entity.getEyeY` of an item (eye height 0.2125), where
|
||||
@@ -94,13 +97,17 @@ impl ItemEntity {
|
||||
#[cfg(test)]
|
||||
pub(super) fn update_fluids(&mut self, world: &World, fluids: &FluidResources) -> (f64, f64) {
|
||||
let (bounds, at, eye) = (self.bounds(), self.position, self.fluid_eye_y());
|
||||
update_entity_fluids(bounds, at, eye, &mut self.velocity, world, fluids)
|
||||
let (water, lava, _) =
|
||||
update_entity_fluids(bounds, at, eye, &mut self.velocity, world, fluids);
|
||||
(water, lava)
|
||||
}
|
||||
}
|
||||
impl ExperienceOrb {
|
||||
/// `Entity.updateFluidInteraction`: refreshes fluid immersion over the
|
||||
/// orb's box and pushes its velocity by any water or lava current.
|
||||
pub fn update_fluids(&mut self, world: &World, fluids: &FluidResources) -> (f64, f64) {
|
||||
/// Answers `isEyeInFluid(FluidTags.WATER)` for the eye the scan reads,
|
||||
/// `0.425` above the orb's feet.
|
||||
pub fn update_fluids(&mut self, world: &World, fluids: &FluidResources) -> bool {
|
||||
update_entity_fluids(
|
||||
self.bounds(),
|
||||
self.position,
|
||||
@@ -109,6 +116,7 @@ impl ExperienceOrb {
|
||||
world,
|
||||
fluids,
|
||||
)
|
||||
.2
|
||||
}
|
||||
}
|
||||
impl ArmorStand {
|
||||
@@ -419,6 +427,7 @@ mod tests {
|
||||
count: 1,
|
||||
ticks: 0,
|
||||
health: 5,
|
||||
following: None,
|
||||
};
|
||||
let mut still = orb.clone();
|
||||
assert!(still.tick(
|
||||
@@ -469,6 +478,65 @@ mod tests {
|
||||
assert!(carried.position.x > at.x + 0.5);
|
||||
}
|
||||
|
||||
/// `ExperienceOrb.tick`'s water branch reads `isEyeInFluid(FluidTags.WATER)`,
|
||||
/// not the feet's block: an orb whose feet hang in the air under the
|
||||
/// surface, with its eye in the water, floats (`setUnderwaterMovement`)
|
||||
/// instead of falling.
|
||||
#[test]
|
||||
fn an_orb_whose_eye_dips_into_water_floats_with_its_feet_in_air() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let fluids = FluidResources::new(&assets, &facts).unwrap();
|
||||
let collision = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let mut world = World::test_scene(&assets, &render, 3).unwrap();
|
||||
let water = assets.registry.default_state("minecraft:water").unwrap();
|
||||
for x in 19..=21 {
|
||||
for z in 19..=21 {
|
||||
world.set(x, 5, z, water).unwrap();
|
||||
}
|
||||
}
|
||||
let at = Vec3::new(20.5, 4.6, 20.5);
|
||||
let mut orb = ExperienceOrb {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
id: 1,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity: Vec3::ZERO,
|
||||
value: 1,
|
||||
age: 0,
|
||||
on_ground: false,
|
||||
count: 1,
|
||||
ticks: 0,
|
||||
health: 5,
|
||||
following: None,
|
||||
};
|
||||
assert!(orb.tick(
|
||||
&world,
|
||||
&facts,
|
||||
&collision,
|
||||
None,
|
||||
true,
|
||||
&mut || 0.0,
|
||||
None,
|
||||
&fluids,
|
||||
&mut |_, _| {},
|
||||
));
|
||||
assert!(
|
||||
orb.velocity.y > 0.0,
|
||||
"the eye in the water floats the orb, got {}",
|
||||
orb.velocity.y
|
||||
);
|
||||
}
|
||||
|
||||
/// `Entity.baseTick` pushes a boat by the current it sits in: a boat box
|
||||
/// in water[level=1..7] gains velocity along the flow, and the same box in
|
||||
/// still source water does not. Vanilla's boat tick runs this before
|
||||
|
||||
@@ -49,6 +49,13 @@ pub fn uuid(tag: &Tag) -> Option<[i32; 4]> {
|
||||
pub fn motion(tag: &Tag) -> Vec3 {
|
||||
read_doubles::<3>(tag.get("Motion")).map_or(Vec3::ZERO, |m| Vec3::new(m[0], m[1], m[2]))
|
||||
}
|
||||
/// A saved Motion as `Entity.load` takes it: a component faster than 10 blocks
|
||||
/// a tick loads as 0.
|
||||
pub fn loaded_motion(tag: &Tag) -> Vec3 {
|
||||
let m = motion(tag);
|
||||
let clamp = |v: f64| if v.abs() > 10.0 { 0.0 } else { v };
|
||||
Vec3::new(clamp(m.x), clamp(m.y), clamp(m.z))
|
||||
}
|
||||
pub fn rotation(tag: &Tag) -> [f32; 2] {
|
||||
read_doubles::<2>(tag.get("Rotation")).map_or([0.0; 2], |r| [r[0] as f32, r[1] as f32])
|
||||
}
|
||||
@@ -223,6 +230,33 @@ pub fn who_uuid(who: Who, uuid_of: &mut impl FnMut(u32) -> Option<[i32; 4]>) ->
|
||||
Who::Mob(id) => uuid_of(id),
|
||||
}
|
||||
}
|
||||
/// Replace every reference to the player `old` in a saved entity with `new`. Older
|
||||
/// Horizon worlds named their single player by the offline UUID (`Owner` on tamed
|
||||
/// animals and horses, a projectile's `Owner` and an item's `Thrower`, a fox's
|
||||
/// `Trusted`, `angry_at`, a leash's `UUID`); when such a world opens under the
|
||||
/// account (`lan::playerdata::migrate_owner`), a chunk's entities are rewritten as
|
||||
/// they load, and their next save keeps the account's UUID. A reference is the
|
||||
/// player's four ints, wherever the entity keeps it; an entity's own `UUID` never
|
||||
/// names the player.
|
||||
pub fn migrate_player_uuid(tag: &mut Tag, old: [i32; 4], new: [i32; 4]) {
|
||||
if old == new {
|
||||
return;
|
||||
}
|
||||
match tag {
|
||||
Tag::IntArray(uuid) if uuid.as_slice() == old.as_slice() => uuid.copy_from_slice(&new),
|
||||
Tag::List(list) => {
|
||||
for tag in list {
|
||||
migrate_player_uuid(tag, old, new);
|
||||
}
|
||||
}
|
||||
Tag::Compound(map) => {
|
||||
for tag in map.values_mut() {
|
||||
migrate_player_uuid(tag, old, new);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// The entity a stored UUID names, while it is in the level: `player` (the
|
||||
/// player's own UUID, when one is in the level), or the mob `uuids` gives that
|
||||
/// UUID to. `uuids` is the game layer's id -> UUID map, so this is the read half
|
||||
@@ -1449,12 +1483,20 @@ pub fn mob_to_nbt(
|
||||
// the attack damage.
|
||||
use crate::mobs::finalize::Attr;
|
||||
// Every other living entity's first tick makes the movement_speed instance
|
||||
// (LivingEntity.removeFrost reads it through getAttribute).
|
||||
let made = if species == crate::mobs::Species::Rabbit {
|
||||
vec![Attr::MovementSpeed, Attr::AttackDamage]
|
||||
} else {
|
||||
vec![Attr::MovementSpeed]
|
||||
};
|
||||
// (LivingEntity.removeFrost reads it through getAttribute); before it, the
|
||||
// mob has only the instances it was loaded with (the kept list above).
|
||||
let speed_at_load =
|
||||
crate::mobs::doors::is_zombie(species) || species == crate::mobs::Species::Piglin;
|
||||
let mut made = Vec::new();
|
||||
if speed_at_load || m.age > 0 {
|
||||
made.push(Attr::MovementSpeed);
|
||||
}
|
||||
if species == crate::mobs::Species::Rabbit {
|
||||
made.push(Attr::AttackDamage);
|
||||
}
|
||||
// AttributeMap.pack writes the list even when no instance exists.
|
||||
map.entry("attributes".into())
|
||||
.or_insert_with(|| Tag::List(Vec::new()));
|
||||
for attr in made {
|
||||
let base = crate::mobs::finalize::base_value(m, attr);
|
||||
let mut list = match map.get(&"attributes".into()) {
|
||||
@@ -1492,6 +1534,15 @@ pub fn mob_to_nbt(
|
||||
if matches!(m.species(), S::Evoker | S::Illusioner) {
|
||||
map.insert("SpellTicks".into(), Tag::Int(m.extra.counter));
|
||||
}
|
||||
// Vindicator.addAdditionalSaveData: `Johnny` only while the flag is set
|
||||
// (`if (this.isJohnny) output.putBoolean("Johnny", true)`).
|
||||
if m.species() == S::Vindicator {
|
||||
if m.extra.johnny {
|
||||
map.insert("Johnny".into(), Tag::Byte(1));
|
||||
} else {
|
||||
map.remove(&"Johnny".into());
|
||||
}
|
||||
}
|
||||
crate::mobs::raider::write_nbt(m, map);
|
||||
crate::mobs::riders::write_nbt(m, map);
|
||||
// LivingEntity equipment: what the mob holds now.
|
||||
@@ -2135,6 +2186,13 @@ pub fn mob_from_nbt(
|
||||
.map(Box::new),
|
||||
);
|
||||
}
|
||||
if m.extra
|
||||
.effects
|
||||
.iter()
|
||||
.any(|e| e.0 == "minecraft:health_boost")
|
||||
{
|
||||
crate::mobs::finalize::health_boost_changed(&mut m);
|
||||
}
|
||||
}
|
||||
m.silent = flag(tag, "Silent");
|
||||
m.invulnerable_flag = flag(tag, "Invulnerable");
|
||||
@@ -2181,12 +2239,9 @@ pub fn mob_from_nbt(
|
||||
if m.species() == crate::mobs::Species::Piglin
|
||||
&& let Some(Tag::List(items)) = tag.get("Inventory")
|
||||
{
|
||||
// InventoryCarrier.readInventoryFromTag (eight slots).
|
||||
m.extra.inventory = items
|
||||
.iter()
|
||||
.filter_map(crate::save::item_from_nbt)
|
||||
.take(8)
|
||||
.collect();
|
||||
// InventoryCarrier.readInventoryFromTag -> SimpleContainer
|
||||
// .fromItemList (eight slots).
|
||||
m.extra.inventory = crate::mobs::villager::load_container(&m, items, 8);
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
@@ -2663,6 +2718,11 @@ pub fn mob_from_nbt(
|
||||
) {
|
||||
m.extra.counter = number(tag.get("SpellTicks")).unwrap_or(0) as i32;
|
||||
}
|
||||
// Vindicator.readAdditionalSaveData: `Johnny`, false when absent. A name
|
||||
// that is Johnny latches the flag again on the next AI step.
|
||||
if m.species() == crate::mobs::Species::Vindicator {
|
||||
m.extra.johnny = flag(tag, "Johnny");
|
||||
}
|
||||
crate::mobs::raider::read_nbt(&mut m, tag);
|
||||
crate::mobs::riders::read_nbt(&mut m, tag);
|
||||
// A mannequin is an `Avatar`, not a `Mob`: it reads none of
|
||||
@@ -2985,6 +3045,9 @@ pub fn orbs_from_nbt(tag: &Tag) -> Vec<ExperienceOrb> {
|
||||
count,
|
||||
ticks: 0,
|
||||
health: number(tag.get("Health")).unwrap_or(5) as i32,
|
||||
// ExperienceOrb's followingPlayer is transient: never saved, so a
|
||||
// loaded orb starts without one.
|
||||
following: None,
|
||||
};
|
||||
vec![orb]
|
||||
}
|
||||
@@ -3890,6 +3953,7 @@ pub fn item_frame_from_nbt(tag: &Tag, id: u32) -> Option<ItemFrame> {
|
||||
invisible: flag(tag, "Invisible"),
|
||||
check_interval: 0,
|
||||
custom_name: tag.get("CustomName").cloned(),
|
||||
random: crate::random_source::entity(),
|
||||
})
|
||||
}
|
||||
/// ArmorStand.addAdditionalSaveData with LivingEntity's `equipment`.
|
||||
@@ -4004,7 +4068,7 @@ pub fn armor_stand_from_nbt(tag: &Tag, id: u32) -> Option<ArmorStand> {
|
||||
// AttributeMap.load makes an instance for each saved attribute.
|
||||
a.speed_instance = matches!(tag.get("attributes"), Some(Tag::List(list))
|
||||
if list.iter().any(|attr| text(attr.get("id")).as_deref() == Some(SPEED_ATTRIBUTE)));
|
||||
a.velocity = motion(tag);
|
||||
a.velocity = loaded_motion(tag);
|
||||
a.health = float(tag.get("Health")).unwrap_or(20.0) as f32;
|
||||
a.fire_ticks = number(tag.get("Fire")).unwrap_or(0) as i32;
|
||||
a.custom_name = tag.get("CustomName").map(name_text);
|
||||
@@ -4037,8 +4101,9 @@ pub fn armor_stand_from_nbt(tag: &Tag, id: u32) -> Option<ArmorStand> {
|
||||
}
|
||||
Some(a)
|
||||
}
|
||||
/// A boat entity (`minecraft:<wood>_boat`, `minecraft:bamboo_chest_raft`...):
|
||||
/// chest boats keep their slots in `Items` (`ContainerHelper.saveAllItems`).
|
||||
/// `AbstractBoat.addAdditionalSaveData` / `ContainerEntity.addChestVehicleSaveData`:
|
||||
/// only chest boats own container fields; a pending table replaces `Items`.
|
||||
/// Ordinary slots use `ContainerHelper.saveAllItems` through the existing item encoder.
|
||||
pub fn boat_to_nbt(
|
||||
id: &str,
|
||||
position: Vec3,
|
||||
@@ -4058,8 +4123,22 @@ pub fn boat_to_nbt(
|
||||
fall_distance: 0.0,
|
||||
}
|
||||
.non_mob_tag(raw);
|
||||
if let (Some(items), Tag::Compound(map)) = (items, &mut tag) {
|
||||
map.insert("Items".into(), crate::save::encode_items(items));
|
||||
if let Tag::Compound(map) = &mut tag {
|
||||
map.remove(&"Items".into());
|
||||
if let Some(items) = items {
|
||||
let table = crate::save::text(map.get(&"LootTable".into()))
|
||||
.and_then(|id| horizon_client_core::identifier::normalize_id(&id));
|
||||
if let Some(table) = table {
|
||||
map.insert("LootTable".into(), Tag::String(table.as_str().into()));
|
||||
} else {
|
||||
map.remove(&"LootTable".into());
|
||||
map.remove(&"LootTableSeed".into());
|
||||
map.insert("Items".into(), crate::save::encode_items(items));
|
||||
}
|
||||
} else {
|
||||
map.remove(&"LootTable".into());
|
||||
map.remove(&"LootTableSeed".into());
|
||||
}
|
||||
}
|
||||
tag
|
||||
}
|
||||
@@ -4523,6 +4602,50 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::items::ItemStack;
|
||||
|
||||
/// Older worlds name their single player by the offline UUID; the migration rewrites
|
||||
/// every reference to it (owner, thrower, trusted list, anger, leash) and nothing else.
|
||||
#[test]
|
||||
fn a_players_offline_uuid_becomes_the_accounts_everywhere_in_an_entity() {
|
||||
let old = [1, 2, 3, 4];
|
||||
let new = [5, 6, 7, 8];
|
||||
let mut tag = Tag::compound([
|
||||
("id", string("minecraft:wolf")),
|
||||
("UUID", Tag::IntArray(vec![9, 9, 9, 9])),
|
||||
("Owner", Tag::IntArray(old.to_vec())),
|
||||
("Thrower", Tag::IntArray(old.to_vec())),
|
||||
("angry_at", Tag::IntArray(old.to_vec())),
|
||||
(
|
||||
"Trusted",
|
||||
Tag::List(vec![
|
||||
Tag::IntArray(old.to_vec()),
|
||||
Tag::IntArray(vec![3, 3, 3, 3]),
|
||||
]),
|
||||
),
|
||||
(
|
||||
"leash",
|
||||
Tag::compound([("UUID", Tag::IntArray(old.to_vec()))]),
|
||||
),
|
||||
("Health", Tag::Float(20.0f32.to_bits())),
|
||||
]);
|
||||
migrate_player_uuid(&mut tag, old, new);
|
||||
assert_eq!(tag.get("Owner"), Some(&Tag::IntArray(new.to_vec())));
|
||||
assert_eq!(tag.get("Thrower"), Some(&Tag::IntArray(new.to_vec())));
|
||||
assert_eq!(tag.get("angry_at"), Some(&Tag::IntArray(new.to_vec())));
|
||||
assert_eq!(
|
||||
tag.get("Trusted"),
|
||||
Some(&Tag::List(vec![
|
||||
Tag::IntArray(new.to_vec()),
|
||||
Tag::IntArray(vec![3, 3, 3, 3]),
|
||||
]))
|
||||
);
|
||||
assert_eq!(
|
||||
tag.get("leash").and_then(|leash| leash.get("UUID")),
|
||||
Some(&Tag::IntArray(new.to_vec()))
|
||||
);
|
||||
// The entity's own UUID is not the player's.
|
||||
assert_eq!(tag.get("UUID"), Some(&Tag::IntArray(vec![9, 9, 9, 9])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_saved_tags_are_omitted_without_changing_raw_or_nonempty_tags() {
|
||||
let base = Base {
|
||||
@@ -5103,6 +5226,27 @@ mod tests {
|
||||
assert!(!armor_stand_from_nbt(&cleared, 7).unwrap().no_gravity);
|
||||
}
|
||||
|
||||
/// `Entity.load`: a Motion component past 10 blocks a tick loads as 0,
|
||||
/// the others as saved.
|
||||
#[test]
|
||||
fn armor_stand_drops_a_motion_component_past_ten() {
|
||||
let doubles = |v: [f64; 3]| Tag::List(v.map(|d| Tag::Double(d.to_bits())).to_vec());
|
||||
let tag = Tag::compound([
|
||||
("id", string("minecraft:armor_stand")),
|
||||
("Pos", doubles([0.0, 64.0, 0.0])),
|
||||
("Motion", doubles([1.0e10, -0.5, -10.5])),
|
||||
]);
|
||||
let a = armor_stand_from_nbt(&tag, 7).unwrap();
|
||||
assert_eq!(a.velocity, Vec3::new(0.0, -0.5, 0.0));
|
||||
let tag = Tag::compound([
|
||||
("id", string("minecraft:armor_stand")),
|
||||
("Pos", doubles([0.0, 64.0, 0.0])),
|
||||
("Motion", doubles([10.0, 0.0, -10.0])),
|
||||
]);
|
||||
let a = armor_stand_from_nbt(&tag, 7).unwrap();
|
||||
assert_eq!(a.velocity, Vec3::new(10.0, 0.0, -10.0), "10 itself stays");
|
||||
}
|
||||
|
||||
fn falling_decode_registry() -> BlockRegistry {
|
||||
BlockRegistry::from_nbt(&Tag::compound([
|
||||
("DataVersion", Tag::Int(horizon_world::DATA_VERSION)),
|
||||
@@ -5903,6 +6047,7 @@ mod tests {
|
||||
count: 1,
|
||||
ticks: 0,
|
||||
health: 5,
|
||||
following: None,
|
||||
};
|
||||
let tag = orb_to_nbt(&orb, [0; 4]);
|
||||
assert_eq!(tag.get("Fire"), Some(&Tag::Short(0)));
|
||||
@@ -5966,6 +6111,7 @@ mod tests {
|
||||
count: 3,
|
||||
ticks: 0,
|
||||
health: 5,
|
||||
following: None,
|
||||
};
|
||||
let tag = orb_to_nbt(&o, [0; 4]);
|
||||
let back = orbs_from_nbt(&tag);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -136,6 +136,9 @@ impl AdvState {
|
||||
/// The game's world for predicates.
|
||||
struct View<'a, 'g>(&'a Game<'g>);
|
||||
impl adv::World for View<'_, '_> {
|
||||
fn predicate(&self, id: &str) -> Option<&Value> {
|
||||
self.0.fns.store.predicates.get(id)
|
||||
}
|
||||
fn dimension(&self) -> &str {
|
||||
self.0.dim.id()
|
||||
}
|
||||
@@ -512,6 +515,10 @@ impl Game<'_> {
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
// An invalid reference anywhere fails the whole tree, even under an inversion.
|
||||
if !adv::references_valid(v, &View(self)) {
|
||||
return false;
|
||||
}
|
||||
adv::conditions_with(v, &ctx, &mut |leaf, ctx| {
|
||||
adv::literal_random_chance(leaf, &mut || self.world.random.next_float())
|
||||
.unwrap_or_else(|| adv::conditions(leaf, ctx, &View(self)))
|
||||
@@ -768,7 +775,21 @@ impl Game<'_> {
|
||||
super::persist::add_passengers(&mut tag, riders);
|
||||
tag
|
||||
});
|
||||
let movement = (m.extra.mount.is_none()
|
||||
&& m.rider.is_none()
|
||||
&& self.riding != Some(m.id)
|
||||
&& self.lan.as_ref().is_none_or(|host| {
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
.all(|guest| guest.vehicle != Some(m.id))
|
||||
}))
|
||||
.then_some(adv::MovementView {
|
||||
per_tick: [m.velocity.x, m.velocity.y, m.velocity.z],
|
||||
fall_distance: m.fall_distance,
|
||||
});
|
||||
EntityView {
|
||||
movement,
|
||||
kind: m.kind.id.clone(),
|
||||
position: [m.position.x, m.position.y, m.position.z],
|
||||
on_fire: m.fire_ticks > 0,
|
||||
@@ -3289,3 +3310,114 @@ mod custom_data_equipment_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod movement_predicate_tests {
|
||||
use super::super::native_machines::tests::server_game;
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_core::position::BlockPos;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared resources and scratch cwd/homes"]
|
||||
fn actual_registered_movement_predicate_uses_admitted_mob() {
|
||||
let mut game = server_game();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
game.block_ticks
|
||||
.add_empty_chunk(BlockPos::new(0, 0, 0).chunk())
|
||||
.unwrap();
|
||||
assert!(game.run_command("forceload add 0 0"));
|
||||
assert!(game.run_command("fill 0 199 0 15 199 15 stone"));
|
||||
assert!(game.world.loaded(4, 4));
|
||||
let root = std::env::current_dir().unwrap().join("movement-world");
|
||||
let pack = root.join("datapacks/movement");
|
||||
let folder = pack.join("data/motion/predicate");
|
||||
std::fs::create_dir_all(&folder).unwrap();
|
||||
std::fs::write(pack.join("pack.mcmeta"), r#"{"pack":{"description":"Movement fixture","min_format":[121,0],"max_format":[121,0]}}"#).unwrap();
|
||||
let entity =
|
||||
|p| json!({"type":"minecraft:entity_properties","entity":"this","predicate":p});
|
||||
let movement =
|
||||
|p| entity(json!({"minecraft:entity_type":"minecraft:pig","minecraft:movement":p}));
|
||||
let high = movement(json!({"x":{"min":100}}));
|
||||
let zero = movement(json!({"x":{"min":0,"max":0}}));
|
||||
let probes = [
|
||||
("high", high.clone(), false),
|
||||
("zero", zero.clone(), true),
|
||||
("empty", movement(json!({})), true),
|
||||
(
|
||||
"not_high",
|
||||
json!({"type":"minecraft:inverted","term":high}),
|
||||
true,
|
||||
),
|
||||
(
|
||||
"not_zero",
|
||||
json!({"type":"minecraft:inverted","term":zero}),
|
||||
false,
|
||||
),
|
||||
];
|
||||
for (name, p, _) in &probes {
|
||||
std::fs::write(folder.join(format!("{name}.json")), p.to_string()).unwrap();
|
||||
}
|
||||
std::fs::write(
|
||||
folder.join("identity.json"),
|
||||
entity(json!({"minecraft:entity_type":"minecraft:pig"})).to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
folder.join("always.json"),
|
||||
r#"{"type":"minecraft:all_of","terms":[]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
game.world_root = root;
|
||||
game.reload_datapacks(false);
|
||||
assert!(game.run_command("scoreboard objectives add motion dummy"));
|
||||
assert!(game.run_command("execute if predicate motion:always"));
|
||||
let clock = game.world.game_time;
|
||||
let actor = "000002a1-0000-0000-0000-000000000001";
|
||||
assert!(game.run_command("summon pig 4.5 200 4.5 {UUID:[I;673,0,0,1],NoAI:1b,NoGravity:1b,Health:10f,Motion:[0d,0d,0d]}"));
|
||||
assert!(game.run_command(&format!("execute as {actor} if entity @s[type=pig,nbt={{UUID:[I;673,0,0,1],Health:10f,Pos:[4.5d,200d,4.5d],Motion:[0d,0d,0d],NoAI:1b,NoGravity:1b}}]")));
|
||||
assert!(game.run_command(&format!("execute as {actor} if predicate motion:identity")));
|
||||
let id = *game
|
||||
.persist
|
||||
.uuids
|
||||
.iter()
|
||||
.find(|(_, u)| **u == [673, 0, 0, 1])
|
||||
.unwrap()
|
||||
.0;
|
||||
let mob = game.mobs.iter().find(|m| m.id == id).unwrap();
|
||||
assert_eq!(mob.velocity, Vec3::ZERO);
|
||||
assert_eq!(mob.fall_distance, 0.0);
|
||||
assert!(mob.no_ai && mob.no_gravity);
|
||||
assert!(mob.extra.mount.is_none());
|
||||
let view = game.entity_view_of(super::super::commands::Target::Mob(id));
|
||||
assert_eq!(view.kind, "minecraft:pig");
|
||||
let mut failures = Vec::new();
|
||||
for (name, p, want) in probes {
|
||||
assert!(
|
||||
game.fns
|
||||
.store
|
||||
.predicates
|
||||
.contains_key(&format!("motion:{name}"))
|
||||
);
|
||||
let direct = game.conditions_hold(&p, Some(&view), [4.5, 200.0, 4.5]);
|
||||
if direct != want {
|
||||
failures.push(format!("direct {name}: {direct} != {want}"));
|
||||
}
|
||||
assert!(game.run_command("scoreboard players set result motion 91"));
|
||||
assert!(game.run_command("scoreboard players set success motion 17"));
|
||||
game.run_command(&format!("execute store result score result motion store success score success motion as {actor} if predicate motion:{name}"));
|
||||
for key in ["result", "success"] {
|
||||
let value = game.score.board.score(key, "motion").unwrap().value;
|
||||
if value != i32::from(want) {
|
||||
failures.push(format!("registered {name} {key}: {value}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(game.world.game_time, clock, "zero observation ticks");
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"actual movement predicate mismatches: {failures:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -458,6 +458,7 @@ impl Game<'_> {
|
||||
Screen::DebugOptions => "debug-options",
|
||||
Screen::Mod => "mod",
|
||||
Screen::HudEditor => "hud-editor",
|
||||
Screen::Studio => "studio",
|
||||
}
|
||||
}
|
||||
pub(super) fn inventory_counts(&self) -> BTreeMap<String, i64> {
|
||||
|
||||
@@ -193,7 +193,8 @@ impl ArmorStandAssets {
|
||||
/// `TrimTextureKey`: the trim texture of `stack` on its humanoid
|
||||
/// layer (`humanoid` or `humanoid_leggings`), if it has a trim.
|
||||
fn trim_texture(&self, asset: &str, kind: &str, stack: &ItemStack) -> Option<TrimTexture> {
|
||||
let (material, pattern) = stack.extra.as_ref()?.trim.as_ref()?;
|
||||
let (material, pattern) =
|
||||
horizon_client::item_data::trim::named(stack.extra.as_ref()?.trim.as_ref()?)?;
|
||||
let overrides = self
|
||||
.trim_overrides
|
||||
.get(asset)
|
||||
@@ -857,6 +858,13 @@ impl Game<'_> {
|
||||
let mut dead = Vec::new();
|
||||
// ServerLevel.tick: only entities in entity-ticking chunks tick.
|
||||
let ticking = self.entity_ticking_area();
|
||||
// EntityGetter.getEntityCollisions: the boxes that stop a stand's move
|
||||
// (a living shulker, a boat, a happy ghast held still), gathered when
|
||||
// the first stand travels. A server's stand rides nothing (`vehicle`
|
||||
// is set on a server's client only), but mobs can ride it (`/ride`):
|
||||
// `isPassengerOfSameVehicle` leaves those riders out.
|
||||
let mut obstacles: Option<horizon_client::entities::ObstacleGrid> = None;
|
||||
let mut nearby = Vec::new();
|
||||
for i in 0..self.armor_stands.len() {
|
||||
let at = self.armor_stands[i].position;
|
||||
if !ticking(at.x.floor() as i32, at.z.floor() as i32) {
|
||||
@@ -865,6 +873,11 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
self.armor_stand_location_effects(i, false);
|
||||
// ArmorStand.travel: a marker or a NoGravity stand does not move.
|
||||
let travels = !self.armor_stands[i].marker && !self.armor_stands[i].no_gravity;
|
||||
if travels && obstacles.is_none() {
|
||||
obstacles = Some(self.stand_entity_collisions());
|
||||
}
|
||||
let a = &mut self.armor_stands[i];
|
||||
a.previous = a.position;
|
||||
a.previous_yaw = a.yaw;
|
||||
@@ -872,11 +885,36 @@ impl Game<'_> {
|
||||
a.first_tick = false;
|
||||
// aiStep's freezing step (removeFrost) makes the speed instance.
|
||||
a.speed_instance = true;
|
||||
// LivingEntity.aiStep -> EntityEquipment.tick, before movement.
|
||||
// Inspect in slot order, then mutate only each actual owned compass.
|
||||
let clear = self.armor_stands[i].equipment.each_ref().map(|stack| {
|
||||
stack.as_ref().is_some_and(|stack| {
|
||||
stack.item.as_ref() == "minecraft:compass"
|
||||
&& super::maps::lodestone_target_gone(self, stack, self.dim.id())
|
||||
})
|
||||
});
|
||||
for (stack, clear) in self.armor_stands[i].equipment.iter_mut().zip(clear) {
|
||||
if clear && let Some(stack) = stack {
|
||||
stack.data_mut().set_other(
|
||||
"minecraft:lodestone_tracker",
|
||||
Some(horizon_world::nbt::Tag::Compound(Default::default())),
|
||||
);
|
||||
}
|
||||
}
|
||||
let a = &mut self.armor_stands[i];
|
||||
let (water_height, lava_height) =
|
||||
a.push_by_fluid(&self.world, &self.res.motion().fluids);
|
||||
let in_water = water_height > 0.0;
|
||||
let medium =
|
||||
horizon_client::entities::StandMedium::from_heights(water_height, lava_height);
|
||||
// Entity.collide asks for the entities meeting the box stretched
|
||||
// by this tick's movement (the fluid push is already in it).
|
||||
nearby.clear();
|
||||
if let Some(grid) = obstacles.as_mut().filter(|_| travels) {
|
||||
let area = a.bounds().expand_towards(a.velocity);
|
||||
let area = area.inflate(Vec3::new(1.0e-7, 1.0e-7, 1.0e-7));
|
||||
grid.near_except(area, Some(a.id), &mut nearby);
|
||||
}
|
||||
let (world, registry, facts, collision) = (
|
||||
&self.world,
|
||||
self.pack.assets.registry,
|
||||
@@ -895,6 +933,7 @@ impl Game<'_> {
|
||||
&self.res.motion().climbing,
|
||||
in_water,
|
||||
Some(self.border.geometry()),
|
||||
&nearby,
|
||||
) {
|
||||
// LivingEntity.aiStep: `travel` first, then the blocks' push.
|
||||
a.tick_settle(medium, step);
|
||||
@@ -953,6 +992,60 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// The armor stands' share of `Shulker.onPeekAmountChange`: the opening
|
||||
/// lid of mob `shulker` moves every stand with physics (vanilla's
|
||||
/// `!noPhysics`: not a marker, not `NoGravity`) whose box meets `area` by
|
||||
/// `delta`, through the stand's `shove`. The stand the shulker
|
||||
/// rides is left out (`isPassengerOfSameVehicle`). Stands tick before mobs
|
||||
/// here, while vanilla ticks a stand that joined after the shulker (one
|
||||
/// placed on it) after the shulker: `shove` makes up for the order.
|
||||
pub(super) fn lid_pushes_armor_stands(&mut self, shulker: u32, area: Aabb, delta: [f64; 3]) {
|
||||
if self.armor_stands.is_empty() {
|
||||
return;
|
||||
}
|
||||
let delta = Vec3::new(delta[0], delta[1], delta[2]);
|
||||
let mut obstacles: Option<horizon_client::entities::ObstacleGrid> = None;
|
||||
// (the stand the shulker rides, when it joined), worked out at the first stand it meets.
|
||||
let mut shell: Option<(Option<u32>, horizon_client::join_seq::JoinSeq)> = None;
|
||||
let mut nearby = Vec::new();
|
||||
for i in 0..self.armor_stands.len() {
|
||||
let a = &self.armor_stands[i];
|
||||
if a.marker || a.no_gravity || a.health <= 0.0 || !a.bounds().intersects(area) {
|
||||
continue;
|
||||
}
|
||||
let (ridden, joined) = *shell.get_or_insert_with(|| {
|
||||
let j = self.mobs.iter().position(|m| m.id == shulker);
|
||||
(
|
||||
j.and_then(|j| self.mob_root_vehicle(j)),
|
||||
j.map_or(horizon_client::join_seq::JoinSeq::UNJOINED, |j| {
|
||||
self.mobs[j].join
|
||||
}),
|
||||
)
|
||||
});
|
||||
if Some(a.id) == ridden {
|
||||
continue;
|
||||
}
|
||||
let grid = obstacles.get_or_insert_with(|| self.stand_entity_collisions());
|
||||
let a = &mut self.armor_stands[i];
|
||||
// Entity.collide: the entities meeting the box stretched by the push.
|
||||
let reach = a.bounds().expand_towards(delta);
|
||||
let reach = reach.inflate(Vec3::new(1.0e-7, 1.0e-7, 1.0e-7));
|
||||
grid.near_except(reach, Some(a.id), &mut nearby);
|
||||
let scene = horizon_client::collisions::SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
let travels_after = a.join > joined;
|
||||
a.shove(
|
||||
scene,
|
||||
delta,
|
||||
Some(self.border.geometry()),
|
||||
&nearby,
|
||||
travels_after,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// `Ravager.roar` with `mob_griefing` on: every armor stand in `area` (the
|
||||
/// ravager's box grown by 4) takes `strongKnockback` from the ravager at
|
||||
/// `from`. Its `mobAttack` leaves the stand whole (`ArmorStand.hurtServer`:
|
||||
@@ -1065,6 +1158,10 @@ impl Game<'_> {
|
||||
pub(super) fn break_armor_stand_by_anything(&mut self, i: usize) {
|
||||
let a = self.armor_stands.remove(i);
|
||||
let at = [a.position.x, a.position.y, a.position.z];
|
||||
// brokenByAnything's dropAllDeathLoot, before the equipment.
|
||||
if !a.small && self.ui.rules.bool("mob_drops") {
|
||||
self.armor_stand_death_loot(at, a.fire_ticks > 0);
|
||||
}
|
||||
let (pos, drops) = broken_armor_stand_equipment(a);
|
||||
for stack in drops {
|
||||
self.pop_resource(pos, stack);
|
||||
@@ -1074,6 +1171,115 @@ impl Game<'_> {
|
||||
self.entity_killed_event(at, false);
|
||||
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
|
||||
}
|
||||
/// `LivingEntity.dropFromLootTable` for a stand that is not small, with
|
||||
/// `mob_drops` on (`shouldDropLoot`): its type's table
|
||||
/// `minecraft:entities/armor_stand` with seed 0 (`getLootTableSeed`), so
|
||||
/// the table's random sequence or else the level's random, each stack
|
||||
/// spawned at the stand's position. Its view is only the type and whether
|
||||
/// it burns; no damage source, killer or player is known here.
|
||||
fn armor_stand_death_loot(&mut self, at: [f64; 3], on_fire: bool) {
|
||||
const TABLE: &str = "minecraft:entities/armor_stand";
|
||||
let Some(loot) = horizon_client::structure_blocks::loot_data(
|
||||
&self.options.resources,
|
||||
&self.pack_files(),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
if !loot.has_table(TABLE) {
|
||||
return;
|
||||
}
|
||||
let params = horizon_world::loot::EntityLootParams {
|
||||
this: horizon_world::loot::LootEntity {
|
||||
on_fire,
|
||||
baby: false,
|
||||
..horizon_world::loot::LootEntity::new("minecraft:armor_stand")
|
||||
},
|
||||
..horizon_world::loot::EntityLootParams::default()
|
||||
};
|
||||
let stacks = match loot.random_sequence(TABLE) {
|
||||
Some(id) => {
|
||||
let random = self.rand_sequences.get(id, self.level.seed);
|
||||
loot.roll_entity(TABLE, random, ¶ms)
|
||||
}
|
||||
None => loot.roll_entity(TABLE, &mut self.world.random, ¶ms),
|
||||
};
|
||||
for stack in stacks.iter().filter(|s| !s.is_empty()) {
|
||||
self.spawn_item_own(super::creatures::from_loot(stack), at, 10);
|
||||
}
|
||||
}
|
||||
/// `ArmorStand.hurtServer` for a command's damage of `damage_type` from the
|
||||
/// causing entity `causing`, up to its sources that act and still return
|
||||
/// false: a blast breaks the stand (`brokenByAnything`, `kill`), fire that
|
||||
/// ignites stands lights it (or burns a burning one 0.15), and burning
|
||||
/// takes 4 health. Every other source is refused here, so this is always
|
||||
/// false and `/damage` fails as vanilla's does.
|
||||
pub(super) fn command_damage_stand(
|
||||
&mut self,
|
||||
id: u32,
|
||||
damage_type: &str,
|
||||
causing: Option<super::commands::Target>,
|
||||
) -> bool {
|
||||
use super::commands::Target;
|
||||
let Some(i) = self.armor_stands.iter().position(|a| a.id == id) else {
|
||||
return false;
|
||||
};
|
||||
let registry = &self.motion.health.damage;
|
||||
let Some(kind) = registry.id(damage_type) else {
|
||||
return false;
|
||||
};
|
||||
let tagged = |tag: &str| registry.in_tag(kind, tag);
|
||||
if !self.ui.rules.bool("mob_griefing") && matches!(causing, Some(Target::Mob(_))) {
|
||||
return false;
|
||||
}
|
||||
if tagged("minecraft:bypasses_invulnerability") {
|
||||
self.kill_target(Target::Stand(id));
|
||||
return false;
|
||||
}
|
||||
// Entity.isInvulnerableToBase: the flag, unless a creative player hits.
|
||||
let creative = match causing {
|
||||
Some(Target::Player) => self.creative,
|
||||
Some(Target::Guest(conn)) => self.command_guest(conn).is_some_and(|g| g.creative()),
|
||||
_ => false,
|
||||
};
|
||||
let invulnerable = self
|
||||
.persist
|
||||
.raw
|
||||
.get(&id)
|
||||
.is_some_and(|tag| horizon_client::entity_nbt::flag(tag, "Invulnerable"));
|
||||
let a = &self.armor_stands[i];
|
||||
if (invulnerable && !creative) || a.invisible || a.marker {
|
||||
return false;
|
||||
}
|
||||
let (explosion, ignites, burns) = (
|
||||
tagged("minecraft:is_explosion"),
|
||||
tagged("minecraft:ignites_armor_stands"),
|
||||
tagged("minecraft:burns_armor_stands"),
|
||||
);
|
||||
if explosion {
|
||||
self.break_armor_stand_by_anything(i);
|
||||
} else if ignites {
|
||||
if self.armor_stands[i].fire_ticks > 0 {
|
||||
self.cause_stand_damage(i, 0.15);
|
||||
} else {
|
||||
// igniteForSeconds(5.0f): at least 100 ticks.
|
||||
let a = &mut self.armor_stands[i];
|
||||
a.fire_ticks = a.fire_ticks.max(100);
|
||||
}
|
||||
} else if burns && self.armor_stands[i].health > 0.5 {
|
||||
self.cause_stand_damage(i, 4.0);
|
||||
}
|
||||
false
|
||||
}
|
||||
/// `ArmorStand.causeDamage`: the health less `amount`, broken by anything
|
||||
/// at 0.5 or less.
|
||||
fn cause_stand_damage(&mut self, i: usize, amount: f32) {
|
||||
let health = self.armor_stands[i].health - amount;
|
||||
if health <= 0.5 {
|
||||
self.break_armor_stand_by_anything(i);
|
||||
} else {
|
||||
self.armor_stands[i].health = health;
|
||||
}
|
||||
}
|
||||
/// ArmorStandRenderer: the stand with its default pose, then armor layers.
|
||||
pub(super) fn armor_stand_geometry(
|
||||
&self,
|
||||
@@ -2117,3 +2323,340 @@ mod pumpkin_equippable_use_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod stand_lodestone_inventory_tick_tests {
|
||||
use horizon_client::motion::PlayerMotion;
|
||||
use horizon_core::{geometry::Vec3, position::BlockPos};
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
|
||||
fn actual_stand_equipment_compasses_tick_after_loaded_lodestone_removal() {
|
||||
let mut game = crate::game::native_machines::tests::server_game();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
game.level.health = 20.0;
|
||||
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(14.5, 200.0, 14.5)).unwrap());
|
||||
game.block_ticks
|
||||
.add_empty_chunk(BlockPos::new(0, 0, 0).chunk())
|
||||
.unwrap();
|
||||
assert!(game.run_command("forceload add 0 0"));
|
||||
assert!(game.run_command("fill 0 199 0 15 199 15 stone"));
|
||||
assert!(game.run_command("setblock 12 200 12 lodestone"));
|
||||
assert!(game.world.loaded(12, 12));
|
||||
let mut ids = Vec::new();
|
||||
for (n, dim, tracked, target) in [
|
||||
(1, "minecraft:overworld", true, true),
|
||||
(2, "minecraft:overworld", false, true),
|
||||
(3, "minecraft:the_nether", true, true),
|
||||
(4, "minecraft:overworld", true, false),
|
||||
] {
|
||||
let who = format!("0000a551-0000-0000-0000-{n:012x}");
|
||||
let x = f64::from(n * 2) + 0.5;
|
||||
assert!(game.run_command(&format!("summon armor_stand {x} 200.0 4.5 {{UUID:[I;42321,0,0,{n}],Health:20f,NoGravity:1b}}")));
|
||||
let id = game
|
||||
.armor_stands
|
||||
.iter()
|
||||
.find(|a| game.persist.uuid_of(a.id) == Some([42321, 0, 0, n]))
|
||||
.unwrap()
|
||||
.id;
|
||||
let tracker = format!(
|
||||
"{}tracked:{tracked}",
|
||||
if target {
|
||||
format!("target:{{dimension:\"{dim}\",pos:[I;12,200,12]}},")
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
);
|
||||
for hand in ["mainhand", "offhand"] {
|
||||
assert!(game.run_command(&format!("item replace entity {who} weapon.{hand} with compass[minecraft:lodestone_tracker={{{tracker}}},minecraft:custom_data={{marker:{n}}}] 1")));
|
||||
}
|
||||
let a = game.armor_stands.iter().find(|a| a.id == id).unwrap();
|
||||
assert_eq!(a.position, Vec3::new(x, 200.0, 4.5));
|
||||
assert!(a.no_gravity);
|
||||
assert!(a.first_tick, "fresh actual stand has not ticked yet");
|
||||
assert_eq!(a.health, 20.0);
|
||||
for stack in a.equipment[..2].iter().flatten() {
|
||||
assert_eq!(&*stack.item, "minecraft:compass");
|
||||
assert_eq!(stack.count, 1);
|
||||
assert_eq!(
|
||||
stack
|
||||
.data()
|
||||
.unwrap()
|
||||
.other("minecraft:lodestone_tracker")
|
||||
.unwrap()
|
||||
.get("target")
|
||||
.is_some(),
|
||||
target
|
||||
);
|
||||
}
|
||||
assert!(game.persist.raw.remove(&id).is_some());
|
||||
ids.push((id, n, target));
|
||||
}
|
||||
for hand in ["mainhand", "offhand"] {
|
||||
assert!(game.run_command(&format!("item replace entity @s weapon.{hand} with compass[minecraft:lodestone_tracker={{target:{{dimension:\"minecraft:overworld\",pos:[I;12,200,12]}},tracked:true}}] 1")));
|
||||
}
|
||||
assert!(game.run_command("setblock 12 200 12 air"));
|
||||
assert_eq!(
|
||||
&*game.res.facts().get(game.world.block(12, 200, 12)).name,
|
||||
"minecraft:air"
|
||||
);
|
||||
game.world.game_time += 1;
|
||||
game.tick_lodestone_compasses();
|
||||
for slot in [0, crate::game::shield::OFFHAND_SLOT] {
|
||||
assert!(
|
||||
game.stack_in(slot)
|
||||
.unwrap()
|
||||
.data()
|
||||
.unwrap()
|
||||
.other("minecraft:lodestone_tracker")
|
||||
.unwrap()
|
||||
.get("target")
|
||||
.is_none(),
|
||||
"actual player tick positive control"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
game.entity_ticking_area()(2, 4),
|
||||
"actual entity-ticking chunk admission"
|
||||
);
|
||||
game.tick_armor_stands().unwrap();
|
||||
let mut mismatches = Vec::new();
|
||||
for (id, n, target) in ids {
|
||||
let a = game.armor_stands.iter().find(|a| a.id == id).unwrap();
|
||||
assert_eq!(a.position, Vec3::new(f64::from(n * 2) + 0.5, 200.0, 4.5));
|
||||
assert_eq!(a.health, 20.0);
|
||||
assert!(a.no_gravity);
|
||||
for (slot, stack) in a.equipment[..2].iter().enumerate() {
|
||||
let stack = stack.as_ref().unwrap();
|
||||
assert_eq!(stack.count, 1);
|
||||
let data = stack.data().unwrap();
|
||||
assert_eq!(
|
||||
data.other("minecraft:custom_data").unwrap().get("marker"),
|
||||
Some(&horizon_world::nbt::Tag::Int(n))
|
||||
);
|
||||
let present = data
|
||||
.other("minecraft:lodestone_tracker")
|
||||
.unwrap()
|
||||
.get("target")
|
||||
.is_some();
|
||||
if present != (target && n != 1) {
|
||||
mismatches.push((n, slot, present));
|
||||
}
|
||||
}
|
||||
let saved = horizon_client::entity_nbt::armor_stand_to_nbt(a, [42321, 0, 0, n], None);
|
||||
assert!(
|
||||
saved.get("equipment").is_some(),
|
||||
"actual no-raw model writer"
|
||||
);
|
||||
for slot in 0..2 {
|
||||
let key = horizon_client::entities::ArmorStand::SLOTS[slot];
|
||||
let saved_target = saved
|
||||
.get("equipment")
|
||||
.unwrap()
|
||||
.get(key)
|
||||
.unwrap()
|
||||
.get("components")
|
||||
.unwrap()
|
||||
.get("minecraft:lodestone_tracker")
|
||||
.unwrap()
|
||||
.get("target")
|
||||
.is_some();
|
||||
let modeled_target = a.equipment[slot]
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.data()
|
||||
.unwrap()
|
||||
.other("minecraft:lodestone_tracker")
|
||||
.unwrap()
|
||||
.get("target")
|
||||
.is_some();
|
||||
assert_eq!(
|
||||
saved_target, modeled_target,
|
||||
"no-raw writer preserves actual tracker state"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
mismatches.is_empty(),
|
||||
"actual stand equipment inventory tick mismatches: {mismatches:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod postbaseline_lodestone_controls {
|
||||
//! Added after baseline: source-backed slots and preservation, not new runtime breadth.
|
||||
use super::Game;
|
||||
use horizon_client::{chunk::Chunk, entities::ArmorStand, items::ItemStack};
|
||||
use horizon_core::position::BlockPos;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn stack<'g>(game: &'g Game<'_>, id: u32, slot: usize) -> &'g ItemStack {
|
||||
game.armor_stands
|
||||
.iter()
|
||||
.find(|a| a.id == id)
|
||||
.unwrap()
|
||||
.equipment[slot]
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
}
|
||||
fn has_target(stack: &ItemStack) -> bool {
|
||||
stack
|
||||
.data()
|
||||
.unwrap()
|
||||
.other("minecraft:lodestone_tracker")
|
||||
.unwrap()
|
||||
.get("target")
|
||||
.is_some()
|
||||
}
|
||||
fn item(id: &str, tracker: &str, marker: usize, count: usize) -> String {
|
||||
format!(
|
||||
"{{id:\"{id}\",count:{count},components:{{\"minecraft:lodestone_tracker\":{{{tracker}}},\"minecraft:custom_data\":{{marker:{marker}}}}}}}"
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
|
||||
fn postbaseline_six_slots_noncompass_guard_and_dimension_preservation() {
|
||||
let mut game = crate::game::native_machines::tests::server_game();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
game.block_ticks
|
||||
.add_empty_chunk(BlockPos::new(0, 0, 0).chunk())
|
||||
.unwrap();
|
||||
assert!(game.run_command("forceload add 0 0"));
|
||||
assert!(game.run_command("fill 0 199 0 15 199 15 stone"));
|
||||
assert!(game.run_command("setblock 12 200 12 lodestone"));
|
||||
let tracked = "target:{dimension:\"minecraft:overworld\",pos:[I;12,200,12]},tracked:true";
|
||||
let slots = ArmorStand::SLOTS
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(slot, key)| format!("{key}:{}", item("minecraft:compass", tracked, slot, 1)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
assert!(game.run_command(&format!("summon armor_stand 4.5 200.0 4.5 {{UUID:[I;42321,0,0,100],Health:20f,NoGravity:1b,equipment:{{{slots}}}}}")));
|
||||
let mixed = format!(
|
||||
"mainhand:{},offhand:{},head:{},feet:{}",
|
||||
item("minecraft:stone", tracked, 66, 3),
|
||||
item(
|
||||
"minecraft:compass",
|
||||
"target:{dimension:\"minecraft:the_nether\",pos:[I;12,200,12]},tracked:true",
|
||||
77,
|
||||
1
|
||||
),
|
||||
item(
|
||||
"minecraft:compass",
|
||||
"target:{dimension:\"minecraft:overworld\",pos:[I;12,200,12]},tracked:false",
|
||||
88,
|
||||
1
|
||||
),
|
||||
item("minecraft:compass", "tracked:true", 99, 1)
|
||||
);
|
||||
assert!(game.run_command(&format!("summon armor_stand 8.5 200.0 8.5 {{UUID:[I;42321,0,0,101],Health:20f,NoGravity:1b,equipment:{{{mixed}}}}}")));
|
||||
game.world
|
||||
.chunks
|
||||
.insert((4, 4), Arc::new(Chunk::empty(4, 4)));
|
||||
game.block_ticks
|
||||
.add_empty_chunk(BlockPos::new(64, 0, 64).chunk())
|
||||
.unwrap();
|
||||
assert!(game.run_command("fill 64 199 64 79 199 79 stone"));
|
||||
assert!(game.run_command(&format!("summon armor_stand 65.5 200.0 65.5 {{UUID:[I;42321,0,0,102],Health:20f,NoGravity:1b,equipment:{{mainhand:{}}}}}",item("minecraft:compass",tracked,100,1))));
|
||||
let id = |n| {
|
||||
game.armor_stands
|
||||
.iter()
|
||||
.find(|a| game.persist.uuid_of(a.id) == Some([42321, 0, 0, n]))
|
||||
.unwrap()
|
||||
.id
|
||||
};
|
||||
let (all, mixed, inactive) = (id(100), id(101), id(102));
|
||||
// Direct class ticks do not refresh the headless fixture's tickets.
|
||||
// Use the existing ticket model: only chunk zero is forced to tick.
|
||||
game.tickets.update(Some(crate::ticket_levels::Sources {
|
||||
forced: vec![(0, 0)],
|
||||
..Default::default()
|
||||
}));
|
||||
assert!(game.entity_ticking_area()(4, 4));
|
||||
assert!(!game.entity_ticking_area()(65, 65));
|
||||
assert!(game.world.loaded(65, 65));
|
||||
let before: Vec<_> = (0..6)
|
||||
.map(|slot| {
|
||||
game.armor_stands
|
||||
.iter()
|
||||
.find(|a| a.id == mixed)
|
||||
.unwrap()
|
||||
.equipment[slot]
|
||||
.as_ref()
|
||||
.map(horizon_items::item_nbt::item_to_nbt)
|
||||
})
|
||||
.collect();
|
||||
assert!(has_target(stack(&game, mixed, 0)));
|
||||
assert!(has_target(stack(&game, mixed, 1)));
|
||||
assert!(has_target(stack(&game, mixed, 5)));
|
||||
assert!(!has_target(stack(&game, mixed, 2)));
|
||||
assert!(has_target(stack(&game, inactive, 0)));
|
||||
for slot in 0..6 {
|
||||
assert!(has_target(stack(&game, all, slot)));
|
||||
}
|
||||
let dimension = game.dim;
|
||||
let chunks: std::collections::HashSet<_> = game.world.chunks.keys().copied().collect();
|
||||
assert!(game.run_command("setblock 12 200 12 air"));
|
||||
game.world.game_time += 1;
|
||||
game.tick_armor_stands().unwrap();
|
||||
assert!(
|
||||
!game
|
||||
.armor_stands
|
||||
.iter()
|
||||
.find(|a| a.id == all)
|
||||
.unwrap()
|
||||
.first_tick,
|
||||
"active stand really ticked"
|
||||
);
|
||||
assert!(
|
||||
game.armor_stands
|
||||
.iter()
|
||||
.find(|a| a.id == inactive)
|
||||
.unwrap()
|
||||
.first_tick,
|
||||
"non-ticking guard unchanged"
|
||||
);
|
||||
for slot in 0..6 {
|
||||
let s = stack(&game, all, slot);
|
||||
assert!(!has_target(s), "source-backed equipment slot {slot}");
|
||||
assert_eq!(s.count, 1);
|
||||
assert_eq!(
|
||||
s.data()
|
||||
.unwrap()
|
||||
.other("minecraft:custom_data")
|
||||
.unwrap()
|
||||
.get("marker"),
|
||||
Some(&horizon_world::nbt::Tag::Int(slot as i32))
|
||||
);
|
||||
}
|
||||
let after: Vec<_> = (0..6)
|
||||
.map(|slot| {
|
||||
game.armor_stands
|
||||
.iter()
|
||||
.find(|a| a.id == mixed)
|
||||
.unwrap()
|
||||
.equipment[slot]
|
||||
.as_ref()
|
||||
.map(horizon_items::item_nbt::item_to_nbt)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
before, after,
|
||||
"actual non-compass/untracked/other-dimension/targetless stacks preserved"
|
||||
);
|
||||
assert!(has_target(stack(&game, inactive, 0)));
|
||||
assert_eq!(game.dim, dimension);
|
||||
assert_eq!(
|
||||
game.world
|
||||
.chunks
|
||||
.keys()
|
||||
.copied()
|
||||
.collect::<std::collections::HashSet<_>>(),
|
||||
chunks
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -913,7 +913,7 @@ impl Game<'_> {
|
||||
return None;
|
||||
};
|
||||
let (value, signature) = profile.textures()?;
|
||||
let url = skins::unpack(&value, signature, &skins::TEXTURE_URIS).skin?;
|
||||
let url = skins::unpack(&value, signature, skins::texture_uris()).skin?;
|
||||
let location = skins::skin_location(&skins::texture_hash(&url)?);
|
||||
Some((url, location))
|
||||
});
|
||||
|
||||
Loaded 100 of 517 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user