mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-08 08:07:53 -10:00
Compare commits
444
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11102a5651 | ||
|
|
0cf6116d8d | ||
|
|
bf53e59fae | ||
|
|
affb4cbc98 | ||
|
|
ab6fe1802d | ||
|
|
c23c62df76 | ||
|
|
67093685db | ||
|
|
012391f4aa | ||
|
|
4757e2ddca | ||
|
|
c1e8fc5d4b | ||
|
|
aa3d35ebc7 | ||
|
|
d178e80ea5 | ||
|
|
7f117f484e | ||
|
|
d3910293e0 | ||
|
|
c3d6c640ac | ||
|
|
37e9e82dfd | ||
|
|
f4eebc417e | ||
|
|
0e83631f9d | ||
|
|
a2140a9ef6 | ||
|
|
35b2938c47 | ||
|
|
236b13f290 | ||
|
|
838ae17615 | ||
|
|
bdc8a23f21 | ||
|
|
087ce1708b | ||
|
|
61b7fa5b26 | ||
|
|
1df93f9739 | ||
|
|
17e2982614 | ||
|
|
eb74ad2a63 | ||
|
|
34aa927de4 | ||
|
|
491403897f | ||
|
|
3f7e6970ae | ||
|
|
d609f56d42 | ||
|
|
37ed06402d | ||
|
|
e5043bb090 | ||
|
|
d035060e66 | ||
|
|
afb2ce0f1e | ||
|
|
2ffc8b5f56 | ||
|
|
447e9d277d | ||
|
|
d0d9e050d3 | ||
|
|
678dfde14b | ||
|
|
1597853de7 | ||
|
|
7d0f866a47 | ||
|
|
600fd5f04a | ||
|
|
2145f26929 | ||
|
|
36e962a58c | ||
|
|
3ce27e636b | ||
|
|
db2b5ff76b | ||
|
|
06fe2cc687 | ||
|
|
a9033e8c41 | ||
|
|
c3a1e1293f | ||
|
|
1e3e77270d | ||
|
|
138616afdb | ||
|
|
1863a04353 | ||
|
|
8634b05990 | ||
|
|
bf928658ef | ||
|
|
8c58505b3c | ||
|
|
d3151e0cc2 | ||
|
|
2017a3b977 | ||
|
|
a3b56d6218 | ||
|
|
30aee13f1d | ||
|
|
fec26a0044 | ||
|
|
9fd2c8c339 | ||
|
|
3f58a9511b | ||
|
|
2d087e4826 | ||
|
|
d426f98ae2 | ||
|
|
48f02115ed | ||
|
|
06b5147e56 | ||
|
|
9fc6abb08b | ||
|
|
1a9663f21a | ||
|
|
eaca6fe37a | ||
|
|
c52446638a | ||
|
|
e8acedb5c2 | ||
|
|
a0d1a9f0e3 | ||
|
|
4b2cba530a | ||
|
|
4b99e9a701 | ||
|
|
4497f18b31 | ||
|
|
e761e6394a | ||
|
|
bced9e4ecd | ||
|
|
4dd22ecd6a | ||
|
|
ba041dc3bb | ||
|
|
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 | ||
|
|
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 | ||
|
|
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 | ||
|
|
b2d87410fc | ||
|
|
a191dcd2b2 | ||
|
|
1bf526ca7f | ||
|
|
635f14ea0e | ||
|
|
4adf2afffb | ||
|
|
5440872e93 | ||
|
|
ce3a7a7587 | ||
|
|
9f6aa5f2f6 | ||
|
|
c3f5770bb9 | ||
|
|
5a07a7edfe | ||
|
|
3cbe777d44 | ||
|
|
28299018c7 | ||
|
|
7e8c7a27c1 | ||
|
|
08f2880b2a | ||
|
|
03d1f86f4a | ||
|
|
65d3e70f5d | ||
|
|
14428f3233 | ||
|
|
4a697d3689 | ||
|
|
baab15d42d | ||
|
|
71c7dfaf97 | ||
|
|
d12d6262bb | ||
|
|
c36df3986f | ||
|
|
38f887eab2 | ||
|
|
71c1240617 | ||
|
|
14100456d1 | ||
|
|
32109b87b6 | ||
|
|
66ecf65d4e | ||
|
|
1315c0e4db | ||
|
|
4169af3fa6 | ||
|
|
306f9425e8 | ||
|
|
b0047b5518 | ||
|
|
1990e4aecc | ||
|
|
16d77f41d4 | ||
|
|
7f21b95989 | ||
|
|
76123f5638 | ||
|
|
3b8ac89d2c | ||
|
|
f8aa8e0e0d | ||
|
|
6ec1865ac1 | ||
|
|
18cb5eb6d0 | ||
|
|
66931c6958 | ||
|
|
0ba45ef93e | ||
|
|
68e1549ebd | ||
|
|
b2c9470eb7 | ||
|
|
dc8f47b2e8 | ||
|
|
02dc69348d | ||
|
|
65e6b5ce95 | ||
|
|
3db0da6dd2 | ||
|
|
bca4a22e08 | ||
|
|
8bb5a572ec | ||
|
|
91b3a7410e | ||
|
|
b918309242 | ||
|
|
68f5909ef6 | ||
|
|
995a99b984 | ||
|
|
6a5fc04295 | ||
|
|
5dc59396d6 | ||
|
|
fec7a5f9e6 | ||
|
|
41d699a458 | ||
|
|
160e997e4a |
No files matched your search
@@ -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` |
|
||||
|
||||
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",
|
||||
]
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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,21 @@ 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)]
|
||||
|
||||
@@ -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>,
|
||||
@@ -279,10 +279,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 +456,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
@@ -35,8 +35,12 @@ fn entity_shader() -> String {
|
||||
crate::overlay::wgsl_constants() + include_str!("hdr_light.wgsl") + include_str!("entity.wgsl")
|
||||
}
|
||||
|
||||
fn written_lane(layered: bool, format: wgpu::TextureFormat, pack: bool, world: bool) -> bool {
|
||||
layered && format == LINEAR_FRAME && !pack && world
|
||||
fn no_pack_hdr_gain(layered: bool, pack: bool, world: bool) -> bool {
|
||||
layered && !pack && world
|
||||
}
|
||||
|
||||
fn light_marks(format: wgpu::TextureFormat, hdr: bool) -> bool {
|
||||
hdr && format != LINEAR_FRAME
|
||||
}
|
||||
|
||||
fn terrain_shader() -> String {
|
||||
@@ -88,10 +92,19 @@ pub fn outline_params(width: u32, height: u32) -> [f32; 4] {
|
||||
|
||||
/// Camera-relative segment endpoints; no shape expansion in vanilla 26.3.
|
||||
pub fn outline_vertices(edges: &[[f64; 6]], camera: glam::DVec3) -> Vec<[f32; 6]> {
|
||||
edges
|
||||
.iter()
|
||||
.map(|e| std::array::from_fn(|i| (e[i] - camera.to_array()[i % 3]) as f32))
|
||||
.collect()
|
||||
let mut out = Vec::with_capacity(edges.len());
|
||||
outline_vertices_into(edges, camera, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn outline_vertices_into(edges: &[[f64; 6]], camera: glam::DVec3, out: &mut Vec<[f32; 6]>) {
|
||||
let camera = camera.to_array();
|
||||
out.clear();
|
||||
out.extend(
|
||||
edges
|
||||
.iter()
|
||||
.map(|e| std::array::from_fn(|i| (e[i] - camera[i % 3]) as f32)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether the renderer should initialize its occlusion culling pass (`cull`).
|
||||
@@ -440,7 +453,8 @@ struct Globals {
|
||||
/// full (`hdr::Hdr::windows`); zero without HDR.
|
||||
hdr_windows: [[f32; 4]; 2],
|
||||
hdr_light: [f32; 4],
|
||||
/// Headroom, effective scene gain, world/look and debug, from the resolve pass's snapshot.
|
||||
/// Headroom, effective scene gain, world/look and debug, as HDR output's pass has them
|
||||
/// (`Hdr::draw_params`).
|
||||
hdr_settings: [f32; 4],
|
||||
hdr_shares: [f32; 4],
|
||||
}
|
||||
@@ -1389,7 +1403,7 @@ pub struct Renderer {
|
||||
outline: wgpu::Buffer,
|
||||
depth: wgpu::TextureView,
|
||||
sections: resident::Resident<SectionGpu>,
|
||||
lods: HashMap<crate::lod::TileKey, lod::Tile>,
|
||||
lods: FxHashMap<crate::lod::TileKey, lod::Tile>,
|
||||
/// Far tiles' occlusion culling slots (`lod::Tile::cull_slot`): those
|
||||
/// free, and the next never used.
|
||||
lod_cull_free: Vec<u32>,
|
||||
@@ -1504,8 +1518,9 @@ pub struct Renderer {
|
||||
/// The no-pack float HDR lane, with independent blending and an R8 coverage target.
|
||||
hud_coverage: bool,
|
||||
/// What `frame_format` was picked from besides the settings and the pack: whether the
|
||||
/// surface offers HDR output, the device draws the 16-bit frame, and it draws the float one.
|
||||
frame_device: (bool, bool, bool),
|
||||
/// surface offers HDR output, the device draws the 16-bit and float frames, and it supports
|
||||
/// the HUD layer. The layer must follow the pack even when the format stays float.
|
||||
frame_device: (bool, bool, bool, bool),
|
||||
/// Whether a pack picked or dropped since the renderer was made has been told it runs on
|
||||
/// the frame that exists (`set_native_shader_pack`).
|
||||
frame_kept_logged: bool,
|
||||
@@ -1514,7 +1529,6 @@ pub struct Renderer {
|
||||
hdr_marker: Option<std::path::PathBuf>,
|
||||
/// The HDR Lighting setting, kept for the HDR output made when HDR turns on.
|
||||
hdr_lighting_setting: bool,
|
||||
hdr_written: bool,
|
||||
/// The shader pack's post-processing, and the dimension (legacy id) it was loaded for.
|
||||
pack_post: Option<(i32, Option<pack_post::PackPost>)>,
|
||||
/// A `shaders/` pack whose pipelines build on a thread of their own (`load_shader_pack`).
|
||||
@@ -1543,6 +1557,12 @@ pub struct Renderer {
|
||||
pack_terrain_flat: bool,
|
||||
/// OptiFine/Iris separate ambient occlusion: pass vertex alpha to hz_Color.a.
|
||||
pub separate_ao: bool,
|
||||
/// Shader-pack terrain pages used by post processing, retained between frames.
|
||||
pack_pages: Vec<(u32, wgpu::Buffer)>,
|
||||
/// Camera-relative selection outline vertices, retained between frames.
|
||||
outline_edges_scratch: Vec<[f32; 6]>,
|
||||
/// Selection outline line vertices, retained between frames.
|
||||
outline_lines_scratch: Vec<[f32; 8]>,
|
||||
/// Lava's sprite layers, which a pack's solid terrain draws (`pack_atlas::terrain_parts`).
|
||||
pack_lava_layers: Vec<u16>,
|
||||
pub hud: HudRenderer,
|
||||
@@ -2160,6 +2180,38 @@ fn frame_format(
|
||||
}
|
||||
}
|
||||
|
||||
fn frame_configuration(
|
||||
wide_frame: bool,
|
||||
device: (bool, bool, bool, bool),
|
||||
swapchain: wgpu::TextureFormat,
|
||||
linear_pack: bool,
|
||||
hdr_float: bool,
|
||||
) -> (wgpu::TextureFormat, bool) {
|
||||
let (hdr_offered, wide_ok, linear_ok, coverage_ok) = device;
|
||||
let layered = !linear_pack && hdr_float && hdr_offered && linear_ok && coverage_ok;
|
||||
(
|
||||
frame_format(
|
||||
wide_frame,
|
||||
hdr_offered,
|
||||
wide_ok,
|
||||
swapchain,
|
||||
(linear_pack, layered, linear_ok),
|
||||
),
|
||||
layered,
|
||||
)
|
||||
}
|
||||
|
||||
fn frame_follows(
|
||||
current: (wgpu::TextureFormat, bool),
|
||||
wide_frame: bool,
|
||||
device: (bool, bool, bool, bool),
|
||||
swapchain: wgpu::TextureFormat,
|
||||
linear_pack: bool,
|
||||
hdr_float: bool,
|
||||
) -> bool {
|
||||
current == frame_configuration(wide_frame, device, swapchain, linear_pack, hdr_float)
|
||||
}
|
||||
|
||||
/// Opens a device on the first of `backend_order`'s backends that gives one.
|
||||
/// Offscreen rendering needs no surface, so it can use a GPU the window's
|
||||
/// display cannot present from (a headless compositor).
|
||||
@@ -2526,7 +2578,7 @@ impl Renderer {
|
||||
let vertices: u64 = self
|
||||
.sections
|
||||
.values()
|
||||
.chain(self.lods.values().map(|tile| &tile.mesh))
|
||||
.chain(self.lods.values().map(|tile| &*tile.mesh))
|
||||
.map(|s| s.solid_slots + u64::from(s.translucent) * 4)
|
||||
.sum();
|
||||
let translucent = self.translucent_arena.as_ref();
|
||||
@@ -2797,20 +2849,19 @@ impl Renderer {
|
||||
"Horizon renderer: the device can't draw a float frame; the shader API 0.2 pack runs on the 8-bit one"
|
||||
);
|
||||
}
|
||||
let frame_format = frame_format(
|
||||
wide_frame,
|
||||
hdr_output.is_some(),
|
||||
wide_ok,
|
||||
config.format,
|
||||
linear,
|
||||
);
|
||||
let hud_coverage = hud_coverage::supported(
|
||||
linear,
|
||||
hdr_output.is_some(),
|
||||
let coverage_ok = hud_coverage::supported(
|
||||
device.limits().max_color_attachments,
|
||||
adapter.get_downlevel_capabilities().flags,
|
||||
adapter.get_texture_format_features(hud_coverage::FORMAT),
|
||||
);
|
||||
let frame_device = (hdr_output.is_some(), wide_ok, linear.2, coverage_ok);
|
||||
let (frame_format, hud_coverage) = frame_configuration(
|
||||
wide_frame,
|
||||
frame_device,
|
||||
config.format,
|
||||
linear_pack,
|
||||
hdr_float,
|
||||
);
|
||||
if previous.is_none() {
|
||||
startup_step(format_args!("creating the pipelines"));
|
||||
}
|
||||
@@ -2964,12 +3015,9 @@ impl Renderer {
|
||||
blend: Option<wgpu::BlendState>,
|
||||
coverage: bool| {
|
||||
let mut constants = constants.to_vec();
|
||||
let plain = constants
|
||||
.iter()
|
||||
.any(|&(k, v)| k == "HDR_LIGHTING" && v == 0.0);
|
||||
constants.push((
|
||||
"HDR_WRITE_LIGHT",
|
||||
f64::from(u8::from(frame_format == LINEAR_FRAME && !plain)),
|
||||
f64::from(u8::from(frame_format == LINEAR_FRAME)),
|
||||
));
|
||||
let desc = wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
@@ -5057,7 +5105,7 @@ impl Renderer {
|
||||
outline,
|
||||
depth,
|
||||
sections: resident::Resident::default(),
|
||||
lods: HashMap::new(),
|
||||
lods: FxHashMap::default(),
|
||||
lod_cull_free: Vec::new(),
|
||||
lod_cull_next: 0,
|
||||
lod_cutout: Default::default(),
|
||||
@@ -5106,12 +5154,11 @@ impl Renderer {
|
||||
entity_atlas: atlas_view.clone(),
|
||||
hdr: None,
|
||||
hdr_lighting_setting: true,
|
||||
hdr_written: false,
|
||||
emission_stats,
|
||||
hdr_output,
|
||||
frame_format,
|
||||
hud_coverage,
|
||||
frame_device: (hdr_output.is_some(), wide_ok, linear.2),
|
||||
frame_device,
|
||||
frame_kept_logged: false,
|
||||
hdr_marker: None,
|
||||
pack_post: None,
|
||||
@@ -5128,6 +5175,9 @@ impl Renderer {
|
||||
shared_material_maps: [None, None],
|
||||
pack_terrain_flat: false,
|
||||
separate_ao: false,
|
||||
pack_pages: Vec::new(),
|
||||
outline_edges_scratch: Vec::new(),
|
||||
outline_lines_scratch: Vec::new(),
|
||||
pack_lava_layers: Vec::new(),
|
||||
hud,
|
||||
menu_blur: None,
|
||||
@@ -5169,7 +5219,7 @@ impl Renderer {
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(0..quads * 6, (first * 4) as i32, 0..1);
|
||||
if self.hdr.is_some() && !self.hdr_written {
|
||||
if light_marks(self.frame_format, self.hdr.is_some()) {
|
||||
// Drawn into the world: the light sources it holds are marked as the world's are.
|
||||
pass.set_pipeline(&self.hdr_entity_mark_pipeline);
|
||||
pass.draw_indexed(0..quads * 6, (first * 4) as i32, 0..1);
|
||||
@@ -5177,7 +5227,9 @@ impl Renderer {
|
||||
}
|
||||
/// HDR lighting on the clouds just drawn: the sky's value (`fs_hdr_cloud_mark`).
|
||||
fn mark_clouds(&self, pass: &mut wgpu::RenderPass<'_>, quads: u32) {
|
||||
if self.hdr_written || self.hdr.as_ref().is_none_or(|h| !h.lighting) {
|
||||
if !light_marks(self.frame_format, self.hdr.is_some())
|
||||
|| self.hdr.as_ref().is_none_or(|h| !h.lighting)
|
||||
{
|
||||
return;
|
||||
}
|
||||
pass.set_pipeline(&self.hdr_cloud_mark_pipeline);
|
||||
@@ -5196,7 +5248,9 @@ impl Renderer {
|
||||
solid_draws: u32,
|
||||
translucent_draws: u32,
|
||||
) {
|
||||
if self.hdr_written || self.hdr.as_ref().is_none_or(|h| !h.lighting) {
|
||||
if !light_marks(self.frame_format, self.hdr.is_some())
|
||||
|| self.hdr.as_ref().is_none_or(|h| !h.lighting)
|
||||
{
|
||||
return;
|
||||
}
|
||||
match flat {
|
||||
@@ -6862,15 +6916,14 @@ impl Renderer {
|
||||
/// `Renderer::new` takes them). Under `HORIZON_HDR_FRAME=float` the game makes the renderer
|
||||
/// again when it isn't (docs/design/hdr-linear-light.md, "When it changes").
|
||||
pub fn frame_follows(&self, wide_frame: bool, linear_pack: bool, hdr_float: bool) -> bool {
|
||||
let (hdr_offered, wide_ok, linear_ok) = self.frame_device;
|
||||
self.frame_format
|
||||
== frame_format(
|
||||
wide_frame,
|
||||
hdr_offered,
|
||||
wide_ok,
|
||||
self.config.format,
|
||||
(linear_pack, hdr_float, linear_ok),
|
||||
)
|
||||
frame_follows(
|
||||
(self.frame_format, self.hud_coverage),
|
||||
wide_frame,
|
||||
self.frame_device,
|
||||
self.config.format,
|
||||
linear_pack,
|
||||
hdr_float,
|
||||
)
|
||||
}
|
||||
/// HDR output (Horizon setting): HDR10 or scRGB at `paper_white` nits, the world's
|
||||
/// highlights expanded up to `peak` nits. Off where the surface can't present it.
|
||||
@@ -8158,35 +8211,21 @@ impl Renderer {
|
||||
if let Some(hdr) = &mut self.hdr {
|
||||
hdr.lighting = hdr_lighting && hdr.look;
|
||||
}
|
||||
let hdr_written = written_lane(
|
||||
let hdr_gain = no_pack_hdr_gain(
|
||||
self.hdr.as_ref().is_some_and(|h| h.layered()),
|
||||
self.frame_format,
|
||||
self.shader_pack_kind().is_some(),
|
||||
!frame.title,
|
||||
);
|
||||
if self.hdr_written != hdr_written {
|
||||
log::info!(
|
||||
"Horizon HDR frame: {}",
|
||||
if hdr_written {
|
||||
"written light"
|
||||
} else {
|
||||
"marked"
|
||||
}
|
||||
);
|
||||
}
|
||||
self.hdr_written = hdr_written;
|
||||
let (hdr_settings, hdr_shares) = self
|
||||
.hdr
|
||||
.as_ref()
|
||||
.map_or(([0.0; 4], [0.0; 4]), |h| h.draw_params(!frame.title));
|
||||
self.world_post
|
||||
.light_representation(self.hdr_written.then(|| {
|
||||
(
|
||||
hdr_settings,
|
||||
hdr_shares,
|
||||
self.hdr.as_ref().unwrap().windows(),
|
||||
)
|
||||
}));
|
||||
self.world_post.baked_light(
|
||||
hdr_gain,
|
||||
hdr_settings,
|
||||
hdr_shares,
|
||||
self.hdr.as_ref().map_or([[0.0; 4]; 2], hdr::Hdr::windows),
|
||||
);
|
||||
let jitter = self.world_post.jitter(
|
||||
[self.config.width, self.config.height],
|
||||
taa,
|
||||
@@ -8341,7 +8380,7 @@ impl Renderer {
|
||||
},
|
||||
// Only HDR output reads the solid terrain's lighting alpha.
|
||||
if self.hdr.is_some() { 1.0 } else { 0.0 },
|
||||
f32::from(u8::from(self.hdr_written)),
|
||||
f32::from(u8::from(hdr_gain)),
|
||||
],
|
||||
hdr_settings,
|
||||
hdr_shares,
|
||||
@@ -8617,7 +8656,7 @@ impl Renderer {
|
||||
let mut boxed = Vec::new();
|
||||
self.sections
|
||||
.visible(camera, radius, &light, |_| true, |_, _| false, &mut boxed);
|
||||
let seen: std::collections::HashSet<_> =
|
||||
let seen: crate::fast_hash::FxHashSet<_> =
|
||||
shadow_visible.iter().map(|s| s.key).collect();
|
||||
shadow_visible.extend(boxed.into_iter().filter(|s| !seen.contains(&s.key)));
|
||||
});
|
||||
@@ -8739,27 +8778,28 @@ impl Renderer {
|
||||
}
|
||||
// `[displacement]`: the solid quads of the sections it reaches, nearest first.
|
||||
if let Some(d) = self.native.as_mut().and_then(|n| n.displacement.as_mut()) {
|
||||
let near: Vec<displace::Near<'_>> = if frame.title {
|
||||
Vec::new()
|
||||
} else {
|
||||
visible
|
||||
.iter()
|
||||
.filter(|v| {
|
||||
let o = glam::Vec3::from(v.origin);
|
||||
let closest = glam::Vec3::ZERO.clamp(o, o + 16.0);
|
||||
closest.length() < d.distance
|
||||
})
|
||||
.filter_map(|v| {
|
||||
let s = self.sections.get(v.slot);
|
||||
(s.solid > 0).then(|| displace::Near {
|
||||
buffer: self.arena.buffer(s.page),
|
||||
start: s.start,
|
||||
quads: s.solid,
|
||||
origin: v.origin,
|
||||
let near: smallvec::SmallVec<[displace::Near<'_>; displace::MAX_SECTIONS]> =
|
||||
if frame.title {
|
||||
smallvec::SmallVec::new()
|
||||
} else {
|
||||
visible
|
||||
.iter()
|
||||
.filter(|v| {
|
||||
let o = glam::Vec3::from(v.origin);
|
||||
let closest = glam::Vec3::ZERO.clamp(o, o + 16.0);
|
||||
closest.length() < d.distance
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
.filter_map(|v| {
|
||||
let s = self.sections.get(v.slot);
|
||||
(s.solid > 0).then(|| displace::Near {
|
||||
buffer: self.arena.buffer(s.page),
|
||||
start: s.start,
|
||||
quads: s.solid,
|
||||
origin: v.origin,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
d.prepare(&self.queue, &mut encoder, &near, view_proj);
|
||||
}
|
||||
// Solid sections front to back, then translucent ones back to front,
|
||||
@@ -9239,28 +9279,34 @@ impl Renderer {
|
||||
if outline_edges > 0 {
|
||||
// The same per-line colour and pixel width layout as the gizmo lines. A high
|
||||
// contrast outline is two strokes: the black one first, the bright one after it.
|
||||
let edges = outline_vertices(&frame.outline[..outline_edges], camera);
|
||||
outline_vertices_into(
|
||||
&frame.outline[..outline_edges],
|
||||
camera,
|
||||
&mut self.outline_edges_scratch,
|
||||
);
|
||||
let width = outline_width(self.config.width);
|
||||
let stroke = |color: u32, width: f32| {
|
||||
edges.iter().map(move |e| {
|
||||
let edges = &self.outline_edges_scratch;
|
||||
let lines = &mut self.outline_lines_scratch;
|
||||
lines.clear();
|
||||
let stroke = |color: u32, width: f32, lines: &mut Vec<[f32; 8]>| {
|
||||
for e in edges {
|
||||
let mut line = [0.0; 8];
|
||||
line[..6].copy_from_slice(e);
|
||||
line[6] = f32::from_bits(color);
|
||||
line[7] = width;
|
||||
line
|
||||
})
|
||||
lines.push(line);
|
||||
}
|
||||
};
|
||||
// A custom outline (PvP & HUD) takes the bright stroke's colour, scales both widths.
|
||||
let (custom, scale) = frame.pvp.outline.map_or((None, 1.0), |(c, s)| (Some(c), s));
|
||||
let lines: Vec<[f32; 8]> = if frame.outline_high_contrast {
|
||||
stroke(0xff00_0000, 7.0 * scale)
|
||||
.chain(stroke(custom.unwrap_or(0xff57_ffe1), width * scale))
|
||||
.collect()
|
||||
if frame.outline_high_contrast {
|
||||
stroke(0xff00_0000, 7.0 * scale, lines);
|
||||
stroke(custom.unwrap_or(0xff57_ffe1), width * scale, lines);
|
||||
} else {
|
||||
stroke(custom.unwrap_or(0x6600_0000), width * scale).collect()
|
||||
};
|
||||
stroke(custom.unwrap_or(0x6600_0000), width * scale, lines);
|
||||
}
|
||||
self.uploads
|
||||
.buffer(&mut encoder, &self.outline, 0, bytemuck::cast_slice(&lines));
|
||||
.buffer(&mut encoder, &self.outline, 0, bytemuck::cast_slice(lines));
|
||||
if let Some(core) = &self.core_lines {
|
||||
let size = [self.config.width, self.config.height];
|
||||
let block = crate::post_chain::Globals {
|
||||
@@ -10811,7 +10857,7 @@ impl Renderer {
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
|
||||
if self.hdr.is_some() && !self.hdr_written {
|
||||
if light_marks(self.frame_format, self.hdr.is_some()) {
|
||||
pass.set_pipeline(&self.hdr_entity_mark_pipeline);
|
||||
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
|
||||
}
|
||||
@@ -10850,7 +10896,7 @@ impl Renderer {
|
||||
((base_quads - translucent_item_quads) * 4) as i32,
|
||||
0..1,
|
||||
);
|
||||
if self.hdr.is_some() && !self.hdr_written {
|
||||
if light_marks(self.frame_format, self.hdr.is_some()) {
|
||||
// A glowing item in the world (Item sheet quads), as entities are.
|
||||
pass.set_pipeline(&self.hdr_mark_pipeline);
|
||||
pass.draw_indexed(
|
||||
@@ -10906,7 +10952,7 @@ impl Renderer {
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
// Cracks multiply the existing light; scalar OIT coverage cannot carry that factor.
|
||||
if crumbling_quads > 0 && (!oit_on || self.hdr_written) {
|
||||
if crumbling_quads > 0 && (!oit_on || hdr_gain) {
|
||||
pass.set_pipeline(&self.crumbling_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
@@ -10935,7 +10981,7 @@ impl Renderer {
|
||||
pass.set_pipeline(&self.lightning_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
self.lightning.draw(&mut pass, &frame.lightning);
|
||||
if self.hdr.is_some() && !self.hdr_written {
|
||||
if light_marks(self.frame_format, self.hdr.is_some()) {
|
||||
pass.set_pipeline(&self.hdr_mark_lightning_pipeline);
|
||||
self.lightning.draw(&mut pass, &frame.lightning);
|
||||
}
|
||||
@@ -10975,7 +11021,7 @@ impl Renderer {
|
||||
pass.draw_indexed(first * 6..end * 6, (world_quads * 4) as i32, 0..1);
|
||||
first = end;
|
||||
}
|
||||
if self.hdr.is_some() && !self.hdr_written {
|
||||
if light_marks(self.frame_format, self.hdr.is_some()) {
|
||||
// The dragon's eyes and death rays, marked as entities are.
|
||||
pass.set_pipeline(&self.hdr_mark_pipeline);
|
||||
pass.draw_indexed(0..additive_quads * 6, (world_quads * 4) as i32, 0..1);
|
||||
@@ -11082,7 +11128,7 @@ impl Renderer {
|
||||
}
|
||||
};
|
||||
quads(pass, &f.text[s], text_quads, entity_quads);
|
||||
if !self.hdr_written {
|
||||
if !hdr_gain {
|
||||
quads(
|
||||
pass,
|
||||
&f.crumbling[s],
|
||||
@@ -11206,7 +11252,7 @@ impl Renderer {
|
||||
// HDR output's mark on what shines in the stages, as the classic
|
||||
// pass marks it after drawing it: eyes, the dragon's eyes and rays,
|
||||
// firework sparks and lightning.
|
||||
if self.hdr.is_some() && !self.hdr_written {
|
||||
if light_marks(self.frame_format, self.hdr.is_some()) {
|
||||
pass.set_pipeline(&self.hdr_mark_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
@@ -11275,7 +11321,7 @@ impl Renderer {
|
||||
((base_quads - translucent_item_quads - translucent_particle_quads) * 4) as i32,
|
||||
0..1,
|
||||
);
|
||||
if self.hdr.is_some() && !self.hdr_written {
|
||||
if light_marks(self.frame_format, self.hdr.is_some()) {
|
||||
// Fireworks: their sparks are translucent-layer particles.
|
||||
pass.set_pipeline(&self.hdr_entity_mark_pipeline);
|
||||
pass.draw_indexed(
|
||||
@@ -11315,7 +11361,7 @@ impl Renderer {
|
||||
if outline_edges > 0 && frame.outline_after_terrain && !pack_draws_outline {
|
||||
self.draw_outline(&mut pass, outline_edges as u32, frame.outline_high_contrast);
|
||||
}
|
||||
if translucent_crumbling_quads > 0 && (!oit_on || self.hdr_written) {
|
||||
if translucent_crumbling_quads > 0 && (!oit_on || hdr_gain) {
|
||||
pass.set_pipeline(&self.crumbling_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
@@ -11403,14 +11449,12 @@ impl Renderer {
|
||||
};
|
||||
post.set_weather(weather);
|
||||
let flat = self.flat.as_ref().filter(|_| flat_frame);
|
||||
let pages: Vec<(u32, wgpu::Buffer)> = match flat {
|
||||
Some(_) if pack_draws_terrain || pack_draws_shadow => self
|
||||
.arena
|
||||
.page_buffers()
|
||||
.map(|(p, b)| (p, b.clone()))
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
self.pack_pages.clear();
|
||||
if flat.is_some() && (pack_draws_terrain || pack_draws_shadow) {
|
||||
self.pack_pages
|
||||
.extend(self.arena.page_buffers().map(|(p, b)| (p, b.clone())));
|
||||
}
|
||||
let pages = &self.pack_pages;
|
||||
let mut terrain =
|
||||
|rp: &mut wgpu::RenderPass<'_>,
|
||||
list: pack_post::TerrainList,
|
||||
@@ -11497,9 +11541,12 @@ impl Renderer {
|
||||
&frame.pack,
|
||||
size,
|
||||
[self.config.width, self.config.height],
|
||||
&pages,
|
||||
pages,
|
||||
&mut terrain,
|
||||
);
|
||||
// Keep the capacity, not the buffers: a page the arena frees must not stay alive
|
||||
// here until the next shader-pack frame (or forever once the pack is off).
|
||||
self.pack_pages.clear();
|
||||
self.pack_post = Some((dimension, Some(post)));
|
||||
} else if let Some((color, depth)) = &scaled
|
||||
&& native_post
|
||||
@@ -11659,6 +11706,7 @@ impl Renderer {
|
||||
}
|
||||
// A 0.2 pack is shown at paper white: HDR output lifts nothing of its world.
|
||||
let lifts_world = !frame.title
|
||||
&& self.frame_format != LINEAR_FRAME
|
||||
&& self
|
||||
.native
|
||||
.as_ref()
|
||||
@@ -11913,17 +11961,13 @@ impl Renderer {
|
||||
pass.draw_indexed(0..overlay.hand_quads * 6, overlay.first_hand_vertex, 0..1);
|
||||
drop(pass);
|
||||
if let Some(hdr) = &self.hdr
|
||||
&& !self.hdr_written
|
||||
&& !no_pack_hdr_gain(layered, self.shader_pack_kind().is_some(), !frame.title)
|
||||
{
|
||||
hdr.mark_hand(encoder, timing.as_mut());
|
||||
// On the layered frame the hand's marks go to the frame, before they become
|
||||
// light with the world's.
|
||||
let marks = if layered {
|
||||
Some(view_target.clone())
|
||||
} else {
|
||||
hdr.hand_light_view()
|
||||
};
|
||||
if let Some(view) = marks {
|
||||
// The non-layered 0.2 hand keeps its UI-brightness sentinel, not light marks.
|
||||
if light_marks(self.frame_format, true)
|
||||
&& let Some(view) = hdr.hand_light_view()
|
||||
{
|
||||
// The light sources in the hand may shine, the rest of it stays at the
|
||||
// world's brightness: nothing the world kept here shows through it.
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
@@ -11960,15 +12004,6 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
}
|
||||
// The layered frame's marks become light: what draws from here draws on light, and
|
||||
// nothing is lifted after.
|
||||
if layered
|
||||
&& !self.hdr_written
|
||||
&& !frame.title
|
||||
&& let Some(hdr) = &self.hdr
|
||||
{
|
||||
hdr.resolve(encoder, timing.as_mut());
|
||||
}
|
||||
// A native mod's turn after the hand, on the real frame only.
|
||||
if overlay.real && !frame.title {
|
||||
native_render::native_hand_drawn(&mut native_render::HandDrawn {
|
||||
@@ -12678,11 +12713,8 @@ impl Frustum {
|
||||
/// Whether the camera-relative box `min..max` is at least partly inside.
|
||||
pub fn intersects(&self, min: Vec3, max: Vec3) -> bool {
|
||||
!self.planes.iter().any(|p| {
|
||||
let v = Vec3::new(
|
||||
if p.x >= 0.0 { max.x } else { min.x },
|
||||
if p.y >= 0.0 { max.y } else { min.y },
|
||||
if p.z >= 0.0 { max.z } else { min.z },
|
||||
);
|
||||
let sign = p.truncate().cmpge(Vec3::ZERO);
|
||||
let v = Vec3::select(sign, max, min);
|
||||
p.truncate().dot(v) + p.w < 0.0
|
||||
})
|
||||
}
|
||||
@@ -12705,11 +12737,8 @@ impl Frustum {
|
||||
return true;
|
||||
}
|
||||
!self.planes[4..].iter().any(|p| {
|
||||
let v = Vec3::new(
|
||||
if p.x >= 0.0 { max.x } else { min.x },
|
||||
if p.y >= 0.0 { max.y } else { min.y },
|
||||
if p.z >= 0.0 { max.z } else { min.z },
|
||||
);
|
||||
let sign = p.truncate().cmpge(Vec3::ZERO);
|
||||
let v = Vec3::select(sign, max, min);
|
||||
p.truncate().dot(v) + p.w < 0.0
|
||||
})
|
||||
}
|
||||
@@ -12719,11 +12748,8 @@ impl Frustum {
|
||||
/// rounding keeps that order.
|
||||
pub fn contains(&self, min: Vec3, max: Vec3) -> bool {
|
||||
self.planes.iter().all(|p| {
|
||||
let v = Vec3::new(
|
||||
if p.x >= 0.0 { min.x } else { max.x },
|
||||
if p.y >= 0.0 { min.y } else { max.y },
|
||||
if p.z >= 0.0 { min.z } else { max.z },
|
||||
);
|
||||
let sign = p.truncate().cmpge(Vec3::ZERO);
|
||||
let v = Vec3::select(sign, min, max);
|
||||
p.truncate().dot(v) + p.w >= 0.0
|
||||
})
|
||||
}
|
||||
@@ -12930,27 +12956,142 @@ fn frustum(m: Mat4) -> [Vec4; 6] {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn written_light_requires_the_entire_default_layered_world_lane() {
|
||||
use super::{LINEAR_FRAME, WIDE_FRAME, written_lane};
|
||||
assert!(written_lane(true, LINEAR_FRAME, false, true));
|
||||
for bits in 0..8 {
|
||||
let layered = bits & 1 != 0;
|
||||
let pack = bits & 2 != 0;
|
||||
let world = bits & 4 != 0;
|
||||
assert_eq!(
|
||||
written_lane(layered, LINEAR_FRAME, pack, world),
|
||||
layered && !pack && world
|
||||
);
|
||||
assert!(!written_lane(layered, WIDE_FRAME, pack, world));
|
||||
assert!(!written_lane(
|
||||
layered,
|
||||
wgpu::TextureFormat::Bgra8Unorm,
|
||||
pack,
|
||||
world,
|
||||
));
|
||||
fn float_frames_never_schedule_light_mark_passes() {
|
||||
use super::{LINEAR_FRAME, WIDE_FRAME, light_marks};
|
||||
use wgpu::TextureFormat::{Bgra8Unorm, Rgb10a2Unorm, Rgba8Unorm};
|
||||
for hdr in [false, true] {
|
||||
assert!(!light_marks(LINEAR_FRAME, hdr));
|
||||
for format in [WIDE_FRAME, Bgra8Unorm, Rgba8Unorm, Rgb10a2Unorm] {
|
||||
assert_eq!(light_marks(format, hdr), hdr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_policy_keeps_compatibility_frames_and_requires_layering_for_float_hdr() {
|
||||
use super::{LINEAR_FRAME, WIDE_FRAME, frame_configuration};
|
||||
use wgpu::TextureFormat::{Bgra8Unorm, Bgra8UnormSrgb};
|
||||
|
||||
for bits in 0..128 {
|
||||
let hdr = bits & 1 != 0;
|
||||
let offered = bits & 2 != 0;
|
||||
let wide_ok = bits & 4 != 0;
|
||||
let float_ok = bits & 8 != 0;
|
||||
let coverage_ok = bits & 16 != 0;
|
||||
let switch = bits & 32 != 0;
|
||||
let smooth = bits & 64 != 0;
|
||||
for pack in ["none", "0.1", "0.2", "shaders"] {
|
||||
let expected = if switch && float_ok && pack == "0.2" {
|
||||
(LINEAR_FRAME, false)
|
||||
} else if switch && float_ok && pack == "none" && hdr && offered && coverage_ok {
|
||||
(LINEAR_FRAME, true)
|
||||
} else if hdr && smooth && offered && wide_ok {
|
||||
(WIDE_FRAME, false)
|
||||
} else {
|
||||
(Bgra8Unorm, false)
|
||||
};
|
||||
assert_eq!(
|
||||
frame_configuration(
|
||||
hdr && smooth,
|
||||
(offered, wide_ok, float_ok, coverage_ok),
|
||||
Bgra8UnormSrgb,
|
||||
switch && pack == "0.2",
|
||||
switch && hdr && pack == "none",
|
||||
),
|
||||
expected,
|
||||
"bits {bits}, pack {pack}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_hdr_falls_back_for_each_missing_hud_layer_requirement() {
|
||||
use super::{LINEAR_FRAME, WIDE_FRAME, frame_configuration, hud_coverage};
|
||||
use wgpu::{DownlevelFlags as D, TextureFormat as F, TextureFormatFeatureFlags as Fl};
|
||||
use wgpu::{TextureFormatFeatures, TextureUsages as U};
|
||||
|
||||
let usages = U::RENDER_ATTACHMENT | U::TEXTURE_BINDING;
|
||||
for (attachments, flags, allowed_usages, format_flags) in [
|
||||
(1, D::INDEPENDENT_BLEND, usages, Fl::BLENDABLE),
|
||||
(2, D::empty(), usages, Fl::BLENDABLE),
|
||||
(2, D::INDEPENDENT_BLEND, U::TEXTURE_BINDING, Fl::BLENDABLE),
|
||||
(2, D::INDEPENDENT_BLEND, U::RENDER_ATTACHMENT, Fl::BLENDABLE),
|
||||
(2, D::INDEPENDENT_BLEND, usages, Fl::empty()),
|
||||
] {
|
||||
let coverage = hud_coverage::supported(
|
||||
attachments,
|
||||
flags,
|
||||
TextureFormatFeatures {
|
||||
allowed_usages,
|
||||
flags: format_flags,
|
||||
},
|
||||
);
|
||||
let device = (true, true, true, coverage);
|
||||
assert_eq!(
|
||||
frame_configuration(true, device, F::Bgra8Unorm, false, true),
|
||||
(WIDE_FRAME, false)
|
||||
);
|
||||
assert_eq!(
|
||||
frame_configuration(false, device, F::Bgra8Unorm, false, true),
|
||||
(F::Bgra8Unorm, false)
|
||||
);
|
||||
assert_eq!(
|
||||
frame_configuration(true, device, F::Bgra8Unorm, true, false),
|
||||
(LINEAR_FRAME, false)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_follows_requires_a_rebuild_when_only_layering_changes() {
|
||||
use super::{LINEAR_FRAME, frame_follows};
|
||||
use wgpu::TextureFormat::Bgra8Unorm;
|
||||
|
||||
let device = (true, true, true, true);
|
||||
assert!(frame_follows(
|
||||
(LINEAR_FRAME, true),
|
||||
true,
|
||||
device,
|
||||
Bgra8Unorm,
|
||||
false,
|
||||
true
|
||||
));
|
||||
assert!(!frame_follows(
|
||||
(LINEAR_FRAME, true),
|
||||
true,
|
||||
device,
|
||||
Bgra8Unorm,
|
||||
true,
|
||||
false
|
||||
));
|
||||
assert!(frame_follows(
|
||||
(LINEAR_FRAME, false),
|
||||
true,
|
||||
device,
|
||||
Bgra8Unorm,
|
||||
true,
|
||||
false
|
||||
));
|
||||
assert!(!frame_follows(
|
||||
(LINEAR_FRAME, false),
|
||||
true,
|
||||
device,
|
||||
Bgra8Unorm,
|
||||
false,
|
||||
true
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_no_pack_layered_world_applies_hdr_gain() {
|
||||
use super::no_pack_hdr_gain;
|
||||
assert!(no_pack_hdr_gain(true, false, true));
|
||||
assert!(!no_pack_hdr_gain(false, false, true));
|
||||
assert!(!no_pack_hdr_gain(true, true, true));
|
||||
assert!(!no_pack_hdr_gain(true, false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn taa_culling_covers_jittered_view_edges_but_still_rejects_behind_the_camera() {
|
||||
use super::*;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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)> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -571,6 +571,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 +1416,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()
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,7 @@ 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;
|
||||
pub use native::{ParticleController, ParticleUpdate};
|
||||
|
||||
/// `ParticleOptions` with the parameters of the option classes used here.
|
||||
@@ -64,6 +65,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 +83,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 +526,8 @@ 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>>,
|
||||
}
|
||||
fn aabb_at(p: [f64; 3], w: f32, h: f32) -> Aabb {
|
||||
let w = f64::from(w) / 2.0;
|
||||
@@ -883,6 +889,7 @@ impl Particle {
|
||||
frame_ticks: 0,
|
||||
random: LegacyRandom::new(0),
|
||||
controller: None,
|
||||
polytone_type: None,
|
||||
}
|
||||
}
|
||||
/// The normal age/removal test and previous pose, without vanilla motion.
|
||||
@@ -1277,6 +1284,8 @@ 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>>,
|
||||
/// `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 +1478,7 @@ impl ParticleEngine {
|
||||
sets: HashMap::new(),
|
||||
mod_sets: HashMap::new(),
|
||||
set_sprites: Vec::new(),
|
||||
polytone: None,
|
||||
emitters: Vec::new(),
|
||||
starters: Vec::new(),
|
||||
tracked_explosions: Vec::new(),
|
||||
@@ -1689,6 +1699,10 @@ 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>>) {
|
||||
self.polytone = registry.filter(|r| !r.is_empty());
|
||||
}
|
||||
/// 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 +2106,12 @@ 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)
|
||||
{
|
||||
return polytone::make(self, ®istry.clone(), name, &def.clone(), o, p, v, seed);
|
||||
}
|
||||
if let Some(name) = &o.mod_particle {
|
||||
let (set, behaviour) = *self.mod_sets.get(name)?;
|
||||
let mut r = LegacyRandom::new(seed);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
|
||||
@@ -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]));
|
||||
|
||||
@@ -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>,
|
||||
@@ -762,11 +762,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 +918,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 +1053,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);
|
||||
});
|
||||
|
||||
@@ -21,6 +21,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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`:
|
||||
@@ -1074,6 +1167,79 @@ impl Game<'_> {
|
||||
self.entity_killed_event(at, false);
|
||||
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
|
||||
}
|
||||
/// `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 +2283,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))
|
||||
});
|
||||
|
||||
@@ -38,8 +38,8 @@ impl Game<'_> {
|
||||
self.forget_gone_hoppers();
|
||||
}
|
||||
let ticking = self.block_ticking_area();
|
||||
let mut spawners = Vec::new();
|
||||
let mut trial_spawners = Vec::new();
|
||||
self.spawner_tick_positions.clear();
|
||||
self.trial_spawner_tick_positions.clear();
|
||||
self.item_columns = Some(Default::default());
|
||||
self.pass_mobs = Some(Default::default());
|
||||
for slot in self.world.block_entity_tickers().iter().copied() {
|
||||
@@ -62,11 +62,11 @@ impl Game<'_> {
|
||||
Some(BlockEntity::CreakingHeart(_)) => self.tick_creaking_heart(pos)?,
|
||||
Some(BlockEntity::Spawner(_)) => {
|
||||
self.tick_spawner(pos)?;
|
||||
spawners.push(pos);
|
||||
self.spawner_tick_positions.push(pos);
|
||||
}
|
||||
Some(BlockEntity::TrialSpawner(_)) => {
|
||||
self.tick_trial_spawner(pos)?;
|
||||
trial_spawners.push(pos);
|
||||
self.trial_spawner_tick_positions.push(pos);
|
||||
}
|
||||
Some(BlockEntity::Vault(_)) => self.tick_vault(pos)?,
|
||||
Some(BlockEntity::Beehive(_)) => self.tick_beehive(pos),
|
||||
@@ -94,10 +94,14 @@ impl Game<'_> {
|
||||
// The client ticks (particles, sounds, the turning mob) are for a
|
||||
// window; a dedicated or integrated server has none.
|
||||
if self.options.dedicated.is_none() {
|
||||
let spawners = std::mem::take(&mut self.spawner_tick_positions);
|
||||
self.client_tick_spawners(&spawners);
|
||||
for key in trial_spawners {
|
||||
self.spawner_tick_positions = spawners;
|
||||
let trial_spawners = std::mem::take(&mut self.trial_spawner_tick_positions);
|
||||
for key in trial_spawners.iter().copied() {
|
||||
self.client_tick_trial_spawner(key);
|
||||
}
|
||||
self.trial_spawner_tick_positions = trial_spawners;
|
||||
}
|
||||
// `ShulkerBoxBlockEntity.tick`: the lids of the boxes this level
|
||||
// animates. Their grown collision shape reaches the collision queries
|
||||
|
||||
@@ -70,11 +70,18 @@ impl Game<'_> {
|
||||
/// acting as it) and the server's other players.
|
||||
/// Starts or ends a stretch of vehicle ticks (`tick_boats`, `tick_carts`): while one runs,
|
||||
/// a vehicle's box query tests the mobs' positions against their [`BoxReach`] first.
|
||||
/// Nothing a vehicle does resizes a mob, and a mob joining or leaving works it out again.
|
||||
/// A mob joining or leaving works it out again, and so does [`Self::vehicle_picked_up`].
|
||||
pub(super) fn vehicles_ticking(&self, on: bool) {
|
||||
self.vehicle_mob_reach
|
||||
.set(on.then(|| (self.mobs.len(), BoxReach::of(&self.mobs))));
|
||||
}
|
||||
/// A vehicle picked a mob up: a shulker turns its lid up (`Shulker.startRiding`), which
|
||||
/// changes its box, so the next query works the [`BoxReach`] out again.
|
||||
pub(super) fn vehicle_picked_up(&self) {
|
||||
if let Some((_, reach)) = self.vehicle_mob_reach.get() {
|
||||
self.vehicle_mob_reach.set(Some((usize::MAX, reach)));
|
||||
}
|
||||
}
|
||||
/// The mobs' [`BoxReach`] while vehicles tick, none otherwise.
|
||||
pub(super) fn vehicle_mob_reach(&self) -> Option<BoxReach> {
|
||||
let (count, reach) = self.vehicle_mob_reach.get()?;
|
||||
@@ -124,7 +131,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// `getRootVehicle` of mob `j`: the mob or boat at the bottom of what it
|
||||
/// rides, None when it rides nothing.
|
||||
fn mob_root_vehicle(&self, mut j: usize) -> Option<u32> {
|
||||
pub(super) fn mob_root_vehicle(&self, mut j: usize) -> Option<u32> {
|
||||
let mut root = None;
|
||||
for _ in 0..8 {
|
||||
let Some(v) = self.mobs[j].extra.mount else {
|
||||
@@ -305,6 +312,7 @@ impl Game<'_> {
|
||||
&& self.native_mob_may_ride(mob, id, true)
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob)
|
||||
{
|
||||
horizon_client::mobs::shulker::on_start_riding(m);
|
||||
m.extra.mount = Some(id);
|
||||
m.extra.passenger = true;
|
||||
m.ai.nav.stop();
|
||||
@@ -312,6 +320,7 @@ impl Game<'_> {
|
||||
// Mob.startRiding drops its lead.
|
||||
self.break_leash(mob, true, false);
|
||||
self.note_started_riding(id);
|
||||
self.vehicle_picked_up();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ use horizon_client::{
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, ShapeOp, Vec3, VoxelShape},
|
||||
position::BlockPos,
|
||||
random::RandomSource,
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
use horizon_world::collision_context::CollisionContext;
|
||||
use horizon_world::nbt::Tag;
|
||||
@@ -109,6 +109,7 @@ pub(super) struct Boat {
|
||||
pub leash_spin: f64,
|
||||
/// `Entity.tickCount`, for the 100 ticks a saved holder is waited for.
|
||||
pub age: i32,
|
||||
pub random: LegacyRandom,
|
||||
}
|
||||
/// `ChestBoat.CONTAINER_SIZE`.
|
||||
pub(super) const CHEST_SLOTS: usize = 27;
|
||||
@@ -220,6 +221,7 @@ impl Boat {
|
||||
leash_pending: None,
|
||||
leash_spin: 0.0,
|
||||
age: 0,
|
||||
random: horizon_client::random_source::entity(),
|
||||
}
|
||||
}
|
||||
/// A chest boat or chest raft.
|
||||
@@ -823,15 +825,11 @@ impl Game<'_> {
|
||||
None,
|
||||
);
|
||||
b.velocity = horizon_world::entity_move::scale_by_block_speed(b.velocity, factor);
|
||||
Self::tick_boat_paddles(&mut b, &mut self.audio, &mut self.world.random);
|
||||
Self::tick_boat_paddles(&mut b, &mut self.audio);
|
||||
b
|
||||
}
|
||||
/// `AbstractBoat.tick` advances the oars even when this client does not move the hull.
|
||||
pub(super) fn tick_boat_paddles(
|
||||
b: &mut Boat,
|
||||
audio: &mut super::Audio,
|
||||
random: &mut horizon_core::random::LegacyRandom,
|
||||
) {
|
||||
pub(super) fn tick_boat_paddles(b: &mut Boat, audio: &mut super::Audio) {
|
||||
// Paddle sounds at the top of each stroke.
|
||||
for i in 0..2 {
|
||||
if b.paddle_state(i) {
|
||||
@@ -847,7 +845,7 @@ impl Game<'_> {
|
||||
if let Some(sound) = sound {
|
||||
let v = view_vector(b.yaw, 0.0);
|
||||
let (dx, dz) = if i == 1 { (-v[2], v[0]) } else { (v[2], -v[0]) };
|
||||
let pitch = 0.8 + 0.4 * random.next_float();
|
||||
let pitch = 0.8 + 0.4 * b.random.next_float();
|
||||
audio.play(
|
||||
sound,
|
||||
Some([b.position.x + dx, b.position.y, b.position.z + dz]),
|
||||
@@ -1687,18 +1685,17 @@ impl Game<'_> {
|
||||
let id = self.boats[i].id;
|
||||
self.release_vehicle_container(id);
|
||||
let b = self.boats.remove(i);
|
||||
// AbstractBoat.remove: a destroyed boat drops its lead.
|
||||
self.boat_removed(&b);
|
||||
// `AbstractChestBoat.remove` spills the chest through
|
||||
// `Containers.dropContents` for every destroying removal reason;
|
||||
// that has no `entity_drops` gate of its own.
|
||||
for stack in b.items.iter().flatten() {
|
||||
self.spawn_item_own(stack.clone(), at, 10);
|
||||
}
|
||||
// `Containers.dropContents` for every destroying removal reason
|
||||
// (the kill below, and a creative hit's discard); that has no
|
||||
// `entity_drops` gate of its own. A plain boat has no slots.
|
||||
self.drop_vehicle_contents(at, &b.items);
|
||||
// AbstractBoat.remove, after that: a destroyed boat drops its lead.
|
||||
self.boat_removed(&b);
|
||||
// `VehicleEntity.destroy(level, dropItem)`: `kill` (the boat's own
|
||||
// ENTITY_DIE), then the boat item on the death path only, and only
|
||||
// with `entity_drops` on, by `spawnAtLocation` (an ItemEntity's own
|
||||
// random motion).
|
||||
// ENTITY_DIE after that removal), then the boat item on the death
|
||||
// path only, and only with `entity_drops` on, by `spawnAtLocation`
|
||||
// (an ItemEntity's own random motion).
|
||||
if !creative {
|
||||
self.entity_killed_event(at, false);
|
||||
}
|
||||
@@ -1714,6 +1711,12 @@ impl Game<'_> {
|
||||
at,
|
||||
10,
|
||||
);
|
||||
// AbstractChestBoat.destroy: `chestVehicleDestroyed` drops the
|
||||
// emptied chest again, which only draws each slot's spot.
|
||||
if b.chest {
|
||||
let empty = vec![None; b.items.len()];
|
||||
self.drop_vehicle_contents(at, &empty);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(destroyed)
|
||||
|
||||
@@ -252,6 +252,9 @@ pub struct UiState {
|
||||
pub show_hand_when_hidden: bool,
|
||||
/// Death message key set by the cause of death (e.g. /kill).
|
||||
pub death_cause: Option<String>,
|
||||
/// What this frame's HUD shows that Horizon's overlays keep clear of (`Game::hud_room`),
|
||||
/// set by `hud_layers` before anything draws.
|
||||
pub room: horizon_client::hud_kit::room::Room,
|
||||
/// Path of an F2 screenshot waiting for its frame.
|
||||
pub screenshot_message: Option<std::path::PathBuf>,
|
||||
/// The options screens.
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
//! `/clone from <sourceDimension>` / `to <targetDimension>`: each name is
|
||||
//! resolved (`DimensionArgument.getDimension`) as its position is read, so an
|
||||
//! unknown one fails the command with `argument.dimension.invalid` before
|
||||
//! anything is cloned (tools/test_clone_dimensions_parity.py measures the
|
||||
//! same on vanilla 26.3).
|
||||
use super::*;
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_core::position::ChunkPos;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn game() -> Game<'static> {
|
||||
let root = std::path::PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES")
|
||||
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
|
||||
);
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
|
||||
let res: &'static _ = Box::leak(Box::new(loaded.resources));
|
||||
let mut game = Game::new(
|
||||
res,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
game.world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
assert!(game.run_command("fill 0 99 0 15 99 15 stone"));
|
||||
assert!(game.run_command("setblock 0 100 0 chest{CustomName:'\"h236\"'}"));
|
||||
game
|
||||
}
|
||||
|
||||
/// Runs `command`: whether it succeeded and the key of the error it raised.
|
||||
fn run(game: &mut Game<'_>, command: &str) -> (bool, Option<String>) {
|
||||
game.chat_capture = Some(Vec::new());
|
||||
let ok = game.run_command(command);
|
||||
let said = game.chat_capture.take().unwrap_or_default();
|
||||
(ok, said.first().map(horizon_client::text::Text::plain))
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn an_unknown_clone_dimension_fails_and_clones_nothing() {
|
||||
let mut game = game();
|
||||
for (command, x, unknown) in [
|
||||
(
|
||||
"clone from h236:none 0 100 0 0 100 0 4 100 0",
|
||||
4,
|
||||
"h236:none",
|
||||
),
|
||||
("clone 0 100 0 0 100 0 to h236:none 6 100 0", 6, "h236:none"),
|
||||
(
|
||||
"clone from h236:first 0 100 0 0 100 0 to h236:second 8 100 0",
|
||||
8,
|
||||
"h236:first",
|
||||
),
|
||||
] {
|
||||
assert!(game.run_command(&format!("setblock {x} 100 0 gold_block")));
|
||||
assert_eq!(
|
||||
run(&mut game, command),
|
||||
(false, Some(format!("Unknown dimension '{unknown}'"))),
|
||||
"{command}"
|
||||
);
|
||||
assert!(
|
||||
run(&mut game, &format!("execute if block {x} 100 0 gold_block")).0,
|
||||
"{command}: the destination stays"
|
||||
);
|
||||
}
|
||||
// The same level, named or not, still clones.
|
||||
assert!(run(&mut game, "clone 0 100 0 0 100 0 10 100 0").0);
|
||||
assert!(
|
||||
run(
|
||||
&mut game,
|
||||
"clone from minecraft:overworld 0 100 0 0 100 0 to minecraft:overworld 12 100 0"
|
||||
)
|
||||
.0
|
||||
);
|
||||
assert!(run(&mut game, "execute if block 12 100 0 chest").0);
|
||||
}
|
||||
|
||||
/// Opens `dim`'s level through the same adapter the clone uses and gives it
|
||||
/// the empty chunk at the origin, then returns to `back`.
|
||||
fn load_chunk_in(game: &mut Game<'_>, dim: Dim, back: Dim) {
|
||||
game.enter_command_level(dim);
|
||||
assert_eq!(game.dim, dim, "the level is in place");
|
||||
game.world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
let _ = game.block_ticks.add_empty_chunk(ChunkPos::new(0, 0));
|
||||
game.enter_command_level(back);
|
||||
assert_eq!(game.dim, back, "back in the first level");
|
||||
}
|
||||
|
||||
fn block_in(game: &mut Game<'_>, dim: &str, at: &str, block: &str) -> bool {
|
||||
run(game, &format!("execute in {dim} if block {at} {block}")).0
|
||||
}
|
||||
|
||||
/// Added controls (after the baseline): a valid other dimension clones into
|
||||
/// that level's own world, not into the command's, and the command's level
|
||||
/// is back in place after every success and every failure.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_clone_to_another_dimension_lands_in_that_world_and_comes_back() {
|
||||
let mut game = game();
|
||||
let here = game.dim;
|
||||
assert_eq!(here, Dim::Overworld);
|
||||
load_chunk_in(&mut game, Dim::Nether, here);
|
||||
assert!(game.run_command("execute in minecraft:the_nether run setblock 14 100 0 gold_block"));
|
||||
assert!(block_in(
|
||||
&mut game,
|
||||
"minecraft:the_nether",
|
||||
"14 100 0",
|
||||
"gold_block"
|
||||
));
|
||||
// Into the nether.
|
||||
assert_eq!(
|
||||
run(
|
||||
&mut game,
|
||||
"clone from minecraft:overworld 0 100 0 0 100 0 to minecraft:the_nether 14 100 0"
|
||||
),
|
||||
(true, Some("Successfully cloned 1 block(s)".to_owned()))
|
||||
);
|
||||
assert_eq!(game.dim, here, "the command's level is back");
|
||||
assert!(block_in(
|
||||
&mut game,
|
||||
"minecraft:the_nether",
|
||||
"14 100 0",
|
||||
"chest"
|
||||
));
|
||||
assert!(
|
||||
run(
|
||||
&mut game,
|
||||
"execute in minecraft:the_nether if data block 14 100 0 {CustomName:'\"h236\"'}"
|
||||
)
|
||||
.0,
|
||||
"the chest's name came along"
|
||||
);
|
||||
assert!(
|
||||
block_in(&mut game, "minecraft:overworld", "14 100 0", "air"),
|
||||
"not in the overworld"
|
||||
);
|
||||
assert!(
|
||||
block_in(&mut game, "minecraft:overworld", "0 100 0", "chest"),
|
||||
"the source stays"
|
||||
);
|
||||
// The same coordinates in two levels do not overlap.
|
||||
assert!(
|
||||
run(
|
||||
&mut game,
|
||||
"clone from minecraft:overworld 0 100 0 0 100 0 to minecraft:the_nether 0 100 0"
|
||||
)
|
||||
.0
|
||||
);
|
||||
assert_eq!(game.dim, here);
|
||||
assert!(block_in(
|
||||
&mut game,
|
||||
"minecraft:the_nether",
|
||||
"0 100 0",
|
||||
"chest"
|
||||
));
|
||||
// Failures leave both worlds and the level as they were.
|
||||
assert!(game.run_command("execute in minecraft:the_nether run setblock 2 100 0 gold_block"));
|
||||
for (command, said) in [
|
||||
(
|
||||
"clone 0 100 0 0 100 0 to h236:none 2 100 0",
|
||||
"Unknown dimension 'h236:none'",
|
||||
),
|
||||
(
|
||||
"clone 0 100 0 0 100 0 to minecraft:the_nether 40 100 40",
|
||||
"That position is not loaded",
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
run(&mut game, command),
|
||||
(false, Some(said.to_owned())),
|
||||
"{command}"
|
||||
);
|
||||
assert_eq!(game.dim, here, "{command}: the level is back");
|
||||
assert!(block_in(
|
||||
&mut game,
|
||||
"minecraft:the_nether",
|
||||
"2 100 0",
|
||||
"gold_block"
|
||||
));
|
||||
assert!(block_in(
|
||||
&mut game,
|
||||
"minecraft:overworld",
|
||||
"0 100 0",
|
||||
"chest"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,8 @@ pub(in crate::game) struct OtherEntity {
|
||||
pub yaw: f32,
|
||||
pub pitch: f32,
|
||||
pub item_transformation: Option<super::native_entities::ItemDisplayTransformation>,
|
||||
/// A `minecraft:interaction`'s saved settings; None for every other kind.
|
||||
pub interaction: Option<super::interactions::InteractionState>,
|
||||
/// Only explicitly loaded leash knots own this BlockAttachedEntity check counter.
|
||||
pub knot_check: Option<u8>,
|
||||
}
|
||||
@@ -1373,6 +1375,9 @@ impl Game<'_> {
|
||||
let custom = s.data().and_then(|data| data.custom_name.as_ref());
|
||||
let hover = if let Some(c) = custom {
|
||||
horizon_client::component::name_text(c)
|
||||
} else if let Some(key) = horizon_client::brewing::contents_name_key(s) {
|
||||
// A potion's or tipped arrow's own name, over its `item_name`.
|
||||
Text::translate(&key, &[])
|
||||
} else if let Some(name) = s.data().and_then(|d| d.other("minecraft:item_name")) {
|
||||
// `ItemStack.getItemName`: the `item_name` component wins over the
|
||||
// item's own name (`Item.getName`), so a datapack's or a server's
|
||||
@@ -1385,18 +1390,6 @@ impl Game<'_> {
|
||||
.filter(|t| !t.trim().is_empty())
|
||||
{
|
||||
Text::literal(title)
|
||||
} else if matches!(
|
||||
item,
|
||||
"minecraft:potion"
|
||||
| "minecraft:splash_potion"
|
||||
| "minecraft:lingering_potion"
|
||||
| "minecraft:tipped_arrow"
|
||||
) && s.potion().is_some()
|
||||
{
|
||||
Text::translate(
|
||||
&horizon_client::brewing::potion_name_key(item, s.potion()),
|
||||
&[],
|
||||
)
|
||||
} else if item == "minecraft:shield"
|
||||
&& let Some(color) = s.data().and_then(|d| d.base_color.as_deref())
|
||||
{
|
||||
@@ -2378,10 +2371,12 @@ impl Game<'_> {
|
||||
self.ui.death_cause = Some("death.attack.genericKill".into());
|
||||
}
|
||||
Target::Mob(id) => {
|
||||
let mut killed = None;
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
// LivingEntity.kill: a generic_kill hit of Float.MAX_VALUE,
|
||||
// which its death message tells.
|
||||
if m.alive() {
|
||||
let alive = m.alive();
|
||||
if alive {
|
||||
m.record_damage(
|
||||
horizon_client::mobs::Damage::of(
|
||||
horizon_client::mobs::DamageKind::GenericKill,
|
||||
@@ -2393,6 +2388,16 @@ impl Game<'_> {
|
||||
if m.death_time == 0 {
|
||||
m.death_time = 1;
|
||||
}
|
||||
killed = alive.then(|| m.clone());
|
||||
}
|
||||
// The hit runs LivingEntity.die before the command returns:
|
||||
// its ENTITY_DIE, loot and experience come now, not in the
|
||||
// next tick's deaths, which skip it (as a frog's bite does).
|
||||
if let Some(m) = killed
|
||||
&& !m.removed
|
||||
&& self.died.insert(id)
|
||||
{
|
||||
self.mob_died(&m);
|
||||
}
|
||||
}
|
||||
Target::Item(id) => {
|
||||
@@ -2422,7 +2427,9 @@ impl Game<'_> {
|
||||
if let Some(i) = self.boats.iter().position(|b| b.id == id) {
|
||||
self.release_vehicle_container(id);
|
||||
let b = self.boats.remove(i);
|
||||
self.spill_killed_vehicle_contents(b.position, &b.items);
|
||||
// AbstractChestBoat.remove, after the live menu writes
|
||||
// back its slots.
|
||||
self.drop_vehicle_contents(b.position.components(), &b.items);
|
||||
self.boat_removed(&b);
|
||||
}
|
||||
}
|
||||
@@ -2430,7 +2437,8 @@ impl Game<'_> {
|
||||
self.release_vehicle_container(id);
|
||||
if let Some(i) = self.carts.iter().position(|c| c.id == id) {
|
||||
let c = self.carts.remove(i);
|
||||
self.spill_killed_vehicle_contents(c.position, &c.items);
|
||||
// AbstractMinecartContainer.remove.
|
||||
self.drop_vehicle_contents(c.position.components(), &c.items);
|
||||
}
|
||||
}
|
||||
Target::Other(id) => self.others.retain(|o| o.id != id),
|
||||
@@ -2452,30 +2460,6 @@ impl Game<'_> {
|
||||
}
|
||||
self.native_leaves_flush();
|
||||
}
|
||||
/// `AbstractChestBoat.remove` / `AbstractMinecartContainer.remove` for
|
||||
/// destroying removal (`Entity.kill`): `Containers.dropContents`, separate
|
||||
/// from hurt-time hull drops and after the live menu writes back its slots.
|
||||
fn spill_killed_vehicle_contents(&mut self, pos: Vec3, slots: &[Option<ItemStack>]) {
|
||||
for stack in slots {
|
||||
let count = stack.as_ref().map_or(0, |s| s.count);
|
||||
for piece in horizon_world::level::removal::drop_item_stack_at(
|
||||
&mut self.world.random,
|
||||
pos.components(),
|
||||
count,
|
||||
) {
|
||||
let Some(stack) = stack else { continue };
|
||||
self.spawn_item(
|
||||
ItemStack {
|
||||
count: piece.count,
|
||||
..stack.clone()
|
||||
},
|
||||
piece.pos,
|
||||
piece.motion,
|
||||
0,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn cmd_kill(&mut self, ctx: &Context) -> R<i32> {
|
||||
let targets = if ctx.get("targets").is_some() {
|
||||
self.entities(ctx, "targets", false)?
|
||||
@@ -2574,6 +2558,7 @@ impl Game<'_> {
|
||||
count: 1,
|
||||
ticks: 0,
|
||||
health: 5,
|
||||
following: None,
|
||||
});
|
||||
// tryAddFreshEntityWithPassengers refused: ERROR_DUPLICATE_UUID.
|
||||
if !self.native_joins_flush(false, self.next_entity) {
|
||||
@@ -3204,10 +3189,115 @@ impl Game<'_> {
|
||||
self.feedback(Text::tr("commands.fill.success", &[&count]));
|
||||
Ok(count)
|
||||
}
|
||||
/// `CloneCommands`: `from <sourceDimension>` is resolved as `begin` is
|
||||
/// read, `to <targetDimension>` as `destination` is
|
||||
/// (`DimensionArgument.getDimension`), each position checked loaded in its
|
||||
/// own level (`BlockPosArgument.getLoadedBlockPos`): an unknown source fails
|
||||
/// before an unknown target. The blocks are read in the source level and
|
||||
/// placed in the target level. The command's own level is back in place,
|
||||
/// whatever happened, before the command reports.
|
||||
fn cmd_clone(&mut self, ctx: &Context) -> R<i32> {
|
||||
let a = self.block_pos_arg(ctx, "begin")?;
|
||||
let b = self.block_pos_arg(ctx, "end")?;
|
||||
let dest = self.block_pos_arg(ctx, "destination")?;
|
||||
let here = self.dim;
|
||||
let result = self.clone_levels(ctx);
|
||||
if self.dim != here {
|
||||
self.enter_command_level(here);
|
||||
}
|
||||
if self.dim != here {
|
||||
log::error!("/clone could not return to {}", here.id());
|
||||
return Err(err("commands.clone.failed", &[]));
|
||||
}
|
||||
let count = result?;
|
||||
self.feedback(Text::tr("commands.clone.success", &[&count]));
|
||||
Ok(count)
|
||||
}
|
||||
fn clone_levels(&mut self, ctx: &Context) -> R<i32> {
|
||||
let here = self.dim;
|
||||
let source_level = self.command_source.map_or(self.dim, |s| s.dimension);
|
||||
// The coordinates, from the command's own source, before any level moves.
|
||||
let position = |name: &str| -> R<[i32; 3]> {
|
||||
let c = self
|
||||
.coords(ctx, name)
|
||||
.ok_or_else(|| err("argument.pos.missing.int", &[]))?;
|
||||
Ok(self.resolve_coords(&c).map(|v| v.floor() as i32))
|
||||
};
|
||||
let (a, b, dest) = (
|
||||
position("begin")?,
|
||||
position("end")?,
|
||||
position("destination")?,
|
||||
);
|
||||
let from_dim = self.clone_dimension(ctx, "sourceDimension")?;
|
||||
self.clone_loaded(from_dim, here, source_level, a)?;
|
||||
self.clone_loaded(from_dim, here, source_level, b)?;
|
||||
let to_dim = self.clone_dimension(ctx, "targetDimension")?;
|
||||
self.clone_loaded(to_dim, here, source_level, dest)?;
|
||||
self.clone_between(
|
||||
ctx,
|
||||
a,
|
||||
b,
|
||||
dest,
|
||||
from_dim.unwrap_or(here),
|
||||
to_dim.unwrap_or(here),
|
||||
)
|
||||
}
|
||||
/// `BlockPosArgument.getLoadedBlockPos` in the level a dimension argument
|
||||
/// names, or, without one, the command's own (as `block_pos_arg` checks it).
|
||||
fn clone_loaded(
|
||||
&mut self,
|
||||
dim: Option<Dim>,
|
||||
here: Dim,
|
||||
source_level: Dim,
|
||||
p: [i32; 3],
|
||||
) -> R<()> {
|
||||
match dim {
|
||||
Some(dim) => self.clone_enter(dim)?,
|
||||
None => {
|
||||
self.clone_enter(here)?;
|
||||
if source_level != self.dim {
|
||||
return Err(err("argument.pos.unloaded", &[]));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !self.world.chunks.contains_key(&(p[0] >> 4, p[2] >> 4)) {
|
||||
return Err(err("argument.pos.unloaded", &[]));
|
||||
}
|
||||
if p[1] < self.world.bottom_y() || p[1] >= self.world.top_y() {
|
||||
return Err(err("argument.pos.outofworld", &[]));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `DimensionArgument.getDimension`: a built-in dimension, or
|
||||
/// `argument.dimension.invalid`; None when the argument is absent.
|
||||
fn clone_dimension(&self, ctx: &Context, name: &str) -> R<Option<Dim>> {
|
||||
let Some(Arg::Resource(id)) = ctx.get(name) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !matches!(
|
||||
id.as_str(),
|
||||
"minecraft:overworld" | "minecraft:the_nether" | "minecraft:the_end"
|
||||
) {
|
||||
return Err(err("argument.dimension.invalid", &[id]));
|
||||
}
|
||||
Ok(Some(Dim::from_id(id)))
|
||||
}
|
||||
/// Put `dim`'s level in place for the clone, or fail it when it cannot be.
|
||||
fn clone_enter(&mut self, dim: Dim) -> R<()> {
|
||||
if self.dim != dim {
|
||||
self.enter_command_level(dim);
|
||||
}
|
||||
if self.dim != dim {
|
||||
return Err(err("commands.clone.failed", &[]));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn clone_between(
|
||||
&mut self,
|
||||
ctx: &Context,
|
||||
a: [i32; 3],
|
||||
b: [i32; 3],
|
||||
dest: [i32; 3],
|
||||
from_dim: Dim,
|
||||
to_dim: Dim,
|
||||
) -> R<i32> {
|
||||
let min = [a[0].min(b[0]), a[1].min(b[1]), a[2].min(b[2])];
|
||||
let max = [a[0].max(b[0]), a[1].max(b[1]), a[2].max(b[2])];
|
||||
let size = [max[0] - min[0], max[1] - min[1], max[2] - min[2]];
|
||||
@@ -3215,7 +3305,7 @@ impl Game<'_> {
|
||||
let overlap = (0..3).all(|i| dest[i] <= max[i] && dmax[i] >= min[i]);
|
||||
let moving = ctx.has("move");
|
||||
// Mode.canOverlap: only the normal mode refuses overlapping regions.
|
||||
if !ctx.has("force") && !moving && overlap {
|
||||
if !ctx.has("force") && !moving && from_dim == to_dim && overlap {
|
||||
return Err(err("commands.clone.overlap", &[]));
|
||||
}
|
||||
let area = (0..3).map(|i| i64::from(size[i] + 1)).product::<i64>();
|
||||
@@ -3223,6 +3313,7 @@ impl Game<'_> {
|
||||
if area > i64::from(limit) {
|
||||
return Err(err("commands.clone.toobig", &[&limit, &area]));
|
||||
}
|
||||
self.clone_enter(to_dim)?;
|
||||
for p in [dest, dmax] {
|
||||
if !self.world.chunks.contains_key(&(p[0] >> 4, p[2] >> 4)) {
|
||||
return Err(err("argument.pos.unloaded", &[]));
|
||||
@@ -3238,6 +3329,7 @@ impl Game<'_> {
|
||||
};
|
||||
let air = self.world.air;
|
||||
let masked = ctx.has("masked");
|
||||
self.clone_enter(from_dim)?;
|
||||
let mut copies = Vec::new();
|
||||
for y in min[1]..=max[1] {
|
||||
for z in min[2]..=max[2] {
|
||||
@@ -3270,6 +3362,7 @@ impl Game<'_> {
|
||||
self.place_state(*from, air, true, false)?;
|
||||
}
|
||||
}
|
||||
self.clone_enter(to_dim)?;
|
||||
let strict = ctx.has("strict");
|
||||
// CloneCommands.clone sets every destination to a barrier first, so
|
||||
// each copied state but a barrier counts as placed.
|
||||
@@ -3306,7 +3399,6 @@ impl Game<'_> {
|
||||
if count == 0 {
|
||||
return Err(err("commands.clone.failed", &[]));
|
||||
}
|
||||
self.feedback(Text::tr("commands.clone.success", &[&count]));
|
||||
Ok(count)
|
||||
}
|
||||
/// Player.giveExperiencePoints including negative amounts.
|
||||
@@ -3734,9 +3826,16 @@ impl Game<'_> {
|
||||
Some(Arg::Coords(c)) => c.rotation(self.source_rotation()),
|
||||
_ => [0.0, 0.0],
|
||||
};
|
||||
// SetWorldSpawnCommand uses RespawnData.of before storing or comparing.
|
||||
let rot = [wrap_degrees(rot[0]), rot[1].clamp(-90.0, 90.0)];
|
||||
// LevelData.setRespawnData: the world's spawn (level.dat `spawn`).
|
||||
let previous_point = self.world_spawn_position().map(|v| v.floor() as i32);
|
||||
let old = self.level.world_spawn.replace(pos);
|
||||
let old_angle = self.level.world_spawn_angle;
|
||||
self.level.world_spawn_angle = rot;
|
||||
if previous_point != pos || old_angle.map(f32::to_bits) != rot.map(f32::to_bits) {
|
||||
self.lan_broadcast_world_spawn();
|
||||
}
|
||||
self.native_spawn_change(pos, rot);
|
||||
// A player whose fallback is the world spawn (no spawnpoint of its
|
||||
// own; a bed or an anchor that is gone falls back to it) respawns at
|
||||
@@ -3892,7 +3991,9 @@ impl Game<'_> {
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
_ => present.is_some(),
|
||||
// Count is a pseudo-component, not a catalog component.
|
||||
_ if id == "minecraft:count" => present.is_some(),
|
||||
_ => self.stack_component_present(stack, &id),
|
||||
};
|
||||
result != negated
|
||||
};
|
||||
@@ -3901,6 +4002,23 @@ impl Game<'_> {
|
||||
.filter(|t| !t.trim().is_empty())
|
||||
.all(|term| split_top_level(term, '|').iter().any(|alt| test(alt)))
|
||||
}
|
||||
/// `ItemPredicateArgument.createComponentExistencePredicate`: the stack's
|
||||
/// effective component presence, with its patch ahead of its prototype.
|
||||
fn stack_component_present(&self, stack: &ItemStack, id: &str) -> bool {
|
||||
// Read the modeled patch before saved-default elision. A removal wins
|
||||
// even when the actual item prototype supplies the component.
|
||||
if let Some(patch) = horizon_client::item_data::components_tag(stack.damage, stack.data()) {
|
||||
if patch.get(&format!("!{id}")).is_some() {
|
||||
return false;
|
||||
}
|
||||
if patch.get(id).is_some() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
self.items()
|
||||
.get(&stack.item)
|
||||
.is_some_and(|item| item.components.iter().any(|key| key == id))
|
||||
}
|
||||
fn cmd_clear(&mut self, ctx: &Context) -> R<i32> {
|
||||
let targets = if ctx.get("targets").is_some() {
|
||||
self.entities(ctx, "targets", true)?
|
||||
@@ -5279,6 +5397,129 @@ mod forced_respawn_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod effective_presence_tests {
|
||||
use super::super::native_machines::tests::server_game;
|
||||
use super::*;
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
|
||||
fn registered_presence_uses_actual_catalog_and_stack_patch() {
|
||||
let mut game = server_game();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
let key = "minecraft:jukebox_playable";
|
||||
assert!(
|
||||
game.items()
|
||||
.get("minecraft:music_disc_cat")
|
||||
.unwrap()
|
||||
.components
|
||||
.iter()
|
||||
.any(|k| k == key)
|
||||
);
|
||||
assert!(
|
||||
!game
|
||||
.items()
|
||||
.get("minecraft:stone")
|
||||
.unwrap()
|
||||
.components
|
||||
.iter()
|
||||
.any(|k| k == key)
|
||||
);
|
||||
assert!(game.run_command("forceload add 0 0 15 15"));
|
||||
assert!(game.world.loaded(4, 4));
|
||||
assert!(game.run_command("setblock 4 199 4 stone"));
|
||||
assert!(game.run_command("setblock 4 200 4 chest{Items:[{Slot:0b,id:\"minecraft:music_disc_cat\",count:1},{Slot:1b,id:\"minecraft:stone\",count:1},{Slot:2b,id:\"minecraft:stone\",count:1,components:{\"minecraft:jukebox_playable\":\"minecraft:cat\",\"minecraft:custom_data\":{h193:1}}},{Slot:3b,id:\"minecraft:music_disc_cat\",count:1,components:{\"!minecraft:jukebox_playable\":{},\"minecraft:custom_data\":{h193:1}}}]}"));
|
||||
assert!(game.run_command("scoreboard objectives add h193 dummy"));
|
||||
let clock = game.world.game_time;
|
||||
let horizon_client::containers::BlockEntity::Chest(stacks) =
|
||||
game.world.block_entities.get(&(4, 200, 4)).unwrap()
|
||||
else {
|
||||
panic!("actual chest missing")
|
||||
};
|
||||
let stacks = stacks.clone();
|
||||
assert_eq!(stacks.len(), 27);
|
||||
assert!(
|
||||
stacks[0].as_ref().unwrap().extra.is_none(),
|
||||
"plain modeled stack has no patch"
|
||||
);
|
||||
assert!(
|
||||
stacks[2]
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.extra
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.other(key)
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
stacks[3]
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.extra
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.other("!minecraft:jukebox_playable")
|
||||
.is_some()
|
||||
);
|
||||
let saved = game.block_entity_nbt([4, 200, 4]).unwrap();
|
||||
let mut failures = Vec::new();
|
||||
for (slot, present) in [true, false, true, false].into_iter().enumerate() {
|
||||
let stack = stacks[slot].as_ref().unwrap();
|
||||
assert_eq!(stack.count, 1);
|
||||
for neg in [false, true] {
|
||||
let expected = present != neg;
|
||||
let term = format!("{}minecraft:jukebox_playable", if neg { "!" } else { "" });
|
||||
let predicate = ItemInput {
|
||||
id: String::new(),
|
||||
tag: false,
|
||||
any: true,
|
||||
components: Some(format!("[{term}]")),
|
||||
};
|
||||
if game.stack_matches(&predicate, stack) != expected {
|
||||
failures.push(format!("direct slot{slot} {term}"));
|
||||
}
|
||||
for sign in ["if", "unless"] {
|
||||
for (name, n) in [("r", 91), ("s", 17)] {
|
||||
assert!(
|
||||
game.run_command(&format!("scoreboard players set #{name} h193 {n}"))
|
||||
);
|
||||
}
|
||||
let accepted = game.run_command(&format!("execute store result score #r h193 store success score #s h193 {sign} items block 4 200 4 container.{slot} *[{term}]"));
|
||||
let yes = if sign == "if" { expected } else { !expected };
|
||||
let score = |name| game.score.board.score(name, "h193").unwrap().value;
|
||||
if (accepted, score("#r"), score("#s")) != (yes, i32::from(yes), i32::from(yes))
|
||||
{
|
||||
failures.push(format!("command slot{slot} {sign} {term}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let plain = ItemStack::new("minecraft:music_disc_cat", 1);
|
||||
assert!(plain.extra.is_none());
|
||||
let p = ItemInput {
|
||||
id: String::new(),
|
||||
tag: false,
|
||||
any: true,
|
||||
components: Some("[minecraft:jukebox_playable]".into()),
|
||||
};
|
||||
if !game.stack_matches(&p, &plain) {
|
||||
failures.push("no-raw modeled prototype".into());
|
||||
}
|
||||
assert_eq!(
|
||||
game.block_entity_nbt([4, 200, 4]).unwrap(),
|
||||
saved,
|
||||
"read-only saved inventory"
|
||||
);
|
||||
assert_eq!(game.world.game_time, clock, "zero actor ticks");
|
||||
assert!(game.world.loaded(4, 4));
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"actual effective component presence mismatches: {failures:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod fill_note_instrument_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -145,6 +145,8 @@ impl Game<'_> {
|
||||
let i = self.boats.iter().position(|b| b.id == id)?;
|
||||
let b = &self.boats[i];
|
||||
let mut tag = b.to_nbt(uuid, self.persist.raw.get(&id));
|
||||
// Entity.saveWithoutId: the actual runtime passenger tree.
|
||||
self.put_mob_passengers(&mut tag, id);
|
||||
// AbstractBoat.addAdditionalSaveData: the live or pending leash.
|
||||
self.write_boat_leash(&mut tag, i);
|
||||
tag
|
||||
@@ -152,7 +154,9 @@ impl Game<'_> {
|
||||
Target::Cart(id) => {
|
||||
let uuid = self.uuid_for(id);
|
||||
let c = self.carts.iter().find(|c| c.id == id)?;
|
||||
c.to_nbt(uuid, self.persist.raw.get(&id))
|
||||
let mut tag = c.to_nbt(uuid, self.persist.raw.get(&id));
|
||||
self.put_mob_passengers(&mut tag, id);
|
||||
tag
|
||||
}
|
||||
Target::Fangs(id) => {
|
||||
let uuid = self.uuid_for(id);
|
||||
@@ -263,6 +267,7 @@ impl Game<'_> {
|
||||
id,
|
||||
kind: "minecraft:area_effect_cloud".into(),
|
||||
item_transformation: None,
|
||||
interaction: None,
|
||||
knot_check: None,
|
||||
position: horizon_core::geometry::Vec3::new(x, y, z),
|
||||
yaw: 0.0,
|
||||
@@ -331,6 +336,18 @@ impl Game<'_> {
|
||||
/// `TypedEntityData.loadInto` is: a spawn egg's data on the mob it spawned.
|
||||
pub(in crate::game) fn apply_entity_nbt(&mut self, t: Target, mut tag: Tag) -> R<()> {
|
||||
let stale = || err("commands.data.merge.failed", &[]);
|
||||
// Entity.load does not read Passengers: the riders stay, and so do the
|
||||
// passengers the load kept raw (none, when it kept none).
|
||||
let kept_passengers = match t {
|
||||
Target::Mob(id) | Target::Boat(id) | Target::Cart(id) | Target::Tnt(id) => Some(
|
||||
self.persist
|
||||
.raw
|
||||
.get(&id)
|
||||
.and_then(|raw| raw.get("Passengers"))
|
||||
.cloned(),
|
||||
),
|
||||
_ => None,
|
||||
};
|
||||
match t {
|
||||
// EntityDataAccessor.setData: players can't be modified, guests neither.
|
||||
Target::Player | Target::Guest(_) => {
|
||||
@@ -364,8 +381,10 @@ impl Game<'_> {
|
||||
tag.get("Brain").and_then(|brain| brain.get("memories")),
|
||||
Some(Tag::Compound(_))
|
||||
);
|
||||
// Runtime-only state (AI, animation) carries over.
|
||||
// Runtime-only state (AI, animation) carries over, and the
|
||||
// entity keeps its place in the level's join order.
|
||||
let old = std::mem::replace(slot, new);
|
||||
slot.join = old.join;
|
||||
if keep_liked_player {
|
||||
slot.extra.tame = old.extra.tame;
|
||||
slot.extra.owner = old.extra.owner;
|
||||
@@ -386,6 +405,9 @@ impl Game<'_> {
|
||||
horizon_client::mobs::villager::ext(&old)
|
||||
.and_then(|ext| ext.last_traded_player);
|
||||
}
|
||||
if slot.species() == horizon_client::mobs::Species::Shulker {
|
||||
horizon_client::mobs::shulker::keep_eased_peek(slot, &old);
|
||||
}
|
||||
slot.ai = old.ai;
|
||||
// Mob.readAdditionalSaveData updates persistent home fields,
|
||||
// while retaining the rest of the live AI/runtime state.
|
||||
@@ -427,8 +449,10 @@ impl Game<'_> {
|
||||
Target::Stand(id) => {
|
||||
let mut new = entity_nbt::armor_stand_from_nbt(&tag, id).ok_or_else(stale)?;
|
||||
if let Some(slot) = self.armor_stands.iter_mut().find(|a| a.id == id) {
|
||||
// Entity.load keeps the existing entity's firstTick state.
|
||||
// Entity.load keeps the existing entity's firstTick state
|
||||
// and its place in the level's join order.
|
||||
new.first_tick = slot.first_tick;
|
||||
new.join = slot.join;
|
||||
*slot = new;
|
||||
}
|
||||
}
|
||||
@@ -543,6 +567,41 @@ impl Game<'_> {
|
||||
// Runtime id/join, owner binding and guest attribution stay live.
|
||||
set_key(&mut tag, "UUID", Tag::IntArray(uuid.to_vec()));
|
||||
}
|
||||
Target::Orb(id) => {
|
||||
// Entity.load and ExperienceOrb.readAdditionalSaveData on the
|
||||
// resident orb: its runtime id, join and tick count stay.
|
||||
let new = entity_nbt::orbs_from_nbt(&tag)
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(stale)?;
|
||||
let uuid = self.uuid_for(id);
|
||||
let orb = self
|
||||
.orbs
|
||||
.iter_mut()
|
||||
.find(|o| o.id == id)
|
||||
.ok_or_else(stale)?;
|
||||
orb.rotation = new.rotation;
|
||||
orb.position = new.position;
|
||||
orb.previous = new.previous;
|
||||
orb.velocity = new.velocity;
|
||||
orb.value = new.value;
|
||||
orb.age = new.age;
|
||||
orb.on_ground = new.on_ground;
|
||||
orb.count = new.count;
|
||||
orb.health = new.health;
|
||||
orb.no_gravity = new.no_gravity;
|
||||
// Entity.load reads portalCooldown and leaves the portal
|
||||
// process as it is.
|
||||
if let Some(portal) = self.portals.entities.get_mut(&id) {
|
||||
portal.cooldown = match tag.get("PortalCooldown") {
|
||||
Some(Tag::Int(cooldown)) => *cooldown,
|
||||
_ => 0,
|
||||
};
|
||||
} else {
|
||||
self.restore_portal_cooldown(id, &tag);
|
||||
}
|
||||
set_key(&mut tag, "UUID", Tag::IntArray(uuid.to_vec()));
|
||||
}
|
||||
Target::Cart(id) => {
|
||||
let uuid = self.uuid_for(id);
|
||||
let i = self
|
||||
@@ -565,7 +624,9 @@ impl Game<'_> {
|
||||
.others
|
||||
.iter_mut()
|
||||
.find(|o| {
|
||||
o.id == id && matches!(o.kind.as_str(), "minecraft:marker" | "marker")
|
||||
o.id == id
|
||||
&& (matches!(o.kind.as_str(), "minecraft:marker" | "marker")
|
||||
|| crate::game::interactions::is_interaction(&o.kind))
|
||||
})
|
||||
.ok_or_else(stale)?;
|
||||
let position = entity_nbt::position(&tag).ok_or_else(stale)?;
|
||||
@@ -573,6 +634,11 @@ impl Game<'_> {
|
||||
marker.position = position;
|
||||
marker.yaw = yaw;
|
||||
marker.pitch = pitch;
|
||||
// Interaction.readAdditionalSaveData on the same entity.
|
||||
if crate::game::interactions::is_interaction(&marker.kind) {
|
||||
marker.interaction =
|
||||
Some(crate::game::interactions::InteractionState::from_tag(&tag));
|
||||
}
|
||||
// Keep the existing marker identity even when the input edits UUID or id.
|
||||
set_key(&mut tag, "UUID", Tag::IntArray(uuid.to_vec()));
|
||||
set_key(&mut tag, "id", Tag::String(marker.kind.as_str().into()));
|
||||
@@ -586,10 +652,23 @@ impl Game<'_> {
|
||||
| Target::Boat(id)
|
||||
| Target::Cart(id)
|
||||
| Target::Tnt(id)
|
||||
| Target::Orb(id)
|
||||
| Target::Cushion(id)
|
||||
| Target::Other(id)
|
||||
| Target::Fangs(id) = t
|
||||
{
|
||||
if let Some(kept) = kept_passengers
|
||||
&& let Tag::Compound(c) = &mut tag
|
||||
{
|
||||
match kept {
|
||||
Some(passengers) => {
|
||||
c.insert("Passengers".into(), passengers);
|
||||
}
|
||||
None => {
|
||||
c.remove(&"Passengers".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
self.persist.raw.insert(id, tag);
|
||||
}
|
||||
Ok(())
|
||||
@@ -1348,6 +1427,15 @@ impl Game<'_> {
|
||||
set_key(&mut tag, "home_pos", Tag::IntArray(home.to_vec()));
|
||||
}
|
||||
place_summoned(&mut tag, pos);
|
||||
// ServerLevel.tryAddFreshEntityWithPassengers: a UUID of the tree that
|
||||
// an entity in the level holds refuses the whole tree, before any of
|
||||
// it joins (players are not looked at here).
|
||||
if self.summon_tree_uuids(&tag).into_iter().any(|u| {
|
||||
self.target_with_uuid(u)
|
||||
.is_some_and(|t| t != Target::Player)
|
||||
}) {
|
||||
return Err(err("commands.summon.failed.uuid", &[]));
|
||||
}
|
||||
let from = self.mobs.len();
|
||||
let canceled = self.persist.joins_canceled;
|
||||
let target = self.spawn_entity_from_nbt(tag);
|
||||
@@ -1541,6 +1629,159 @@ mod wolf_live_anger_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod boat_passenger_tree_tests {
|
||||
use super::*;
|
||||
use crate::game::native_machines::tests::server_game;
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
|
||||
fn actual_boat_getter_projects_runtime_children_and_existing_bookkeeping() {
|
||||
let mut game = server_game();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
assert!(game.run_command("forceload add 0 0 15 15"));
|
||||
assert!(game.world.loaded(8, 8));
|
||||
assert!(game.run_command("setblock 8 199 8 stone"));
|
||||
assert!(game.run_command("summon oak_boat 8.5 200 8.5 {UUID:[I;7,151,1,1],NoGravity:1b,Silent:1b,CustomName:\"Hull\"}"));
|
||||
let bi = game.boats.iter().position(|b| b.position.x == 8.5).unwrap();
|
||||
let bid = game.boats[bi].id;
|
||||
let child = game
|
||||
.spawn_mob_reason("minecraft:cow", [8.5, 200.0, 8.5], true, true, false)
|
||||
.unwrap();
|
||||
let ci = game.mobs.iter().position(|m| m.id == child).unwrap();
|
||||
game.mobs[ci].no_ai = true;
|
||||
game.mobs[ci].no_gravity = true;
|
||||
assert!(
|
||||
!game.persist.raw.contains_key(&child),
|
||||
"fresh actual runtime child has no raw NBT"
|
||||
);
|
||||
assert_eq!(
|
||||
game.mobs[ci].extra.anger_end, 0,
|
||||
"unprimed constructor state"
|
||||
);
|
||||
game.ride_mount(Target::Mob(child), Target::Boat(bid))
|
||||
.unwrap();
|
||||
assert_eq!(game.mobs[ci].extra.mount, Some(bid));
|
||||
let clock = game.world.game_time;
|
||||
let chunks = game.world.chunks.len();
|
||||
let raw = game.persist.raw.get(&bid).cloned();
|
||||
assert!(raw.as_ref().unwrap().get("Passengers").is_none());
|
||||
let before = (
|
||||
game.persist.uuids.len(),
|
||||
game.persist.uuid_of(child),
|
||||
game.mobs[ci].extra.anger_end,
|
||||
game.mobs[ci].extra.angry_at,
|
||||
);
|
||||
let first = game.entity_nbt(Target::Boat(bid)).unwrap();
|
||||
let after = (
|
||||
game.persist.uuids.len(),
|
||||
game.persist.uuid_of(child),
|
||||
game.mobs[ci].extra.anger_end,
|
||||
game.mobs[ci].extra.angry_at,
|
||||
);
|
||||
println!("parent-first bookkeeping before={before:?} after={after:?}");
|
||||
let mut failures = Vec::new();
|
||||
if after.2 != -1 {
|
||||
failures.push("first parent must use existing 0->-1 normalization".to_owned());
|
||||
}
|
||||
if after.1.is_none() || after.0 != before.0 + usize::from(before.1.is_none()) {
|
||||
failures.push("first parent must use existing UUID registration once".into());
|
||||
}
|
||||
let control = game
|
||||
.spawn_mob_reason("minecraft:cow", [12.5, 200.0, 8.5], true, true, false)
|
||||
.unwrap();
|
||||
let di = game.mobs.iter().position(|m| m.id == control).unwrap();
|
||||
game.mobs[di].no_ai = true;
|
||||
game.mobs[di].no_gravity = true;
|
||||
assert!(!game.persist.raw.contains_key(&control));
|
||||
assert_eq!(game.mobs[di].extra.anger_end, 0);
|
||||
let direct_before = (
|
||||
game.persist.uuids.len(),
|
||||
game.persist.uuid_of(control),
|
||||
game.mobs[di].extra.anger_end,
|
||||
);
|
||||
let direct_control = game.entity_nbt(Target::Mob(control)).unwrap();
|
||||
let direct_after = (
|
||||
game.persist.uuids.len(),
|
||||
game.persist.uuid_of(control),
|
||||
game.mobs[di].extra.anger_end,
|
||||
);
|
||||
println!(
|
||||
"existing child-getter bookkeeping before={direct_before:?} after={direct_after:?}"
|
||||
);
|
||||
assert_eq!(direct_after.2, -1);
|
||||
assert_eq!(
|
||||
direct_after.0,
|
||||
direct_before.0 + usize::from(direct_before.1.is_none())
|
||||
);
|
||||
assert!(direct_after.1.is_some());
|
||||
assert_eq!(
|
||||
direct_control.get("UUID"),
|
||||
Some(&Tag::IntArray(direct_after.1.unwrap().to_vec()))
|
||||
);
|
||||
let direct_child = game.entity_nbt(Target::Mob(child)).unwrap();
|
||||
let saved_child = |tag: &Tag| match tag.get("Passengers") {
|
||||
Some(Tag::List(v)) if v.len() == 1 => Some(v[0].clone()),
|
||||
_ => None,
|
||||
};
|
||||
if saved_child(&first).as_ref() != Some(&direct_child) {
|
||||
failures.push("first parent typed child must equal actual child getter".into());
|
||||
}
|
||||
let stable = game.persist.uuids.len();
|
||||
for _ in 0..2 {
|
||||
let again = game.entity_nbt(Target::Boat(bid)).unwrap();
|
||||
if saved_child(&again).as_ref() != Some(&direct_child) {
|
||||
failures.push("repeated parent must keep exactly one actual child".into());
|
||||
}
|
||||
assert_eq!(game.persist.uuids.len(), stable);
|
||||
assert_eq!(game.mobs[ci].extra.anger_end, -1);
|
||||
assert_eq!(game.mobs[ci].extra.mount, Some(bid));
|
||||
}
|
||||
assert!(game.run_command("scoreboard objectives add h151 dummy"));
|
||||
assert!(game.run_command("scoreboard players set #r h151 91"));
|
||||
assert!(game.run_command("scoreboard players set #s h151 17"));
|
||||
let accepted=game.run_command("execute store result score #r h151 store success score #s h151 run data get entity @e[nbt={UUID:[I;7,151,1,1]},limit=1] Passengers[0].UUID");
|
||||
let score = |name| game.score.board.score(name, "h151").unwrap().value;
|
||||
if (accepted, score("#r"), score("#s")) != (true, 4, 1) {
|
||||
failures.push("registered data command must reach live tree".into());
|
||||
}
|
||||
game.boats[bi].leash = Some(horizon_client::mobs::leash::Holder::Mob(control));
|
||||
let leashed = game.entity_nbt(Target::Boat(bid)).unwrap();
|
||||
assert_eq!(
|
||||
leashed.get("leash").and_then(|t| t.get("UUID")),
|
||||
direct_control.get("UUID")
|
||||
);
|
||||
assert_eq!(leashed.get("CustomName"), Some(&Tag::String("Hull".into())));
|
||||
assert_eq!(leashed.get("Silent"), Some(&Tag::Byte(1)));
|
||||
game.dismount_mob(child);
|
||||
assert!(
|
||||
game.entity_nbt(Target::Boat(bid))
|
||||
.unwrap()
|
||||
.get("Passengers")
|
||||
.is_none()
|
||||
);
|
||||
game.ride_mount(Target::Mob(child), Target::Boat(bid))
|
||||
.unwrap();
|
||||
let remount_child = game.entity_nbt(Target::Mob(child)).unwrap();
|
||||
if saved_child(&game.entity_nbt(Target::Boat(bid)).unwrap()).as_ref()
|
||||
!= Some(&remount_child)
|
||||
{
|
||||
failures.push("actual remount tree".into());
|
||||
}
|
||||
assert_eq!(
|
||||
game.persist.raw.get(&bid),
|
||||
raw.as_ref(),
|
||||
"getter must not replay/mutate raw passenger input"
|
||||
);
|
||||
assert_eq!(game.world.chunks.len(), chunks, "no chunk loading");
|
||||
assert_eq!(game.world.game_time, clock, "no scene ticks");
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"actual boat passenger getter mismatches: {failures:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod loot_table_tests {
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
@@ -2175,3 +2416,251 @@ mod falling_live_adoption_tests {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod vehicle_container_tests {
|
||||
use super::*;
|
||||
use crate::game::{Audio, Options, Progress, Settings, load_resources};
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
|
||||
fn loaded_vehicle_saves_own_only_their_container_fields() {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
let cases = [
|
||||
(
|
||||
"boat_plain",
|
||||
"minecraft:oak_boat",
|
||||
"Items:[{Slot:0b,id:\"minecraft:diamond\",count:3,components:{\"minecraft:custom_data\":{h052:9}}}],LootTable:\"minecraft:empty\",LootTableSeed:73L",
|
||||
None,
|
||||
false,
|
||||
"",
|
||||
),
|
||||
(
|
||||
"cart_plain",
|
||||
"minecraft:minecart",
|
||||
"Items:[{Slot:0b,id:\"minecraft:diamond\",count:3,components:{\"minecraft:custom_data\":{h052:9}}}],LootTable:\"minecraft:empty\",LootTableSeed:73L",
|
||||
None,
|
||||
false,
|
||||
"",
|
||||
),
|
||||
(
|
||||
"boat_items",
|
||||
"minecraft:oak_chest_boat",
|
||||
"Items:[{Slot:0b,id:\"minecraft:diamond\",count:3,components:{\"minecraft:custom_data\":{h052:9}}}],LootTableSeed:73L",
|
||||
Some(1usize),
|
||||
false,
|
||||
"",
|
||||
),
|
||||
(
|
||||
"cart_items",
|
||||
"minecraft:chest_minecart",
|
||||
"Items:[{Slot:0b,id:\"minecraft:diamond\",count:3,components:{\"minecraft:custom_data\":{h052:9}}}],LootTableSeed:73L",
|
||||
Some(1usize),
|
||||
false,
|
||||
"",
|
||||
),
|
||||
(
|
||||
"boat_table",
|
||||
"minecraft:oak_chest_boat",
|
||||
"LootTable:\"minecraft:empty\",LootTableSeed:73L",
|
||||
None,
|
||||
true,
|
||||
"minecraft:empty",
|
||||
),
|
||||
(
|
||||
"cart_table",
|
||||
"minecraft:chest_minecart",
|
||||
"LootTable:\"minecraft:empty\",LootTableSeed:73L",
|
||||
None,
|
||||
true,
|
||||
"minecraft:empty",
|
||||
),
|
||||
(
|
||||
"boat_empty_table",
|
||||
"minecraft:oak_chest_boat",
|
||||
"LootTable:\"\",LootTableSeed:73L",
|
||||
None,
|
||||
true,
|
||||
"minecraft:",
|
||||
),
|
||||
(
|
||||
"cart_empty_table",
|
||||
"minecraft:chest_minecart",
|
||||
"LootTable:\"\",LootTableSeed:73L",
|
||||
None,
|
||||
true,
|
||||
"minecraft:",
|
||||
),
|
||||
(
|
||||
"boat_empty",
|
||||
"minecraft:oak_chest_boat",
|
||||
"",
|
||||
Some(0usize),
|
||||
false,
|
||||
"",
|
||||
),
|
||||
(
|
||||
"cart_empty",
|
||||
"minecraft:chest_minecart",
|
||||
"",
|
||||
Some(0usize),
|
||||
false,
|
||||
"",
|
||||
),
|
||||
];
|
||||
let clock = game.world.game_time;
|
||||
let mut differences = Vec::new();
|
||||
for (i, (name, kind, fields, expected_items, pending, table_text)) in
|
||||
cases.iter().enumerate()
|
||||
{
|
||||
let uuid = [52, 0, 0, i as i32 + 1];
|
||||
let source = [
|
||||
r#"{Pos:[4.5d,200d,4.5d],NoGravity:1b,Tags:["h052"]"#,
|
||||
if fields.is_empty() { "" } else { "," },
|
||||
fields,
|
||||
"}",
|
||||
]
|
||||
.concat();
|
||||
let (tag, _) = parse_snbt(&source).unwrap();
|
||||
let target = if kind.contains("boat") {
|
||||
game.load_boat(tag, Some(uuid), kind);
|
||||
let b = game.boats.last().unwrap();
|
||||
assert_eq!(
|
||||
b.items
|
||||
.first()
|
||||
.and_then(Option::as_ref)
|
||||
.map_or(0, |s| s.count),
|
||||
if *expected_items == Some(1) { 3 } else { 0 },
|
||||
"{name}: actual boat inventory"
|
||||
);
|
||||
Target::Boat(b.id)
|
||||
} else {
|
||||
game.load_cart(tag, Some(uuid), kind);
|
||||
let c = game.carts.last().unwrap();
|
||||
assert_eq!(
|
||||
c.items
|
||||
.first()
|
||||
.and_then(Option::as_ref)
|
||||
.map_or(0, |s| s.count),
|
||||
if *expected_items == Some(1) { 3 } else { 0 },
|
||||
"{name}: actual cart inventory"
|
||||
);
|
||||
Target::Cart(c.id)
|
||||
};
|
||||
let id = match target {
|
||||
Target::Boat(id) | Target::Cart(id) => id,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let saved = game.entity_nbt(target).unwrap();
|
||||
assert_eq!(saved.get("UUID"), Some(&Tag::IntArray(uuid.to_vec())));
|
||||
assert_eq!(
|
||||
saved.get("Tags"),
|
||||
Some(&Tag::List(vec![Tag::String("h052".into())]))
|
||||
);
|
||||
let actual_items = match saved.get("Items") {
|
||||
Some(Tag::List(items)) => Some(items.len()),
|
||||
None => None,
|
||||
other => panic!("{name}: {other:?}"),
|
||||
};
|
||||
if actual_items != *expected_items {
|
||||
differences.push(format!(
|
||||
"{name}: Items {actual_items:?} != {expected_items:?}"
|
||||
));
|
||||
}
|
||||
let table = pending.then(|| Tag::String((*table_text).into()));
|
||||
let seed = pending.then_some(Tag::Long(73));
|
||||
for (key, expected) in [
|
||||
("LootTable", table.as_ref()),
|
||||
("LootTableSeed", seed.as_ref()),
|
||||
] {
|
||||
if saved.get(key) != expected {
|
||||
differences.push(format!(
|
||||
"{name}: {key} {:?} != {expected:?}",
|
||||
saved.get(key)
|
||||
));
|
||||
}
|
||||
}
|
||||
if *expected_items == Some(1) {
|
||||
// The model, not a stale loaded Items list, owns the inventory.
|
||||
let slots = match target {
|
||||
Target::Boat(_) => &mut game.boats.last_mut().unwrap().items,
|
||||
Target::Cart(_) => &mut game.carts.last_mut().unwrap().items,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
slots[0].as_mut().unwrap().count = 4;
|
||||
}
|
||||
// A normal model can save without any raw input. Boat pending tables
|
||||
// already live in the retained source; no replacement pending state is invented.
|
||||
if !pending {
|
||||
game.persist.raw.remove(&id);
|
||||
let saved = game.entity_nbt(target).unwrap();
|
||||
let expected = if *expected_items == Some(1) { 4 } else { 0 };
|
||||
let count = match saved.get("Items") {
|
||||
Some(Tag::List(items)) => items.first().and_then(|t| t.get("count")).cloned(),
|
||||
_ => None,
|
||||
};
|
||||
assert_eq!(
|
||||
count,
|
||||
(expected != 0).then_some(Tag::Int(expected)),
|
||||
"{name}: no-raw model count"
|
||||
);
|
||||
assert!(saved.get("LootTable").is_none() && saved.get("LootTableSeed").is_none());
|
||||
assert_eq!(saved.get("UUID"), Some(&Tag::IntArray(uuid.to_vec())));
|
||||
}
|
||||
}
|
||||
// Retained loader diagnostic, not a writer parity assertion: an input
|
||||
// containing both a table and Items currently keeps modeled contents.
|
||||
for kind in ["minecraft:oak_chest_boat", "minecraft:chest_minecart"] {
|
||||
let source = r#"{Pos:[4.5d,200d,4.5d],LootTable:"minecraft:empty",LootTableSeed:73L,Items:[{Slot:0b,id:"minecraft:diamond",count:3}]}"#;
|
||||
let (tag, _) = parse_snbt(source).unwrap();
|
||||
let count = if kind.contains("boat") {
|
||||
game.load_boat(tag, Some([52, 1, 0, 1]), kind);
|
||||
game.boats.last().unwrap().items[0]
|
||||
.as_ref()
|
||||
.map_or(0, |s| s.count)
|
||||
} else {
|
||||
game.load_cart(tag, Some([52, 1, 0, 2]), kind);
|
||||
game.carts.last().unwrap().items[0]
|
||||
.as_ref()
|
||||
.map_or(0, |s| s.count)
|
||||
};
|
||||
eprintln!(
|
||||
"separate loader diagnostic {kind}: supplied table+Items retains modeled count {count}"
|
||||
);
|
||||
}
|
||||
assert_eq!(game.world.game_time, clock);
|
||||
assert!(
|
||||
differences.is_empty(),
|
||||
"class-owned save differences:\n{}",
|
||||
differences.join("\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,58 @@ use horizon_core::{position::ChunkPos, random::RandomSource};
|
||||
/// How long `/forceload add` waits for its chunks, as the game tests' loads do.
|
||||
const FORCED_CHUNK_WAIT: std::time::Duration = std::time::Duration::from_secs(120);
|
||||
|
||||
/// `LocationCheck.test` / `BlockPredicate`: the measured loaded block-state subset.
|
||||
fn loot_location_block(
|
||||
condition: &serde_json::Value,
|
||||
origin: [i32; 3],
|
||||
chunks: &horizon_client::chunk_map::ChunkMap,
|
||||
registry: &horizon_world::registry::BlockRegistry,
|
||||
items: &horizon_client::items::Items,
|
||||
) -> Option<bool> {
|
||||
let condition = condition.as_object()?;
|
||||
if condition.keys().any(|k| {
|
||||
!matches!(
|
||||
k.as_str(),
|
||||
"type" | "predicate" | "offsetX" | "offsetY" | "offsetZ"
|
||||
)
|
||||
}) {
|
||||
return None;
|
||||
}
|
||||
let predicate = condition.get("predicate")?.as_object()?;
|
||||
if predicate.len() != 1 || !predicate.contains_key("block") {
|
||||
return None;
|
||||
}
|
||||
let block = predicate.get("block")?.as_object()?;
|
||||
if block
|
||||
.keys()
|
||||
.any(|k| !matches!(k.as_str(), "blocks" | "state"))
|
||||
|| block.get("blocks")?.as_str().is_none()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if let Some(state) = block.get("state")
|
||||
&& state.as_object()?.values().any(|v| !v.is_string())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let mut at = origin;
|
||||
for (axis, key) in ["offsetX", "offsetY", "offsetZ"].iter().enumerate() {
|
||||
let offset = match condition.get(*key) {
|
||||
None => 0,
|
||||
Some(value) => i32::try_from(value.as_i64()?).ok()?,
|
||||
};
|
||||
// floor(integer block origin + .5 + integer offset), also for negatives.
|
||||
at[axis] = origin[axis].checked_add(offset)?;
|
||||
}
|
||||
// Unloaded positions are unsupported: never load or invent a block here.
|
||||
// `LocationPredicate.matches`: a block check fails where `Level.isLoaded` is false.
|
||||
let Some(chunk) = chunks.get(&(at[0] >> 4, at[2] >> 4)) else {
|
||||
return Some(false);
|
||||
};
|
||||
let state = registry.state(chunk.block(at[0] & 15, at[1], at[2] & 15))?;
|
||||
Some(items.matches_block_state(&predicate["block"], &state.name, &state.properties))
|
||||
}
|
||||
|
||||
fn num(n: impl std::fmt::Display) -> Text {
|
||||
Text::literal(n.to_string())
|
||||
}
|
||||
@@ -235,7 +287,7 @@ impl Game<'_> {
|
||||
continue;
|
||||
};
|
||||
let current = chunk.biome(x, y, z);
|
||||
let current_name = &world_biomes!(self).list[usize::from(current)].name;
|
||||
let current_name = world_biomes!(self).name(current);
|
||||
if let Some(f) = &filter
|
||||
&& !self.biome_matches(f, current_name)
|
||||
{
|
||||
@@ -615,11 +667,25 @@ impl Game<'_> {
|
||||
&[&self.target_name(t).plain()],
|
||||
));
|
||||
}
|
||||
let by_player = matches!(
|
||||
self.command_source.map(|s| s.entity),
|
||||
None | Some(Some(Target::Player))
|
||||
);
|
||||
let held = self.slot_get(&super::items::Slot::Inv(self.selected));
|
||||
// LootCommand.dropKillLoot: the source's entity is the killer.
|
||||
// A connected player is the last damage player, and its own
|
||||
// stack counts; it is in place only to read that stack.
|
||||
let guest = match self.command_source.map(|s| s.entity) {
|
||||
Some(Some(Target::Guest(conn))) => self.for_player(Target::Guest(conn), |g| {
|
||||
g.slot_get(&super::items::Slot::Inv(g.selected))
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
let (by_player, held) = match guest {
|
||||
Some(held) => (true, held),
|
||||
None => (
|
||||
matches!(
|
||||
self.command_source.map(|s| s.entity),
|
||||
None | Some(Some(Target::Player))
|
||||
),
|
||||
self.slot_get(&super::items::Slot::Inv(self.selected)),
|
||||
),
|
||||
};
|
||||
// LootCommand.dropKillLoot's THIS_ENTITY is the selected live mob.
|
||||
let target = match t {
|
||||
Target::Mob(id) => self
|
||||
@@ -712,22 +778,30 @@ impl Game<'_> {
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let chunks = &self.world.chunks;
|
||||
let registry = self.pack.assets.registry;
|
||||
let items = world_items!(self);
|
||||
let location = |condition: &serde_json::Value| {
|
||||
loot_location_block(condition, p, chunks, registry, items)
|
||||
};
|
||||
let mut drops = match world_items!(self).loot_sequence(&table) {
|
||||
Some(seq) => {
|
||||
let random = self.rand_sequences.get(&seq, self.level.seed);
|
||||
world_items!(self).block_drops_for_command(
|
||||
items.block_drops_with_location(
|
||||
self.pack.assets.registry,
|
||||
state,
|
||||
tool.as_ref(),
|
||||
&location,
|
||||
random,
|
||||
executor_present,
|
||||
keep_empty,
|
||||
)
|
||||
}
|
||||
None => world_items!(self).block_drops_for_command(
|
||||
None => items.block_drops_with_location(
|
||||
self.pack.assets.registry,
|
||||
state,
|
||||
tool.as_ref(),
|
||||
&location,
|
||||
&mut self.world.random,
|
||||
executor_present,
|
||||
keep_empty,
|
||||
@@ -753,13 +827,12 @@ impl Game<'_> {
|
||||
for &t in &self.entities(ctx, "players", true)? {
|
||||
// LootCommand.playerGive gives to every target player: a
|
||||
// guest gets it with their state in place, as with /give.
|
||||
let given = self.for_player(t, |g| {
|
||||
if let Some(rest) = g.give(d.clone()) {
|
||||
let p = g.source_position();
|
||||
g.spawn_item(rest, [p[0], p[1] + 1.32, p[2]], [0.0; 3], 0);
|
||||
}
|
||||
});
|
||||
if given.is_some() {
|
||||
// `Inventory.add` keeps what fits and drops the rest. It is
|
||||
// true only when all of it went in (its last pass, which
|
||||
// adds nothing, is what it compares), or always for a
|
||||
// player with infinite materials (creative).
|
||||
let added = self.for_player(t, |g| g.give(d.clone()).is_none() || g.creative);
|
||||
if added == Some(true) {
|
||||
used.push(d.clone());
|
||||
}
|
||||
}
|
||||
@@ -1474,3 +1547,237 @@ mod loot_kill_target_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod loot_mine_location_tests {
|
||||
use super::*;
|
||||
use crate::game::{Audio, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::{chunk::Chunk, containers::BlockEntity};
|
||||
use std::sync::Arc;
|
||||
const TABLE: &str = r###"{"type":"minecraft:block","pools":[{"rolls":1,"entries":[{"type":"minecraft:item","name":"minecraft:emerald","modifier":{"type":"minecraft:set_count","count":2}}]},{"rolls":1,"condition":{"type":"minecraft:location_check","offsetX":1,"predicate":{"block":{"blocks":"minecraft:oak_log","state":{"axis":"x"}}}},"entries":[{"type":"minecraft:item","name":"minecraft:diamond"}]}]}"###;
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
|
||||
fn loot_mine_location_reads_actual_offset_block_state() {
|
||||
let root = std::path::PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES").expect("prepared resources required"),
|
||||
);
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.game_dir = Some(
|
||||
std::path::PathBuf::from(std::env::var_os("HORIZON_HOME").unwrap()).join("h079-game"),
|
||||
);
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
game.world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
|
||||
game.world
|
||||
.chunks
|
||||
.insert((-1, -1), Arc::new(Chunk::empty(-1, -1)));
|
||||
game.world
|
||||
.chunks
|
||||
.insert((-1, 0), Arc::new(Chunk::empty(-1, 0)));
|
||||
game.world
|
||||
.chunks
|
||||
.insert((0, -1), Arc::new(Chunk::empty(0, -1)));
|
||||
for (x, z) in [(-1, -1), (-1, 0), (0, -1)] {
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(x, z))
|
||||
.unwrap();
|
||||
}
|
||||
game.world_root =
|
||||
std::path::PathBuf::from(std::env::var_os("HORIZON_HOME").unwrap()).join("h079-world");
|
||||
let pack = game.world_root.join("datapacks/h079");
|
||||
std::fs::create_dir_all(pack.join("data/minecraft/loot_table/blocks")).unwrap();
|
||||
std::fs::create_dir_all(pack.join("data/h079/function")).unwrap();
|
||||
std::fs::write(
|
||||
pack.join("pack.mcmeta"),
|
||||
r#"{"pack":{"description":"H079 fixture"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
pack.join("data/minecraft/loot_table/blocks/stone.json"),
|
||||
TABLE,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
pack.join("data/h079/function/admit.mcfunction"),
|
||||
"scoreboard players set pack h079 42\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
game.world_root.join("datapacks.json"),
|
||||
r#"{"enabled":["vanilla","file/h079"],"disabled":[]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
game.world_tables = game.build_world_tables(false, &[]).unwrap().0;
|
||||
assert!(game.world_tables.is_some());
|
||||
game.ensure_datapacks();
|
||||
assert!(game.run_command("scoreboard objectives add h079 dummy"));
|
||||
game.run_command("function h079:admit");
|
||||
assert!(
|
||||
game.run_command("execute if score pack h079 matches 42"),
|
||||
"real pack function admission"
|
||||
);
|
||||
assert!(game.run_command("forceload add -16 -16 15 15"));
|
||||
assert!(game.run_command("fill -16 199 -16 15 199 15 stone"));
|
||||
let clock = game.world.game_time;
|
||||
let mut differences = Vec::new();
|
||||
for (label, x, z, counterpart, expected) in [
|
||||
("correct", 4, 4, Some("oak_log[axis=x]"), 1),
|
||||
("wrong-state", 8, 4, Some("oak_log[axis=z]"), 0),
|
||||
("absent", 12, 4, None, 0),
|
||||
("negative-center", -4, -4, Some("oak_log[axis=x]"), 1),
|
||||
] {
|
||||
assert!(game.run_command(&format!("setblock {x} 200 {z} stone strict")));
|
||||
if let Some(other) = counterpart {
|
||||
assert!(game.run_command(&format!("setblock {} 200 {z} {other} strict", x + 1)));
|
||||
}
|
||||
assert!(game.run_command(&format!(
|
||||
"execute if block {} 200 {z} {}",
|
||||
x + 1,
|
||||
counterpart.unwrap_or("air")
|
||||
)));
|
||||
let before = (game.world.block(x, 200, z), game.world.block(x + 1, 200, z));
|
||||
assert!(game.run_command(&format!("setblock {x} 200 10 chest strict")));
|
||||
let Some(BlockEntity::Chest(initial)) = game.world.block_entities.get(&(x, 200, 10))
|
||||
else {
|
||||
panic!("real output chest")
|
||||
};
|
||||
assert!(initial.iter().all(Option::is_none));
|
||||
let prefix = if x < 0 {
|
||||
"execute positioned -8.25 200.125 -8.75 run "
|
||||
} else {
|
||||
""
|
||||
};
|
||||
assert!(game.run_command(&format!(
|
||||
"{prefix}loot insert {x} 200 10 mine {x} 200 {z} stick"
|
||||
)));
|
||||
let Some(BlockEntity::Chest(output)) = game.world.block_entities.get(&(x, 200, 10))
|
||||
else {
|
||||
panic!("actual output")
|
||||
};
|
||||
assert!(
|
||||
output
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|s| s.item.as_ref() == "minecraft:emerald" && s.count == 2),
|
||||
"pack override and unconditional roll are real"
|
||||
);
|
||||
let diamonds: u32 = output
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|s| s.item.as_ref() == "minecraft:diamond")
|
||||
.map(|s| s.count)
|
||||
.sum();
|
||||
if diamonds != expected {
|
||||
differences.push((label, diamonds, expected));
|
||||
}
|
||||
assert_eq!(
|
||||
(game.world.block(x, 200, z), game.world.block(x + 1, 200, z)),
|
||||
before
|
||||
);
|
||||
}
|
||||
assert_eq!(game.world.game_time, clock);
|
||||
assert!(
|
||||
differences.is_empty(),
|
||||
"actual offset block predicate differences: {differences:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod loot_location_shape_tests {
|
||||
use super::*;
|
||||
use horizon_client::{chunk::Chunk, chunk_map::ChunkMap};
|
||||
use horizon_world::registry::BlockRegistry;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn location_shape_uses_loaded_facts_and_rejects_whole_unsupported_conjunctions() {
|
||||
let registry = BlockRegistry::from_facts_json(
|
||||
&br#"{"defaults":{"minecraft:air":0,"minecraft:oak_log":1},"states":[
|
||||
["air",true,false,"","minecraft:air"],
|
||||
["log",false,false,"",{"id":"minecraft:oak_log","properties":{"axis":"x"}}]
|
||||
]}"#[..],
|
||||
)
|
||||
.unwrap();
|
||||
let mut positive = Chunk::empty(0, 0);
|
||||
positive.set(5, 200, 4, 1);
|
||||
let mut negative = Chunk::empty(-1, -1);
|
||||
negative.set(13, 200, 12, 1);
|
||||
let mut chunks = ChunkMap::default();
|
||||
chunks.insert((0, 0), Arc::new(positive));
|
||||
chunks.insert((-1, -1), Arc::new(negative));
|
||||
let items = horizon_client::items::Items::for_tests(Default::default());
|
||||
let valid = json!({"type":"minecraft:location_check","offsetX":1,"predicate":{"block":{"blocks":"minecraft:oak_log","state":{"axis":"x"}}}});
|
||||
assert_eq!(
|
||||
loot_location_block(&valid, [4, 200, 4], &chunks, ®istry, &items),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
loot_location_block(&valid, [-4, 200, -4], &chunks, ®istry, &items),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
loot_location_block(&valid, [8, 200, 4], &chunks, ®istry, &items),
|
||||
Some(false)
|
||||
);
|
||||
for (key, value) in [
|
||||
("biomes", json!("minecraft:plains")),
|
||||
("light", json!({"light":15})),
|
||||
("dimension", json!("minecraft:overworld")),
|
||||
] {
|
||||
let mut unsupported = valid.clone();
|
||||
unsupported["predicate"][key] = value;
|
||||
assert_eq!(
|
||||
loot_location_block(&unsupported, [4, 200, 4], &chunks, ®istry, &items),
|
||||
None
|
||||
);
|
||||
}
|
||||
for value in [json!(1.5), json!(2147483648_i64), json!("1")] {
|
||||
let mut unsupported = valid.clone();
|
||||
unsupported["offsetX"] = value;
|
||||
assert_eq!(
|
||||
loot_location_block(&unsupported, [4, 200, 4], &chunks, ®istry, &items),
|
||||
None
|
||||
);
|
||||
}
|
||||
let count = chunks.len();
|
||||
assert_eq!(
|
||||
loot_location_block(&valid, [32, 200, 32], &chunks, ®istry, &items),
|
||||
Some(false),
|
||||
"an unloaded counterpart fails, as Level.isLoaded does"
|
||||
);
|
||||
assert_eq!(chunks.len(), count, "no chunk is loaded as a side effect");
|
||||
}
|
||||
}
|
||||
@@ -274,7 +274,7 @@ impl Game<'_> {
|
||||
&[Text::literal(infos.len().to_string())],
|
||||
));
|
||||
let grid = Grid::new(self.test_around(), per_row);
|
||||
self.track_and_start_runner(infos, false, Some(grid), true);
|
||||
self.track_and_start_command_runner(infos, grid);
|
||||
Ok(1)
|
||||
}
|
||||
|
||||
|
||||
@@ -310,6 +310,7 @@ impl Game<'_> {
|
||||
Target::Crystal(id) => {
|
||||
self.command_damage_crystal(id, ty, Self::damage_kind(ty), cause.or(by))
|
||||
}
|
||||
Target::Stand(id) => self.command_damage_stand(id, ty, cause.or(by)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -1299,7 +1300,12 @@ impl Game<'_> {
|
||||
)))
|
||||
}
|
||||
"attribute.modifier.add" => {
|
||||
if finalize::has_modifier(m, attr, &modifier_id) {
|
||||
// AttributeInstance.hasModifier: the modeled transient
|
||||
// minecraft:baby counts too.
|
||||
if finalize::has_modifier(m, attr, &modifier_id)
|
||||
|| (modifier_id == "minecraft:baby"
|
||||
&& finalize::transient(m, attr).is_some())
|
||||
{
|
||||
return Err(text_error(
|
||||
"commands.attribute.failed.modifier_already_present",
|
||||
&[Text::literal(modifier_id.clone()), description, name],
|
||||
@@ -1320,10 +1326,20 @@ impl Game<'_> {
|
||||
)))
|
||||
}
|
||||
"attribute.modifier.remove" => {
|
||||
if !finalize::has_modifier(m, attr, &modifier_id) {
|
||||
// AttributeInstance.removeModifier: a permanent one, else
|
||||
// the modeled transient minecraft:baby (until setBaby).
|
||||
if finalize::has_modifier(m, attr, &modifier_id) {
|
||||
finalize::remove_modifier(m, attr, &modifier_id);
|
||||
} else if modifier_id == "minecraft:baby"
|
||||
&& finalize::transient(m, attr).is_some()
|
||||
{
|
||||
m.extra
|
||||
.attributes
|
||||
.get_or_insert_with(Box::default)
|
||||
.baby_speed_removed = true;
|
||||
} else {
|
||||
return Err(no_modifier);
|
||||
}
|
||||
finalize::remove_modifier(m, attr, &modifier_id);
|
||||
Ok(Some((
|
||||
1,
|
||||
Text::translate(
|
||||
|
||||
@@ -1381,6 +1381,7 @@ impl Game<'_> {
|
||||
Screen::DebugOptions => "debug-options",
|
||||
Screen::Mod => "mod",
|
||||
Screen::HudEditor => "hud-editor",
|
||||
Screen::Studio => "studio",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -129,6 +129,9 @@ fn screen_ids_are_distinct_and_stable() {
|
||||
Screen::DialogWarning,
|
||||
Screen::Win,
|
||||
Screen::DebugOptions,
|
||||
Screen::Mod,
|
||||
Screen::HudEditor,
|
||||
Screen::Studio,
|
||||
];
|
||||
let ids: Vec<&'static str> = all.iter().map(|screen| screen.id()).collect();
|
||||
let unique: BTreeSet<&'static str> = ids.iter().copied().collect();
|
||||
@@ -144,6 +147,30 @@ fn screen_ids_are_distinct_and_stable() {
|
||||
assert_eq!(Screen::Playing.id(), "playing");
|
||||
assert_eq!(Screen::Inventory.id(), "inventory");
|
||||
assert_eq!(Screen::Win.id(), "win");
|
||||
assert_eq!(Screen::Studio.id(), "studio");
|
||||
assert_ne!(Screen::Studio.id(), Screen::Chat.id());
|
||||
}
|
||||
|
||||
/// `Screen::Studio` is the Studio script slot: it never draws the chat screen
|
||||
/// (`Screen::Studio => {}` in `game.rs`'s draw match, unlike
|
||||
/// `Screen::Chat => draw_chat_screen`), it never blurs the background, and
|
||||
/// while it is up gameplay does not own the keyboard (WASD cannot walk
|
||||
/// through it, and movement keys are cleared on open).
|
||||
#[test]
|
||||
fn studio_screen_stays_off_the_chat_slot() {
|
||||
assert!(Screen::Studio != Screen::Chat);
|
||||
assert_eq!(Screen::Studio.id(), "studio");
|
||||
assert!(!Screen::Studio.blurs_background(false));
|
||||
assert!(!Screen::Studio.blurs_background(true));
|
||||
assert!(!gameplay_has_keyboard(Screen::Studio, false, false));
|
||||
// The pure gate behind `Game::open_studio_screen`, as
|
||||
// `native_mod_screen_open`'s `mod_screen_opens` is gated.
|
||||
use super::studio::studio_screen_opens;
|
||||
assert_eq!(studio_screen_opens(Screen::Playing, false), None);
|
||||
assert_eq!(studio_screen_opens(Screen::Playing, true), Some(false));
|
||||
assert_eq!(studio_screen_opens(Screen::Chat, false), Some(false));
|
||||
assert_eq!(studio_screen_opens(Screen::Studio, false), Some(true));
|
||||
assert_eq!(studio_screen_opens(Screen::Studio, true), Some(true));
|
||||
}
|
||||
|
||||
/// The `modifiers` a native mod's input hooks hand over are GLFW's bits
|
||||
|
||||
@@ -108,6 +108,8 @@ pub(super) struct Creatures {
|
||||
/// The snapshot's buffer between ticks, empty: ~400 bytes a mob, so
|
||||
/// thousands of mobs would allocate and free megabytes every tick.
|
||||
seen_buf: Vec<Seen>,
|
||||
/// Per-tick scratch retained between creature ticks.
|
||||
event_buf: Vec<MobEvent>,
|
||||
/// The targets the mobs had before their ticks, kept between ticks
|
||||
/// (empty) as `seen_buf` is: `native_living_change_target` hears a change.
|
||||
target_buf: Vec<Option<Who>>,
|
||||
@@ -215,11 +217,13 @@ impl ProjectileWorld for ProjWorld<'_, '_> {
|
||||
)
|
||||
.map(|c| (c.drag, c.surface))
|
||||
}
|
||||
fn solid(&self, p: [i32; 3]) -> bool {
|
||||
self.w
|
||||
.facts
|
||||
.get(self.w.world.block(p[0], p[1], p[2]))
|
||||
.full_collision
|
||||
fn can_stand_on(&self, p: [i32; 3], entity_y: f64) -> bool {
|
||||
horizon_client::collisions::projectile_can_stand_on(
|
||||
self.w.world,
|
||||
self.w.collision,
|
||||
BlockPos::new(p[0], p[1], p[2]),
|
||||
entity_y,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1166,7 +1170,8 @@ impl Game<'_> {
|
||||
let level_id = self.dim.id();
|
||||
let mut snapshot = std::mem::take(&mut self.creatures.seen_buf);
|
||||
snapshot.extend(self.mobs.iter().map(|m| seen(m, uuid)));
|
||||
let mut events = Vec::new();
|
||||
let mut events = std::mem::take(&mut self.creatures.event_buf);
|
||||
events.clear();
|
||||
let mut mobs = std::mem::take(&mut self.mobs);
|
||||
// Each mob's target as the mods last heard it, by its place in `mobs`
|
||||
// (`native_living_change_target`).
|
||||
@@ -1203,11 +1208,7 @@ impl Game<'_> {
|
||||
};
|
||||
let mut guest_events = Vec::new();
|
||||
// The block writes since the last tick: mobs re-path around them.
|
||||
let path_writes = self
|
||||
.world
|
||||
.path_writes
|
||||
.get_or_insert_with(Vec::new)
|
||||
.split_off(0);
|
||||
let path_writes = std::mem::take(self.world.path_writes.get_or_insert_with(Vec::new));
|
||||
// The mobs whose bodies go this tick: (id, feet, width, height, split).
|
||||
// 🤖 Agent: the last field is the grown slime, magma cube or sulfur
|
||||
// cube the body leaves behind (`AbstractCubeMob.remove`), captured as
|
||||
@@ -1575,7 +1576,9 @@ impl Game<'_> {
|
||||
let bounds = m.bounds();
|
||||
self.effects_on_death(&names, bounds, false, Some(m.id), Some(&mut m.random));
|
||||
}
|
||||
self.handle_mob_events(events)?;
|
||||
let event_result = self.handle_mob_events_reusable(&mut events);
|
||||
self.creatures.event_buf = events;
|
||||
event_result?;
|
||||
self.lan_guest_mob_events(guest_events);
|
||||
laps.lap("creature_events");
|
||||
self.ordered_before(super::ordered_tick::Batch::Projectiles)?;
|
||||
@@ -1653,7 +1656,7 @@ impl Game<'_> {
|
||||
cancel
|
||||
}
|
||||
/// LivingEntity.die / dropAllDeathLoot.
|
||||
fn mob_died(&mut self, m: &Mob) {
|
||||
pub(super) fn mob_died(&mut self, m: &Mob) {
|
||||
if self.mob_death_canceled(m) {
|
||||
return;
|
||||
}
|
||||
@@ -2312,8 +2315,12 @@ impl Game<'_> {
|
||||
self.projectile_event("minecraft:projectile_shoot", owner, at);
|
||||
}
|
||||
}
|
||||
pub(super) fn handle_mob_events(&mut self, events: Vec<MobEvent>) -> Result<()> {
|
||||
for e in events {
|
||||
pub(super) fn handle_mob_events(&mut self, mut events: Vec<MobEvent>) -> Result<()> {
|
||||
self.handle_mob_events_reusable(&mut events)
|
||||
}
|
||||
|
||||
pub(super) fn handle_mob_events_reusable(&mut self, events: &mut Vec<MobEvent>) -> Result<()> {
|
||||
for e in events.drain(..) {
|
||||
match e {
|
||||
MobEvent::BubbleColumnParticles { pos } => {
|
||||
self.bubble_column_particles(BlockPos::new(pos[0], pos[1], pos[2]))
|
||||
@@ -2608,6 +2615,11 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
MobEvent::LidPush {
|
||||
shulker,
|
||||
area,
|
||||
delta,
|
||||
} => self.lid_pushes_armor_stands(shulker, area, delta),
|
||||
MobEvent::Wololo { mob } => {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) {
|
||||
// DyeColor.RED.
|
||||
|
||||
@@ -537,7 +537,8 @@ fn debug_options_interrupts(screen: Screen, playing_overlay: bool) -> bool {
|
||||
| Screen::Dead
|
||||
| Screen::Win
|
||||
| Screen::DebugOptions
|
||||
| Screen::HudEditor => false,
|
||||
| Screen::HudEditor
|
||||
| Screen::Studio => false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1166,6 +1166,7 @@ impl Game<'_> {
|
||||
Vec::new()
|
||||
},
|
||||
owner_profile: None,
|
||||
singleplayer_profile: None,
|
||||
};
|
||||
let config = HubConfig {
|
||||
allow_flight: props.allow_flight,
|
||||
|
||||
@@ -433,11 +433,7 @@ impl Game<'_> {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
let cursor = self.cursor;
|
||||
let atlas = &self.gui.atlas;
|
||||
if self.player.is_none() {
|
||||
// In place of the connecting screen, on its background.
|
||||
batch.fill(atlas, 0.0, 0.0, w, h, rgba(20, 20, 20, 255));
|
||||
}
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.player.is_some());
|
||||
draw_background(batch, atlas, self.player.is_some());
|
||||
if self.dialogs.warning {
|
||||
let (title, message, [disconnect, back]) = warning_texts();
|
||||
// DialogScreen.WarningScreen, a ConfirmScreen: its title and its
|
||||
@@ -1197,3 +1193,69 @@ impl Game<'_> {
|
||||
serde_json::json!({ "open": open, "screen": screen })
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_background(batch: &mut HudBatch, atlas: &horizon_client::hud::HudAtlas, in_world: bool) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
// Screen.extractBackground draws the panorama and blur before this menu overlay.
|
||||
// The frame renderer already supplies them while a server is configuring.
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod background_tests {
|
||||
use super::*;
|
||||
use horizon_client::hud::{HudAtlas, Rect};
|
||||
|
||||
#[test]
|
||||
fn a_configuration_dialog_keeps_the_panorama_visible() {
|
||||
let mut atlas = HudAtlas::build(std::path::Path::new(
|
||||
"/nonexistent-horizon-dialog-background-test",
|
||||
))
|
||||
.unwrap();
|
||||
atlas.sprites.remove("gui/menu_background");
|
||||
let mut batch = HudBatch::new([640, 480]);
|
||||
draw_background(&mut batch, &atlas, false);
|
||||
assert_eq!(
|
||||
batch.vertices.len(),
|
||||
6,
|
||||
"only the menu overlay covers the panorama"
|
||||
);
|
||||
assert!(batch.vertices.iter().all(|v| v.color >> 24 == 160));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialogs_keep_the_menu_and_in_world_pack_backgrounds() {
|
||||
let mut atlas = HudAtlas::build(std::path::Path::new(
|
||||
"/nonexistent-horizon-dialog-background-test",
|
||||
))
|
||||
.unwrap();
|
||||
for (name, u) in [
|
||||
("gui/menu_background", 0.1),
|
||||
("gui/inworld_menu_background", 0.6),
|
||||
] {
|
||||
atlas.sprites.insert(
|
||||
name.into(),
|
||||
Rect {
|
||||
u0: u,
|
||||
v0: 0.2,
|
||||
u1: u + 0.1,
|
||||
v1: 0.3,
|
||||
w: 32,
|
||||
h: 32,
|
||||
},
|
||||
);
|
||||
}
|
||||
for (in_world, u) in [(false, 0.1), (true, 0.6)] {
|
||||
let mut batch = HudBatch::new([640, 480]);
|
||||
draw_background(&mut batch, &atlas, in_world);
|
||||
assert!(!batch.vertices.is_empty());
|
||||
assert!(
|
||||
batch
|
||||
.vertices
|
||||
.iter()
|
||||
.all(|v| v.uv[0] >= u && v.uv[0] <= u + 0.1)
|
||||
);
|
||||
assert!(batch.vertices.iter().all(|v| v.color == u32::MAX));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -297,6 +297,17 @@ impl EyeOfEnder {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The environment attributes the frame draws: the packs' Polytone layers over the
|
||||
/// current dimension's, when a pack has some. Gameplay keeps `dim_environment`.
|
||||
pub(super) fn view_environment(&self) -> &Environment {
|
||||
match (&self.pack.environments, self.dim) {
|
||||
(Some(e), Dim::Overworld) => &e[0],
|
||||
(Some(e), Dim::Nether) => &e[1],
|
||||
(Some(e), Dim::End) => &e[2],
|
||||
(None, _) => self.dim_environment(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The environment attributes of the current dimension.
|
||||
pub(super) fn dim_environment(&self) -> &Environment {
|
||||
match self.dim {
|
||||
|
||||
@@ -194,14 +194,15 @@ pub(super) trait Entity: EntityRules {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let (bounds, at, eye) = (self.bounds(), self.position(), self.fluid_eye_y());
|
||||
horizon_client::entities::update_entity_fluids(
|
||||
let (water, lava, _) = horizon_client::entities::update_entity_fluids(
|
||||
bounds,
|
||||
at,
|
||||
eye,
|
||||
self.velocity_mut(),
|
||||
world,
|
||||
fluids,
|
||||
)
|
||||
);
|
||||
(water, lava)
|
||||
}
|
||||
/// `BubbleColumnBlock.entityInside` through `applyEffectsFromBlocks`: a
|
||||
/// column lifts or drags every kind alike.
|
||||
@@ -525,6 +526,7 @@ mod render_bounds_tests {
|
||||
invisible: false,
|
||||
check_interval: 0,
|
||||
custom_name: None,
|
||||
random: horizon_core::random::LegacyRandom::new(0),
|
||||
};
|
||||
let bounds = frame.render_bounds(1.0);
|
||||
assert!(bounds.min_position().x <= -0.71875);
|
||||
@@ -966,7 +968,33 @@ macro_rules! load_entity_of_any_kind {
|
||||
};
|
||||
}
|
||||
|
||||
/// The type ids the minecart kind loads.
|
||||
fn loads_as_cart(kind: &str) -> bool {
|
||||
super::minecarts::CartKind::from_id(kind).is_some()
|
||||
}
|
||||
/// The type id the primed TNT kind loads.
|
||||
fn loads_as_tnt(kind: &str) -> bool {
|
||||
kind == "minecraft:tnt"
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The type ids the mob kind loads.
|
||||
fn loads_as_mob(&self, kind: &str) -> bool {
|
||||
self.res.mob_types().contains_key(kind)
|
||||
}
|
||||
/// The type ids the boat kind loads.
|
||||
fn loads_as_boat(&self, kind: &str) -> bool {
|
||||
super::boats::any_boat_wood(kind)
|
||||
.is_some_and(|(wood, chest)| self.boat_models.has(wood, chest))
|
||||
}
|
||||
/// Whether `load_listed` loads `kind` as a vehicle that takes its mob
|
||||
/// riders (`take_mob_passengers`): a mob, boat, minecart or primed TNT.
|
||||
pub(super) fn listed_kind_takes_riders(&self, kind: &str) -> bool {
|
||||
self.loads_as_mob(kind)
|
||||
|| self.loads_as_boat(kind)
|
||||
|| loads_as_cart(kind)
|
||||
|| loads_as_tnt(kind)
|
||||
}
|
||||
/// Loads a saved entity as the first kind in `entity_lists!` that claims
|
||||
/// its type id: `Err` hands the compound back when none does.
|
||||
pub(super) fn load_listed(
|
||||
@@ -1053,7 +1081,7 @@ impl Entity for Mob {
|
||||
_player_uuid: Option<[i32; 4]>,
|
||||
tag: Tag,
|
||||
) -> Result<(), Tag> {
|
||||
if !(game.res.mob_types().contains_key(kind)) {
|
||||
if !game.loads_as_mob(kind) {
|
||||
return Err(tag);
|
||||
}
|
||||
game.load_mob(tag, None);
|
||||
@@ -1293,9 +1321,7 @@ impl Entity for Boat {
|
||||
_player_uuid: Option<[i32; 4]>,
|
||||
tag: Tag,
|
||||
) -> Result<(), Tag> {
|
||||
if !(super::boats::any_boat_wood(kind)
|
||||
.is_some_and(|(wood, chest)| game.boat_models.has(wood, chest)))
|
||||
{
|
||||
if !game.loads_as_boat(kind) {
|
||||
return Err(tag);
|
||||
}
|
||||
game.load_boat(tag, uuid, kind);
|
||||
@@ -1364,7 +1390,7 @@ impl Entity for Minecart {
|
||||
_player_uuid: Option<[i32; 4]>,
|
||||
tag: Tag,
|
||||
) -> Result<(), Tag> {
|
||||
if !(super::minecarts::CartKind::from_id(kind).is_some()) {
|
||||
if !loads_as_cart(kind) {
|
||||
return Err(tag);
|
||||
}
|
||||
game.load_cart(tag, uuid, kind);
|
||||
@@ -1552,16 +1578,17 @@ impl Entity for PrimedTnt {
|
||||
_player_uuid: Option<[i32; 4]>,
|
||||
tag: Tag,
|
||||
) -> Result<(), Tag> {
|
||||
if !(kind == "minecraft:tnt") {
|
||||
if !loads_as_tnt(kind) {
|
||||
return Err(tag);
|
||||
}
|
||||
game.load_tnt(tag, uuid);
|
||||
Ok(())
|
||||
}
|
||||
stored_motion!();
|
||||
/// `PrimedTnt` eye height: `0.98 * 0.85`.
|
||||
/// `EntityTypes.TNT`'s explicit `eyeHeight(0.15F)`. Nothing vanilla reads
|
||||
/// for TNT depends on it (only `isEyeInFluid` does), but it is the type's.
|
||||
fn fluid_eye_y(&self) -> f64 {
|
||||
self.position.y + f64::from(0.98f32 * 0.85f32)
|
||||
self.position.y + f64::from(0.15_f32)
|
||||
}
|
||||
/// `PrimedTnt` is 0.98 across.
|
||||
fn portal_size(&self) -> Option<(f32, f32)> {
|
||||
@@ -2142,6 +2169,7 @@ mod set_rotation_tests {
|
||||
ticks: 0,
|
||||
health: 5,
|
||||
rotation: [0.0, 0.0],
|
||||
following: None,
|
||||
};
|
||||
orb.set_rotation([90.0, 10.0]);
|
||||
assert_eq!(orb.rotation(), [90.0, 10.0]);
|
||||
|
||||
@@ -135,7 +135,13 @@ impl Game<'_> {
|
||||
/// (the launcher's session file is gone after the first read).
|
||||
pub(super) fn extras_retry(&mut self) -> horizon_client::Result<()> {
|
||||
if let Some(retry) = self.title.as_mut().and_then(|t| t.due_retry()) {
|
||||
self.connect_server_with_options(&retry.address, &retry.name, None, retry.lan)?;
|
||||
self.connect_server_with_options(
|
||||
&retry.address,
|
||||
&retry.name,
|
||||
None,
|
||||
retry.lan,
|
||||
self.realm_session,
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -24,10 +24,19 @@ use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use super::lan_actions::GuestPlay;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "fishing_medium_tests.rs"]
|
||||
mod medium_tests;
|
||||
|
||||
/// The table a catch rolls where no mod's fishing medium names one.
|
||||
const FISHING_TABLE: &str = "minecraft:gameplay/fishing";
|
||||
|
||||
/// `FishingHook.retrieve`: the table's named random sequence, or the level
|
||||
/// random when a datapack table does not name one.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn fishing_loot(
|
||||
loot: &horizon_world::loot::LootData,
|
||||
table: &str,
|
||||
sequences: &mut horizon_client::random_sequences::RandomSequences,
|
||||
level_random: &mut LegacyRandom,
|
||||
world_seed: i64,
|
||||
@@ -35,7 +44,6 @@ fn fishing_loot(
|
||||
biome: Option<&str>,
|
||||
open_water: bool,
|
||||
) -> Vec<horizon_world::loot::LootStack> {
|
||||
let table = "minecraft:gameplay/fishing";
|
||||
match loot.random_sequence(table) {
|
||||
Some(sequence) => loot.roll(
|
||||
table,
|
||||
@@ -79,10 +87,39 @@ pub(super) struct Hook {
|
||||
open_water: bool,
|
||||
luck: i32,
|
||||
lure: i32,
|
||||
/// The fishing medium it last floated in outside water (`native_fishing_medium`): the
|
||||
/// table its catch rolls and the wait it draws. None in water.
|
||||
medium: Option<HookMedium>,
|
||||
/// The position a server's update put it heading for.
|
||||
lerp: HookLerp,
|
||||
pub(super) random: LegacyRandom,
|
||||
}
|
||||
/// What a hook keeps of the mod's fishing medium it floats in.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
struct HookMedium {
|
||||
loot_table: Option<String>,
|
||||
lure_ticks: Option<(i32, i32)>,
|
||||
}
|
||||
/// The medium a mod answered, when the hook is in it: in the block of its
|
||||
/// surface or below, as a hook is in water anywhere in a water block.
|
||||
fn inside_medium(
|
||||
position: Vec3,
|
||||
medium: Option<horizon_client::mod_api::NativeFishingMedium>,
|
||||
) -> Option<horizon_client::mod_api::NativeFishingMedium> {
|
||||
medium.filter(|m| m.surface.is_finite() && position.y < m.surface.floor() + 1.0)
|
||||
}
|
||||
/// `catchingFish`'s wait for a fish: vanilla's 100 to 600 ticks, or a medium's
|
||||
/// own range, less the rod's Lure.
|
||||
fn lure_wait(random: &mut LegacyRandom, range: Option<(i32, i32)>, lure: i32) -> i32 {
|
||||
match range {
|
||||
None => 100 + random.next_int_bound(501).unwrap_or(0) - lure,
|
||||
Some((min, max)) => {
|
||||
let min = min.max(1);
|
||||
let max = max.max(min);
|
||||
min + random.next_int_bound(max - min + 1).unwrap_or(0) - lure
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A player a hook can catch (`canHitEntity`): their entity id, which
|
||||
/// guest they are (none: the host) and their box.
|
||||
pub(super) struct HookTarget {
|
||||
@@ -369,7 +406,7 @@ impl Game<'_> {
|
||||
let mut state = (Vec3::new(x, 301.1, z), motion, State::Flying, false);
|
||||
let mut random = LegacyRandom::new(0);
|
||||
for tick in 0..5 {
|
||||
state = self.client_hook_step(state, &mut random);
|
||||
state = self.client_hook_step(1, None, state, &mut random);
|
||||
rows.push(serde_json::json!({
|
||||
"case": case, "tick": tick,
|
||||
"position": [state.0.x - f64::from(block[0]), state.0.y, state.0.z - f64::from(block[2])],
|
||||
@@ -476,6 +513,7 @@ impl Game<'_> {
|
||||
open_water: true,
|
||||
luck,
|
||||
lure,
|
||||
medium: None,
|
||||
lerp: HookLerp::default(),
|
||||
random,
|
||||
});
|
||||
@@ -568,8 +606,14 @@ impl Game<'_> {
|
||||
// explicit random source. LootContext uses the table's sequence,
|
||||
// or the level random if the table has no sequence.
|
||||
let luck = hook.luck as f32 + owner_luck;
|
||||
let table = hook
|
||||
.medium
|
||||
.as_ref()
|
||||
.and_then(|m| m.loot_table.as_deref())
|
||||
.unwrap_or(FISHING_TABLE);
|
||||
let items = fishing_loot(
|
||||
&loot,
|
||||
table,
|
||||
&mut self.rand_sequences,
|
||||
&mut self.world.random,
|
||||
self.level.seed,
|
||||
@@ -650,11 +694,7 @@ impl Game<'_> {
|
||||
pub(super) fn biome_name_at(&self, p: Vec3) -> Option<String> {
|
||||
let (x, y, z) = (p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
|
||||
let c = self.world.chunks.get(&(x >> 4, z >> 4))?;
|
||||
Some(
|
||||
world_biomes!(self).list[c.biome(x, y, z) as usize]
|
||||
.name
|
||||
.clone(),
|
||||
)
|
||||
Some(world_biomes!(self).name(c.biome(x, y, z)).to_owned())
|
||||
}
|
||||
fn water_height(&self, x: i32, y: i32, z: i32) -> Option<(f32, bool)> {
|
||||
let f = self.res.facts().get(self.world.block(x, y, z));
|
||||
@@ -832,7 +872,8 @@ impl Game<'_> {
|
||||
h.time_until_hooked = 20 + h.random.next_int_bound(61).unwrap_or(0);
|
||||
}
|
||||
} else {
|
||||
h.time_until_lured = 100 + h.random.next_int_bound(501).unwrap_or(0) - h.lure;
|
||||
let range = h.medium.as_ref().and_then(|m| m.lure_ticks);
|
||||
h.time_until_lured = lure_wait(&mut h.random, range, h.lure);
|
||||
}
|
||||
}
|
||||
/// `catchingFish`'s check below a fish: the block under it is water.
|
||||
@@ -909,7 +950,23 @@ impl Game<'_> {
|
||||
h.position.z.floor() as i32,
|
||||
];
|
||||
let liquid = self.water_height(b[0], b[1], b[2]).map_or(0.0, |w| w.0);
|
||||
let in_water = liquid > 0.0;
|
||||
// Out of water, a mod may say the bobber floats here all the same.
|
||||
let medium = if liquid > 0.0 || matches!(h.state, State::Hooked(_)) {
|
||||
None
|
||||
} else {
|
||||
self.hook_medium(h.id, h.position, Some(owner))
|
||||
};
|
||||
match &medium {
|
||||
Some(m) => {
|
||||
h.medium = Some(HookMedium {
|
||||
loot_table: m.loot_table.clone(),
|
||||
lure_ticks: m.lure_ticks,
|
||||
})
|
||||
}
|
||||
None if liquid > 0.0 => h.medium = None,
|
||||
None => {}
|
||||
}
|
||||
let in_water = liquid > 0.0 || medium.is_some();
|
||||
// The block a flying bobber meets or rests on this tick (and the face), for
|
||||
// `native_fishing_hook_landed` once the tick is done.
|
||||
let rested = h.on_ground;
|
||||
@@ -1068,7 +1125,10 @@ impl Game<'_> {
|
||||
}
|
||||
State::Bobbing => {
|
||||
let v = h.velocity;
|
||||
let mut force = h.position.y + v.y - f64::from(b[1]) - f64::from(liquid);
|
||||
let mut force = match &medium {
|
||||
Some(m) => h.position.y + v.y - m.surface,
|
||||
None => h.position.y + v.y - f64::from(b[1]) - f64::from(liquid),
|
||||
};
|
||||
if force.abs() < 0.01 {
|
||||
force += force.signum() * 0.1;
|
||||
}
|
||||
@@ -1077,8 +1137,10 @@ impl Game<'_> {
|
||||
if h.nibble <= 0 && h.time_until_hooked <= 0 {
|
||||
h.open_water = true;
|
||||
} else {
|
||||
h.open_water =
|
||||
h.open_water && h.out_of_water < 10 && self.calculate_open_water(b);
|
||||
// A medium has no water around it to be open: it counts as open.
|
||||
h.open_water = h.open_water
|
||||
&& h.out_of_water < 10
|
||||
&& (medium.is_some() || self.calculate_open_water(b));
|
||||
}
|
||||
if in_water {
|
||||
h.out_of_water = (h.out_of_water - 1).max(0);
|
||||
@@ -1368,6 +1430,7 @@ impl Game<'_> {
|
||||
"lured_in": h.time_until_lured,
|
||||
"hooked_in": h.time_until_hooked,
|
||||
"open_water": h.open_water,
|
||||
"loot_table": h.medium.as_ref().and_then(|m| m.loot_table.clone()),
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -1480,6 +1543,7 @@ impl Hook {
|
||||
open_water: true,
|
||||
luck: 0,
|
||||
lure: 0,
|
||||
medium: None,
|
||||
lerp: HookLerp::default(),
|
||||
random: random_source::entity(),
|
||||
}
|
||||
@@ -1616,7 +1680,10 @@ impl Game<'_> {
|
||||
h.previous = h.position;
|
||||
h.position = h.lerp.step(h.position);
|
||||
let before = h.position;
|
||||
let owner = self.player.as_ref().map(|p| p.position);
|
||||
(h.position, h.velocity, h.state, h.on_ground) = self.client_hook_step(
|
||||
h.id,
|
||||
owner,
|
||||
(h.position, h.velocity, h.state, h.on_ground),
|
||||
&mut h.random,
|
||||
);
|
||||
@@ -1628,7 +1695,14 @@ impl Game<'_> {
|
||||
h.previous = h.position;
|
||||
h.position = h.lerp.step(h.position);
|
||||
let before = h.position;
|
||||
let owner = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == h.owner)
|
||||
.map(|m| m.position);
|
||||
(h.position, h.velocity, h.state, h.on_ground) = self.client_hook_step(
|
||||
h.id,
|
||||
owner,
|
||||
(h.position, h.velocity, h.state, h.on_ground),
|
||||
&mut h.random,
|
||||
);
|
||||
@@ -1637,6 +1711,25 @@ impl Game<'_> {
|
||||
self.remote_hooks = hooks;
|
||||
}
|
||||
|
||||
/// The fishing medium a mod says the hook at `position` floats in
|
||||
/// (`native_fishing_medium`), when it is in it. API @API:fishing_medium@.
|
||||
fn hook_medium(
|
||||
&mut self,
|
||||
id: u32,
|
||||
position: Vec3,
|
||||
owner: Option<Vec3>,
|
||||
) -> Option<horizon_client::mod_api::NativeFishingMedium> {
|
||||
let mut medium = None;
|
||||
let dimension = self.dim.id();
|
||||
self.native_fishing_medium(
|
||||
dimension,
|
||||
id,
|
||||
[position.x, position.y, position.z],
|
||||
owner.map(|o| [o.x, o.y, o.z]),
|
||||
&mut medium,
|
||||
);
|
||||
inside_medium(position, medium)
|
||||
}
|
||||
/// `FishingHook.checkCollision` clips blocks on both sides of the connection.
|
||||
fn hook_block_hit(&self, from: Vec3, to: Vec3) -> Option<Vec3> {
|
||||
self.hook_block_hit_at(from, to).map(|hit| hit.location)
|
||||
@@ -1666,6 +1759,8 @@ impl Game<'_> {
|
||||
/// server's): position, motion, state and whether it rests on the ground.
|
||||
fn client_hook_step(
|
||||
&mut self,
|
||||
id: u32,
|
||||
owner: Option<Vec3>,
|
||||
(mut position, mut velocity, state, mut on_ground): (Vec3, Vec3, State, bool),
|
||||
random: &mut impl RandomSource,
|
||||
) -> (Vec3, Vec3, State, bool) {
|
||||
@@ -1675,7 +1770,13 @@ impl Game<'_> {
|
||||
position.z.floor() as i32,
|
||||
];
|
||||
let liquid = self.water_height(b[0], b[1], b[2]).map_or(0.0, |w| w.0);
|
||||
let in_water = liquid > 0.0;
|
||||
// The same fishing medium the server's hook floats in, so it is drawn floating too.
|
||||
let medium = if liquid > 0.0 || matches!(state, State::Hooked(_)) {
|
||||
None
|
||||
} else {
|
||||
self.hook_medium(id, position, owner)
|
||||
};
|
||||
let in_water = liquid > 0.0 || medium.is_some();
|
||||
match state {
|
||||
State::Flying if in_water => {
|
||||
velocity = Vec3::new(velocity.x * 0.3, velocity.y * 0.2, velocity.z * 0.3);
|
||||
@@ -1730,7 +1831,10 @@ impl Game<'_> {
|
||||
};
|
||||
}
|
||||
State::Bobbing => {
|
||||
let mut force = position.y + velocity.y - f64::from(b[1]) - f64::from(liquid);
|
||||
let mut force = match &medium {
|
||||
Some(m) => position.y + velocity.y - m.surface,
|
||||
None => position.y + velocity.y - f64::from(b[1]) - f64::from(liquid),
|
||||
};
|
||||
if force.abs() < 0.01 {
|
||||
force += force.signum() * 0.1;
|
||||
}
|
||||
@@ -1887,6 +1991,7 @@ mod tests {
|
||||
let mut level_random = LegacyRandom::new(1);
|
||||
let items = super::fishing_loot(
|
||||
&loot,
|
||||
super::FISHING_TABLE,
|
||||
&mut sequences,
|
||||
&mut level_random,
|
||||
seed,
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
//! Run alone with private prepared resources: installs process-wide trait mods.
|
||||
use super::super::*;
|
||||
use super::{Hook, State};
|
||||
use crate::mod_api::{self, FishingMediumEvent, Hooks, Host, NativeFishingMedium};
|
||||
use horizon_client::chunk::Chunk;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Fishes the air east of x = 3, four blocks under its owner's feet, rolling the junk table.
|
||||
#[derive(Default)]
|
||||
struct Probe {
|
||||
asked: Mutex<Vec<(String, Option<[f64; 3]>)>>,
|
||||
}
|
||||
const JUNK: &str = "minecraft:gameplay/fishing/junk";
|
||||
impl Hooks for Arc<Probe> {
|
||||
fn fishing_medium(
|
||||
&self,
|
||||
_: &mut dyn Host,
|
||||
event: &mut FishingMediumEvent<'_>,
|
||||
) -> Option<NativeFishingMedium> {
|
||||
self.asked
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((event.dimension.to_owned(), event.owner));
|
||||
(event.position[0] > 3.0).then(|| NativeFishingMedium {
|
||||
surface: event.owner.map_or(60.0, |o| o[1] - 4.0),
|
||||
loot_table: Some(JUNK.into()),
|
||||
lure_ticks: Some((5, 10)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const FISH: [&str; 4] = [
|
||||
"minecraft:cod",
|
||||
"minecraft:salmon",
|
||||
"minecraft:pufferfish",
|
||||
"minecraft:tropical_fish",
|
||||
];
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and an isolated mod registry"]
|
||||
fn a_bobber_floats_bites_and_rolls_the_table_of_a_mods_medium() {
|
||||
let root = PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
|
||||
let probe = Arc::new(Probe::default());
|
||||
assert!(
|
||||
mod_api::MODS
|
||||
.set(Arc::from(vec![Box::new(probe.clone()) as Box<dyn Hooks>]))
|
||||
.is_ok()
|
||||
);
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.world = World::new(game.res.facts().air, 1);
|
||||
game.world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(0.5, 64.0, 0.5)).unwrap());
|
||||
game.selected = 0;
|
||||
game.set_mode_id(0);
|
||||
let rod = ItemStack::new("minecraft:fishing_rod", 1);
|
||||
game.inventory[0] = Some(rod.clone());
|
||||
|
||||
// Over air the mod doesn't claim, the bobber falls as vanilla's does.
|
||||
game.hook = Some(Hook::remote(1, Vec3::new(1.5, 66.0, 0.5)));
|
||||
for _ in 0..40 {
|
||||
game.tick_fishing();
|
||||
}
|
||||
let hook = game.hook.as_ref().expect("still within reach");
|
||||
assert!(hook.position.y < 58.0, "fell to {}", hook.position.y);
|
||||
assert_eq!(game.hook_status().unwrap()["state"], "Flying");
|
||||
assert!(game.hook_status().unwrap()["loot_table"].is_null());
|
||||
{
|
||||
let asked = probe.asked.lock().unwrap();
|
||||
assert!(!asked.is_empty());
|
||||
assert!(
|
||||
asked.iter().all(
|
||||
|(dim, owner)| dim == "minecraft:overworld" && *owner == Some([0.5, 64.0, 0.5])
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// In the mod's medium it falls to the surface, floats there and gets a bite.
|
||||
let mut caught = Vec::new();
|
||||
for cast in 0..6 {
|
||||
let id = 10 + cast;
|
||||
game.hook = Some(Hook::remote(id, Vec3::new(5.5, 66.0, 0.5)));
|
||||
let mut bit = false;
|
||||
for tick in 0..400 {
|
||||
game.tick_fishing();
|
||||
let status = game.hook_status().expect("the bobber stays out");
|
||||
let y = game.hook.as_ref().unwrap().position.y;
|
||||
if tick > 60 {
|
||||
assert_eq!(status["state"], "Bobbing");
|
||||
assert!((59.0..61.0).contains(&y), "floats at {y} on tick {tick}");
|
||||
}
|
||||
if status["nibble"].as_i64().unwrap() > 0 {
|
||||
assert_eq!(status["loot_table"], JUNK);
|
||||
bit = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(bit, "no bite in 20 seconds on cast {cast}");
|
||||
let before = game.dropped.len();
|
||||
assert!(game.use_fishing_rod(&rod));
|
||||
assert!(game.hook.is_none());
|
||||
let items: Vec<String> = game.dropped[before..]
|
||||
.iter()
|
||||
.map(|i| i.stack.item.to_string())
|
||||
.collect();
|
||||
assert!(!items.is_empty(), "a bite reeled in nothing");
|
||||
caught.extend(items);
|
||||
}
|
||||
// The junk table has no fish, where vanilla's would have landed some by now.
|
||||
assert!(
|
||||
caught.iter().all(|item| !FISH.contains(&item.as_str())),
|
||||
"{caught:?}"
|
||||
);
|
||||
|
||||
// A client draws its own bobber floating in the same medium.
|
||||
let mut state = (Vec3::new(5.5, 62.0, 0.5), Vec3::ZERO, State::Flying, false);
|
||||
let mut random = horizon_core::random::LegacyRandom::new(7);
|
||||
for tick in 0..100 {
|
||||
state = game.client_hook_step(1, Some(Vec3::new(0.5, 64.0, 0.5)), state, &mut random);
|
||||
if tick > 60 {
|
||||
assert!((59.0..61.0).contains(&state.0.y), "client at {}", state.0.y);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,6 +182,7 @@ impl Game<'_> {
|
||||
check_interval: 0,
|
||||
// `applyComponentsFromItemStack` sets `CUSTOM_NAME`.
|
||||
custom_name: stack.data().and_then(|d| d.custom_name.clone()),
|
||||
random: horizon_client::random_source::entity(),
|
||||
};
|
||||
// `EntityType.createDefaultStackConfig`: the item's `custom_data`, then its
|
||||
// `entity_data` merged over the frame (`TypedEntityData.loadInto`), before
|
||||
@@ -354,7 +355,7 @@ impl Game<'_> {
|
||||
.to_owned();
|
||||
let rotation = self.item_frames[i].rotation;
|
||||
let at = center(&self.item_frames[i]);
|
||||
let f = if with_frame {
|
||||
let mut f = if with_frame {
|
||||
self.item_frames.remove(i)
|
||||
} else {
|
||||
self.item_frames[i].clone()
|
||||
@@ -372,7 +373,10 @@ impl Game<'_> {
|
||||
// item only gives the item up (ItemFrame.hurtServer).
|
||||
self.entity_killed_event(at, true);
|
||||
}
|
||||
self.drop_frame_items(&f, with_frame, true);
|
||||
self.drop_frame_items(&mut f, with_frame, true);
|
||||
if !with_frame {
|
||||
self.item_frames[i].random = f.random.clone();
|
||||
}
|
||||
// BLOCK_CHANGE by the attacker after the drops: ItemFrame.hurtServer
|
||||
// for the item, ItemFrame.dropItem(level, causedBy) for the frame.
|
||||
let ctx = self.who_context(Some(horizon_client::mobs::Who::Player), None);
|
||||
@@ -415,7 +419,7 @@ impl Game<'_> {
|
||||
self.with_level(|b, level, ticks| b.update_neighbour_for_output_signal(level, ticks, at));
|
||||
}
|
||||
/// `ItemFrame.dropItem`: the frame (when breaking) and the held item.
|
||||
fn drop_frame_items(&mut self, f: &ItemFrame, with_frame: bool, by_player: bool) {
|
||||
fn drop_frame_items(&mut self, f: &mut ItemFrame, with_frame: bool, by_player: bool) {
|
||||
// The framed map forgets the frame, except when the drops are off and
|
||||
// something caused the break (`removeFramedMap` runs only for a null
|
||||
// `causedBy` then).
|
||||
@@ -434,7 +438,7 @@ impl Game<'_> {
|
||||
drops.push(f.item_stack_with_data());
|
||||
}
|
||||
if let Some(item) = f.item.clone()
|
||||
&& self.world.random.next_float() < f.drop_chance
|
||||
&& f.random.next_float() < f.drop_chance
|
||||
{
|
||||
drops.push(item);
|
||||
}
|
||||
@@ -481,11 +485,11 @@ impl Game<'_> {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let f = self.item_frames.remove(i);
|
||||
let mut f = self.item_frames.remove(i);
|
||||
self.entity_killed_event(center(&f), false);
|
||||
self.audio
|
||||
.play(&sound(&f, "break"), Some(center(&f)), 1.0, 1.0);
|
||||
self.drop_frame_items(&f, true, false);
|
||||
self.drop_frame_items(&mut f, true, false);
|
||||
// ItemFrame.dropItem(level, causedBy): BLOCK_CHANGE after the drops.
|
||||
let ctx = self.who_context(None, None);
|
||||
self.post_game_event("minecraft:block_change", center(&f), ctx);
|
||||
@@ -509,14 +513,14 @@ impl Game<'_> {
|
||||
self.item_frames[i].check_interval += 1;
|
||||
if self.item_frames[i].check_interval >= 100 {
|
||||
self.item_frames[i].check_interval = 0;
|
||||
let f = self.item_frames[i].clone();
|
||||
let mut f = self.item_frames[i].clone();
|
||||
if !self.frame_survives(&f) && !self.hanging_break_cancelled(f.kind(), f.pos, false)
|
||||
{
|
||||
self.item_frames.remove(i);
|
||||
self.entity_killed_event(center(&f), false);
|
||||
self.audio
|
||||
.play(&sound(&f, "break"), Some(center(&f)), 1.0, 1.0);
|
||||
self.drop_frame_items(&f, true, false);
|
||||
self.drop_frame_items(&mut f, true, false);
|
||||
// ItemFrame.dropItem(level, null): BLOCK_CHANGE after the drops.
|
||||
let ctx = self.who_context(None, None);
|
||||
self.post_game_event("minecraft:block_change", center(&f), ctx);
|
||||
@@ -780,6 +784,7 @@ mod map_limit_tests {
|
||||
invisible: false,
|
||||
check_interval: 0,
|
||||
custom_name: None,
|
||||
random: horizon_core::random::LegacyRandom::new(0),
|
||||
}
|
||||
}
|
||||
/// A map whose `count` frame markers all count toward the limit.
|
||||
|
||||
@@ -2486,4 +2486,261 @@ mod required_tag_tests {
|
||||
"required tag partial execution: {failures:#?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn named_predicates_resolve_loaded_references_for_actual_sources() {
|
||||
use crate::game::{Audio, Options, Settings, load_resources};
|
||||
use std::time::Instant;
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&crate::game::Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"horizon-predicate-reference-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let pack = dir.join("datapacks/references");
|
||||
let folder = pack.join("data/h121/predicate");
|
||||
std::fs::create_dir_all(&folder).unwrap();
|
||||
std::fs::write(pack.join("pack.mcmeta"), r#"{"pack":{"description":"Predicate references","pack_format":121,"min_format":[121,0],"max_format":[121,0]}}"#).unwrap();
|
||||
let write = |name: &str, value: serde_json::Value| {
|
||||
std::fs::write(folder.join(format!("{name}.json")), value.to_string()).unwrap();
|
||||
};
|
||||
write(
|
||||
"pig",
|
||||
serde_json::json!({"type":"minecraft:entity_properties","entity":"this","predicate":{"minecraft:entity_type":"minecraft:pig"}}),
|
||||
);
|
||||
write(
|
||||
"cow",
|
||||
serde_json::json!({"type":"minecraft:entity_properties","entity":"this","predicate":{"minecraft:entity_type":"minecraft:cow"}}),
|
||||
);
|
||||
write(
|
||||
"reference",
|
||||
serde_json::json!({"type":"minecraft:all_of","terms":["h121:pig"]}),
|
||||
);
|
||||
write(
|
||||
"inverted",
|
||||
serde_json::json!({"type":"minecraft:inverted","term":"h121:pig"}),
|
||||
);
|
||||
write(
|
||||
"conjunction",
|
||||
serde_json::json!({"type":"minecraft:all_of","terms":["h121:pig",{"type":"minecraft:inverted","term":"h121:cow"}]}),
|
||||
);
|
||||
write(
|
||||
"both",
|
||||
serde_json::json!({"type":"minecraft:all_of","terms":["h121:pig","h121:cow"]}),
|
||||
);
|
||||
write(
|
||||
"either",
|
||||
serde_json::json!({"type":"minecraft:any_of","terms":["h121:pig","h121:cow"]}),
|
||||
);
|
||||
write(
|
||||
"repeated",
|
||||
serde_json::json!({"type":"minecraft:all_of","terms":["h121:pig","h121:pig"]}),
|
||||
);
|
||||
for index in 0..33 {
|
||||
let next = if index == 32 {
|
||||
"h121:pig".to_owned()
|
||||
} else {
|
||||
format!("h121:chain_{}", index + 1)
|
||||
};
|
||||
write(
|
||||
&format!("chain_{index}"),
|
||||
serde_json::json!({"type":"minecraft:all_of","terms":[next]}),
|
||||
);
|
||||
}
|
||||
game.world_root = dir.clone();
|
||||
game.reload_datapacks(false);
|
||||
assert!(game.fns.store.predicates.contains_key("h121:reference"));
|
||||
assert!(
|
||||
game.ui
|
||||
.registries
|
||||
.predicates
|
||||
.contains(&"h121:reference".into())
|
||||
);
|
||||
let run = |game: &mut Game<'_>, line: &str| {
|
||||
game.exec = ExecState::default();
|
||||
let source = game.player_source();
|
||||
game.execute_line(line, source).unwrap();
|
||||
};
|
||||
run(&mut game, "scoreboard objectives add h121 dummy");
|
||||
let mut actors = Vec::new();
|
||||
for (index, kind) in ["pig", "cow"].into_iter().enumerate() {
|
||||
let uuid = [121, 20, 30, index as i32 + 1];
|
||||
let tag = Tag::compound([
|
||||
(
|
||||
"id",
|
||||
Tag::String(format!("minecraft:{kind}").as_str().into()),
|
||||
),
|
||||
("UUID", Tag::IntArray(uuid.to_vec())),
|
||||
(
|
||||
"Pos",
|
||||
Tag::List(vec![
|
||||
Tag::Double((4.5 + index as f64 * 4.0).to_bits()),
|
||||
Tag::Double(200.0_f64.to_bits()),
|
||||
Tag::Double(4.5_f64.to_bits()),
|
||||
]),
|
||||
),
|
||||
("Health", Tag::Float(10.0_f32.to_bits())),
|
||||
("NoAI", Tag::Byte(1)),
|
||||
("NoGravity", Tag::Byte(1)),
|
||||
]);
|
||||
let id = game.load_mob(tag, None).unwrap();
|
||||
assert_eq!(game.persist.uuids.get(&id), Some(&uuid));
|
||||
assert_eq!(
|
||||
game.entity_view_of(Target::Mob(id)).kind,
|
||||
format!("minecraft:{kind}")
|
||||
);
|
||||
actors.push((id, save::uuid_text(uuid), kind));
|
||||
}
|
||||
let clock = game.world.game_time;
|
||||
for (_, uuid, kind) in &actors {
|
||||
let pig = *kind == "pig";
|
||||
for (name, expected) in [
|
||||
("pig", pig),
|
||||
("cow", !pig),
|
||||
("reference", pig),
|
||||
("inverted", !pig),
|
||||
("conjunction", pig),
|
||||
("both", false),
|
||||
("either", true),
|
||||
("repeated", pig),
|
||||
("chain_0", pig),
|
||||
] {
|
||||
for sign in ["if", "unless"] {
|
||||
run(&mut game, "scoreboard players set branch h121 -99");
|
||||
run(
|
||||
&mut game,
|
||||
&format!(
|
||||
"execute as {uuid} {sign} predicate h121:{name} run scoreboard players set branch h121 1"
|
||||
),
|
||||
);
|
||||
// The intended old failure is the real loaded reference on the pig.
|
||||
assert_eq!(
|
||||
game.score.board.score("branch", "h121").unwrap().value,
|
||||
if expected == (sign == "if") { 1 } else { -99 },
|
||||
"{kind} {sign} {name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let previous = game.command_source;
|
||||
let server = game.server_source();
|
||||
assert!(server.entity.is_none());
|
||||
game.command_source = Some(server);
|
||||
assert!(!game.check_predicate("h121:pig").unwrap());
|
||||
assert!(!game.check_predicate("h121:reference").unwrap());
|
||||
assert!(game.check_predicate("h121:inverted").unwrap());
|
||||
assert!(game.check_predicate("h121:absent").is_err());
|
||||
game.command_source = previous;
|
||||
// Defensive evaluator controls only. Java rejects these graphs at
|
||||
// world load, so this does not establish startup/availability parity.
|
||||
write(
|
||||
"missing",
|
||||
serde_json::json!({"type":"minecraft:all_of","terms":["h121:absent"]}),
|
||||
);
|
||||
write(
|
||||
"nested_missing",
|
||||
serde_json::json!({"type":"minecraft:all_of","terms":["h121:missing"]}),
|
||||
);
|
||||
write(
|
||||
"not_missing",
|
||||
serde_json::json!({"type":"minecraft:inverted","term":"h121:absent"}),
|
||||
);
|
||||
write(
|
||||
"cycle_a",
|
||||
serde_json::json!({"type":"minecraft:all_of","terms":["h121:cycle_b"]}),
|
||||
);
|
||||
write(
|
||||
"cycle_b",
|
||||
serde_json::json!({"type":"minecraft:all_of","terms":["h121:cycle_a"]}),
|
||||
);
|
||||
write(
|
||||
"not_cycle",
|
||||
serde_json::json!({"type":"minecraft:inverted","term":"h121:cycle_a"}),
|
||||
);
|
||||
for invalid in ["h121:absent", "h121:cycle_a"] {
|
||||
for (label, kind, first) in [
|
||||
("any_skip", "minecraft:any_of", "h121:pig"),
|
||||
("any_reach", "minecraft:any_of", "h121:cow"),
|
||||
("all_skip", "minecraft:all_of", "h121:cow"),
|
||||
("all_reach", "minecraft:all_of", "h121:pig"),
|
||||
] {
|
||||
write(
|
||||
&format!("{label}_{}", invalid.split(':').next_back().unwrap()),
|
||||
serde_json::json!({"type":kind,"terms":[first,invalid]}),
|
||||
);
|
||||
}
|
||||
}
|
||||
let canonical = pack.join("data/minecraft/predicate/h121_canonical.json");
|
||||
std::fs::create_dir_all(canonical.parent().unwrap()).unwrap();
|
||||
std::fs::write(
|
||||
canonical,
|
||||
r#"{"type":"minecraft:all_of","terms":["h121_canonical"]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
game.reload_datapacks(false);
|
||||
let mut source = game.player_source();
|
||||
source.entity = Some(Target::Mob(actors[0].0));
|
||||
source.pos = game.target_position(Target::Mob(actors[0].0)).unwrap();
|
||||
game.command_source = Some(source);
|
||||
for name in [
|
||||
"missing",
|
||||
"nested_missing",
|
||||
"not_missing",
|
||||
"cycle_a",
|
||||
"not_cycle",
|
||||
"any_skip_absent",
|
||||
"any_reach_absent",
|
||||
"all_skip_absent",
|
||||
"all_reach_absent",
|
||||
"any_skip_cycle_a",
|
||||
"any_reach_cycle_a",
|
||||
"all_skip_cycle_a",
|
||||
"all_reach_cycle_a",
|
||||
] {
|
||||
assert!(
|
||||
game.fns
|
||||
.store
|
||||
.predicates
|
||||
.contains_key(&format!("h121:{name}"))
|
||||
);
|
||||
assert!(
|
||||
!game.check_predicate(&format!("h121:{name}")).unwrap(),
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
assert!(!game.check_predicate("minecraft:h121_canonical").unwrap());
|
||||
assert!(game.check_predicate("h121:reference").unwrap());
|
||||
game.command_source = previous;
|
||||
assert_eq!(game.world.game_time, clock);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
Loaded 100 of 435 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user