mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 22:43:47 -10:00
Compare commits
151
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
159b5c4be6 | ||
|
|
a392d4b3ba | ||
|
|
491710cd2f | ||
|
|
0932cefc79 | ||
|
|
77af961663 | ||
|
|
2f60199b75 | ||
|
|
033b4a9e60 | ||
|
|
6a0b9e435b | ||
|
|
485226a6fd | ||
|
|
e4ce68e271 | ||
|
|
151aa6c82b | ||
|
|
b5406e647a | ||
|
|
2d72b0b260 | ||
|
|
d79a36101f | ||
|
|
35f3dad207 | ||
|
|
5de5b6fd91 | ||
|
|
ca17c0c5ba | ||
|
|
23c3faedc1 | ||
|
|
ba3c3feebf | ||
|
|
ec298576f6 | ||
|
|
97c9d1a14f | ||
|
|
9099175f8a | ||
|
|
e1e39361db | ||
|
|
ef19179033 | ||
|
|
b189c72fcc | ||
|
|
3a229da6b3 | ||
|
|
e22ccc17d2 | ||
|
|
493dd75acf | ||
|
|
f4481233b0 | ||
|
|
63cbec701e | ||
|
|
a597387a66 | ||
|
|
2d306dc52c | ||
|
|
4f6be12c2e | ||
|
|
8c93f81143 | ||
|
|
a20e9ce2d0 | ||
|
|
aba1a90e6f | ||
|
|
73a3c0348d | ||
|
|
c95f24bc32 | ||
|
|
ca0b945857 | ||
|
|
33be2a611a | ||
|
|
1199c117f2 | ||
|
|
2e749948f4 | ||
|
|
f5f96b1be4 | ||
|
|
522634ca7f | ||
|
|
f7543ccffa | ||
|
|
1fd2f02081 | ||
|
|
8d8c250f48 | ||
|
|
d21bc19b84 | ||
|
|
3afc51e740 | ||
|
|
17778b9722 | ||
|
|
abaa2a9f68 | ||
|
|
d1c1684fb5 | ||
|
|
fdc87aecdb | ||
|
|
52343380b3 | ||
|
|
e2680a5450 | ||
|
|
5e7d248148 | ||
|
|
67f34e0fdd | ||
|
|
17a68d7b97 | ||
|
|
90b30ee17b | ||
|
|
5578753b58 | ||
|
|
6faf3ef3f3 | ||
|
|
ab44611814 | ||
|
|
8c612bc687 | ||
|
|
9debdf26d4 | ||
|
|
27a2eb565e | ||
|
|
d7ae75b441 | ||
|
|
bc017eec9b | ||
|
|
693178dc36 | ||
|
|
b19634003c | ||
|
|
8711aabd75 | ||
|
|
76a5bd8a22 | ||
|
|
453d8b89ce | ||
|
|
845132a56b | ||
|
|
051e3dd546 | ||
|
|
ab5dfae985 | ||
|
|
19a6e9b81b | ||
|
|
de118feb4e | ||
|
|
e508e7919f | ||
|
|
f222616f7e | ||
|
|
0f89fd7e00 | ||
|
|
6889d9366b | ||
|
|
0804e2219a | ||
|
|
1c6a22db1a | ||
|
|
8a9a6d5e3f | ||
|
|
d17e8fa343 | ||
|
|
6f374959b7 | ||
|
|
15a8b2a77c | ||
|
|
1206818cde | ||
|
|
12157a2d67 | ||
|
|
b029b9d04f | ||
|
|
9b89ff00ee | ||
|
|
2e13076a93 | ||
|
|
9974322832 | ||
|
|
b21f0c92f2 | ||
|
|
31545199d4 | ||
|
|
9a8781468e | ||
|
|
1117b98d15 | ||
|
|
8fe0e40d68 | ||
|
|
f68c335eb2 | ||
|
|
12dad67f8c | ||
|
|
922f00f52e | ||
|
|
200615a30a | ||
|
|
1ba0db3c3c | ||
|
|
0ce0e67282 | ||
|
|
c794b87841 | ||
|
|
0f8cf58043 | ||
|
|
dd93b0164a | ||
|
|
7622c02706 | ||
|
|
7cf67fa99d | ||
|
|
ac02477732 | ||
|
|
37cee5b4fd | ||
|
|
0748416e9e | ||
|
|
073e47caed | ||
|
|
e36bbcfc63 | ||
|
|
a26743d278 | ||
|
|
f834560860 | ||
|
|
9ffa81a748 | ||
|
|
7ce65988b2 | ||
|
|
02e7620924 | ||
|
|
3cdf31772f | ||
|
|
0fe1d0e15d | ||
|
|
3f38b2d0bd | ||
|
|
effe6ea423 | ||
|
|
c40c92e0bc | ||
|
|
e08c7a3ee0 | ||
|
|
cc3ee16e46 | ||
|
|
42372aa760 | ||
|
|
d29c8eb63f | ||
|
|
8852c0e0b8 | ||
|
|
21f7031e72 | ||
|
|
7dde5f518c | ||
|
|
b65d5f5dcd | ||
|
|
f88c50772f | ||
|
|
d064372d9d | ||
|
|
ca0a90eee3 | ||
|
|
c6780b8daf | ||
|
|
aca15c91d4 | ||
|
|
01166b25ae | ||
|
|
311cf0d31f | ||
|
|
9e9078f17f | ||
|
|
d00c6bcd25 | ||
|
|
a96e04043e | ||
|
|
424f045246 | ||
|
|
dfd4b135ef | ||
|
|
baceff1b55 | ||
|
|
de808d2d30 | ||
|
|
44338647fa | ||
|
|
3f41fda7a1 | ||
|
|
57bc596fe1 | ||
|
|
4090de57e0 | ||
|
|
1d78698703 |
No files matched your search
@@ -152,23 +152,23 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|
||||
|
||||
| Subsystem | Files |
|
||||
|---|---|
|
||||
| Game loop, tick, frame | `game.rs`: `fn run`, `impl ApplicationHandler for Game` (`window_event`, `about_to_wait`), `fn tick`, `fn tick_world`, `fn render`; L: `clock.rs`, `spans.rs`; `horizon-client-input/src/window.rs` |
|
||||
| Rendering | L: `gfx.rs` (`Renderer::new`, `Renderer::render`), `gfx/` (cull, occlusion, translucency, upload, timing, lod, weather, stars, end_sky), `*.wgsl`, `particles.rs`, `block_entity_render.rs`, `item_render.rs`, `fog.rs`, `clouds.rs`, `camera.rs`, `first_person.rs`; G: `render_tick.rs`, `item_draw.rs` |
|
||||
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `server_terrain.rs` (a server's kept chunks), `bluemap.rs` + `bluemap_detect.rs` (far terrain from its web map), `ticket_levels.rs`; G: `world_loading.rs`, `postprocess.rs`; game.rs `fn update_world` |
|
||||
| Game loop, tick, frame | `game.rs`: `fn run`, `fn tick`, `fn tick_world`, `fn render`; G: `window_handler.rs` (`impl ApplicationHandler for Game`: `window_event`, `about_to_wait`); L: `clock.rs`, `spans.rs`; `horizon-client-input/src/window.rs` |
|
||||
| Rendering | L: `gfx.rs` (`Renderer::new`, `Renderer::render`), `gfx/` (cull, occlusion, translucency, upload, timing, lod, weather, stars, end_sky), `*.wgsl`, `particles.rs`, `block_entity_render.rs`, `item_render.rs`, `fog.rs`, `clouds.rs`, `camera.rs`, `first_person.rs`; G: `render_tick.rs`, `item_draw.rs`, `screen_backgrounds.rs` (panorama, menu blur, border wall) |
|
||||
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `server_terrain.rs` (a server's kept chunks), `bluemap.rs` + `bluemap_detect.rs` (far terrain from its web map), `ticket_levels.rs`; G: `world_loading.rs`, `world_entry.rs`, `postprocess.rs`; game.rs `fn update_world` |
|
||||
| World generation | `crates/horizon-world/src/worldgen/` (`generator*.rs`, `feature_graph.rs`, `structure/`, `carver/`) |
|
||||
| Block behaviour | `crates/horizon-world/src/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `redstone.rs`, `place.rs`, `interact.rs`); game.rs `fn tick_blocks`, `fn process_level_events` |
|
||||
| Mobs | L: `mobs.rs` (types), `mobs/` (`ai.rs`, `goals.rs`, `brain.rs`, `*_brain.rs`, `nav.rs`, `path.rs`, `species.rs`, `spawning.rs`, `render.rs`, `anim.rs`); G: `creatures.rs` (ticking, spawning, mob events), one file per mob family (`dragon.rs`, `wither.rs`, `villagers.rs`, `piglins.rs`, `golems.rs`...), `natural_spawning.rs` |
|
||||
| Other entities | L: `entities.rs`, `projectiles.rs`, `arrows.rs`; G: `boats.rs`, `minecarts.rs`, `mounts.rs`, `armor_stands.rs`, `paintings.rs`, `frames.rs`, `fireworks.rs`, `tnt.rs`, `explosion.rs` |
|
||||
| Player actions | game.rs (`fn use_hand`, `fn use_item`, `fn attack`, `fn tick_mining`, `fn break_block`); G: `hand.rs`, `combat.rs`, `consume.rs`, `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`; crate `horizon-items`: `items.rs`, `item_data.rs`, `enchant.rs`, `blocks.rs` |
|
||||
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs` |
|
||||
| Player actions | game.rs (`fn use_hand`, `fn tick_mining`, `fn break_block`); G: `item_actions.rs` (`fn use_item`), `melee.rs` (`fn attack`), `hand.rs`, `combat.rs`, `consume.rs`, `using_item.rs` (an item in use, tick by tick), `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`; crate `horizon-items`: `items.rs`, `item_data.rs`, `enchant.rs`, `blocks.rs` |
|
||||
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs`, `inventory_input.rs` (keys and clicks over a container), `inventory_slots.rs` (slot layout and drawing) |
|
||||
| Commands | parse: crate `horizon-command` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`; `horizon-client-core`: `nbt_path.rs`, `scoreboard.rs`; G: `functions.rs`, `command_blocks.rs` |
|
||||
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs` |
|
||||
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `loading_screen.rs` (the loading screen, the pre-game `App`), `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs`, `creative_screen.rs` |
|
||||
| Multiplayer client | L: `net.rs` (network thread); G: `remote.rs` (`fn handle_packet`, `fn tick_remote`), `remote_*.rs` (mob data, objects, projectiles, vehicles, stations) |
|
||||
| LAN host / server side | L: `lan/` (`conn.rs` handshake + login threads, `hub.rs` + `hub/` play state, `chunks.rs`, `entity.rs`, `tracking.rs`, `guest.rs`, `playerdata.rs`); G: `lan.rs` (`LanHost`), `lan_actions.rs` (`fn as_guest`, `fn tick_guests`), `lan_*.rs` per feature, `lan_entities.rs` (tracked entities) |
|
||||
| Integrated server | G: `integrated.rs` (the server thread), `levels.rs` (one level per dimension, `fn tick_server`), `server_travel.rs` |
|
||||
| Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server`, `horizon-anticheat` (L: `lan/anticheat.rs`) |
|
||||
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
|
||||
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs` |
|
||||
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities), `world_session.rs` (opening and closing a world); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
|
||||
| Input | game.rs `fn window_event`; G: `play_keys.rs` (`fn key_pressed`), `play_input.rs` (`fn play_mouse`, toggled keys, the hotbar wheel); crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs`, `pointer_capture.rs` (mouse look, pointer grab) |
|
||||
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
|
||||
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `mobs/cem.rs` + `mobs/cem_anim.rs` (OptiFine/EMF entity models and their animations); `horizon-client-core/src/lang.rs`; crates `horizon-assets`, `horizon-optifine`; G: `packs.rs` |
|
||||
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
|
||||
@@ -191,7 +191,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|
||||
`levels.rs` swaps whole dimensions in the same way. Changes reach clients through the block log, level
|
||||
events and the entity tracker (`lan/tracking.rs`, `game/lan_entities.rs`).
|
||||
- Shared read-only data: `Resources` (`res`: blocks, items, recipes, mob types, biomes) and `PackResources`
|
||||
(`pack`), both in game.rs.
|
||||
(`pack`), both in game.rs, their methods in `game/resource_tables.rs`.
|
||||
|
||||
## Single player runs on a server thread
|
||||
|
||||
|
||||
@@ -61,6 +61,10 @@ another project's source may also be read and used, **only when that project's l
|
||||
"source available" licenses, all rights reserved (OptiFine), and any project with no license or an unclear one.
|
||||
Don't read, copy, translate or paraphrase their source, and don't send code an AI tool wrote from it. Being able
|
||||
to download a mod or a pack does not give you the right to read its files.
|
||||
- **Java's own library is GPL too.** OpenJDK's source (`java.lang.Math`, `StrictMath`, `FdLibm`, `HashMap`, the
|
||||
sorts, `Double.toString`, HotSpot's intrinsics...) is GPL-2.0, so it is off limits like the rest. Reproduce Java's
|
||||
behaviour from its API documentation (the Javadoc) and from what a JVM returns, or from a permissive original such
|
||||
as Sun's fdlibm C sources (see [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)); see #6741.
|
||||
- **Check the license yourself.** Read the LICENSE file at the commit you use and the header of each file you take:
|
||||
some repositories mix licenses, and a file under another license is off limits. A license that changed: the one at
|
||||
your commit counts. In doubt, don't: ask in #contributors first.
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
# Third-party notices
|
||||
|
||||
## Code removed for its license
|
||||
|
||||
Horizon once shipped three pieces of code under MIT that came from projects whose licenses don't allow that. All
|
||||
three are gone from main. They are listed here for anyone who holds an older commit, tag or release: in those
|
||||
versions, that code is not covered by Horizon's MIT license. It stays under its authors' copyright and its original
|
||||
license.
|
||||
|
||||
- **Shader packs written from Iris's source.** `crates/horizon-shaderpack` and the render stages that used it were
|
||||
written from the source of [Iris](https://github.com/IrisShaders/Iris) (LGPL-3.0, copyright the Iris contributors).
|
||||
They were added from 15 October 2026 (`b22583d057`), shipped in the game releases `v2026.10.16.02` to `v0.2.46`,
|
||||
and were removed on 24 October 2026 ([#1561](https://github.com/horizon-rust/horizon/pull/1561)) after a developer
|
||||
of Iris showed us. Shader packs came back in `crates/horizon-shaders`, first written from a description of the
|
||||
pack format.
|
||||
- **Math functions translated from OpenJDK.** In `crates/horizon-core/src/jmath.rs`, `fdlibm::{log, log10, cbrt,
|
||||
hypot}` were translated from OpenJDK's `java/lang/FdLibm.java` and `round_f32` from its `java/lang/Math.java`
|
||||
(GPL-2.0 with the Classpath Exception), and `aarch64::{sin, cos}` from HotSpot's `macroAssembler_aarch64_trig.cpp`
|
||||
(GPL-2.0); copyright Oracle and/or its affiliates, and Cavium for that file. The `fdlibm` and `aarch64` functions
|
||||
were added on 13 October 2026 (`558d140fca`, first released in `v2026.10.13`). `round_f32` was added on 14 October
|
||||
2026 as `java_round` in `crates/horizon-client/src/mipmap.rs` (`95db0fce9d`) and moved into `jmath.rs` on 3
|
||||
November ([#7770](https://github.com/horizon-rust/horizon/pull/7770)). All of them were replaced in
|
||||
[#11944](https://github.com/horizon-rust/horizon/pull/11944) by code written without OpenJDK's sources, from Sun's
|
||||
fdlibm C sources, the Java SE API documentation and a description of the behaviour, and checked against a JVM
|
||||
([#6741](https://github.com/horizon-rust/horizon/issues/6741)).
|
||||
- **Java's `HashMap` translated from OpenJDK.** `crates/horizon-world/src/java_hash_map.rs`, which reproduces the
|
||||
iteration order of `java.util.HashMap`, was translated from OpenJDK's `java/util/HashMap.java` (GPL-2.0 with the
|
||||
Classpath Exception; copyright Oracle and/or its affiliates). It was added on 7 October 2026 (`118e93ea51` and
|
||||
`194db0dbb4`, first released in `v2026.10.12`). It was replaced in
|
||||
[#12043](https://github.com/horizon-rust/horizon/pull/12043) by code written without OpenJDK's sources, from a
|
||||
description of the behaviour, the Java SE API documentation and the textbook red-black tree, and checked against a
|
||||
JVM ([#6741](https://github.com/horizon-rust/horizon/issues/6741)).
|
||||
|
||||
## Catnip / Ponder
|
||||
|
||||
`crates/horizon-client-render/src/fluid_render.rs` translates the still-fluid face tiling and
|
||||
@@ -256,3 +287,22 @@ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
## fdlibm
|
||||
|
||||
`crates/horizon-core/src/jmath/fdlibm.rs` and the `fdlibm_asin`, `fdlibm_atan` and
|
||||
`fdlibm_atan2` functions in `crates/horizon-core/src/geometry/trig.rs` are Rust ports of
|
||||
[fdlibm](https://www.netlib.org/fdlibm/) 5.3, Sun's freely distributable math library, whose
|
||||
results the Java SE API specifies for `StrictMath`. `fdlibm.rs` ports `e_log.c`, `e_log10.c`,
|
||||
`s_cbrt.c` and `e_hypot.c`, and, with fused multiply-adds, `s_sin.c`, `s_cos.c`, `k_sin.c`,
|
||||
`k_cos.c` and `e_rem_pio2.c`; `trig.rs` ports `e_asin.c`, `s_atan.c` and `e_atan2.c`. This
|
||||
notice also heads both files:
|
||||
|
||||
```
|
||||
Copyright (C) 1993 by Sun Microsystems, Inc. All rights reserved.
|
||||
|
||||
Developed at SunSoft, a Sun Microsystems, Inc. business.
|
||||
Permission to use, copy, modify, and distribute this
|
||||
software is freely granted, provided that this notice
|
||||
is preserved.
|
||||
```
|
||||
@@ -917,6 +917,46 @@ impl NumericProperty {
|
||||
/// (`None` removes a prototype component).
|
||||
pub type Components = BTreeMap<String, Value>;
|
||||
|
||||
/// A stack's components read in place: its patch first, then its item's defaults. Reading one copies
|
||||
/// nothing, where [`ItemModels::components`] clones the defaults for a stack with a patch (an enchanted
|
||||
/// sword's are a few dozen JSON values, on every draw of it).
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct ComponentsView<'a> {
|
||||
prototype: &'a Components,
|
||||
patch: &'a BTreeMap<String, Option<Value>>,
|
||||
}
|
||||
|
||||
impl<'a> ComponentsView<'a> {
|
||||
pub fn get(&self, key: &str) -> Option<&'a Value> {
|
||||
match self.patch.get(key) {
|
||||
Some(patched) => patched.as_ref(),
|
||||
None => self.prototype.get(key),
|
||||
}
|
||||
}
|
||||
pub fn contains_key(&self, key: &str) -> bool {
|
||||
self.get(key).is_some()
|
||||
}
|
||||
/// Components with no patch: `components` as they are.
|
||||
pub fn of(components: &'a Components) -> Self {
|
||||
static NO_PATCH: BTreeMap<String, Option<Value>> = BTreeMap::new();
|
||||
Self {
|
||||
prototype: components,
|
||||
patch: &NO_PATCH,
|
||||
}
|
||||
}
|
||||
/// The merged map, for what needs one whole.
|
||||
pub fn to_map(&self) -> Components {
|
||||
let mut out = self.prototype.clone();
|
||||
for (key, value) in self.patch {
|
||||
match value {
|
||||
Some(v) => out.insert(key.clone(), v.clone()),
|
||||
None => out.remove(key),
|
||||
};
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// An item stack as the resolver reads it.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct StackView {
|
||||
@@ -1087,14 +1127,15 @@ impl ItemModels {
|
||||
if stack.patch.is_empty() {
|
||||
return std::borrow::Cow::Borrowed(prototype);
|
||||
}
|
||||
let mut out = prototype.clone();
|
||||
for (key, value) in &stack.patch {
|
||||
match value {
|
||||
Some(v) => out.insert(key.clone(), v.clone()),
|
||||
None => out.remove(key),
|
||||
};
|
||||
std::borrow::Cow::Owned(self.components_view(stack).to_map())
|
||||
}
|
||||
/// The stack's components, read in place (see [`ComponentsView`]).
|
||||
pub fn components_view<'a>(&'a self, stack: &'a StackView) -> ComponentsView<'a> {
|
||||
static NONE: Components = BTreeMap::new();
|
||||
ComponentsView {
|
||||
prototype: self.prototypes.get(&stack.item).unwrap_or(&NONE),
|
||||
patch: &stack.patch,
|
||||
}
|
||||
std::borrow::Cow::Owned(out)
|
||||
}
|
||||
/// `ItemModelResolver.updateForTopItem`.
|
||||
pub fn resolve(&self, stack: &StackView, ctx: &Context, env: &dyn ItemEnv) -> ResolvedItem {
|
||||
@@ -1112,7 +1153,7 @@ impl ItemModels {
|
||||
ctx: &Context,
|
||||
env: &dyn ItemEnv,
|
||||
) {
|
||||
let components = self.components(stack);
|
||||
let components = self.components_view(stack);
|
||||
let Some(Value::String(model_id)) = components.get("minecraft:item_model") else {
|
||||
return;
|
||||
};
|
||||
@@ -1122,7 +1163,7 @@ impl ItemModels {
|
||||
let mut state = Resolve {
|
||||
models: self,
|
||||
stack,
|
||||
components: &components,
|
||||
components,
|
||||
ctx,
|
||||
env,
|
||||
};
|
||||
@@ -1140,7 +1181,7 @@ impl ItemModels {
|
||||
self.properties(stack).2
|
||||
}
|
||||
fn properties(&self, stack: &StackView) -> (bool, bool, f32) {
|
||||
match self.components(stack).get("minecraft:item_model") {
|
||||
match self.components_view(stack).get("minecraft:item_model") {
|
||||
Some(Value::String(id)) => identifier(id)
|
||||
.ok()
|
||||
.and_then(|id| self.definitions.get(&id))
|
||||
@@ -1159,7 +1200,7 @@ impl ItemModels {
|
||||
struct Resolve<'a> {
|
||||
models: &'a ItemModels,
|
||||
stack: &'a StackView,
|
||||
components: &'a Components,
|
||||
components: ComponentsView<'a>,
|
||||
ctx: &'a Context<'a>,
|
||||
env: &'a dyn ItemEnv,
|
||||
}
|
||||
@@ -1455,9 +1496,9 @@ impl Resolve<'_> {
|
||||
fn numeric(&self, property: &NumericProperty) -> f32 {
|
||||
let owner = self.ctx.owner;
|
||||
match property {
|
||||
NumericProperty::BundleFullness => {
|
||||
self.env.bundle_fullness(self.stack, self.components)
|
||||
}
|
||||
NumericProperty::BundleFullness => self
|
||||
.env
|
||||
.bundle_fullness(self.stack, &self.components.to_map()),
|
||||
NumericProperty::Compass {
|
||||
wobble,
|
||||
target,
|
||||
|
||||
@@ -63,7 +63,7 @@ pub(super) fn decode(m: &Map<String, Value>) -> Result<Value> {
|
||||
/// with a matching level. An empty predicate list still needs the component.
|
||||
pub(super) fn matches(
|
||||
predicate: &Value,
|
||||
components: &Components,
|
||||
components: ComponentsView<'_>,
|
||||
env: &dyn ItemEnv,
|
||||
) -> Option<bool> {
|
||||
let (id, expected) = predicate.as_object()?.iter().next()?;
|
||||
@@ -143,13 +143,17 @@ mod tests {
|
||||
json!({"minecraft:density":0}),
|
||||
)]);
|
||||
assert_eq!(
|
||||
matches(&json!({"minecraft:enchantments":[{}]}), &components, &NoEnv),
|
||||
matches(
|
||||
&json!({"minecraft:enchantments":[{}]}),
|
||||
ComponentsView::of(&components),
|
||||
&NoEnv
|
||||
),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
matches(
|
||||
&json!({"minecraft:enchantments":[{"enchantments":"minecraft:density"}]}),
|
||||
&components,
|
||||
ComponentsView::of(&components),
|
||||
&NoEnv
|
||||
),
|
||||
Some(false)
|
||||
@@ -166,12 +170,21 @@ mod tests {
|
||||
"minecraft:enchantments".into(),
|
||||
json!({"minecraft:density":3}),
|
||||
)]);
|
||||
assert_eq!(matches(&predicate, &components, &Tags), Some(true));
|
||||
assert_eq!(matches(&predicate, &components, &NoEnv), Some(false));
|
||||
assert_eq!(
|
||||
matches(&predicate, ComponentsView::of(&components), &Tags),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
matches(&predicate, ComponentsView::of(&components), &NoEnv),
|
||||
Some(false)
|
||||
);
|
||||
components.insert(
|
||||
"minecraft:enchantments".into(),
|
||||
json!({"minecraft:density":2}),
|
||||
);
|
||||
assert_eq!(matches(&predicate, &components, &Tags), Some(false));
|
||||
assert_eq!(
|
||||
matches(&predicate, ComponentsView::of(&components), &Tags),
|
||||
Some(false)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn identifier_adds_default_namespace() {
|
||||
@@ -111,3 +112,49 @@ fn custom_condition_validates_its_name_and_both_branches() {
|
||||
assert!(matches!(node("example:charged", empty).unwrap(),
|
||||
ItemModelNode::Condition { property: ConditionProperty::Custom(name), .. } if name == "example:charged"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_patched_stack_reads_its_patch_over_its_items_defaults() {
|
||||
let mut models = ItemModels::default();
|
||||
models.prototypes.insert(
|
||||
"minecraft:diamond_sword".into(),
|
||||
Components::from_iter([
|
||||
(
|
||||
"minecraft:item_model".into(),
|
||||
json!("minecraft:diamond_sword"),
|
||||
),
|
||||
("minecraft:max_damage".into(), json!(1561)),
|
||||
("minecraft:rarity".into(), json!("common")),
|
||||
]),
|
||||
);
|
||||
let mut stack = StackView::new("minecraft:diamond_sword", 1);
|
||||
stack.patch.insert(
|
||||
"minecraft:enchantments".into(),
|
||||
Some(json!({"minecraft:sharpness": 5})),
|
||||
);
|
||||
stack
|
||||
.patch
|
||||
.insert("minecraft:rarity".into(), Some(json!("rare")));
|
||||
stack.patch.insert("minecraft:max_damage".into(), None);
|
||||
let view = models.components_view(&stack);
|
||||
assert_eq!(view.get("minecraft:rarity"), Some(&json!("rare")));
|
||||
assert_eq!(view.get("minecraft:max_damage"), None, "a removed default");
|
||||
assert_eq!(
|
||||
view.get("minecraft:item_model"),
|
||||
Some(&json!("minecraft:diamond_sword"))
|
||||
);
|
||||
assert!(view.contains_key("minecraft:enchantments"));
|
||||
assert!(!view.contains_key("minecraft:custom_name"));
|
||||
// Every key reads as the merged map has it.
|
||||
let merged = models.components(&stack);
|
||||
for key in [
|
||||
"minecraft:rarity",
|
||||
"minecraft:max_damage",
|
||||
"minecraft:item_model",
|
||||
"minecraft:enchantments",
|
||||
"minecraft:custom_name",
|
||||
] {
|
||||
assert_eq!(view.get(key), merged.get(key), "{key}");
|
||||
}
|
||||
assert_eq!(view.to_map(), *merged);
|
||||
}
|
||||
@@ -20,6 +20,14 @@ use crate::{
|
||||
fn tr(key: &str) -> Text {
|
||||
Text::translate(key, &[])
|
||||
}
|
||||
|
||||
/// False only in a game built without the `horizon-extras` feature (docs/forking.md), which says
|
||||
/// so once at startup: the Options screen then has vanilla's Telemetry Data slot instead of
|
||||
/// Horizon Settings and Mods, and Credits & Attribution names the official build.
|
||||
pub static HORIZON_EXTRAS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
|
||||
fn horizon_extras() -> bool {
|
||||
HORIZON_EXTRAS.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
fn generic(caption: &str, value: Text) -> Text {
|
||||
Text::translate("options.generic_value", &[tr(caption), value])
|
||||
}
|
||||
@@ -3113,6 +3121,22 @@ impl OptionsUi {
|
||||
("horizon.pages.open.mods", Some(Page::Mods)),
|
||||
("options.credits_and_attribution", Some(Page::Credits)),
|
||||
];
|
||||
// horizon-extras: without it, vanilla's last row instead of Horizon Settings and
|
||||
// Mods: Telemetry Data (greyed: nothing is sent) and Credits & Attribution.
|
||||
let plain;
|
||||
let entries: &[_] = if horizon_extras() {
|
||||
&entries
|
||||
} else {
|
||||
plain = [
|
||||
&entries[..entries.len() - 3],
|
||||
&[
|
||||
("options.telemetry", None),
|
||||
("options.credits_and_attribution", Some(Page::Credits)),
|
||||
],
|
||||
]
|
||||
.concat();
|
||||
&plain
|
||||
};
|
||||
let rows = entries.len().div_ceil(2) as f32;
|
||||
let gy = (61.0 + 30.0f32).min(h - 33.0 - rows * 24.0);
|
||||
for (i, (key, page)) in entries.iter().enumerate() {
|
||||
@@ -3162,6 +3186,22 @@ impl OptionsUi {
|
||||
let y = y + 28.0 * (i + 1) as f32;
|
||||
out.push(inactive(button(x, y, 210.0, tr(key), Act::None)));
|
||||
}
|
||||
// horizon-extras: a build without them says where the official one is.
|
||||
if !horizon_extras() {
|
||||
out.push(Widget {
|
||||
x: 0.0,
|
||||
y: (y + 92.0).min(h - 42.0),
|
||||
w,
|
||||
h: 9.0,
|
||||
kind: WKind::Text {
|
||||
text: tr("horizon.plain.official"),
|
||||
color: 0xFFA0A0A0,
|
||||
centered: true,
|
||||
},
|
||||
list: false,
|
||||
row: 0,
|
||||
});
|
||||
}
|
||||
out.push(done(h - 27.0));
|
||||
top = 0.0;
|
||||
bottom = 0.0;
|
||||
|
||||
@@ -58,6 +58,8 @@ pub struct ParticleOptions {
|
||||
/// A native mod's own particle type, by its namespaced name (`mymod:spark`):
|
||||
/// `SimpleParticleType` plus the provider `particles/<name>.json` names.
|
||||
pub mod_particle: Option<String>,
|
||||
/// Pre-1.20.5 entity-effect providers use spawn velocity as RGB; true is the ambient provider.
|
||||
pub legacy_entity_effect: Option<bool>,
|
||||
}
|
||||
impl ParticleOptions {
|
||||
pub fn simple(ty: ParticleType) -> Self {
|
||||
@@ -72,6 +74,7 @@ impl ParticleOptions {
|
||||
geyser: None,
|
||||
vibration: None,
|
||||
mod_particle: None,
|
||||
legacy_entity_effect: None,
|
||||
}
|
||||
}
|
||||
/// A native mod's `SimpleParticleType`: the sprite set of
|
||||
@@ -3167,8 +3170,16 @@ impl ParticleEngine {
|
||||
q.set_color(b, 0.0, b);
|
||||
}
|
||||
T::EntityEffect => {
|
||||
q.set_color(o.color[0], o.color[1], o.color[2]);
|
||||
q.rgba[3] = o.color[3];
|
||||
if let Some(ambient) = o.legacy_entity_effect {
|
||||
q.set_color(v[0] as f32, v[1] as f32, v[2] as f32);
|
||||
// AmbientMobProvider overrides constructor alpha; MobProvider only sets RGB.
|
||||
if ambient {
|
||||
q.rgba[3] = 0.15;
|
||||
}
|
||||
} else {
|
||||
q.set_color(o.color[0], o.color[1], o.color[2]);
|
||||
q.rgba[3] = o.color[3];
|
||||
}
|
||||
}
|
||||
T::Effect | T::InstantEffect => {
|
||||
q.set_color(o.color[0], o.color[1], o.color[2]);
|
||||
@@ -3177,7 +3188,12 @@ impl ParticleEngine {
|
||||
_ => {}
|
||||
}
|
||||
q.behaviour = Behaviour::Spell {
|
||||
original_alpha: q.rgba[3],
|
||||
// The old SpellParticle.tick always eased back to 1, including ambient effects.
|
||||
original_alpha: if o.legacy_entity_effect.is_some() {
|
||||
1.0
|
||||
} else {
|
||||
q.rgba[3]
|
||||
},
|
||||
};
|
||||
q.set = set;
|
||||
q
|
||||
@@ -3469,7 +3485,13 @@ impl ParticleEngine {
|
||||
scaled(o.color[2], jb),
|
||||
);
|
||||
q.size = 0.26;
|
||||
q.lifetime = duration;
|
||||
// The older-server adapter marks pre-1.21.4 TargetColorParticleOption with -1.
|
||||
// Its TrailParticle.Provider chose nextInt(40) + 10 after constructing the particle.
|
||||
q.lifetime = if duration == -1 {
|
||||
self.level_random.next_int_bound(40).unwrap_or(0) + 10
|
||||
} else {
|
||||
duration
|
||||
};
|
||||
q.has_physics = false;
|
||||
q.free_move = true;
|
||||
q.light = Light::FullBright;
|
||||
@@ -4941,6 +4963,45 @@ const CREEPER: [[f64; 2]; 12] = [
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn legacy_spell_providers_use_velocity_rgb_and_their_original_alpha_rules() {
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
let velocity = [0.25, 0.5, 0.75];
|
||||
for scoped in [false, true] {
|
||||
engine.set_scope_eye(scoped.then_some([0.0; 3]));
|
||||
for ambient in [false, true] {
|
||||
let mut options =
|
||||
ParticleOptions::colored(ParticleType::EntityEffect, [1.0, 0.0, 0.0, 0.8]);
|
||||
options.legacy_entity_effect = Some(ambient);
|
||||
let particle = engine.make(&options, [0.0; 3], velocity, 7).unwrap();
|
||||
let alpha = if ambient {
|
||||
0.15
|
||||
} else if scoped {
|
||||
0.0
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
assert_eq!(particle.rgba, [0.25, 0.5, 0.75, alpha]);
|
||||
assert_eq!(
|
||||
particle.behaviour,
|
||||
Behaviour::Spell {
|
||||
original_alpha: 1.0
|
||||
}
|
||||
);
|
||||
}
|
||||
let options =
|
||||
ParticleOptions::colored(ParticleType::EntityEffect, [1.0, 0.0, 0.0, 0.8]);
|
||||
let current = engine.make(&options, [0.0; 3], velocity, 7).unwrap();
|
||||
assert_eq!(current.rgba, options.color);
|
||||
assert_eq!(
|
||||
current.behaviour,
|
||||
Behaviour::Spell {
|
||||
original_alpha: 0.8
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn particle_light_cache_reuses_exact_blocks_and_fails_open_on_collisions() {
|
||||
let reads = std::cell::Cell::new(0);
|
||||
@@ -5700,6 +5761,33 @@ mod tests {
|
||||
assert!((0.15 * 0.4 - 1e-6..=0.45 * 0.4 + 1e-6).contains(&s));
|
||||
}
|
||||
#[test]
|
||||
fn legacy_trails_choose_a_lifetime_for_each_particle_from_the_level_random() {
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
engine.seed_randoms(1, 42, 2);
|
||||
for _ in 0..5 {
|
||||
engine.create(
|
||||
&ParticleOptions::trail([1.0, 2.0, 3.0], 0x7f3f1f, -1),
|
||||
[0.0; 3],
|
||||
[0.0; 3],
|
||||
);
|
||||
}
|
||||
// java.util.Random(42).nextInt(40) + 10, as vanilla 1.21.3 TrailParticle.Provider does.
|
||||
assert_eq!(
|
||||
engine
|
||||
.pending
|
||||
.iter()
|
||||
.map(|p| p.lifetime)
|
||||
.collect::<Vec<_>>(),
|
||||
[20, 13, 18, 14, 20]
|
||||
);
|
||||
engine.create(
|
||||
&ParticleOptions::trail([1.0, 2.0, 3.0], 0x7f3f1f, 37),
|
||||
[0.0; 3],
|
||||
[0.0; 3],
|
||||
);
|
||||
assert_eq!(engine.pending.last().unwrap().lifetime, 37);
|
||||
}
|
||||
#[test]
|
||||
fn sizes_follow_vanilla_curves() {
|
||||
let mut r = LegacyRandom::new(2);
|
||||
let mut p = Particle::at(&mut r, ParticleType::Flame, [0.0; 3]);
|
||||
|
||||
@@ -109,12 +109,19 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
|
||||
|
||||
[features]
|
||||
default = ["audio", "controllers", "discord", "friends", "heartbeat"]
|
||||
default = ["audio", "controllers", "horizon-extras"]
|
||||
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
|
||||
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
|
||||
audio = ["horizon-client-render/audio", "horizon-client-lib/audio"]
|
||||
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
|
||||
controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
|
||||
# Horizon's own extras over the vanilla game, for a fork that wants them out (docs/forking.md):
|
||||
# cosmetics and emotes, the "Horizon player" presence, the official and partner servers added to
|
||||
# the server list, the Horizon Settings and Mods pages, Discord, Play with Friends and the
|
||||
# heartbeat. Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
|
||||
# every place it gates. Without it (`--no-default-features --features audio,controllers`) the
|
||||
# client is best effort: CI builds only the default.
|
||||
horizon-extras = ["discord", "friends", "heartbeat"]
|
||||
# Online extras a server or a port can leave out; the server build has none of them. "Playing Horizon"
|
||||
# on Discord (game/discord.rs).
|
||||
discord = ["horizon-client-lib/discord"]
|
||||
|
||||
@@ -226,6 +226,7 @@
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Pro Bild an die GPU gesendete Mesh-Daten. Weniger ist flüssiger beim Laden.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Bildrate und Bildzeiten über dem Spiel, mit Diagramm.",
|
||||
"horizon.multiplayer.finding_friend": "Die Welt deines Freundes wird gesucht …",
|
||||
"horizon.plain.official": "Offizielles Horizon mit Launcher, Mods und Updates: playhorizon.dev",
|
||||
"horizon.title.not_affiliated": "Nicht mit Mojang oder Microsoft verbunden",
|
||||
"horizon.extras.header.lighting": "Beleuchtung",
|
||||
"horizon.extras.header.third_person": "Dritte Person",
|
||||
|
||||
@@ -366,6 +366,7 @@
|
||||
"Brighter-than-white output on an HDR display.": "Brighter-than-white output on an HDR display.",
|
||||
"Results for \"%s\"": "Results for \"%s\"",
|
||||
"No settings match.": "No settings match.",
|
||||
"horizon.plain.official": "Official Horizon with launcher, mods and updates: playhorizon.dev",
|
||||
"horizon.title.not_affiliated": "Not affiliated with Mojang or Microsoft",
|
||||
"horizon.extras.header.lighting": "Lighting",
|
||||
"horizon.extras.header.third_person": "Third person",
|
||||
|
||||
@@ -295,6 +295,7 @@
|
||||
"Brighter-than-white output on an HDR display.": "Salida más brillante que el blanco en una pantalla HDR.",
|
||||
"Results for \"%s\"": "Resultados de \"%s\"",
|
||||
"No settings match.": "Ningún ajuste coincide.",
|
||||
"horizon.plain.official": "Horizon oficial con launcher, mods y actualizaciones: playhorizon.dev",
|
||||
"horizon.title.not_affiliated": "No está afiliado a Mojang ni a Microsoft",
|
||||
"horizon.extras.header.lighting": "Iluminación",
|
||||
"horizon.extras.header.third_person": "Tercera persona",
|
||||
|
||||
@@ -188,6 +188,7 @@
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Données de maillage vers le GPU par image. Moins : plus fluide.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Fréquence et temps d'image par-dessus le jeu, avec un graphique.",
|
||||
"horizon.multiplayer.finding_friend": "Recherche du monde de votre ami...",
|
||||
"horizon.plain.official": "Horizon officiel avec launcher, mods et mises à jour : playhorizon.dev",
|
||||
"horizon.title.not_affiliated": "Non affilié à Mojang ou à Microsoft",
|
||||
"horizon.extras.header.lighting": "Éclairage",
|
||||
"horizon.extras.header.third_person": "Troisième personne",
|
||||
|
||||
@@ -231,6 +231,7 @@
|
||||
"Brighter-than-white output on an HDR display.": "Uscita più luminosa del bianco su un display HDR.",
|
||||
"Results for \"%s\"": "Risultati per \"%s\"",
|
||||
"No settings match.": "Nessuna impostazione corrisponde.",
|
||||
"horizon.plain.official": "Horizon ufficiale con launcher, mod e aggiornamenti: playhorizon.dev",
|
||||
"horizon.title.not_affiliated": "Non affiliato con Mojang o Microsoft",
|
||||
"horizon.extras.header.lighting": "Illuminazione",
|
||||
"horizon.extras.header.third_person": "Terza persona",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"horizon.pages.extras": "Horizon エクストラ",
|
||||
"horizon.pages.settings": "Horizon 設定",
|
||||
"horizon.pages.agent": "AIエージェント",
|
||||
"horizon.plain.official": "ランチャー、Mod、アップデート付きの公式 Horizon: playhorizon.dev",
|
||||
"horizon.split.leave": "休暇を取る",
|
||||
"horizon.pages.split_settings": "分割画面設定",
|
||||
"horizon.pages.split_profiles": "誰が遊んでるの?",
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"horizon.pages.extras": "Horizon 추가 기능",
|
||||
"horizon.pages.settings": "Horizon 설정",
|
||||
"horizon.pages.agent": "AI 에이전트",
|
||||
"horizon.plain.official": "런처, 모드, 업데이트가 포함된 공식 Horizon: playhorizon.dev",
|
||||
"horizon.split.leave": "나가기",
|
||||
"horizon.pages.split_settings": "화면 분할 설정",
|
||||
"horizon.pages.split_profiles": "플레이어 선택",
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"Brighter-than-white output on an HDR display.": "Helderder dan witte uitvoer op een HDR-scherm.",
|
||||
"Results for \"%s\"": "Resultaten voor \"%s\"",
|
||||
"No settings match.": "Er komen geen instellingen overeen.",
|
||||
"horizon.plain.official": "Officiële Horizon met launcher, mods en updates: playhorizon.dev",
|
||||
"horizon.title.not_affiliated": "Niet aangesloten bij Mojang of Microsoft",
|
||||
"horizon.extras.header.lighting": "Verlichting",
|
||||
"horizon.extras.header.third_person": "Derde persoon",
|
||||
|
||||
@@ -222,6 +222,7 @@
|
||||
"Brighter-than-white output on an HDR display.": "Obraz jaśniejszy niż biel na ekranie HDR.",
|
||||
"Results for \"%s\"": "Wyniki dla \"%s\"",
|
||||
"No settings match.": "Żadne ustawienie nie pasuje.",
|
||||
"horizon.plain.official": "Oficjalny Horizon z launcherem, modami i aktualizacjami: playhorizon.dev",
|
||||
"horizon.title.not_affiliated": "Niepowiązane z Mojang ani Microsoft",
|
||||
"horizon.extras.header.lighting": "Oświetlenie",
|
||||
"horizon.extras.header.third_person": "Widok z trzeciej osoby",
|
||||
|
||||
@@ -233,6 +233,7 @@
|
||||
"Brighter-than-white output on an HDR display.": "Saída mais brilhante que o branco em uma tela HDR.",
|
||||
"Results for \"%s\"": "Resultados para \"%s\"",
|
||||
"No settings match.": "Nenhuma configuração corresponde.",
|
||||
"horizon.plain.official": "Horizon oficial com launcher, mods e atualizações: playhorizon.dev",
|
||||
"horizon.title.not_affiliated": "Não afiliado à Mojang ou Microsoft",
|
||||
"horizon.extras.header.lighting": "Iluminação",
|
||||
"horizon.extras.header.third_person": "Terceira pessoa",
|
||||
|
||||
@@ -185,6 +185,7 @@
|
||||
"Frame rate and frame times over the game, with a graph.": "Частота кадров и их время поверх игры, с графиком.",
|
||||
"horizon.multiplayer.finding_friend": "Ищем мир друга…",
|
||||
"horizon.multiplayer.not_signed_in": "Этот сервер работает в онлайн-режиме. Войдите с помощью аккаунта Microsoft в лаунчере Horizon, чтобы присоединиться.",
|
||||
"horizon.plain.official": "Официальный Horizon с лаунчером, модами и обновлениями: playhorizon.dev",
|
||||
"horizon.realms.not_signed_in": "Войдите в лаунчере Horizon с учётной записью Microsoft, чтобы играть в Realms.",
|
||||
"horizon.multiplayer.invalid_address": "Недействительный адрес сервера: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Установлено прямое соединение с другом…",
|
||||
|
||||
@@ -187,6 +187,7 @@
|
||||
"horizon.pages.shader_options": "Gölgelendirici Seçenekleri",
|
||||
"horizon.pages.split_profiles": "Kim oynuyor?",
|
||||
"horizon.pages.split_profiles.tooltip": "Kim oynuyor: bu oyun klasöründe tutulan bir profil.",
|
||||
"horizon.plain.official": "Başlatıcı, modlar ve güncellemelerle resmi Horizon: playhorizon.dev",
|
||||
"horizon.shader_options.profile": "Profil",
|
||||
"Particle Culling": "Particle Culling",
|
||||
"Draws every particle.": "Draws every particle.",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"horizon.mods.dev": " (розробка)",
|
||||
"horizon.mods.settings": "Налаштування",
|
||||
"horizon.mods.open_folder": "Відкрити теку з модами",
|
||||
"horizon.plain.official": "Офіційний Horizon із лаунчером, модами та оновленнями: playhorizon.dev",
|
||||
"horizon.shader_options.profile": "Профіль",
|
||||
"horizon.agent.copy_details": "Копіювати дані з’єднання",
|
||||
"horizon.multiplayer.join": "Приєднатися",
|
||||
|
||||
@@ -195,6 +195,7 @@
|
||||
"horizon.pages.shader_options": "光影选项",
|
||||
"horizon.pages.split_profiles": "谁在玩?",
|
||||
"horizon.pages.split_profiles.tooltip": "谁在玩:保存在此游戏文件夹中的一份档案。",
|
||||
"horizon.plain.official": "带启动器、模组和更新的官方 Horizon:playhorizon.dev",
|
||||
"horizon.shader_options.profile": "档案",
|
||||
"General": "常规",
|
||||
"Advanced": "高级",
|
||||
|
||||
@@ -13,6 +13,11 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
log::warn!("Trait mods already installed; ignoring the second list");
|
||||
}
|
||||
horizon_threads::configure_allocator();
|
||||
// horizon-extras (docs/forking.md): without the feature, no Horizon Settings or Mods page.
|
||||
if !cfg!(feature = "horizon-extras") {
|
||||
horizon_client::options_ui::HORIZON_EXTRAS
|
||||
.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
horizon_client::telemetry::mark_start();
|
||||
horizon_client::spans::startup::init(std::time::Instant::now());
|
||||
horizon_client::spans::trace::start_from_env();
|
||||
|
||||
+170
-7033
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,97 @@
|
||||
//! The world's save on a timer (`MinecraftServer.computeNextAutosaveInterval`,
|
||||
//! `onTickRateChanged`), the players' autosaves, and the world's metadata (level.dat).
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn save_game(&mut self) -> Result<()> {
|
||||
self.save_game_with(true)
|
||||
}
|
||||
/// `MinecraftServer.computeNextAutosaveInterval`: three hundred seconds
|
||||
/// of ticks at the rate the server runs at now - its measured rate while
|
||||
/// a `/tick sprint` runs - never fewer than a hundred.
|
||||
pub(super) fn next_autosave_interval(&self) -> i64 {
|
||||
let rate = if self.clock.sprinting() {
|
||||
1.0e9 / (self.clock.average_nanos() + 1) as f32
|
||||
} else {
|
||||
self.clock.rate()
|
||||
};
|
||||
autosave_interval(rate)
|
||||
}
|
||||
/// `MinecraftServer.onTickRateChanged`: a rate that asks for a shorter
|
||||
/// wait than the one already running shortens it.
|
||||
pub(super) fn on_tick_rate_changed(&mut self) {
|
||||
let interval = self.next_autosave_interval();
|
||||
if interval < self.ticks_until_autosave {
|
||||
self.ticks_until_autosave = interval;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn finish_player_autosaves(&mut self, wait: bool) -> Result<bool> {
|
||||
let saved = self.player_autosaves.completed(wait);
|
||||
if !saved.is_empty() {
|
||||
let directory = horizon_client::lan::playerdata::directory(&self.world_root);
|
||||
for player in saved {
|
||||
self.native_player_save_to_file(player.uuid, &player.name, &directory);
|
||||
}
|
||||
}
|
||||
self.player_autosaves.writes_finished()
|
||||
}
|
||||
pub(super) fn save_game_metadata(&mut self) -> Result<()> {
|
||||
self.save_maps();
|
||||
self.save_random_sequences();
|
||||
self.save_fight();
|
||||
self.save_wandering_trader();
|
||||
self.save_portal_arrivals();
|
||||
self.save_stats();
|
||||
self.save_advancements();
|
||||
self.save_scoreboard();
|
||||
self.save_bossbars();
|
||||
self.save_schedule();
|
||||
self.save_warden_tracker();
|
||||
self.sync_level();
|
||||
// Level::write can synchronously replace the integrated owner file.
|
||||
let _ = self.finish_player_autosaves(true);
|
||||
let stored_owner = self.store_owner();
|
||||
write_level_for_role(
|
||||
&mut self.level,
|
||||
&self.world_root,
|
||||
self.options.execution_role,
|
||||
)?;
|
||||
// PlayerEvent.SaveToFile for the owner, once level.dat is written.
|
||||
if let Some((uuid, name)) = stored_owner {
|
||||
let directory = horizon_client::lan::playerdata::directory(&self.world_root);
|
||||
self.native_player_save_to_file(uuid, &name, &directory);
|
||||
}
|
||||
// PlayerList.saveAll: every placed player saves with the server, to
|
||||
// their own file (`PlayerDataStorage.save`); the owner is also in the
|
||||
// file level.dat names, which is the one it loads from
|
||||
// (`playerdata::load_owner`).
|
||||
if self.options.dedicated.is_some()
|
||||
&& !self.options.execution_role.is_client()
|
||||
&& let Some(host) = self.lan.take()
|
||||
{
|
||||
// The vehicle tree goes into a player's file when they leave
|
||||
// (`take_parent_vehicle`); here it stays in the chunk saves.
|
||||
let mut host = host;
|
||||
for g in host.hub.guests.iter_mut().filter(|g| g.placed) {
|
||||
// Read-only fixture input, correlated with the real save receipt.
|
||||
// Keep this identical on the untouched/fixed selector builds.
|
||||
if std::env::var_os("HORIZON_DEV_ADVANCEMENT_SELECTOR_AUDIT").is_some() {
|
||||
eprintln!(
|
||||
"advancement selector model raw: {}",
|
||||
serde_json::json!({"conn":g.conn,"name":g.name,
|
||||
"uuid":horizon_protocol::crypto::uuid_string(g.uuid),
|
||||
"game_time":self.world.game_time,
|
||||
"progress":g.advancements.to_json(&self.adv.tree, horizon_world::DATA_VERSION)})
|
||||
);
|
||||
}
|
||||
self.guest_attachments_into(g);
|
||||
self.player_autosaves.enqueue(g, &self.world_root);
|
||||
}
|
||||
self.lan = Some(host);
|
||||
}
|
||||
self.save_world_state();
|
||||
self.message = Some(("Saved the game".into(), Instant::now()));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Block particles: the sprite and tint of a block's faces and particle material, honey slides,
|
||||
//! and the burst a destroyed block leaves (`ClientLevel.addDestroyBlockEffect`).
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
/// The block's first up face (or any face) and its fixed tint, for block
|
||||
/// models drawn as entities (falling blocks, carried blocks).
|
||||
pub(super) fn block_face_sprite(&self, state: BlockStateId) -> Option<(String, u32)> {
|
||||
let pack = &self.pack;
|
||||
let models = pack
|
||||
.render
|
||||
.model_catalog
|
||||
.collect(state, horizon_core::position::BlockPos::new(0, 0, 0))
|
||||
.ok()?;
|
||||
let face = models
|
||||
.iter()
|
||||
.flat_map(|(_, m)| m.faces.iter())
|
||||
.find(|f| f.quad.facing == horizon_assets::model::Direction::Up)
|
||||
.or_else(|| models.iter().flat_map(|(_, m)| m.faces.iter()).next())?;
|
||||
let name = pack.layer_names.get(&face.layer)?.clone();
|
||||
let tint = if face.quad.tint_index >= 0 {
|
||||
let n = &self.res.facts().get(state).name;
|
||||
if n.contains("leaves") || n.contains("vine") {
|
||||
0x48B518
|
||||
} else {
|
||||
0x91BD59
|
||||
}
|
||||
} else {
|
||||
0xFFFFFF
|
||||
};
|
||||
Some((name, tint))
|
||||
}
|
||||
/// `TerrainParticle` at `pos`: the model's particle sprite and tint source
|
||||
/// 0's `colorAsTerrainParticle` (white when the block has none).
|
||||
/// `HoneyBlock.showSlideParticles` (entity event 53): five honey block
|
||||
/// particles at the entity.
|
||||
pub(super) fn honey_slide_particles(&mut self, at: [f64; 3]) {
|
||||
self.honey_entity_particles(at, 53);
|
||||
}
|
||||
/// `HoneyBlock.showParticles`: the crumb count of entity event 53 or 54.
|
||||
pub(super) fn honey_entity_particles(&mut self, at: [f64; 3], event: i8) {
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
let Some(count) = horizon_client::landing::honey_particle_count(event) else {
|
||||
return;
|
||||
};
|
||||
let Some(honey) = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:honey_block")
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let block = [
|
||||
at[0].floor() as i32,
|
||||
at[1].floor() as i32,
|
||||
at[2].floor() as i32,
|
||||
];
|
||||
if let Some((sprite, tint)) = self.particle_sprite(honey, block) {
|
||||
for _ in 0..count {
|
||||
self.particles.add(
|
||||
&ParticleOptions::block(ParticleType::Block, sprite.clone(), tint),
|
||||
at,
|
||||
[0.0; 3],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `BlockStateModelSet.getParticleMaterial`: the sprite of the model's
|
||||
/// `particle` texture, else its first face (entity-rendered blocks may
|
||||
/// have a particle texture without faces, and faces without one).
|
||||
pub(super) fn block_particle_sprite(
|
||||
pack: &PackResources,
|
||||
state: BlockStateId,
|
||||
) -> Option<String> {
|
||||
let catalog = &pack.render.model_catalog;
|
||||
let layer = catalog.particle(state).or_else(|| {
|
||||
let models = catalog
|
||||
.collect(state, horizon_core::position::BlockPos::new(0, 0, 0))
|
||||
.ok()?;
|
||||
models
|
||||
.iter()
|
||||
.flat_map(|(_, m)| m.faces.iter())
|
||||
.next()
|
||||
.map(|f| f.layer)
|
||||
})?;
|
||||
pack.layer_names.get(&layer).cloned()
|
||||
}
|
||||
pub(super) fn particle_shape(&self, pos: [i32; 3], state: BlockStateId) -> Vec<[f64; 6]> {
|
||||
let f = self.res.facts().get(state);
|
||||
let offset = if f.outline_offset {
|
||||
self.pack
|
||||
.context
|
||||
.tables
|
||||
.offset(state, pos[0], pos[2])
|
||||
.unwrap_or([0.0; 3])
|
||||
} else {
|
||||
[0.0; 3]
|
||||
};
|
||||
f.outline
|
||||
.iter()
|
||||
.map(|b| std::array::from_fn(|i| b[i] + offset[i % 3]))
|
||||
.collect()
|
||||
}
|
||||
/// ClientLevel.addDestroyBlockEffect: a burst per outline box.
|
||||
pub(super) fn destroy_particles(&mut self, pos: [i32; 3], state: BlockStateId) {
|
||||
if self.settings.extras.hide_block_particles {
|
||||
return;
|
||||
}
|
||||
let Some((sprite, tint)) = self.particle_sprite(state, pos) else {
|
||||
return;
|
||||
};
|
||||
let boxes = self.particle_shape(pos, state);
|
||||
horizon_client::particles::destroy_positions(pos, &boxes, |p, v| {
|
||||
let particle = Particle::terrain(self.particles.random(), p, v, sprite.clone(), tint);
|
||||
self.particles.push(particle);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -468,7 +468,9 @@ impl Game<'_> {
|
||||
if self.boat_can_attach(i, player) {
|
||||
let mut b = self.boats[i].clone();
|
||||
self.release_boat_leash(&mut b, true);
|
||||
// LeashData.setLeashHolder forgets a holder still waiting in the save.
|
||||
b.leash = Some(Holder::Player);
|
||||
b.leash_pending = None;
|
||||
b.leash_player = Some(self.acting_player_uuid());
|
||||
self.boats[i] = b;
|
||||
self.lead_sound("tied", at);
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Using an item on a cauldron (`CauldronInteraction`): what the click does, and doing it.
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
/// What the cauldron at `pos` answers to `held` on a client, which only
|
||||
/// asks (`CauldronInteractions` acts `if (!level.isClientSide())`). A
|
||||
/// sneak with something in hand skips `useItemOn`
|
||||
/// (`Player.isSecondaryUseActive`).
|
||||
pub(super) fn cauldron_outcome(
|
||||
&self,
|
||||
pos: [i32; 3],
|
||||
held: Option<&ItemStack>,
|
||||
) -> horizon_client::cauldron::Outcome {
|
||||
use horizon_client::cauldron::{self, Outcome};
|
||||
let Some(held) = held else {
|
||||
return Outcome::Pass;
|
||||
};
|
||||
if self.keys.held(Mapping::Sneak) {
|
||||
return Outcome::Pass;
|
||||
}
|
||||
let Some((state, under_water)) = self.cauldron_at(pos) else {
|
||||
return Outcome::Pass;
|
||||
};
|
||||
let removes_dye = cauldron::removes_dye(self.items(), &held.item);
|
||||
cauldron::outcome(&state, under_water, removes_dye, held)
|
||||
}
|
||||
/// `CauldronInteractions` for the held item aimed at a cauldron.
|
||||
pub(super) fn use_on_cauldron(&mut self, pos: [i32; 3]) -> Result<bool> {
|
||||
use horizon_client::cauldron::{self, Outcome};
|
||||
struct Hand<'g, 'a>(&'g mut Game<'a>);
|
||||
impl cauldron::Player for Hand<'_, '_> {
|
||||
fn creative(&self) -> bool {
|
||||
self.0.creative
|
||||
}
|
||||
fn hand(&mut self) -> &mut Option<ItemStack> {
|
||||
self.0.held_mut()
|
||||
}
|
||||
fn add(&mut self, stack: &mut ItemStack) -> bool {
|
||||
let before = stack.count;
|
||||
match self.0.give(stack.clone()) {
|
||||
None => stack.count = 0,
|
||||
Some(rest) => stack.count = rest.count,
|
||||
}
|
||||
stack.count < before
|
||||
}
|
||||
fn contains(&self, stack: &ItemStack) -> bool {
|
||||
self.0
|
||||
.inventory
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|have| have.same_item_same_components(stack))
|
||||
}
|
||||
fn drop_stack(&mut self, stack: ItemStack) {
|
||||
if let Some(eye) = self.0.eye_position() {
|
||||
// ItemUtils.createFilledResult: `player.drop(stack, false)`.
|
||||
self.0.throw_from_eyes(stack, eye, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
let registry = self.pack.assets.registry;
|
||||
let Some((state, under_water)) = self.cauldron_at(pos) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let Some(held) = self.held() else {
|
||||
return Ok(false);
|
||||
};
|
||||
// Player.isSecondaryUseActive with an item in hand skips useItemOn.
|
||||
if self.keys.held(Mapping::Sneak) {
|
||||
return Ok(false);
|
||||
}
|
||||
let removes_dye = cauldron::removes_dye(self.items(), &held.item);
|
||||
let result = cauldron::interact(&state, under_water, removes_dye, &mut Hand(self));
|
||||
if result.outcome == Outcome::Pass {
|
||||
return Ok(false);
|
||||
}
|
||||
if let Some(stat) = result.stat {
|
||||
self.award_custom_stat(stat, 1);
|
||||
}
|
||||
if let Some(block) = result.block
|
||||
&& let Ok(new) = registry.resolve(&block)
|
||||
{
|
||||
self.level_set(pos, new)?;
|
||||
if result.lower_fill_level {
|
||||
self.post_block_game_event("minecraft:block_change", pos, None, Some(new));
|
||||
}
|
||||
}
|
||||
if let Some(sound) = result.sound {
|
||||
self.audio.play(
|
||||
sound,
|
||||
Some([
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]) + 0.5,
|
||||
f64::from(pos[2]) + 0.5,
|
||||
]),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,580 @@
|
||||
//! Unit tests that lived in game.rs's inline `tests` module.
|
||||
use super::*;
|
||||
use horizon_client::container_screen::{Button, Clicks, Release, Target};
|
||||
use horizon_client::keymapping::{KeyAction, SNEAK, SPRINT, ToggleKeys};
|
||||
|
||||
/// `ClipContext.Block.OUTLINE` for the SOURCE_ONLY ray (#8180): a ray
|
||||
/// through the empty part of a torch's cell is not stopped by it.
|
||||
#[test]
|
||||
fn the_fluid_ray_passes_through_the_hollow_of_a_torch() {
|
||||
// A torch: a 2x10x2 pillar in the middle of its cell, at (0, 0, 0).
|
||||
let torch = || vec![[0.4375, 0.0, 0.4375, 0.5625, 0.625, 0.5625]];
|
||||
let dir = [0.0, 0.0, 1.0];
|
||||
// Beside the pillar: no hit, the ray goes on to the water behind.
|
||||
assert!(outline_stop([0.1, 0.3, -1.0], dir, 5.0, [0, 0, 0], [0, 0, -1], torch).is_none());
|
||||
// Above the pillar (a torch is 10/16 tall): no hit either.
|
||||
assert!(outline_stop([0.5, 0.8, -1.0], dir, 5.0, [0, 0, 0], [0, 0, -1], torch).is_none());
|
||||
// Through the pillar: stopped on its front face, not at the cell's edge.
|
||||
let hit = outline_stop([0.5, 0.3, -1.0], dir, 5.0, [0, 0, 0], [0, 0, -1], torch)
|
||||
.expect("the pillar stops the ray");
|
||||
assert_eq!(hit.face, [0, 0, -1]);
|
||||
assert!((hit.point[2] - 0.4375).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_death_drops_the_off_hand_before_the_armour() {
|
||||
let stack = |item: &str| Some(ItemStack::new(item, 1));
|
||||
let mut inventory = vec![stack("minecraft:diamond"), None, stack("minecraft:stick")];
|
||||
let mut offhand = stack("minecraft:shield");
|
||||
let mut armor = [
|
||||
stack("minecraft:iron_helmet"),
|
||||
stack("minecraft:iron_chestplate"),
|
||||
stack("minecraft:iron_leggings"),
|
||||
stack("minecraft:iron_boots"),
|
||||
];
|
||||
let dropped: Vec<String> = death_drops(&mut inventory, &mut offhand, &mut armor)
|
||||
.into_iter()
|
||||
.map(|s| s.item.to_string())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
dropped,
|
||||
[
|
||||
"minecraft:diamond",
|
||||
"minecraft:stick",
|
||||
"minecraft:shield",
|
||||
"minecraft:iron_boots",
|
||||
"minecraft:iron_leggings",
|
||||
"minecraft:iron_chestplate",
|
||||
"minecraft:iron_helmet",
|
||||
]
|
||||
);
|
||||
assert!(inventory.iter().all(Option::is_none));
|
||||
assert!(offhand.is_none() && armor.iter().all(Option::is_none));
|
||||
}
|
||||
|
||||
/// `MinecraftServer.computeNextAutosaveInterval`: three hundred seconds of
|
||||
/// ticks at the rate the server runs at, never fewer than a hundred.
|
||||
#[test]
|
||||
fn the_autosave_waits_for_three_hundred_seconds_of_ticks() {
|
||||
// 20 ticks a second is the six thousand ticks a world autosaved on
|
||||
// before the rate could be changed (`/tick rate`).
|
||||
assert_eq!(autosave_interval(20.0), 6000);
|
||||
assert_eq!(autosave_interval(10.0), 3000);
|
||||
assert_eq!(autosave_interval(40.0), 12000);
|
||||
assert_eq!(autosave_interval(1000.0), 300_000);
|
||||
// `Math.max(100, ...)`: a very low rate still autosaves every hundred
|
||||
// ticks, and a frozen clock (0) does too.
|
||||
assert_eq!(autosave_interval(0.5), 150);
|
||||
assert_eq!(autosave_interval(0.0), 100);
|
||||
}
|
||||
|
||||
/// `Minecraft.<init>`: only an explicit `startedCleanly: false` is an
|
||||
/// unclean start, and it leaves fullscreen; the packs stay selected.
|
||||
#[test]
|
||||
fn an_unclean_start_leaves_fullscreen_and_keeps_the_packs() {
|
||||
let fullscreen = || Settings {
|
||||
fullscreen: true,
|
||||
resource_packs: vec!["pack".into()],
|
||||
..Default::default()
|
||||
};
|
||||
// The key absent (a first boot) is a clean start.
|
||||
let mut settings = fullscreen();
|
||||
assert!(!unclean_start_recovery(&mut settings));
|
||||
assert!(settings.fullscreen);
|
||||
let mut settings = fullscreen();
|
||||
settings.file.fullscreen_resolution = Some("1920x1080@60:24".into());
|
||||
settings.file.set(
|
||||
"startedCleanly",
|
||||
horizon_client::options_file::V::Bool(false),
|
||||
);
|
||||
assert!(unclean_start_recovery(&mut settings));
|
||||
assert!(!settings.fullscreen);
|
||||
assert_eq!(settings.file.fullscreen_resolution, None);
|
||||
assert_eq!(settings.resource_packs, ["pack"]);
|
||||
let mut settings = fullscreen();
|
||||
settings.file.set(
|
||||
"startedCleanly",
|
||||
horizon_client::options_file::V::Bool(true),
|
||||
);
|
||||
assert!(!unclean_start_recovery(&mut settings));
|
||||
assert!(settings.fullscreen);
|
||||
}
|
||||
|
||||
/// `NativeScreenView.id` (the `screen_render_post` hook) is a mod's
|
||||
/// contract: every `Screen` has its own id, and it does not change.
|
||||
/// `Screen::id`'s match makes a new variant give itself one; this list
|
||||
/// makes the test cover it too.
|
||||
#[test]
|
||||
fn screen_ids_are_distinct_and_stable() {
|
||||
use std::collections::BTreeSet;
|
||||
let all = [
|
||||
Screen::Playing,
|
||||
Screen::GameModeSwitcher,
|
||||
Screen::EmoteWheel,
|
||||
Screen::Paused,
|
||||
Screen::Options,
|
||||
Screen::Chat,
|
||||
Screen::Creative,
|
||||
Screen::Inventory,
|
||||
Screen::Dead,
|
||||
Screen::Stats,
|
||||
Screen::Advancements,
|
||||
Screen::WorldOptions,
|
||||
Screen::Friends,
|
||||
Screen::Social,
|
||||
Screen::CommandBlock,
|
||||
Screen::BlockEditor,
|
||||
Screen::ServerLinks,
|
||||
Screen::ConfirmLink,
|
||||
Screen::DialogWarning,
|
||||
Screen::Win,
|
||||
Screen::DebugOptions,
|
||||
];
|
||||
let ids: Vec<&'static str> = all.iter().map(|screen| screen.id()).collect();
|
||||
let unique: BTreeSet<&'static str> = ids.iter().copied().collect();
|
||||
assert_eq!(unique.len(), all.len(), "two screens share an id");
|
||||
for id in &ids {
|
||||
assert!(!id.is_empty(), "an empty screen id");
|
||||
assert!(
|
||||
id.chars().all(|c| c.is_ascii_lowercase() || c == '_'),
|
||||
"{id} is not a snake_case id"
|
||||
);
|
||||
}
|
||||
// The ones a mod can match on in the hook's docs.
|
||||
assert_eq!(Screen::Playing.id(), "playing");
|
||||
assert_eq!(Screen::Inventory.id(), "inventory");
|
||||
assert_eq!(Screen::Win.id(), "win");
|
||||
}
|
||||
|
||||
/// The `modifiers` a native mod's input hooks hand over are GLFW's bits
|
||||
/// (`GLFW_MOD_SHIFT` 1, `GLFW_MOD_CONTROL` 2, `GLFW_MOD_ALT` 4,
|
||||
/// `GLFW_MOD_SUPER` 8), each on its own and all together.
|
||||
#[test]
|
||||
fn glfw_modifier_bits_are_glfw_modifier_bits() {
|
||||
use winit::keyboard::ModifiersState;
|
||||
for (state, bits) in [
|
||||
(ModifiersState::empty(), 0),
|
||||
(ModifiersState::SHIFT, 1),
|
||||
(ModifiersState::CONTROL, 2),
|
||||
(ModifiersState::ALT, 4),
|
||||
(ModifiersState::SUPER, 8),
|
||||
(ModifiersState::SHIFT | ModifiersState::ALT, 5),
|
||||
(ModifiersState::CONTROL | ModifiersState::SUPER, 10),
|
||||
(ModifiersState::SHIFT | ModifiersState::CONTROL, 3),
|
||||
(
|
||||
ModifiersState::SHIFT
|
||||
| ModifiersState::CONTROL
|
||||
| ModifiersState::ALT
|
||||
| ModifiersState::SUPER,
|
||||
15,
|
||||
),
|
||||
] {
|
||||
assert_eq!(glfw_modifiers(state), bits, "{state:?}");
|
||||
}
|
||||
// Every modifier GLFW has a bit for, and no other.
|
||||
assert_eq!(glfw_modifiers(ModifiersState::all()), 15);
|
||||
}
|
||||
|
||||
/// The `button` a native mod's mouse hooks hand over is GLFW's index (0
|
||||
/// left, 1 right, 2 middle, 3 back, 4 forward, else the button's number),
|
||||
/// the one `keybinds::mouse_name` names (`key.mouse.<index + 1>` from the
|
||||
/// back button on).
|
||||
#[test]
|
||||
fn glfw_button_indices_are_glfw_indices() {
|
||||
for (button, index, name) in [
|
||||
(MouseButton::Left, 0, "key.mouse.left"),
|
||||
(MouseButton::Right, 1, "key.mouse.right"),
|
||||
(MouseButton::Middle, 2, "key.mouse.middle"),
|
||||
(MouseButton::Back, 3, "key.mouse.4"),
|
||||
(MouseButton::Forward, 4, "key.mouse.5"),
|
||||
] {
|
||||
assert_eq!(glfw_button(button), index);
|
||||
assert_eq!(horizon_client::keybinds::mouse_name(button), name);
|
||||
}
|
||||
for n in 5..12u16 {
|
||||
assert_eq!(glfw_button(MouseButton::Other(n)), i32::from(n));
|
||||
assert_eq!(
|
||||
horizon_client::keybinds::mouse_name(MouseButton::Other(n)),
|
||||
format!("key.mouse.{}", n + 1)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// F3 left on when leaving a world kept the axes on the title screen.
|
||||
#[test]
|
||||
fn the_debug_crosshair_is_gone_once_the_world_is() {
|
||||
assert!(debug_crosshair_drawn(true, true, false, false));
|
||||
assert!(!debug_crosshair_drawn(true, true, false, true));
|
||||
}
|
||||
|
||||
/// Snuffing a candle or a candle cake draws one smoke per
|
||||
/// `getParticleOffsets`: by `candles` for a candle, a block up for a cake.
|
||||
#[test]
|
||||
fn a_snuffed_candle_and_candle_cake_smoke_at_vanillas_offsets() {
|
||||
assert_eq!(
|
||||
Game::candle_offsets("minecraft:candle", Some(1)),
|
||||
[[8u8, 8, 8]].as_slice()
|
||||
);
|
||||
assert_eq!(
|
||||
Game::candle_offsets("minecraft:red_candle", Some(3)),
|
||||
[[8u8, 5, 10], [6, 7, 8], [9, 8, 7]].as_slice()
|
||||
);
|
||||
assert_eq!(
|
||||
Game::candle_offsets("minecraft:candle_cake", None),
|
||||
[[8u8, 16, 8]].as_slice()
|
||||
);
|
||||
}
|
||||
|
||||
/// Held items apply their main-hand and off-hand modifiers to the player (#4118, #4777).
|
||||
#[test]
|
||||
fn equipment_modifiers_follow_their_slot_group() {
|
||||
assert!(equipment_modifier_applies("mainhand", "mainhand"));
|
||||
assert!(equipment_modifier_applies("hand", "mainhand"));
|
||||
assert!(equipment_modifier_applies("any", "mainhand"));
|
||||
assert!(!equipment_modifier_applies("armor", "mainhand"));
|
||||
assert!(!equipment_modifier_applies("mainhand", "head"));
|
||||
assert!(equipment_modifier_applies("armor", "chest"));
|
||||
assert!(!equipment_modifier_applies("hand", "feet"));
|
||||
assert!(equipment_modifier_applies("offhand", "offhand"));
|
||||
assert!(equipment_modifier_applies("hand", "offhand"));
|
||||
assert!(equipment_modifier_applies("any", "offhand"));
|
||||
assert!(!equipment_modifier_applies("armor", "offhand"));
|
||||
assert!(!equipment_modifier_applies("mainhand", "offhand"));
|
||||
assert!(!equipment_modifier_applies("offhand", "mainhand"));
|
||||
}
|
||||
|
||||
/// A guest's interact packet names its entity and the server keeps it:
|
||||
/// `ServerGamePacketListenerImpl.handleInteract` resolves
|
||||
/// `packet.entityId()` once and `Player.interactOn` clicks that entity,
|
||||
/// while a fresh pick would take the nearest one. Two cows stand one
|
||||
/// behind the other; the packet names the far one.
|
||||
#[test]
|
||||
fn an_interact_packet_clicks_the_entity_it_names_not_the_nearest() {
|
||||
// (server id, distance from the guest) in the level's own order.
|
||||
let cows = [(17u32, 1.5f64), (23, 2.5)];
|
||||
assert_eq!(Game::named_entity(&cows, 23, |c| c.0), Some(1));
|
||||
// The near cow is reachable too, when that is the one named.
|
||||
assert_eq!(Game::named_entity(&cows, 17, |c| c.0), Some(0));
|
||||
// An id the level does not hold clicks nothing (Level.getEntity).
|
||||
assert_eq!(Game::named_entity(&cows, 31, |c| c.0), None);
|
||||
}
|
||||
|
||||
/// The id path keeps the crosshair pick's filters, so a packet cannot name
|
||||
/// a mob the pick would refuse: one in its death animation, the one the
|
||||
/// player rides, or the camera entity. Without them a guest could shear,
|
||||
/// feed or leash a dying mob that `mob_hit` never returns.
|
||||
#[test]
|
||||
fn an_interact_packet_cannot_name_a_mob_the_pick_would_refuse() {
|
||||
use super::creatures::named_mob_pickable;
|
||||
assert!(named_mob_pickable(true, 17, None, None));
|
||||
// Dead: `Mob.alive()` is health > 0.
|
||||
assert!(!named_mob_pickable(false, 17, None, None));
|
||||
// Ridden, and the camera entity (`remote_camera_id`).
|
||||
assert!(!named_mob_pickable(true, 17, Some(17), None));
|
||||
assert!(!named_mob_pickable(true, 17, None, Some(17)));
|
||||
// Another mob being ridden or watched does not block this one.
|
||||
assert!(named_mob_pickable(true, 17, Some(23), Some(29)));
|
||||
}
|
||||
|
||||
/// The packet's location is `EntityHitResult.getLocation() -
|
||||
/// entity.position()`, the point the view ray met the box at measured
|
||||
/// from the mob's feet (`MultiPlayerGameMode.interact`). The old fixed
|
||||
/// [0, 1, 0] was a point inside every mob, which the server's
|
||||
/// `interactAt` paths (armour stands, saddles, leashes) read as the click.
|
||||
#[test]
|
||||
fn an_interact_packet_carries_the_point_the_ray_hit() {
|
||||
let feet = Vec3::new(0.5, 0.0, 2.0);
|
||||
let eye = [0.5, 1.5, 1.0];
|
||||
let dir = [0.0, 0.0, 1.0];
|
||||
let box_at = Aabb::new(feet, Vec3::new(1.5, 2.0, 3.0));
|
||||
let t = ray_box(eye, dir, 3.0, box_at).unwrap();
|
||||
let point = Vec3::new(
|
||||
eye[0] + dir[0] * t,
|
||||
eye[1] + dir[1] * t,
|
||||
eye[2] + dir[2] * t,
|
||||
);
|
||||
// The ray enters the box's near face halfway up it, directly above
|
||||
// the feet: not the old centre point of the entity.
|
||||
assert_eq!(interact_location(point, feet), [0.0, 1.5, 0.0]);
|
||||
}
|
||||
|
||||
/// A mod screen on a hopper or dispenser menu owns its slots: the stock
|
||||
/// grid stands back so `slot_position` reaches the mod's own slots
|
||||
/// (`native_screen_slot`) and its `inventory_y` (#5141). Without a mod
|
||||
/// screen the stock layout answers exactly as before, which is what keeps
|
||||
/// this invisible to players who run no native mods.
|
||||
#[test]
|
||||
fn a_mod_screen_on_a_machine_menu_takes_the_stock_layout_out() {
|
||||
let stock = Some((8.0, 109.0));
|
||||
// A mod screen is open: the stock grid does not answer.
|
||||
assert_eq!(Game::stock_machine_slot(stock, true), None);
|
||||
// No mod screen: the hopper and dispenser layouts are untouched.
|
||||
assert_eq!(Game::stock_machine_slot(stock, false), stock);
|
||||
// Other menus have no stock layout to stand back from.
|
||||
assert_eq!(Game::stock_machine_slot(None, true), None);
|
||||
assert_eq!(Game::stock_machine_slot(None, false), None);
|
||||
}
|
||||
|
||||
/// A key bound to key.attack keeps attacking while it is held: each
|
||||
/// repeat clicks like the press (#3243).
|
||||
#[test]
|
||||
fn a_held_key_bound_to_a_button_repeats_its_clicks() {
|
||||
assert_eq!(keyboard_button(true, KeyAction::Press), Some(true));
|
||||
assert_eq!(keyboard_button(true, KeyAction::Repeat), Some(true));
|
||||
assert_eq!(keyboard_button(true, KeyAction::Release), Some(false));
|
||||
for action in [KeyAction::Press, KeyAction::Repeat, KeyAction::Release] {
|
||||
assert_eq!(keyboard_button(false, action), None);
|
||||
}
|
||||
}
|
||||
|
||||
/// A server dialog, a book, a sign editor or a prompt over the world is a
|
||||
/// screen: held keys and the mouse buttons are released
|
||||
/// (`KeyMapping.releaseAll`), and toggled sneak comes back when it closes
|
||||
/// (#4974, #9729).
|
||||
#[test]
|
||||
fn a_dialog_releases_held_keys_like_a_screen() {
|
||||
assert!(gameplay_has_keyboard(Screen::Playing, false, false));
|
||||
assert!(!gameplay_has_keyboard(Screen::Playing, false, true));
|
||||
assert!(!gameplay_has_keyboard(Screen::Playing, true, false));
|
||||
assert!(!gameplay_has_keyboard(Screen::Chat, false, false));
|
||||
|
||||
let mut keys = horizon_client::held::HeldKeys::from_iter([KeyCode::KeyW]);
|
||||
let mut left = true;
|
||||
let mut right = true;
|
||||
drop_held_behind_dialog(false, &mut keys, &mut left, &mut right);
|
||||
assert!(keys.raw(&KeyCode::KeyW) && left && right);
|
||||
drop_held_behind_dialog(true, &mut keys, &mut left, &mut right);
|
||||
assert!(keys.is_empty() && !left && !right);
|
||||
|
||||
let options = [true, false, false, false];
|
||||
let mut toggles = ToggleKeys::default();
|
||||
toggles.key(SNEAK, KeyAction::Press, false, true);
|
||||
toggles.key(SNEAK, KeyAction::Release, false, true);
|
||||
assert!(toggles.is_down(SNEAK));
|
||||
toggles.screen_opened(options);
|
||||
assert!(!toggles.is_down(SNEAK));
|
||||
toggles.screen_closed(options);
|
||||
assert!(toggles.is_down(SNEAK));
|
||||
}
|
||||
/// A key bound to key.pickItem clones over a container screen, as the
|
||||
/// middle button does; a key bound to attack or use does nothing there (#5378).
|
||||
#[test]
|
||||
fn a_key_bound_to_pick_block_clones_behind_a_container_screen() {
|
||||
assert!(container_key_clones(MouseButton::Middle, KeyAction::Press));
|
||||
assert!(container_key_clones(MouseButton::Middle, KeyAction::Repeat));
|
||||
assert!(!container_key_clones(
|
||||
MouseButton::Middle,
|
||||
KeyAction::Release
|
||||
));
|
||||
for button in [MouseButton::Left, MouseButton::Right] {
|
||||
assert!(!container_key_clones(button, KeyAction::Press));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn sleep_fade_lasts_ten_ticks_after_waking() {
|
||||
let alpha = |timer| match hud_overlays::sleep_overlay(320, 240, timer).first() {
|
||||
Some(hud_layout::GuiDraw::Fill { color, .. }) => color >> 24,
|
||||
None => 0,
|
||||
_ => panic!("sleep overlay must be a full-screen fill"),
|
||||
};
|
||||
assert_eq!(tick_sleep_timer(0, false), 0);
|
||||
let mut timer = 0;
|
||||
for _ in 0..50 {
|
||||
timer = tick_sleep_timer(timer, true);
|
||||
}
|
||||
assert_eq!(alpha(timer as i32), 110);
|
||||
for _ in 0..100 {
|
||||
timer = tick_sleep_timer(timer, true);
|
||||
}
|
||||
assert_eq!(timer, 100);
|
||||
assert_eq!(alpha(timer as i32), 220);
|
||||
for _ in 0..5 {
|
||||
timer = tick_sleep_timer(timer, false);
|
||||
}
|
||||
assert_eq!(alpha(timer as i32), 110);
|
||||
for _ in 0..5 {
|
||||
timer = tick_sleep_timer(timer, false);
|
||||
}
|
||||
assert_eq!(timer, 0);
|
||||
assert!(hud_overlays::sleep_overlay(320, 240, timer as i32).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_rest_poll_does_not_wait_for_a_blocked_worker() {
|
||||
let (release, wait) = std::sync::mpsc::channel();
|
||||
let loader = WorldRestLoader {
|
||||
progress: Progress::new(),
|
||||
handle: std::thread::spawn(move || {
|
||||
wait.recv().unwrap();
|
||||
Ok(())
|
||||
}),
|
||||
};
|
||||
assert!(!loader.ready().unwrap());
|
||||
release.send(()).unwrap();
|
||||
loader.finish().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_rest_poll_waits_for_a_slow_worker() {
|
||||
let (release, wait) = std::sync::mpsc::channel();
|
||||
let loader = WorldRestLoader {
|
||||
progress: Progress::new(),
|
||||
handle: std::thread::spawn(move || {
|
||||
wait.recv().unwrap();
|
||||
Ok(())
|
||||
}),
|
||||
};
|
||||
// However long it takes, a worker still loading is not an error.
|
||||
assert!(!loader.ready().unwrap());
|
||||
release.send(()).unwrap();
|
||||
while !loader.ready().unwrap() {
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
loader.finish().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_rest_worker_failure_reaches_the_client() {
|
||||
let loader = WorldRestLoader {
|
||||
progress: Progress::new(),
|
||||
handle: std::thread::spawn(|| Err("missing world table".to_owned())),
|
||||
};
|
||||
assert_eq!(
|
||||
loader.finish().unwrap_err().to_string(),
|
||||
"missing world table"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_rest_worker_panic_reaches_the_client() {
|
||||
let loader = WorldRestLoader {
|
||||
progress: Progress::new(),
|
||||
handle: std::thread::spawn(|| panic!("broken loader")),
|
||||
};
|
||||
assert_eq!(
|
||||
loader.finish().unwrap_err().to_string(),
|
||||
"world resource loader panicked"
|
||||
);
|
||||
}
|
||||
|
||||
/// An integrated server thread shares the loaded resources with the client.
|
||||
#[test]
|
||||
fn shared_resources_can_cross_threads() {
|
||||
fn shared<T: Send + Sync>() {}
|
||||
shared::<Resources>();
|
||||
shared::<PackResources>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_creative_tabs_does_not_drop_the_carried_stack_on_release() {
|
||||
let mut creative = Creative::from_json(&serde_json::json!({
|
||||
"schema": 2,
|
||||
"tabs": [
|
||||
{"type": "CATEGORY", "items": ["{count:1,id:\"minecraft:stone\"}"]},
|
||||
{"type": "INVENTORY"}
|
||||
]
|
||||
}))
|
||||
.unwrap();
|
||||
for button in [Button::Left, Button::Right] {
|
||||
let mut clicks = Clicks::default();
|
||||
for tab in [1, 1, 0, 1] {
|
||||
select_creative_tab(&mut creative, &mut clicks, tab);
|
||||
assert_eq!(creative.selected, tab);
|
||||
assert_eq!(creative.tab().inventory, tab == 1);
|
||||
assert_eq!(
|
||||
clicks.release(
|
||||
&Target::<usize>::Outside,
|
||||
button,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
),
|
||||
Release::Nothing
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Eyes under water: the ray hits the source the eyes are in, at the eyes
|
||||
/// (#3787), whichever way it looks.
|
||||
#[test]
|
||||
fn a_ray_starting_inside_a_source_hits_it_at_the_start() {
|
||||
let eye = [0.5, 65.62, 0.5];
|
||||
let down = [0.0, -1.0, 0.0];
|
||||
assert_eq!(
|
||||
clip_start_source(eye, down, 4.5, [0, 65, 0], true),
|
||||
Some(([0, 1, 0], eye))
|
||||
);
|
||||
// Without water above, 0.62 is still below the surface (8/9).
|
||||
assert_eq!(
|
||||
clip_start_source(eye, [0.0, 1.0, 0.0], 4.5, [0, 65, 0], false),
|
||||
Some(([0, -1, 0], eye))
|
||||
);
|
||||
// Looking ahead, slightly down: SOUTH's dot product is largest.
|
||||
let ahead = [0.0, -0.2f64, 0.98f64.sqrt()];
|
||||
assert_eq!(
|
||||
clip_start_source(eye, ahead, 4.5, [0, 65, 0], true),
|
||||
Some(([0, 0, -1], eye))
|
||||
);
|
||||
}
|
||||
|
||||
/// Eyes above a surface source's 8/9: a ray going down enters it through
|
||||
/// the top inside the block; level or rising rays miss it.
|
||||
#[test]
|
||||
fn a_ray_starting_above_a_source_surface_enters_through_the_top() {
|
||||
let eye = [0.5, 65.95, 0.5];
|
||||
let hit = clip_start_source(eye, [0.0, -1.0, 0.0], 4.5, [0, 65, 0], false);
|
||||
let (face, point) = hit.unwrap();
|
||||
assert_eq!(face, [0, 1, 0]);
|
||||
assert!((point[1] - (65.0 + f64::from(8.0f32 / 9.0f32))).abs() < 1e-12);
|
||||
assert_eq!(
|
||||
clip_start_source(eye, [1.0, 0.0, 0.0], 4.5, [0, 65, 0], false),
|
||||
None
|
||||
);
|
||||
// Leaves the block sideways before it comes down to the surface.
|
||||
let shallow = [0.995f64.sqrt(), -0.0707, 0.0];
|
||||
assert_eq!(
|
||||
clip_start_source(eye, shallow, 4.5, [0, 65, 0], false),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stack_drop_requires_ctrl_even_with_sprint_latched() {
|
||||
for sprint in [false, true] {
|
||||
let mut toggles = ToggleKeys::default();
|
||||
if sprint {
|
||||
toggles.key(SPRINT, KeyAction::Press, false, true);
|
||||
toggles.key(SPRINT, KeyAction::Release, false, true);
|
||||
}
|
||||
assert_eq!(toggles.is_down(SPRINT), sprint);
|
||||
|
||||
for held in 0..4 {
|
||||
let mut modifiers = winit::keyboard::ModifiersState::empty();
|
||||
let mut physical_keys = HashSet::from([KeyCode::ShiftLeft, KeyCode::KeyQ]);
|
||||
let sides = [KeyCode::ControlLeft, KeyCode::ControlRight];
|
||||
for (bit, key) in sides.into_iter().enumerate() {
|
||||
if held & (1 << bit) != 0 {
|
||||
horizon_client_input::modifiers::set_key(
|
||||
&mut modifiers,
|
||||
&mut physical_keys,
|
||||
key,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(drop_whole_stack(modifiers), held != 0);
|
||||
for key in sides {
|
||||
horizon_client_input::modifiers::set_key(
|
||||
&mut modifiers,
|
||||
&mut physical_keys,
|
||||
key,
|
||||
false,
|
||||
);
|
||||
}
|
||||
assert!(!drop_whole_stack(modifiers));
|
||||
assert_eq!(toggles.is_down(SPRINT), sprint);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Cosmetics on players ([docs/cosmetics.md](../../../../docs/cosmetics.md)): only Horizon
|
||||
//! clients draw them, off unless `horizon_cosmetics::enabled()`. What another player wears comes
|
||||
//! clients draw them, off unless [`enabled`]. What another player wears comes
|
||||
//! from the cosmetics service by UUID (`horizon_cosmetics::Cosmetics`, its own thread); this file
|
||||
//! puts the items on the avatar's bones, on the GPU skin path when the renderer takes it.
|
||||
//!
|
||||
@@ -433,11 +433,18 @@ fn body_top(
|
||||
.reduce(f32::max)
|
||||
}
|
||||
|
||||
/// Whether cosmetics, emotes and the "Horizon player" presence run at all: the `horizon-extras`
|
||||
/// feature's gate for all three (docs/forking.md), then `horizon_cosmetics::enabled()`. The game
|
||||
/// asks this, never `horizon_cosmetics::enabled()` itself.
|
||||
pub(super) fn enabled() -> bool {
|
||||
cfg!(feature = "horizon-extras") && horizon_cosmetics::enabled()
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Once a frame, before drawing: starts the service's client on first use and puts the
|
||||
/// textures the last frame wanted into the atlas.
|
||||
pub(super) fn cosmetics_frame(&mut self) {
|
||||
if !horizon_cosmetics::enabled() {
|
||||
if !enabled() {
|
||||
return;
|
||||
}
|
||||
let c = &mut self.cosmetics;
|
||||
@@ -644,7 +651,7 @@ impl Game<'_> {
|
||||
const AGAIN: Duration = Duration::from_secs(600);
|
||||
let e = &mut self.emotes;
|
||||
if self.cosmetics.local
|
||||
|| !horizon_cosmetics::enabled()
|
||||
|| !enabled()
|
||||
|| horizon_client::auth::current().is_none()
|
||||
|| e.loading.is_some()
|
||||
|| e.loaded.is_some_and(|at| at.elapsed() < AGAIN)
|
||||
|
||||
@@ -255,7 +255,7 @@ impl Game<'_> {
|
||||
alpha: f64,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
if !horizon_cosmetics::enabled() {
|
||||
if !super::enabled() {
|
||||
return;
|
||||
}
|
||||
if !self.own_body_in_view()
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
//! The creative inventory screen (`CreativeModeInventoryScreen`): its tabs, pages, tooltips,
|
||||
//! trash slot, search box and clicks.
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `CreativeModeInventoryScreen.hasPermissions` (`canUseGameMasterBlocks`
|
||||
/// and the Operator Items Tab option) given to `tryRefreshInvalidatedTabs`,
|
||||
/// as the screen's constructor and `containerTick` do.
|
||||
pub(super) fn refresh_creative_tabs(&mut self) {
|
||||
let permissions = self.can_use_game_master_blocks() && self.settings.operator_items_tab;
|
||||
self.creative_screen
|
||||
.set_permissions(permissions, &self.pack.names);
|
||||
}
|
||||
pub(super) fn creative_inventory_open(&self) -> bool {
|
||||
self.screen == Screen::Creative && self.creative_screen.tab().inventory
|
||||
}
|
||||
/// An item tab of the creative screen (its ItemPickerMenu).
|
||||
pub(super) fn creative_tab_open(&self) -> bool {
|
||||
self.screen == Screen::Creative && !self.creative_screen.tab().inventory
|
||||
}
|
||||
|
||||
pub(super) fn creative_origin(batch: &HudBatch) -> (f32, f32) {
|
||||
batch.centered_origin(Creative::WIDTH, Creative::HEIGHT)
|
||||
}
|
||||
/// A creative tab's title: `itemGroup.*` for vanilla's; a mod tab's
|
||||
/// `itemGroup.<mod>.<name>`, else its `title` as a lang key (a mod's own
|
||||
/// `itemGroup.<mod>`), else `title` as English text.
|
||||
pub(super) fn creative_tab_title(&self, tab: &horizon_client::creative::Tab) -> String {
|
||||
match horizon_client::creative::item_group_key(&tab.id) {
|
||||
Some(key) => self.translate(key, &tab.title),
|
||||
None => {
|
||||
let own = self.translate(&tab.title, &tab.title);
|
||||
self.translate(&format!("itemGroup.{}", tab.id.replace(':', ".")), &own)
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Vanilla tooltip: dark box with a purple gradient border, laid out and
|
||||
/// drawn like an item tooltip (4 px of padding on every side).
|
||||
pub(super) fn tooltip(&self, batch: &mut HudBatch, text: &str, x: f32, y: f32) {
|
||||
let atlas = &self.gui.atlas;
|
||||
let lines = vec![(horizon_client::text::Text::literal(text), u32::MAX)];
|
||||
let (x, y, w, h) =
|
||||
Self::tooltip_geometry(atlas, &lines, (x, y), (batch.width, batch.height), [0.0; 2]);
|
||||
self.tooltip_box(batch, None, x, y, w, h);
|
||||
Self::tooltip_text(batch, atlas, &lines, x, y);
|
||||
}
|
||||
/// `CreativeModeInventoryScreen.getTooltipFromContainerItem`: the lines
|
||||
/// that follow the item's name, none for a category tab's own grid slots
|
||||
/// (`grid`): the names of the tabs containing the stack, in blue, then on
|
||||
/// the search tab's grid the tags a `#` search shows, in dark purple.
|
||||
pub(super) fn creative_tooltip_tabs(
|
||||
&self,
|
||||
stack: &ItemStack,
|
||||
grid: bool,
|
||||
) -> Vec<(horizon_client::text::Text, u32)> {
|
||||
let screen = &self.creative_screen;
|
||||
let tab = screen.tab();
|
||||
let category = !tab.search && !tab.inventory;
|
||||
if !horizon_client::creative::tooltip_lists_tabs(category, grid) {
|
||||
return Vec::new();
|
||||
}
|
||||
let blue = rgba(85, 85, 255, 255);
|
||||
let dark_purple = rgba(170, 0, 170, 255);
|
||||
let mut lines: Vec<(horizon_client::text::Text, u32)> = screen
|
||||
.tabs_containing(stack)
|
||||
.into_iter()
|
||||
.map(|i| {
|
||||
let title = self.creative_tab_title(&screen.tabs[i]);
|
||||
(horizon_client::text::Text::literal(title), blue)
|
||||
})
|
||||
.collect();
|
||||
if tab.search && grid {
|
||||
for tag in screen.search_tag_lines(stack) {
|
||||
lines.push((horizon_client::text::Text::literal(tag), dark_purple));
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
/// `CreativeModeInventoryScreen.extractRenderState`: item slots share the
|
||||
/// container screen's hover target for their highlight and tooltip.
|
||||
pub(super) fn draw_creative(&mut self, batch: &mut HudBatch) {
|
||||
// CreativeModeInventoryScreen: `CommonComponents.EMPTY` for a title.
|
||||
screen_info::begin_container("creative");
|
||||
let (ox, oy) = Self::creative_origin(batch);
|
||||
let selected = self.creative_screen.selected;
|
||||
// Only what is drawn of the tabs: their stacks stay where they are.
|
||||
let tabs = self.creative_screen.tab_headers();
|
||||
let tab = &tabs[selected.min(tabs.len() - 1)];
|
||||
let tab_sprite = |t: &horizon_client::creative::Tab, on: bool| {
|
||||
format!(
|
||||
"gui/sprites/container/creative_inventory/tab_{}_{}_{}",
|
||||
if t.top { "top" } else { "bottom" },
|
||||
if on { "selected" } else { "unselected" },
|
||||
t.column + 1
|
||||
)
|
||||
};
|
||||
self.render_transparent_background("creative", batch);
|
||||
// Unselected tabs behind the panel, then the panel, then the selected tab.
|
||||
for (i, t) in tabs.iter().enumerate() {
|
||||
if i != selected && self.creative_screen.on_page(t) {
|
||||
let (tx, ty) = Creative::tab_position(t);
|
||||
batch.sprite(&self.gui.atlas, &tab_sprite(t, false), ox + tx, oy + ty);
|
||||
let mouse = (self.cursor.0 - ox, self.cursor.1 - oy);
|
||||
if Creative::tab_wants_hand(t, false, mouse) {
|
||||
horizon_client::widgets::request_cursor(
|
||||
horizon_client::widgets::Cursor::PointingHand,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let background = if tab.search {
|
||||
"gui/container/creative_inventory/tab_item_search"
|
||||
} else if tab.inventory {
|
||||
"gui/container/creative_inventory/tab_inventory"
|
||||
} else {
|
||||
"gui/container/creative_inventory/tab_items"
|
||||
};
|
||||
if let Some(mut r) = self.gui.atlas.sprites.get(background).copied() {
|
||||
r.u1 = r.u0 + (r.u1 - r.u0) * Creative::WIDTH / r.w as f32;
|
||||
r.v1 = r.v0 + (r.v1 - r.v0) * Creative::HEIGHT / r.h as f32;
|
||||
batch.rect_uv(ox, oy, Creative::WIDTH, Creative::HEIGHT, r, u32::MAX);
|
||||
}
|
||||
let (tx, ty) = Creative::tab_position(tab);
|
||||
batch.sprite(&self.gui.atlas, &tab_sprite(tab, true), ox + tx, oy + ty);
|
||||
for t in tabs.iter().filter(|t| self.creative_screen.on_page(t)) {
|
||||
let (tx, ty) = Creative::tab_position(t);
|
||||
let stack = ItemStack {
|
||||
item: Arc::from(t.icon.as_str()),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
};
|
||||
self.draw_item(
|
||||
batch,
|
||||
&stack,
|
||||
ox + tx + 5.0,
|
||||
oy + ty + if t.top { 9.0 } else { 7.0 },
|
||||
);
|
||||
}
|
||||
let atlas = &self.gui.atlas;
|
||||
if !tab.inventory {
|
||||
let title = self.creative_tab_title(tab);
|
||||
batch.text(
|
||||
atlas,
|
||||
&title,
|
||||
ox + 8.0,
|
||||
oy + 6.0,
|
||||
rgba(64, 64, 64, 255),
|
||||
false,
|
||||
);
|
||||
}
|
||||
if tab.search {
|
||||
// CreativeModeInventoryScreen: an unbordered EditBox of 50 at (82, 6).
|
||||
let search = &mut self.creative_screen.search;
|
||||
search.x = ox + 82.0;
|
||||
search.y = oy + 6.0;
|
||||
search.width = 80.0;
|
||||
search.bordered = false;
|
||||
search.focused = true;
|
||||
search.text_color = u32::MAX;
|
||||
search.max_length = clipboard::CREATIVE_SEARCH_CHARS;
|
||||
if horizon_client::widgets::narrating() {
|
||||
search.message = screen_info::tr("itemGroup.search", &[]);
|
||||
}
|
||||
search.draw(batch, atlas, None);
|
||||
}
|
||||
// The hovered stack, and whether it is a grid slot (`CustomCreativeSlot`).
|
||||
let mut hovered: Option<(ItemStack, bool)> = None;
|
||||
// Use the same padded slot bounds as clicks and the other menus, so
|
||||
// highlights and tooltips remain visible between adjacent items.
|
||||
let hovered_slot = self.slot_at(batch, self.cursor.0, self.cursor.1);
|
||||
if tab.inventory {
|
||||
self.draw_player_preview(
|
||||
batch,
|
||||
ox + 73.0,
|
||||
oy + 6.0,
|
||||
ox + 105.0,
|
||||
oy + 49.0,
|
||||
20.0,
|
||||
0.0625,
|
||||
);
|
||||
let trash = Self::creative_trash_hovered(batch, self.cursor);
|
||||
let (tx, ty) = Creative::TRASH_POSITION;
|
||||
if trash {
|
||||
self.draw_slot_highlight(batch, false, ox + tx, oy + ty);
|
||||
}
|
||||
self.draw_hovered_slot_highlight(batch, false, ox, oy);
|
||||
self.draw_inventory_slots(batch, ox, oy);
|
||||
if trash {
|
||||
self.draw_slot_highlight(batch, true, ox + tx, oy + ty);
|
||||
}
|
||||
self.draw_hovered_slot_highlight(batch, true, ox, oy);
|
||||
} else {
|
||||
let items = self.creative_screen.items(&self.pack.names);
|
||||
let first = self.creative_screen.first_row(&self.pack.names);
|
||||
for r in 0..5 {
|
||||
for c in 0..9 {
|
||||
let Some(stack) = items.get((first + r) * 9 + c).filter(|s| s.count > 0) else {
|
||||
continue;
|
||||
};
|
||||
let (x, y) = (ox + 9.0 + c as f32 * 18.0, oy + 18.0 + r as f32 * 18.0);
|
||||
let over = hovered_slot == Some(Creative::DISPLAY + r * 9 + c);
|
||||
if over {
|
||||
self.draw_slot_highlight(batch, false, x, y);
|
||||
}
|
||||
self.draw_item(batch, stack, x, y);
|
||||
if over {
|
||||
self.draw_slot_highlight(batch, true, x, y);
|
||||
hovered = Some((stack.clone(), true));
|
||||
}
|
||||
}
|
||||
}
|
||||
let drag = self.drag_view();
|
||||
for c in 0..9 {
|
||||
let (x, y) = (ox + 9.0 + c as f32 * 18.0, oy + 112.0);
|
||||
let over = hovered_slot == Some(c);
|
||||
if over {
|
||||
self.draw_slot_highlight(batch, false, x, y);
|
||||
}
|
||||
self.draw_slot_item(batch, drag.as_ref(), c, &self.inventory[c], x, y);
|
||||
if over {
|
||||
self.draw_slot_highlight(batch, true, x, y);
|
||||
hovered = self.inventory[c].clone().map(|s| (s, false));
|
||||
}
|
||||
}
|
||||
let scrollable = self.creative_screen.rows(&self.pack.names) > 5;
|
||||
let scroller = if scrollable {
|
||||
"gui/sprites/container/creative_inventory/scroller"
|
||||
} else {
|
||||
"gui/sprites/container/creative_inventory/scroller_disabled"
|
||||
};
|
||||
batch.sprite(
|
||||
&self.gui.atlas,
|
||||
scroller,
|
||||
ox + Creative::SCROLL_X,
|
||||
oy + Creative::SCROLL_Y + self.creative_screen.scroller_offset(),
|
||||
);
|
||||
}
|
||||
let effect_tip = self.draw_inventory_effects(batch, (ox, oy), Creative::WIDTH as i32);
|
||||
self.draw_creative_pages(batch, ox, oy);
|
||||
for t in tabs.iter().filter(|t| self.creative_screen.on_page(t)) {
|
||||
let (tx, ty) = Creative::tab_position(t);
|
||||
if self.cursor.0 >= ox + tx
|
||||
&& self.cursor.0 < ox + tx + 26.0
|
||||
&& self.cursor.1 >= oy + ty
|
||||
&& self.cursor.1 < oy + ty + 32.0
|
||||
{
|
||||
self.tooltip(
|
||||
batch,
|
||||
&self.creative_tab_title(t),
|
||||
self.cursor.0,
|
||||
self.cursor.1,
|
||||
);
|
||||
}
|
||||
}
|
||||
self.draw_carried(batch);
|
||||
if tab.inventory {
|
||||
if Self::creative_trash_hovered(batch, self.cursor) {
|
||||
// `CreativeModeInventoryScreen`: the destroy slot's tooltip is
|
||||
// `inventory.binSlot`, not an English literal.
|
||||
let destroy = self.translate("inventory.binSlot", "Destroy Item");
|
||||
self.tooltip(batch, &destroy, self.cursor.0, self.cursor.1);
|
||||
} else if let Some(slot) = self.slot_at(batch, self.cursor.0, self.cursor.1)
|
||||
&& let Some(stack) = self.slot_view(slot)
|
||||
&& self.tooltip_with_carried(&stack)
|
||||
{
|
||||
let tabs = self.creative_tooltip_tabs(&stack, false);
|
||||
self.draw_stack_tooltip_with(
|
||||
batch,
|
||||
&stack,
|
||||
false,
|
||||
self.cursor.0,
|
||||
self.cursor.1,
|
||||
tabs,
|
||||
);
|
||||
}
|
||||
} else if let Some((item, grid)) = hovered
|
||||
&& self.tooltip_with_carried(&item)
|
||||
{
|
||||
let tabs = self.creative_tooltip_tabs(&item, grid);
|
||||
// A `CustomCreativeSlot`: TooltipFlag.asCreative.
|
||||
self.draw_stack_tooltip_with(batch, &item, grid, self.cursor.0, self.cursor.1, tabs);
|
||||
}
|
||||
if let Some(lines) = effect_tip {
|
||||
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
|
||||
}
|
||||
}
|
||||
pub(super) fn creative_trash_hovered(batch: &HudBatch, cursor: (f32, f32)) -> bool {
|
||||
let (ox, oy) = Self::creative_origin(batch);
|
||||
let (x, y) = Creative::TRASH_POSITION;
|
||||
cursor.0 >= ox + x
|
||||
&& cursor.0 < ox + x + 16.0
|
||||
&& cursor.1 >= oy + y
|
||||
&& cursor.1 < oy + y + 16.0
|
||||
}
|
||||
/// NeoForge's creative page buttons, 20 square, 50 above the screen at
|
||||
/// its left and right edges: -1 or 1 for the one at `cursor`, when there
|
||||
/// is more than one page.
|
||||
pub(super) fn creative_page_button(
|
||||
ox: f32,
|
||||
oy: f32,
|
||||
cursor: (f32, f32),
|
||||
pages: u32,
|
||||
) -> Option<i64> {
|
||||
if pages <= 1 {
|
||||
return None;
|
||||
}
|
||||
let y = oy - 50.0;
|
||||
let inside =
|
||||
|x: f32| cursor.0 >= x && cursor.0 < x + 20.0 && cursor.1 >= y && cursor.1 < y + 20.0;
|
||||
if inside(ox) {
|
||||
Some(-1)
|
||||
} else if inside(ox + Creative::WIDTH - 20.0) {
|
||||
Some(1)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
/// The page buttons and "page / pages" between them.
|
||||
pub(super) fn draw_creative_pages(&self, batch: &mut HudBatch, ox: f32, oy: f32) {
|
||||
let pages = self.creative_screen.pages();
|
||||
if pages <= 1 {
|
||||
return;
|
||||
}
|
||||
let atlas = &self.gui.atlas;
|
||||
let y = oy - 50.0;
|
||||
for (x, label, step) in [(ox, "<", -1), (ox + Creative::WIDTH - 20.0, ">", 1)] {
|
||||
let hover = Self::creative_page_button(ox, oy, self.cursor, pages) == Some(step);
|
||||
let page = self.creative_screen.page as i64 + step;
|
||||
let active = (0..pages as i64).contains(&page);
|
||||
let sprite = match (active, hover) {
|
||||
(false, _) => "gui/sprites/widget/button_disabled",
|
||||
(true, true) => "gui/sprites/widget/button_highlighted",
|
||||
(true, false) => "gui/sprites/widget/button",
|
||||
};
|
||||
horizon_client::ui::blit_sprite(batch, atlas, sprite, x, y, 20.0, 20.0, u32::MAX);
|
||||
let color = if active {
|
||||
u32::MAX
|
||||
} else {
|
||||
rgba(160, 160, 160, 255)
|
||||
};
|
||||
let w = HudBatch::text_width(atlas, label);
|
||||
batch.text(atlas, label, x + (20.0 - w) / 2.0, y + 6.0, color, true);
|
||||
}
|
||||
let text = format!("{} / {}", self.creative_screen.page + 1, pages);
|
||||
let w = HudBatch::text_width(atlas, &text);
|
||||
batch.text(
|
||||
atlas,
|
||||
&text,
|
||||
ox + (Creative::WIDTH - w) / 2.0,
|
||||
oy - 44.0,
|
||||
u32::MAX,
|
||||
true,
|
||||
);
|
||||
}
|
||||
pub(super) fn click_creative(&mut self, right: bool) {
|
||||
if self.mod_browser_press(right) {
|
||||
return;
|
||||
}
|
||||
let Some(size) = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.map(|r| [r.config.width, r.config.height])
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let batch = HudBatch::new(size);
|
||||
let (ox, oy) = Self::creative_origin(&batch);
|
||||
let (cx, cy) = self.cursor;
|
||||
// NeoForge's page buttons, when mods add tabs.
|
||||
if let Some(step) =
|
||||
Self::creative_page_button(ox, oy, (cx, cy), self.creative_screen.pages())
|
||||
{
|
||||
// Page buttons are outside the inventory panel; their release must
|
||||
// not throw a carried stack, including when the button is disabled.
|
||||
self.inv_clicks.skip_release();
|
||||
let page = self.creative_screen.page as i64 + step;
|
||||
if (0..self.creative_screen.pages() as i64).contains(&page) {
|
||||
self.audio.play_button_click();
|
||||
self.creative_screen.set_page(page as u32);
|
||||
self.refresh_saved_hotbars();
|
||||
}
|
||||
return;
|
||||
}
|
||||
let tabs = self.creative_screen.tabs.clone();
|
||||
for (i, t) in tabs.iter().enumerate() {
|
||||
if !self.creative_screen.on_page(t) {
|
||||
continue;
|
||||
}
|
||||
let (tx, ty) = Creative::tab_position(t);
|
||||
if cx >= ox + tx && cx < ox + tx + 26.0 && cy >= oy + ty && cy < oy + ty + 32.0 {
|
||||
select_creative_tab(&mut self.creative_screen, &mut self.inv_clicks, i);
|
||||
self.refresh_saved_hotbars();
|
||||
self.inv_drag = None;
|
||||
self.last_inv_click = None;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if self.creative_inventory_open() {
|
||||
if Self::creative_trash_hovered(&batch, self.cursor) {
|
||||
if self.has_shift_down() {
|
||||
let before = self.player_equipment();
|
||||
self.inventory.fill(None);
|
||||
self.armor.fill(None);
|
||||
self.offhand = None;
|
||||
self.craft_grid[..4].fill(None);
|
||||
self.player_equipment_changed(&before);
|
||||
self.apply_equipment();
|
||||
} else {
|
||||
self.carried = None;
|
||||
}
|
||||
self.inv_drag = None;
|
||||
self.inv_clicks = Default::default();
|
||||
} else if self.slot_at(&batch, cx, cy).is_some() || self.clicked_outside(&batch) {
|
||||
self.inventory_press(right);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if !right && self.creative_search_click(ox, oy) {
|
||||
return;
|
||||
}
|
||||
// Holding the scrollbar scrolls as the mouse moves (mouseDragged).
|
||||
if !right
|
||||
&& self
|
||||
.creative_screen
|
||||
.press_scrollbar(cx - ox, cy - oy, &self.pack.names)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if self.slot_at(&batch, cx, cy).is_some() || self.clicked_outside(&batch) {
|
||||
self.inventory_press(right);
|
||||
}
|
||||
}
|
||||
/// A click on the search tab's box (`EditBox.onClick`).
|
||||
pub(super) fn creative_search_click(&mut self, ox: f32, oy: f32) -> bool {
|
||||
if !self.creative_screen.tab().search {
|
||||
return false;
|
||||
}
|
||||
let (x, y, w, h) = (ox + 82.0, oy + 6.0, 80.0, 9.0);
|
||||
let (mx, my) = self.cursor;
|
||||
if mx < x || my < y || mx >= x + w || my >= y + h {
|
||||
return false;
|
||||
}
|
||||
let shift = self.has_shift_down();
|
||||
let search = &mut self.creative_screen.search;
|
||||
search.x = x;
|
||||
search.y = y;
|
||||
search.width = w;
|
||||
search.click(&self.gui.atlas, mx, shift, false);
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -4023,17 +4023,36 @@ impl Game<'_> {
|
||||
// runs `applyItemBlocking` before `dealDefaultKnockback`).
|
||||
let mut default_push: Option<([f64; 2], HurtSource<'_>)> = None;
|
||||
let events_before = self.player.as_ref().map_or(0, |p| p.events.len());
|
||||
if let Some(player) = self.player.as_mut()
|
||||
&& let Some(id) = self.health_resources.damage.id(damage_type)
|
||||
&& let Ok(o) = player.hurt_caused_by(
|
||||
&self.health_resources,
|
||||
self.world.health_rules,
|
||||
id,
|
||||
amount,
|
||||
blocking,
|
||||
living_cause,
|
||||
causing,
|
||||
)
|
||||
let mut resolved_damage = None;
|
||||
let damage_outcome = if let Some(id) = self.health_resources.damage.id(damage_type) {
|
||||
if self
|
||||
.health_resources
|
||||
.damage
|
||||
.in_tag(id, "minecraft:damages_helmet")
|
||||
{
|
||||
self.hurt_player_equipment(id, amount, blocking, living_cause, causing)
|
||||
.map(|(outcome, actual)| {
|
||||
resolved_damage = Some(actual);
|
||||
outcome
|
||||
})
|
||||
} else if let Some(player) = self.player.as_mut() {
|
||||
player.hurt_caused_by(
|
||||
&self.health_resources,
|
||||
self.world.health_rules,
|
||||
id,
|
||||
amount,
|
||||
blocking,
|
||||
living_cause,
|
||||
causing,
|
||||
)
|
||||
} else {
|
||||
Ok(Default::default())
|
||||
}
|
||||
} else {
|
||||
Ok(Default::default())
|
||||
};
|
||||
if let Ok(o) = damage_outcome
|
||||
&& let Some(player) = self.player.as_mut()
|
||||
&& {
|
||||
outcome = o;
|
||||
o.hurt
|
||||
@@ -4113,7 +4132,7 @@ impl Game<'_> {
|
||||
is_direct: !projectile,
|
||||
source,
|
||||
original: amount,
|
||||
actual: amount - outcome.blocked,
|
||||
actual: resolved_damage.unwrap_or(amount - outcome.blocked),
|
||||
blocked: outcome.blocked > 0.0,
|
||||
});
|
||||
}
|
||||
@@ -7666,7 +7685,7 @@ impl Game<'_> {
|
||||
// Cosmetics (game/cosmetics.rs), posed on the GPU with the mob's own jobs: culled
|
||||
// with it.
|
||||
if !layers_only
|
||||
&& horizon_cosmetics::enabled()
|
||||
&& super::cosmetics::enabled()
|
||||
&& (is_player || &*mob.kind.id == "minecraft:mannequin")
|
||||
{
|
||||
let mark = self.skin_frame.borrow().mark();
|
||||
|
||||
@@ -87,7 +87,7 @@ impl Game<'_> {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
horizon_cosmetics::enabled()
|
||||
super::cosmetics::enabled()
|
||||
&& self.player_model.is_some()
|
||||
&& !p.health.dead_or_dying()
|
||||
&& !p.fall_flying
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//! `Entity.applyEffectsFromBlocks` runs in the entity's own tick, and
|
||||
//! `ServerLevel.tick` ticks only the entities in entity-ticking chunks: a mob
|
||||
//! past the simulation distance presses no plate, however long it stands on it.
|
||||
|
||||
use super::Game;
|
||||
use super::native_machines::tests::server_game;
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_client::mobs::Mob;
|
||||
use horizon_core::geometry::Vec3;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_mob_presses_a_plate_only_inside_the_simulation_distance() {
|
||||
let mut game = server_game();
|
||||
game.settings.simulation_distance = 4;
|
||||
game.settings.render_distance = 12;
|
||||
let registry = game.pack.assets.registry;
|
||||
let stone = registry.default_state("minecraft:stone").unwrap();
|
||||
let plate = registry
|
||||
.default_state("minecraft:stone_pressure_plate")
|
||||
.unwrap();
|
||||
let zombie = game
|
||||
.res
|
||||
.mob_types()
|
||||
.get("minecraft:zombie")
|
||||
.unwrap()
|
||||
.clone();
|
||||
// The player in chunk (0, 0); a zombie on a plate in chunk (2, 0), inside
|
||||
// the simulation distance, and another in chunk (8, 0): loaded and in
|
||||
// view, but past it.
|
||||
game.player = Some(
|
||||
horizon_client::motion::PlayerMotion::new(game.motion, Vec3::new(8.5, 65.0, 8.5)).unwrap(),
|
||||
);
|
||||
for (cx, x, id) in [(2, 40, 1), (8, 136, 2)] {
|
||||
game.world
|
||||
.chunks
|
||||
.insert((cx, 0), Arc::new(Chunk::empty(cx, 0)));
|
||||
game.world.set(x, 63, 8, stone).unwrap();
|
||||
game.world.set(x, 64, 8, plate).unwrap();
|
||||
game.mobs.push(Mob::new(
|
||||
id,
|
||||
zombie.clone(),
|
||||
Vec3::new(f64::from(x) + 0.5, 64.0, 8.5),
|
||||
0.0,
|
||||
));
|
||||
}
|
||||
game.update_chunk_tickets();
|
||||
let ticking = game.entity_ticking_area();
|
||||
assert!(ticking(40, 8), "chunk (2, 0) ticks entities");
|
||||
assert!(
|
||||
!ticking(136, 8),
|
||||
"chunk (8, 0) is past the simulation distance"
|
||||
);
|
||||
for _ in 0..3 {
|
||||
game.tick_blocks().unwrap();
|
||||
}
|
||||
let powered = |game: &Game<'_>, x: i32| {
|
||||
let state = game.world.block(x, 64, 8);
|
||||
registry
|
||||
.state(state)
|
||||
.unwrap()
|
||||
.properties
|
||||
.get("powered")
|
||||
.cloned()
|
||||
};
|
||||
assert_eq!(powered(&game, 40).as_deref(), Some("true"));
|
||||
assert_eq!(powered(&game, 136).as_deref(), Some("false"));
|
||||
}
|
||||
@@ -196,16 +196,15 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let original = amount;
|
||||
let amount = if self.armor[0].is_some() {
|
||||
amount * 0.75
|
||||
} else {
|
||||
amount
|
||||
let Some(id) = self.health_resources.damage.id(kind) else {
|
||||
return;
|
||||
};
|
||||
let hurt = match (self.player.as_mut(), self.health_resources.damage.id(kind)) {
|
||||
(Some(p), Some(id)) => p
|
||||
.hurt(&self.health_resources, self.world.health_rules, id, amount)
|
||||
.unwrap_or(false),
|
||||
_ => false,
|
||||
let (hurt, amount) = match self.hurt_player_equipment(id, amount, None, false, 0) {
|
||||
Ok((outcome, actual)) => (outcome.hurt, actual),
|
||||
Err(error) => {
|
||||
log::warn!("falling block damage failed: {error}");
|
||||
(false, amount)
|
||||
}
|
||||
};
|
||||
if hurt {
|
||||
// LivingEntity.hurtServer: ENTITY_HURT_PLAYER, the falling block
|
||||
@@ -228,3 +227,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod helmet_tests;
|
||||
@@ -0,0 +1,170 @@
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::{items::ItemStack, motion::PlayerMotion};
|
||||
use horizon_core::{
|
||||
geometry::Vec3,
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn falling_impacts_wear_the_helmet_before_normal_armor_and_reduction() {
|
||||
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();
|
||||
let scratch = std::env::temp_dir().join(format!("horizon-helmet-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&scratch).unwrap();
|
||||
game.world_root = scratch.clone();
|
||||
game.creative = false;
|
||||
// Equipment breaks are server notifications, with no local visual RNG.
|
||||
game.lan_acting = Some(1);
|
||||
let position = Vec3::new(4.5, 100.0, 4.5);
|
||||
let cases: serde_json::Value = serde_json::from_str(include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../tests/fixtures/helmet_damage.json"
|
||||
)))
|
||||
.unwrap();
|
||||
for (case_index, case) in cases.as_array().unwrap().iter().enumerate() {
|
||||
for path in 0..2 {
|
||||
let index = case_index * 2 + path;
|
||||
game.player = Some(PlayerMotion::new(game.motion, position).unwrap());
|
||||
game.armor = [None, None, None, None];
|
||||
if let Some(item) = case["head"].as_str() {
|
||||
let mut stack = ItemStack::new(&format!("minecraft:{item}"), 1);
|
||||
stack.damage = case["head_damage"].as_u64().unwrap() as u32;
|
||||
if case["unbreaking"].as_u64().unwrap() > 0 {
|
||||
stack.enchant(
|
||||
"minecraft:unbreaking",
|
||||
case["unbreaking"].as_u64().unwrap() as u32,
|
||||
);
|
||||
}
|
||||
if case["unbreakable"].as_bool().unwrap_or(false) {
|
||||
stack.data_mut().set_other(
|
||||
"minecraft:unbreakable",
|
||||
Some(horizon_world::nbt::Tag::Compound(Default::default())),
|
||||
);
|
||||
}
|
||||
game.armor[0] = Some(stack);
|
||||
}
|
||||
if let Some(damage) = case["boots_damage"].as_u64() {
|
||||
let mut stack = ItemStack::new("minecraft:iron_boots", 1);
|
||||
stack.damage = damage as u32;
|
||||
if case["unbreaking"].as_u64().unwrap() > 0 {
|
||||
stack.enchant(
|
||||
"minecraft:unbreaking",
|
||||
case["unbreaking"].as_u64().unwrap() as u32,
|
||||
);
|
||||
}
|
||||
game.armor[3] = Some(stack);
|
||||
}
|
||||
game.apply_equipment();
|
||||
game.apply_effect_status();
|
||||
game.world.random = LegacyRandom::new(case["seed"].as_i64().unwrap());
|
||||
let source = format!("minecraft:{}", case["source"].as_str().unwrap());
|
||||
if path == 0 {
|
||||
game.falling_hurt_player(position, 8.0, &source);
|
||||
} else {
|
||||
// This is the authoritative player path used by /damage. A
|
||||
// position avoids unrelated random-direction knockback draws.
|
||||
game.hurt_player_from(8.0, Some([5.5, 100.0, 4.5]), &source, Some(None));
|
||||
}
|
||||
// Flush the same equipment events as the server-player tick. This
|
||||
// also makes the baseline regression observe completed legacy wear.
|
||||
let events = std::mem::take(&mut game.player.as_mut().unwrap().events);
|
||||
game.wear_armor(&events);
|
||||
let head = game.armor[0].as_ref().map_or(-1, |s| i64::from(s.damage));
|
||||
let boots = game.armor[3].as_ref().map_or(-1, |s| i64::from(s.damage));
|
||||
let health = game.player.as_ref().unwrap().health.health();
|
||||
let next = game.world.random.clone().next_long();
|
||||
println!(
|
||||
"HELMET {index} {head} {boots} {:08x} {next}",
|
||||
health.to_bits()
|
||||
);
|
||||
if let Some(expected) = case["expected_head"].as_i64() {
|
||||
assert_eq!(head, expected, "case={index}");
|
||||
}
|
||||
if let Some(expected) = case["expected_health"].as_f64() {
|
||||
assert_eq!(health, expected as f32, "case={index}");
|
||||
}
|
||||
let events = std::mem::take(&mut game.player.as_mut().unwrap().events);
|
||||
game.wear_armor(&events);
|
||||
assert_eq!(
|
||||
game.armor[0].as_ref().map_or(-1, |s| i64::from(s.damage)),
|
||||
head,
|
||||
"equipment must not wear twice when events drain"
|
||||
);
|
||||
assert_eq!(game.world.random.clone().next_long(), next);
|
||||
}
|
||||
}
|
||||
// LivingEntity.hurtServer wears the helmet even when the cooldown then
|
||||
// rejects an equal hit: the second hit has no ordinary armor wear.
|
||||
for (initial, expected) in [(0, Some(5)), (160, None)] {
|
||||
game.player = Some(PlayerMotion::new(game.motion, position).unwrap());
|
||||
let mut helmet = ItemStack::new("minecraft:iron_helmet", 1);
|
||||
helmet.damage = initial;
|
||||
game.armor = [Some(helmet), None, None, None];
|
||||
game.apply_equipment();
|
||||
game.apply_effect_status();
|
||||
game.falling_hurt_player(position, 8.0, "minecraft:falling_anvil");
|
||||
game.falling_hurt_player(position, 8.0, "minecraft:falling_anvil");
|
||||
assert_eq!(game.armor[0].as_ref().map(|s| s.damage), expected);
|
||||
assert_eq!(
|
||||
game.player.as_ref().unwrap().health.health(),
|
||||
20.0_f32 - 5.9040003_f32
|
||||
);
|
||||
if expected.is_none() {
|
||||
// The hit that breaks the helmet still gets its 0.75 reduction;
|
||||
// only the next hit compares an unprotected 8 against lastHurt=6.
|
||||
game.falling_hurt_player(position, 8.0, "minecraft:falling_anvil");
|
||||
assert_eq!(game.player.as_ref().unwrap().health.health(), 12.096_f32);
|
||||
}
|
||||
}
|
||||
// Refreshing equipment protection during a hit must preserve absorption
|
||||
// that the damage pipeline has not consumed yet.
|
||||
game.player = Some(PlayerMotion::new(game.motion, position).unwrap());
|
||||
game.armor = [
|
||||
Some(ItemStack::new("minecraft:iron_helmet", 1)),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
];
|
||||
game.apply_equipment();
|
||||
let player = game.player.as_mut().unwrap();
|
||||
player
|
||||
.set_modifier_unclamped(
|
||||
"minecraft:max_absorption",
|
||||
"horizon:helmet_test",
|
||||
4.0,
|
||||
"add_value",
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
player.set_health(4.0, true).unwrap();
|
||||
game.falling_hurt_player(position, 8.0, "minecraft:falling_anvil");
|
||||
assert_eq!(game.player.as_ref().unwrap().health.health(), 18.096_f32);
|
||||
assert_eq!(game.player.as_ref().unwrap().health.absorption(), 0.0);
|
||||
std::fs::remove_dir_all(scratch).unwrap();
|
||||
}
|
||||
@@ -130,7 +130,7 @@ impl Game<'_> {
|
||||
self.pack
|
||||
.item_renderer
|
||||
.models
|
||||
.components(&horizon_client::item_render::stack_view(stack))
|
||||
.components_view(&horizon_client::item_render::stack_view(stack))
|
||||
.get("minecraft:attack_range")
|
||||
.cloned()
|
||||
});
|
||||
@@ -259,7 +259,7 @@ impl Game<'_> {
|
||||
self.pack
|
||||
.item_renderer
|
||||
.models
|
||||
.components(&horizon_client::item_render::stack_view(stack))
|
||||
.components_view(&horizon_client::item_render::stack_view(stack))
|
||||
.get("minecraft:attack_range")
|
||||
.cloned()
|
||||
});
|
||||
@@ -275,7 +275,7 @@ impl Game<'_> {
|
||||
self.pack
|
||||
.item_renderer
|
||||
.models
|
||||
.components(&horizon_client::item_render::stack_view(active))
|
||||
.components_view(&horizon_client::item_render::stack_view(active))
|
||||
.get("minecraft:attack_range")
|
||||
.cloned()
|
||||
}
|
||||
@@ -586,7 +586,7 @@ impl Game<'_> {
|
||||
self.pack
|
||||
.item_renderer
|
||||
.models
|
||||
.components(&horizon_client::item_render::stack_view(stack))
|
||||
.components_view(&horizon_client::item_render::stack_view(stack))
|
||||
.get("minecraft:attack_range")
|
||||
.cloned()
|
||||
});
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
//! Keys and mouse buttons over an open container screen: hotbar and pick keys, modifier keys,
|
||||
//! drags, throws and the press/release of a slot click.
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `CreativeModeInventoryScreen.keyPressed` on the search tab: a hotbar
|
||||
/// key over a slot swaps before the search box sees it.
|
||||
pub(super) fn search_tab_hotbar_key(&mut self, code: KeyCode) -> bool {
|
||||
use horizon_client::creative::{Hover, search_key_swaps};
|
||||
let numeric = HOTBAR_DIGITS.contains(&code) || NUMERIC_KEYS.contains(&code);
|
||||
if !numeric {
|
||||
return false;
|
||||
}
|
||||
let Some(key) = self.settings.keys.container_hotbar_key(code) else {
|
||||
return false;
|
||||
};
|
||||
let Some(size) = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.map(|r| [r.config.width, r.config.height])
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let batch = HudBatch::new(size);
|
||||
let hover = match self.slot_at(&batch, self.cursor.0, self.cursor.1) {
|
||||
None => Hover::Nothing,
|
||||
Some(slot) if slot >= Creative::DISPLAY => {
|
||||
let first = self.creative_screen.first_row(&self.pack.names);
|
||||
Hover::Display {
|
||||
filled: self
|
||||
.creative_screen
|
||||
.shown(&self.pack.names, first * 9 + slot - Creative::DISPLAY)
|
||||
.is_some(),
|
||||
}
|
||||
}
|
||||
Some(_) => Hover::Slot,
|
||||
};
|
||||
search_key_swaps(numeric, true, hover, self.carried.is_some()) && self.inventory_hotkey(key)
|
||||
}
|
||||
/// Number keys, F and Q over a slot of the open menu: SWAP with a hotbar
|
||||
/// slot or the off hand, THROW one or the whole stack.
|
||||
pub(super) fn inventory_hotkey(&mut self, key: KeyCode) -> bool {
|
||||
use horizon_client::menu::Input;
|
||||
let Some(size) = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.map(|r| [r.config.width, r.config.height])
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let batch = HudBatch::new(size);
|
||||
let Some(slot) = self.slot_at(&batch, self.cursor.0, self.cursor.1) else {
|
||||
return false;
|
||||
};
|
||||
let digit = HOTBAR_DIGITS.iter().position(|k| *k == key);
|
||||
let done = match (digit, key) {
|
||||
(Some(n), _) => self.menu_click(Some(slot), n as i32, Input::Swap),
|
||||
(None, KeyCode::KeyF) => self.menu_click(Some(slot), 40, Input::Swap),
|
||||
(None, KeyCode::KeyQ) => {
|
||||
let all = drop_whole_stack(self.modifiers);
|
||||
self.menu_click(Some(slot), i32::from(all), Input::Throw)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if done && let Some(key) = self.container {
|
||||
self.container_changed(key);
|
||||
}
|
||||
done
|
||||
}
|
||||
/// The pick block key bound to a keyboard key, over a slot of the open
|
||||
/// menu (`AbstractContainerScreen.keyPressed`): `CLONE` with button 0. The
|
||||
/// search tab's box takes the key instead.
|
||||
pub(super) fn inventory_pick_key(&mut self) {
|
||||
let menu = (self.screen == Screen::Inventory
|
||||
|| (self.screen == Screen::Creative && !self.creative_screen.tab().search))
|
||||
&& !self.dialog_open();
|
||||
let Some(size) = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.map(|r| [r.config.width, r.config.height])
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let batch = HudBatch::new(size);
|
||||
let Some(slot) = self
|
||||
.slot_at(&batch, self.cursor.0, self.cursor.1)
|
||||
.filter(|_| menu)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if self.menu_click(Some(slot), 0, horizon_client::menu::Input::Clone)
|
||||
&& let Some(key) = self.container
|
||||
{
|
||||
self.container_changed(key);
|
||||
}
|
||||
}
|
||||
/// The plain inventory mouse press: starts a quick craft drag when a
|
||||
/// stack is held, gathers on a double click, else the normal click.
|
||||
pub(super) fn inventory_press(&mut self, right: bool) {
|
||||
use horizon_client::container_screen::Target;
|
||||
use horizon_client::menu::Input;
|
||||
if self.mod_browser_press(right) {
|
||||
return;
|
||||
}
|
||||
if !right && self.sort_press() {
|
||||
return;
|
||||
}
|
||||
if self.native_screen_press(right) {
|
||||
self.inv_clicks.skip_release();
|
||||
return;
|
||||
}
|
||||
// AbstractRecipeBookScreen lets the book handle its buttons before
|
||||
// AbstractContainerScreen starts a drag or chooses an outside slot.
|
||||
// Toggling it can move the inventory under the pointer before release.
|
||||
if self.recipe_book_click(right) {
|
||||
self.inv_clicks.skip_release();
|
||||
return;
|
||||
}
|
||||
let (slot, outside) = self.inventory_target();
|
||||
let shift = self.has_shift_down();
|
||||
let target = Target::of(slot, outside);
|
||||
let input = self.inv_clicks.press(
|
||||
&target,
|
||||
Self::screen_button(right),
|
||||
self.carried.is_none(),
|
||||
shift,
|
||||
false,
|
||||
);
|
||||
if outside {
|
||||
// slotId -999: an empty hand throws nothing; a carried stack
|
||||
// starts a drag whose release drops it.
|
||||
match input {
|
||||
Some(input) => self.throw_click(right, input),
|
||||
None if self.inv_clicks.quick_crafting().is_some() => {
|
||||
self.inv_drag = Some((i32::from(right), Vec::new()));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if self.tweak_press(slot, right, shift) {
|
||||
return;
|
||||
}
|
||||
if let Some(slot) = slot
|
||||
&& shift
|
||||
&& slot != 200
|
||||
{
|
||||
let now = std::time::Instant::now();
|
||||
let double = self.last_inv_click.is_some_and(|(at, last, was_right)| {
|
||||
last == slot && was_right == right && now.duration_since(at).as_millis() < 250
|
||||
});
|
||||
self.last_inv_click = Some((now, slot, right));
|
||||
// mouseClicked's QUICK_MOVE (and Horizon's at-press QUICK_MOVE
|
||||
// of a carried stack, mouseReleased's in vanilla) remember the
|
||||
// stack it moves.
|
||||
if input == Some(Input::QuickMove) || (self.carried.is_some() && !double) {
|
||||
self.last_quick_moved = self.slot_view(slot);
|
||||
}
|
||||
if double && !right {
|
||||
// mouseReleased: every stack of lastQuickMoved's item in the
|
||||
// clicked slot's container is quick-moved.
|
||||
if let Some(input) = input
|
||||
&& !self.menu_click(Some(slot), 0, input)
|
||||
{
|
||||
// A screen the menu engine doesn't model: its own click.
|
||||
self.click_inventory(right);
|
||||
return;
|
||||
}
|
||||
if let Some(moved) = self.last_quick_moved.clone() {
|
||||
// The creative screen clicks its own menu, on a server too.
|
||||
if self.remote.is_some() && self.screen != Screen::Creative {
|
||||
self.remote_quick_move_all(slot, &moved);
|
||||
} else {
|
||||
self.menu_session(|menu, env, to_menu| {
|
||||
Some(menu.quick_move_all(env, to_menu(slot)?, &moved).is_ok())
|
||||
});
|
||||
if self.remote.is_some() {
|
||||
self.send_creative_slots();
|
||||
}
|
||||
}
|
||||
}
|
||||
self.last_inv_click = None;
|
||||
self.inv_clicks.skip_release();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Some(slot) = slot
|
||||
&& slot != 200
|
||||
{
|
||||
// An item tab's display slots take no drags or PICKUP_ALL.
|
||||
let display = slot >= Creative::DISPLAY;
|
||||
let now = std::time::Instant::now();
|
||||
let double = !shift
|
||||
&& !right
|
||||
&& !display
|
||||
&& self.carried.is_some()
|
||||
&& self.last_inv_click.is_some_and(|(at, last, was_right)| {
|
||||
last == slot && !was_right && now.duration_since(at).as_millis() < 250
|
||||
});
|
||||
self.last_inv_click = Some((now, slot, right));
|
||||
if double && self.menu_click(Some(slot), 0, Input::PickupAll) {
|
||||
self.last_inv_click = None;
|
||||
// The double click's own release (`mouseReleased`'s PICKUP_ALL).
|
||||
self.inv_clicks.skip_release();
|
||||
return;
|
||||
}
|
||||
if self.carried.is_some() {
|
||||
let ty = i32::from(right);
|
||||
// `mouseClicked` starts a quick craft drag whenever the hand holds a stack,
|
||||
// Shift or not: `mouseDragged` fills the slots the cursor crosses, so a
|
||||
// Shift held over the drag spreads the same way (#4166). The drag starts
|
||||
// empty on a Shift press: a release with nothing spread is `mouseReleased`'s
|
||||
// click on the hovered slot (Pickup, or QuickMove under Shift).
|
||||
if !shift && !display && self.quick_craft_accepts(slot, ty, 0) {
|
||||
self.inv_drag = Some((ty, vec![slot]));
|
||||
} else {
|
||||
self.inv_drag = Some((ty, Vec::new()));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
if self.creative_tab_open() {
|
||||
// CreativeModeInventoryScreen goes through the same mouseClicked:
|
||||
// a click now, or a drag that its release ends.
|
||||
match input {
|
||||
Some(input) => {
|
||||
self.menu_click(slot, i32::from(right), input);
|
||||
}
|
||||
None if self.inv_clicks.quick_crafting().is_some() => {
|
||||
self.inv_drag = Some((i32::from(right), Vec::new()));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// mouseClicked: an empty hand's PICKUP is the menu's click at once
|
||||
// (a right click on a bundle takes its first stack out).
|
||||
if let (Some(input), Some(_)) = (input, slot)
|
||||
&& self.remote.is_none()
|
||||
&& self.menu_click(slot, i32::from(right), input)
|
||||
{
|
||||
if let Some(key) = self.container {
|
||||
self.container_changed(key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.click_inventory(right);
|
||||
}
|
||||
/// Mouse movement while a quick craft drag is running.
|
||||
pub(super) fn inventory_drag_move(&mut self) {
|
||||
if self.tweaks_on() && self.tweak_drag_move(self.inventory_target().0) {
|
||||
return;
|
||||
}
|
||||
let Some((ty, slots)) = self.inv_drag.clone() else {
|
||||
return;
|
||||
};
|
||||
let Some(r) = self.renderer.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let batch = HudBatch::new([r.config.width, r.config.height]);
|
||||
if let Some(slot) = self.slot_at(&batch, self.cursor.0, self.cursor.1)
|
||||
&& slot != 200
|
||||
&& slot < Creative::DISPLAY
|
||||
&& !slots.contains(&slot)
|
||||
&& self.quick_craft_accepts(slot, ty, slots.len())
|
||||
&& let Some((_, slots)) = self.inv_drag.as_mut()
|
||||
{
|
||||
slots.push(slot);
|
||||
}
|
||||
}
|
||||
/// The hovered slot and whether the cursor is outside the window.
|
||||
pub(super) fn inventory_target(&self) -> (Option<usize>, bool) {
|
||||
let Some(r) = self.renderer.as_ref() else {
|
||||
return (None, false);
|
||||
};
|
||||
let batch = HudBatch::new([r.config.width, r.config.height]);
|
||||
(
|
||||
self.slot_at(&batch, self.cursor.0, self.cursor.1),
|
||||
self.clicked_outside(&batch),
|
||||
)
|
||||
}
|
||||
pub(super) fn screen_button(right: bool) -> horizon_client::container_screen::Button {
|
||||
use horizon_client::container_screen::Button;
|
||||
if right { Button::Right } else { Button::Left }
|
||||
}
|
||||
/// `Screen.hasShiftDown` / `InputWithModifiers.hasShiftDown`: 26.3 keeps
|
||||
/// no key cache — every event answers with its own modifiers bit
|
||||
/// (`InputWithModifiers.java:54-56`, `keys = 3`), and the pointer and
|
||||
/// keyboard events carry the OS's live word, refreshed at the focus. The
|
||||
/// window's `ModifiersChanged` is that word here; key releases can die
|
||||
/// on the way from the OS (a stuck Shift then kept every screen's Shift
|
||||
/// and a sneak the key no longer held: #4166's in-game report), so a
|
||||
/// cached key set is not vanilla's answer.
|
||||
pub(super) fn has_shift_down(&self) -> bool {
|
||||
self.modifiers.shift_key()
|
||||
}
|
||||
/// The same live word for Control (`InputWithModifiers.hasControlDown`).
|
||||
pub(super) fn has_control_down(&self) -> bool {
|
||||
self.modifiers.control_key()
|
||||
}
|
||||
/// The same live word for Alt (`InputWithModifiers.hasAltDown`).
|
||||
pub(super) fn has_alt_down(&self) -> bool {
|
||||
self.modifiers.alt_key()
|
||||
}
|
||||
/// Real events word the modifiers by their own `ModifiersChanged`; a
|
||||
/// control socket's `key` is synthesized, so it words the bit directly
|
||||
/// and every reader can ask the one live state.
|
||||
pub(super) fn press_modifier_key(&mut self, key: KeyCode, down: bool) {
|
||||
horizon_client_input::modifiers::set_key(
|
||||
&mut self.modifiers,
|
||||
&mut self.physical_keys,
|
||||
key,
|
||||
down,
|
||||
);
|
||||
}
|
||||
/// `hasClickedOutside` of the open screen at the cursor.
|
||||
pub(super) fn clicked_outside(&self, batch: &HudBatch) -> bool {
|
||||
use horizon_client::container_screen::{Book, clicked_outside};
|
||||
let image = self.container_image(batch);
|
||||
let book = self
|
||||
.recipe_book_area(batch)
|
||||
.map(|(visible, tab_hovered)| Book {
|
||||
visible,
|
||||
selected_tab_hovered: tab_hovered,
|
||||
});
|
||||
clicked_outside(
|
||||
f64::from(self.cursor.0),
|
||||
f64::from(self.cursor.1),
|
||||
image,
|
||||
book,
|
||||
)
|
||||
}
|
||||
/// The open container screen's window: its place and size.
|
||||
pub(super) fn container_image(
|
||||
&self,
|
||||
batch: &HudBatch,
|
||||
) -> horizon_client::container_screen::Image {
|
||||
let (ox, oy) = self.inventory_origin(batch);
|
||||
let (shift, width, height) = if self.screen == Screen::Creative {
|
||||
(0.0, Creative::WIDTH as i32, Creative::HEIGHT as i32)
|
||||
} else {
|
||||
let (shift, width) = match self.station.as_ref().map(|s| s.kind) {
|
||||
Some(stations::Kind::Merchant) => (-50.0, 276),
|
||||
Some(stations::Kind::Beacon) => (beacon::SHIFT, 230),
|
||||
_ => (0.0, self.native_screen_width().map_or(176, |w| w as i32)),
|
||||
};
|
||||
(shift, width, self.screen_height() as i32)
|
||||
};
|
||||
horizon_client::container_screen::Image {
|
||||
left: (ox + shift) as i32,
|
||||
top: oy as i32,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
/// A click outside the window (slot id -999): `THROW` does nothing; a
|
||||
/// `PICKUP` drops the carried stack (left) or one of it (right), thrown
|
||||
/// from the hand (`AbstractContainerMenu.doClick`, `LivingEntity.drop`).
|
||||
pub(super) fn throw_click(&mut self, right: bool, input: horizon_client::menu::Input) {
|
||||
use horizon_client::menu::Input;
|
||||
let button = i32::from(right);
|
||||
if self.remote.is_some() {
|
||||
if self.screen == Screen::Creative {
|
||||
self.remote_creative_drop(right);
|
||||
} else {
|
||||
self.remote_throw(right, input);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let before = self.carried.clone();
|
||||
if !self.menu_click(None, button, input) && input == Input::Pickup {
|
||||
// A screen the menu engine doesn't model: the same doClick.
|
||||
if let Some(c) = self.carried.as_mut() {
|
||||
let thrown = if right {
|
||||
let one = ItemStack {
|
||||
count: 1,
|
||||
..c.clone()
|
||||
};
|
||||
c.count -= 1;
|
||||
if c.count == 0 {
|
||||
self.carried = None;
|
||||
}
|
||||
one
|
||||
} else {
|
||||
self.carried.take().expect("some")
|
||||
};
|
||||
self.player_drop(thrown);
|
||||
}
|
||||
}
|
||||
if self.carried != before {
|
||||
self.swing_arm();
|
||||
}
|
||||
if let Some(key) = self.container {
|
||||
self.container_changed(key);
|
||||
}
|
||||
}
|
||||
/// Mouse release over an inventory screen: `mouseReleased` ends or
|
||||
/// cancels the drag, drops a stack released outside, or clicks.
|
||||
pub(super) fn inventory_mouse_release(&mut self, right: bool) {
|
||||
use horizon_client::container_screen::{Release, Target};
|
||||
self.tweak_release();
|
||||
let (slot, outside) = self.inventory_target();
|
||||
let dragged = self.inv_drag.as_ref().is_some_and(|(_, s)| !s.is_empty());
|
||||
let release = self.inv_clicks.release(
|
||||
&Target::of(slot, outside),
|
||||
Self::screen_button(right),
|
||||
self.carried.is_none(),
|
||||
dragged,
|
||||
self.has_shift_down(),
|
||||
false,
|
||||
);
|
||||
match (release, Target::of(slot, outside)) {
|
||||
(Release::QuickCraft, _) => self.inventory_release(),
|
||||
(Release::Click(input), Target::Outside) => {
|
||||
self.inv_drag = None;
|
||||
self.throw_click(right, input);
|
||||
}
|
||||
(Release::Click(input), Target::Slot(slot)) if self.inv_drag.is_some() => {
|
||||
// The drag never reached a slot: a plain click where it ends.
|
||||
self.inv_drag = None;
|
||||
if !self.menu_click(Some(slot), i32::from(right), input) {
|
||||
self.click_inventory(right);
|
||||
}
|
||||
if let Some(key) = self.container {
|
||||
self.container_changed(key);
|
||||
}
|
||||
}
|
||||
_ => self.inv_drag = None,
|
||||
}
|
||||
}
|
||||
/// A press of the pick block button (`keyPickItem`, the middle button by
|
||||
/// default) over an inventory screen: `mouseClicked` with `cloning`
|
||||
/// (infinite materials). An empty hand clones the hovered stack at its
|
||||
/// full size (`CLONE`), a carried stack starts a quick craft drag of type
|
||||
/// 2 that fills every slot it crosses; without infinite materials the
|
||||
/// press does nothing.
|
||||
pub(super) fn inventory_pick_press(&mut self) {
|
||||
use horizon_client::container_screen::{Button, Target};
|
||||
let (slot, outside) = self.inventory_target();
|
||||
let was_crafting = self.inv_clicks.quick_crafting().is_some();
|
||||
let input = self.inv_clicks.press(
|
||||
&Target::of(slot, outside),
|
||||
Button::Middle,
|
||||
self.carried.is_none(),
|
||||
self.has_shift_down(),
|
||||
self.creative,
|
||||
);
|
||||
match (input, slot) {
|
||||
// Slot -999 (outside the window) clones nothing.
|
||||
(Some(input), Some(slot)) if !outside => {
|
||||
self.menu_click(Some(slot), 2, input);
|
||||
}
|
||||
(None, _) if !was_crafting && self.inv_clicks.quick_crafting().is_some() => {
|
||||
// Item tab display slots take no drags.
|
||||
let first = slot.filter(|&s| {
|
||||
s != 200 && s < Creative::DISPLAY && self.quick_craft_accepts(s, 2, 0)
|
||||
});
|
||||
self.inv_drag = Some((2, first.into_iter().collect()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// The pick block button's release (`mouseReleased`): ends its quick
|
||||
/// craft drag, cancels another button's, or clones onto the slot under a
|
||||
/// carried stack (`CLONE`, which only an item tab's display slot takes).
|
||||
pub(super) fn inventory_pick_release(&mut self) {
|
||||
use horizon_client::container_screen::{Button, Release, Target};
|
||||
let (slot, outside) = self.inventory_target();
|
||||
let dragged = self.inv_drag.as_ref().is_some_and(|(_, s)| !s.is_empty());
|
||||
let release = self.inv_clicks.release(
|
||||
&Target::of(slot, outside),
|
||||
Button::Middle,
|
||||
self.carried.is_none(),
|
||||
dragged,
|
||||
self.has_shift_down(),
|
||||
true,
|
||||
);
|
||||
match (release, slot) {
|
||||
(Release::QuickCraft, _) => self.inventory_release(),
|
||||
(Release::Click(input), Some(slot)) if !outside => {
|
||||
self.inv_drag = None;
|
||||
self.menu_click(Some(slot), 2, input);
|
||||
}
|
||||
_ => self.inv_drag = None,
|
||||
}
|
||||
}
|
||||
/// Mouse release: finishes a quick craft drag (one slot is a plain click).
|
||||
pub(super) fn inventory_release(&mut self) {
|
||||
use horizon_client::menu::Input;
|
||||
let Some((ty, slots)) = self.inv_drag.take() else {
|
||||
return;
|
||||
};
|
||||
if slots.len() == 1 {
|
||||
if !self.menu_click(Some(slots[0]), ty, Input::Pickup) {
|
||||
self.click_inventory(ty == 1);
|
||||
}
|
||||
} else {
|
||||
let mask = |header: i32| header | (ty << 2);
|
||||
let mut ops = vec![(None, mask(0), Input::QuickCraft)];
|
||||
ops.extend(
|
||||
slots
|
||||
.into_iter()
|
||||
.map(|slot| (Some(slot), mask(1), Input::QuickCraft)),
|
||||
);
|
||||
ops.push((None, mask(2), Input::QuickCraft));
|
||||
if !self.menu_ops(&ops) {
|
||||
// Not a menu the engine models: the drag is a plain click.
|
||||
self.click_inventory(ty == 1);
|
||||
}
|
||||
}
|
||||
if let Some(key) = self.container {
|
||||
self.container_changed(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
//! Container screen slots: item and decoration drawing, the carried stack and quick craft drag,
|
||||
//! slot positions and hit tests, highlights, and the crafting result slot.
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn draw_item(&self, batch: &mut HudBatch, stack: &ItemStack, x: f32, y: f32) {
|
||||
// `GuiGraphics.renderItem`: the resolved item model (tints, foil and
|
||||
// special renderers included), then the decorations.
|
||||
self.draw_item_model(batch, stack, x, y);
|
||||
self.draw_item_decorations(batch, stack, x, y, None);
|
||||
}
|
||||
/// `extractSlot` of slot `slot` holding `stack` at (x, y): during a quick
|
||||
/// craft drag a dragged slot shows what it would hold over a white
|
||||
/// highlight (its count yellow when the slot caps it), and hides while
|
||||
/// it is the drag's only slot.
|
||||
pub(super) fn draw_slot_item(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
drag: Option<&DragView>,
|
||||
slot: usize,
|
||||
stack: &Option<ItemStack>,
|
||||
x: f32,
|
||||
y: f32,
|
||||
) {
|
||||
let carried;
|
||||
let (stack, capped) = match drag.and_then(|d| d.slots.get(&slot)) {
|
||||
Some(None) => return,
|
||||
Some(Some((count, capped))) => {
|
||||
batch.fill(&self.gui.atlas, x, y, 16.0, 16.0, rgba(255, 255, 255, 128));
|
||||
carried = self
|
||||
.carried
|
||||
.clone()
|
||||
.map(|c| ItemStack { count: *count, ..c });
|
||||
(carried.as_ref(), *capped)
|
||||
}
|
||||
None => (stack.as_ref(), false),
|
||||
};
|
||||
if let Some(stack) = stack {
|
||||
self.draw_item_model(batch, stack, x, y);
|
||||
let text = capped.then(|| format!("\u{a7}e{}", stack.count));
|
||||
self.draw_item_decorations(batch, stack, x, y, text);
|
||||
}
|
||||
}
|
||||
/// `extractCarriedItem`: the carried stack at the cursor, showing what a
|
||||
/// quick craft drag over several slots leaves in hand.
|
||||
pub(super) fn draw_carried(&mut self, batch: &mut HudBatch) {
|
||||
let Some(mut stack) = self.carried.clone() else {
|
||||
return;
|
||||
};
|
||||
if let Some(left) = self.drag_view().and_then(|d| d.left) {
|
||||
if left == 0 {
|
||||
return;
|
||||
}
|
||||
stack.count = left;
|
||||
}
|
||||
self.draw_item(batch, &stack, self.cursor.0 - 8.0, self.cursor.1 - 8.0);
|
||||
}
|
||||
/// The quick craft drag in progress, as the menu engine previews it.
|
||||
pub(super) fn drag_view(&mut self) -> Option<DragView> {
|
||||
let (ty, slots) = self.inv_drag.clone()?;
|
||||
let mut view = None;
|
||||
self.menu_session(|menu, env, to_menu| {
|
||||
let menu_slots: Option<Vec<usize>> = slots.iter().map(|&g| to_menu(g)).collect();
|
||||
let shown = match menu.quick_craft_preview(env, ty, &menu_slots?) {
|
||||
Some((shown, left)) => {
|
||||
let slots = slots.iter().copied().zip(shown).collect();
|
||||
DragView {
|
||||
slots,
|
||||
left: Some(left),
|
||||
}
|
||||
}
|
||||
// A single dragged slot is hidden.
|
||||
None => DragView {
|
||||
slots: slots.iter().map(|&g| (g, None)).collect(),
|
||||
left: None,
|
||||
},
|
||||
};
|
||||
view = Some(shown);
|
||||
Some(false)
|
||||
});
|
||||
view
|
||||
}
|
||||
/// Whether a quick craft drag takes `slot`
|
||||
/// (`shouldAddSlotToQuickCraft`); screens the menu engine doesn't
|
||||
/// model take every slot.
|
||||
pub(super) fn quick_craft_accepts(&mut self, slot: usize, ty: i32, dragged: usize) -> bool {
|
||||
let mut accepts = true;
|
||||
self.menu_session(|menu, env, to_menu| {
|
||||
accepts = to_menu(slot).is_some_and(|s| menu.quick_craft_accepts(env, s, ty, dragged));
|
||||
Some(false)
|
||||
});
|
||||
accepts
|
||||
}
|
||||
/// `GuiGraphicsExtractor.itemDecorations`: the count (or `count_text`)
|
||||
/// and damage bar.
|
||||
pub(super) fn draw_item_decorations(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
stack: &ItemStack,
|
||||
x: f32,
|
||||
y: f32,
|
||||
count_text: Option<String>,
|
||||
) {
|
||||
// GuiGraphicsExtractor.itemDecorations, laid out at the origin and
|
||||
// moved to (x, y): the damage bar, the cooldown shade and the count.
|
||||
let info = self.items().get(&stack.item);
|
||||
// ItemStack.isBarVisible: isDamaged, which an unbreakable stack never is.
|
||||
let max_damage = if horizon_client::stations::is_damageable(self.items(), stack) {
|
||||
info.map_or(0, |info| info.max_damage as i32)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let cooldown =
|
||||
horizon_client::cooldowns::group(stack, info.and_then(|i| i.use_cooldown.as_ref()));
|
||||
let partial = self.clock.fraction() as f32;
|
||||
// BundleItem overrides the bar with its fullness.
|
||||
let mut bar = horizon_client::bundle::Contents::of(self.items(), stack)
|
||||
.map(|c| c.bar())
|
||||
.unwrap_or_else(|| hud_overlays::damage_bar(stack.damage as i32, max_damage));
|
||||
// Client-only: a mod may replace or hide the bar vanilla just chose.
|
||||
self.native_item_bar(stack, &mut bar);
|
||||
let decorations = hud_overlays::ItemDecorations {
|
||||
count: stack.count,
|
||||
count_text,
|
||||
bar,
|
||||
cooldown: self.cooldowns.percent(&cooldown, partial),
|
||||
};
|
||||
let mut draws = vec![hud_layout::GuiDraw::Push(vec![
|
||||
hud_layout::PoseOp::Translate(x, y),
|
||||
])];
|
||||
draws.extend(hud_overlays::item_decorations(
|
||||
0,
|
||||
0,
|
||||
&decorations,
|
||||
&self.gui.atlas.font,
|
||||
));
|
||||
draws.push(hud_layout::GuiDraw::Pop);
|
||||
hud_layout::paint(batch, &self.gui.atlas, &draws);
|
||||
self.native_item_decorations(stack, x, y, batch);
|
||||
}
|
||||
|
||||
pub(super) fn inventory_origin(&self, batch: &HudBatch) -> (f32, f32) {
|
||||
if self.screen == Screen::Creative {
|
||||
return Self::creative_origin(batch);
|
||||
}
|
||||
let width = self.native_screen_width().unwrap_or(176.0);
|
||||
let (x, y) = batch.centered_origin(width, self.screen_height());
|
||||
(x + self.recipe_book_shift(batch.width), y)
|
||||
}
|
||||
|
||||
/// InventoryMenu / CraftingMenu slot layouts.
|
||||
pub(super) fn slot_position(&self, i: usize) -> (f32, f32) {
|
||||
if self.creative_inventory_open()
|
||||
&& let Some(position) = Creative::inventory_slot_position(i)
|
||||
{
|
||||
return position;
|
||||
}
|
||||
if self.creative_tab_open()
|
||||
&& let Some(position) = Creative::tab_slot_position(i)
|
||||
{
|
||||
return position;
|
||||
}
|
||||
if let Some(p) = self
|
||||
.station_slot_position(i)
|
||||
.or_else(|| {
|
||||
Self::stock_machine_slot(self.machine_slot_position(i), self.native_screen_shown())
|
||||
})
|
||||
.or_else(|| self.crafter_slot_position(i))
|
||||
.or_else(|| self.mount_slot_position(i))
|
||||
{
|
||||
return p;
|
||||
}
|
||||
let table = self.craft_size == 3;
|
||||
// ContainerScreen (3 rows) shifts the player inventory down by one pixel.
|
||||
// ChestMenu: the inventory sits `18 * (rows - 3) + 1` lower than the
|
||||
// 3-row layout of the other menus.
|
||||
let shift = match (self.chest_rows(), self.native_screen_inventory_y()) {
|
||||
(_, Some(y)) => y - 84.0,
|
||||
(Some(rows), None) => 1.0 + (rows as f32 - 3.0) * 18.0,
|
||||
(None, None) => 0.0,
|
||||
};
|
||||
let inventory_x = self.native_screen_inventory_x().unwrap_or(8.0);
|
||||
match i {
|
||||
0..9 => (inventory_x + i as f32 * 18.0, 142.0 + shift),
|
||||
9..36 => (
|
||||
inventory_x + ((i - 9) % 9) as f32 * 18.0,
|
||||
84.0 + shift + ((i - 9) / 9) as f32 * 18.0,
|
||||
),
|
||||
500..504 => (8.0, 8.0 + (i - 500) as f32 * 18.0),
|
||||
// InventoryMenu: the shield slot.
|
||||
504 => (77.0, 62.0),
|
||||
300..500 if let Some(p) = self.native_screen_slot(i - 300) => p,
|
||||
300..500 => match self.container_kind() {
|
||||
Some(BlockEntity::Furnace(_)) => {
|
||||
[(56.0, 17.0), (56.0, 53.0), (116.0, 35.0)][(i - 300).min(2)]
|
||||
}
|
||||
_ => (
|
||||
8.0 + ((i - 300) % 9) as f32 * 18.0,
|
||||
18.0 + ((i - 300) / 9) as f32 * 18.0,
|
||||
),
|
||||
},
|
||||
200 => {
|
||||
if table {
|
||||
(124.0, 35.0)
|
||||
} else {
|
||||
(154.0, 28.0)
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let g = i - 100;
|
||||
let (r, c) = (g / self.craft_size, g % self.craft_size);
|
||||
if table {
|
||||
(30.0 + c as f32 * 18.0, 17.0 + r as f32 * 18.0)
|
||||
} else {
|
||||
(98.0 + c as f32 * 18.0, 18.0 + r as f32 * 18.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The stock hopper/dispenser slot layout, unless a mod screen is shown for
|
||||
/// that menu: a mod screen places its own container slots
|
||||
/// (`native_screen_slot`) and the player's inventory (`native_screen_inventory_y`),
|
||||
/// and this layout answers first in `slot_position`, which kept a mod's screen
|
||||
/// on a hopper or a dispenser on the stock grid (#5141). The rest of those
|
||||
/// menus is already the mod's: `draw_native_screen` owns the background and
|
||||
/// `screen_height` its height.
|
||||
pub(super) fn stock_machine_slot(
|
||||
stock: Option<(f32, f32)>,
|
||||
mod_screen_shown: bool,
|
||||
) -> Option<(f32, f32)> {
|
||||
if mod_screen_shown { None } else { stock }
|
||||
}
|
||||
|
||||
pub(super) fn slot_at(&self, batch: &HudBatch, x: f32, y: f32) -> Option<usize> {
|
||||
if self.book_covers_menu(batch.width) {
|
||||
return None;
|
||||
}
|
||||
let (ox, oy) = self.inventory_origin(batch);
|
||||
self.slots().into_iter().find(|&i| {
|
||||
let (sx, sy) = self.slot_position(i);
|
||||
// AbstractContainerScreen.isHovering: the 16x16 slot grows by one
|
||||
// pixel on every side, so the gaps between slots still hover one.
|
||||
x >= ox + sx - 1.0 && x < ox + sx + 17.0 && y >= oy + sy - 1.0 && y < oy + sy + 17.0
|
||||
})
|
||||
}
|
||||
/// `AbstractContainerScreen.extractSlotHighlightBack` and
|
||||
/// `AbstractContainerScreen.extractSlotHighlightFront`: the 24 x 24
|
||||
/// `container/slot_highlight_back` sprite behind the slot at `x`, `y` (its
|
||||
/// 16 x 16 item box), the `_front` one over its item.
|
||||
pub(super) fn draw_slot_highlight(&self, batch: &mut HudBatch, front: bool, x: f32, y: f32) {
|
||||
let sprite = if front {
|
||||
"gui/sprites/container/slot_highlight_front"
|
||||
} else {
|
||||
"gui/sprites/container/slot_highlight_back"
|
||||
};
|
||||
horizon_client::ui::blit_sprite(
|
||||
batch,
|
||||
&self.gui.atlas,
|
||||
sprite,
|
||||
x - 4.0,
|
||||
y - 4.0,
|
||||
24.0,
|
||||
24.0,
|
||||
u32::MAX,
|
||||
);
|
||||
}
|
||||
/// The highlight of the slot under the cursor, for the screens whose
|
||||
/// slots all sit in `slot_at`'s grid. `Slot.isHighlightable` is false
|
||||
/// for the crafter's result (`NonInteractiveResultSlot`).
|
||||
pub(super) fn draw_hovered_slot_highlight(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
front: bool,
|
||||
ox: f32,
|
||||
oy: f32,
|
||||
) {
|
||||
let Some(slot) = self.slot_at(batch, self.cursor.0, self.cursor.1) else {
|
||||
return;
|
||||
};
|
||||
if slot == 309 && matches!(self.container_kind(), Some(BlockEntity::Crafter(_))) {
|
||||
return;
|
||||
}
|
||||
let (sx, sy) = self.slot_position(slot);
|
||||
self.draw_slot_highlight(batch, front, ox + sx, oy + sy);
|
||||
}
|
||||
pub(super) fn slot_mut(&mut self, i: usize) -> &mut Option<ItemStack> {
|
||||
if i >= mount_menu::SADDLE {
|
||||
return self.mount_slot_mut(i);
|
||||
}
|
||||
if i >= stations::INPUT {
|
||||
return self.station_slot_mut(i);
|
||||
}
|
||||
if (500..504).contains(&i) {
|
||||
return &mut self.armor[i - 500];
|
||||
}
|
||||
if i == 504 {
|
||||
return &mut self.offhand;
|
||||
}
|
||||
if i >= 300 {
|
||||
return self.container_slot_mut(i - 300);
|
||||
}
|
||||
if (100..109).contains(&i) {
|
||||
&mut self.craft_grid[i - 100]
|
||||
} else {
|
||||
&mut self.inventory[i]
|
||||
}
|
||||
}
|
||||
/// What a slot shows: the crafting result slots are computed.
|
||||
pub(super) fn slot_view(&mut self, i: usize) -> Option<ItemStack> {
|
||||
match i {
|
||||
200 => self.craft_result(),
|
||||
309 if matches!(self.container_kind(), Some(BlockEntity::Crafter(_))) => {
|
||||
self.crafter_result()
|
||||
}
|
||||
_ => self.slot_mut(i).clone(),
|
||||
}
|
||||
}
|
||||
pub(super) fn craft_result(&self) -> Option<ItemStack> {
|
||||
// A server's result slot is the server's (`CraftingMenu.slotChangedCraftingGrid`).
|
||||
if let Some(r) = &self.remote {
|
||||
return r.craft_result.clone();
|
||||
}
|
||||
self.craft_recipe().map(|(_, result)| result)
|
||||
}
|
||||
/// The recipe id and result of the crafting grid.
|
||||
/// `RecipeCraftingHolder.awardUsedRecipes`: a crafted recipe becomes known
|
||||
/// (special ones never do).
|
||||
pub(super) fn award_crafted(&mut self, crafts: &[(String, Vec<Option<ItemStack>>)]) {
|
||||
for (recipe, ingredients) in crafts {
|
||||
// The whole id: the advancements' `recipe_crafted` criteria name
|
||||
// `minecraft:x`, and the crafting copy names the recipe bare.
|
||||
let recipe = horizon_client::crafting::qualified(recipe);
|
||||
self.trigger_recipe_crafted(&recipe, ingredients);
|
||||
}
|
||||
let recipes: Vec<String> = crafts
|
||||
.iter()
|
||||
.map(|(r, _)| horizon_client::crafting::qualified(r))
|
||||
.collect();
|
||||
let index = &world_stations!(self).recipe_index;
|
||||
let (_, unlocked) = self.level.recipe_book.add_recipes(index, &recipes);
|
||||
self.recipe_unlocked(&unlocked);
|
||||
}
|
||||
pub(super) fn craft_recipe(&self) -> Option<(String, ItemStack)> {
|
||||
let n = self.craft_size;
|
||||
let grid: Vec<Option<ItemStack>> = (0..n * n).map(|i| self.craft_grid[i].clone()).collect();
|
||||
let scale = |id: i32| self.maps.data.get(&id).map(|m| m.scale);
|
||||
// The world's table, the one taking the result (`menu_click`) crafts from.
|
||||
self.recipes()
|
||||
.find_with(&grid, n, &scale)
|
||||
.map(|(name, result)| (name.to_owned(), result))
|
||||
}
|
||||
pub(super) fn draw_inventory_slots(&mut self, batch: &mut HudBatch, ox: f32, oy: f32) {
|
||||
let drag = self.drag_view();
|
||||
for i in self.slots() {
|
||||
let (sx, sy) = self.slot_position(i);
|
||||
if i == 504 && self.offhand.is_none() {
|
||||
batch.sprite(
|
||||
&self.gui.atlas,
|
||||
"gui/sprites/container/slot/shield",
|
||||
ox + sx,
|
||||
oy + sy,
|
||||
);
|
||||
}
|
||||
if (500..504).contains(&i) && self.armor[i - 500].is_none() {
|
||||
let icon = ["helmet", "chestplate", "leggings", "boots"][i - 500];
|
||||
batch.sprite(
|
||||
&self.gui.atlas,
|
||||
&format!("gui/sprites/container/slot/{icon}"),
|
||||
ox + sx,
|
||||
oy + sy,
|
||||
);
|
||||
}
|
||||
let stack = self.slot_view(i);
|
||||
self.draw_slot_item(batch, drag.as_ref(), i, &stack, ox + sx, oy + sy);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
//! A click that uses the held item at nothing or at an entity (`Minecraft.startUseItem`,
|
||||
//! `Player.interactOn`), the context a block's use sees, and the mod hooks that cancel a placement
|
||||
//! or an emptied bucket.
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
/// The hand whose use ended the click, if any.
|
||||
pub(super) fn use_item(&mut self) -> Result<Option<Hand>> {
|
||||
self.with_player_actor(|game| game.use_item_as_player())
|
||||
}
|
||||
/// `Minecraft.startUseItem`: the main hand, then the off hand.
|
||||
pub(super) fn use_item_as_player(&mut self) -> Result<Option<Hand>> {
|
||||
// Spectators only look (MultiPlayerGameMode.useItemOn/useItem pass),
|
||||
// but on a server a spectator's use on a block opens the block's
|
||||
// menu (ServerPlayerGameMode.useItemOn).
|
||||
if self.spectator() {
|
||||
if matches!(self.guest_use, Some(GuestUse::OnBlock(_)))
|
||||
&& let Some(hit) = self.pick()
|
||||
{
|
||||
self.spectator_open_menu(hit.pos);
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
// A carrot or warped fungus on a stick boosts the ridden pig or strider.
|
||||
// A guest's use packets name one hand and touch no entity: the client
|
||||
// sends one per hand and part of its click, and Interact for entities.
|
||||
let guest = self.guest_use;
|
||||
if let Some(GuestUse::Entity(hand, target)) = guest {
|
||||
return Ok(self
|
||||
.with_hand(hand, |g| g.interact_entity(Some(target)))?
|
||||
.then_some(hand));
|
||||
}
|
||||
let on_block = matches!(guest, Some(GuestUse::OnBlock(_)));
|
||||
if !on_block
|
||||
&& self.riding.is_some()
|
||||
&& let Some(item) = self.held().map(|s| s.item.clone())
|
||||
&& self.use_steering_stick(&item)
|
||||
{
|
||||
return Ok(Some(Hand::Main));
|
||||
}
|
||||
if guest.is_none() && self.interact_entity(None)? {
|
||||
return Ok(Some(Hand::Main));
|
||||
}
|
||||
// An EntityHitResult may fall through to Item.use, never to useItemOn.
|
||||
let block_hit = match guest {
|
||||
Some(GuestUse::OnBlock(_)) => self.pick(),
|
||||
Some(GuestUse::Item(_)) => None,
|
||||
_ if self.entity_targeted() => None,
|
||||
_ => self.pick(),
|
||||
};
|
||||
let hands = match guest {
|
||||
Some(GuestUse::OnBlock(hand) | GuestUse::Item(hand) | GuestUse::Entity(hand, _)) => {
|
||||
vec![hand]
|
||||
}
|
||||
None => Hand::BOTH.to_vec(),
|
||||
};
|
||||
let entity = guest.is_none() && self.entity_targeted();
|
||||
for hand in hands {
|
||||
// The off hand's turn starts on the entity too (Minecraft.startUseItem).
|
||||
if entity && hand == Hand::Off && self.with_hand(hand, |g| g.interact_entity(None))? {
|
||||
return Ok(Some(hand));
|
||||
}
|
||||
// A click on the fence block: `LeadItem.useOn`. A client whose crosshair
|
||||
// met the knot first sends Interact with the knot's id instead.
|
||||
if on_block && self.with_hand(hand, Game::interact_knot) {
|
||||
return Ok(Some(hand));
|
||||
}
|
||||
if self.with_hand(hand, |game| game.use_hand(block_hit.as_ref()))? {
|
||||
return Ok(Some(hand));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
/// The index in `list` of the entity an interact packet's `target` names,
|
||||
/// by its server id (`Level.getEntity(id)`), or None for an id this level
|
||||
/// does not hold: `handleInteract` finds nothing then either.
|
||||
pub(super) fn named_entity<T>(
|
||||
list: &[T],
|
||||
target: i32,
|
||||
id: impl Fn(&T) -> u32,
|
||||
) -> Option<usize> {
|
||||
list.iter().position(|e| id(e) as i32 == target)
|
||||
}
|
||||
/// `Player.interactOn` with the acting hand: whether it took the click. A
|
||||
/// guest's interact packet names its entity (`target`), which the helpers
|
||||
/// use as it is; the local player picks from the crosshair instead, the
|
||||
/// way `Minecraft.hitResult` does. `native_entity_interact_specific` and
|
||||
/// `native_interact_entity` hear it first; a claim swings the hand and
|
||||
/// ends the click.
|
||||
pub(super) fn interact_entity(&mut self, target: Option<InteractTarget>) -> Result<bool> {
|
||||
// Native mods, where Player.interactOn starts: before the entity's
|
||||
// own interact and the item's interactLivingEntity. The entity is the
|
||||
// one the packet named, or the one the crosshair picked.
|
||||
let (named, distance) = match target {
|
||||
Some(t) => (Some(t.id), None),
|
||||
None => match self.pick_entity_hit_at() {
|
||||
Some((distance, id)) => (id, Some(distance)),
|
||||
None => (None, None),
|
||||
},
|
||||
};
|
||||
if let Some(id) = named
|
||||
&& let Some(entity) = self.level_entity(id)
|
||||
{
|
||||
// The point clicked, measured from the entity's position: a
|
||||
// guest's packet carries it, and the local player's is where the
|
||||
// crosshair's ray met the entity (`EntityHitResult.getLocation()`).
|
||||
let location = match target {
|
||||
Some(t) => Some(t.location),
|
||||
None => distance
|
||||
.zip(self.aim())
|
||||
.zip(self.target_position(entity))
|
||||
.map(|((d, (eye, dir)), at)| {
|
||||
std::array::from_fn(|k| eye[k] + dir[k] * d - at[k])
|
||||
}),
|
||||
};
|
||||
let kind = self.target_type(entity);
|
||||
let stack = self.held().cloned();
|
||||
let mut claimed = false;
|
||||
if let Some(location) = location {
|
||||
self.native_entity_interact_specific(
|
||||
id,
|
||||
&kind,
|
||||
stack.as_ref(),
|
||||
location,
|
||||
&mut claimed,
|
||||
);
|
||||
}
|
||||
if !claimed {
|
||||
self.native_interact_entity(id, &kind, stack.as_ref(), &mut claimed);
|
||||
self.native_interact_entity_v2(id, &kind, self.hand, stack.as_ref(), &mut claimed);
|
||||
}
|
||||
if claimed {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(self.interact_frame(target)
|
||||
|| self.interact_armor_stand(target)
|
||||
|| self.interact_cushion(target)
|
||||
|| self.interact_boat(target)
|
||||
|| self.interact_cart(target)
|
||||
|| self.interact_mob(target)?
|
||||
// A guest's Interact naming a knot's id goes to `use_knot` before this.
|
||||
|| target.is_none() && self.interact_knot()
|
||||
|| self.item_interact_mob(target))
|
||||
}
|
||||
|
||||
/// Asks the mods whether the acting guest may place `stack` where and as
|
||||
/// `BlockItem.place` would for `ctx` (`native_block_place`): true when
|
||||
/// one canceled it. A game's own player is not asked, nor a placement
|
||||
/// vanilla refuses.
|
||||
pub(super) fn block_place_canceled(&mut self, stack: &ItemStack, ctx: &PlaceContext) -> bool {
|
||||
let (Some(uuid), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) else {
|
||||
return false;
|
||||
};
|
||||
let Some((pos, state)) =
|
||||
self.with_level(|b, level, ticks| b.placement_state(level, ticks, ctx))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let mut cancel = false;
|
||||
self.native_block_place(
|
||||
uuid,
|
||||
&name,
|
||||
stack,
|
||||
[pos.x, pos.y, pos.z],
|
||||
state,
|
||||
&mut cancel,
|
||||
);
|
||||
cancel
|
||||
}
|
||||
/// Asks the mods whether the acting guest may empty `stack` at `pos`
|
||||
/// (`native_bucket_empty`): true when one cancelled it. A game's own
|
||||
/// player is not asked.
|
||||
pub(super) fn bucket_empty_cancelled(&mut self, stack: &ItemStack, pos: [i32; 3]) -> bool {
|
||||
let (Some(uuid), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) else {
|
||||
return false;
|
||||
};
|
||||
let fluid = match &*stack.item {
|
||||
"minecraft:lava_bucket" => "minecraft:lava",
|
||||
"minecraft:powder_snow_bucket" => "minecraft:powder_snow",
|
||||
_ => "minecraft:water",
|
||||
};
|
||||
let mut cancel = false;
|
||||
self.native_bucket_empty(uuid, &name, pos, fluid, &mut cancel);
|
||||
cancel
|
||||
}
|
||||
/// The player as a block's `useItemOn` / `useWithoutItem` sees them.
|
||||
pub(super) fn use_context(&self) -> PlayerContext {
|
||||
PlayerContext {
|
||||
yaw: self.yaw,
|
||||
pitch: self.pitch,
|
||||
may_build: self.may_build(),
|
||||
can_eat: self.creative
|
||||
|| self
|
||||
.player
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.health.food.food < 20),
|
||||
game_master: self.can_use_game_master_blocks(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@
|
||||
//! (`ItemEnv`) and the special renderers' registries to the game.
|
||||
use super::{Dim, Game, entity_culling::EntityCullDecision};
|
||||
use horizon_assets::item_model::{
|
||||
Components, DisplayContext, GlobalPos, ItemEnv, LevelState, OwnerState, StackView,
|
||||
Components, ComponentsView, DisplayContext, GlobalPos, ItemEnv, LevelState, OwnerState,
|
||||
StackView,
|
||||
};
|
||||
use horizon_client::{
|
||||
block_entity_render::{diffuse_lights, vertex_light},
|
||||
@@ -188,7 +189,7 @@ pub(super) fn owner_at(position: [f64; 3], yaw: f32, entity_type: &str) -> Owner
|
||||
}
|
||||
/// `UseDuration` of a stack (`Item.getUseDuration`): 72000 for drawn items
|
||||
/// and for `blocks_attacks` items (shields), the consume time for food.
|
||||
fn use_duration(components: &Components) -> i32 {
|
||||
fn use_duration(components: ComponentsView<'_>) -> i32 {
|
||||
if let Some(c) = components.get("minecraft:consumable") {
|
||||
let seconds = c["consume_seconds"].as_f64().unwrap_or(1.6);
|
||||
return (seconds * 20.0) as i32;
|
||||
@@ -286,7 +287,7 @@ impl Game<'_> {
|
||||
&& self.stack_in(slot) == Some(stack)
|
||||
{
|
||||
let view = item_render::stack_view(stack);
|
||||
let mut duration = use_duration(&self.pack.item_renderer.models.components(&view));
|
||||
let mut duration = use_duration(self.pack.item_renderer.models.components_view(&view));
|
||||
self.native_use_duration(stack, &mut duration);
|
||||
owner.using_this_item = true;
|
||||
owner.use_duration = duration;
|
||||
@@ -784,19 +785,19 @@ mod tests {
|
||||
// runs instead of ending at once (#2289).
|
||||
let mut shield = Components::new();
|
||||
shield.insert("minecraft:blocks_attacks".into(), json!({}));
|
||||
assert_eq!(use_duration(&shield), 72_000);
|
||||
assert_eq!(use_duration(ComponentsView::of(&shield)), 72_000);
|
||||
// Food is used for as long as it takes to eat it.
|
||||
let mut food = Components::new();
|
||||
food.insert(
|
||||
"minecraft:consumable".into(),
|
||||
json!({"consume_seconds": 1.6}),
|
||||
);
|
||||
assert_eq!(use_duration(&food), 32);
|
||||
assert_eq!(use_duration(ComponentsView::of(&food)), 32);
|
||||
// The bow keeps its own use time.
|
||||
let mut bow = Components::new();
|
||||
bow.insert("minecraft:item_model".into(), json!("minecraft:bow"));
|
||||
assert_eq!(use_duration(&bow), 72_000);
|
||||
assert_eq!(use_duration(ComponentsView::of(&bow)), 72_000);
|
||||
// Anything else is not used at all.
|
||||
assert_eq!(use_duration(&Components::new()), 0);
|
||||
assert_eq!(use_duration(ComponentsView::of(&Components::new())), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Key and button codes as GLFW and vanilla's `InputConstants` number them, and the small rules
|
||||
//! that decide which device or screen a key press goes to.
|
||||
use super::*;
|
||||
|
||||
pub(super) fn follow_named_keymap(
|
||||
logical_key: &winit::keyboard::Key,
|
||||
physical_key: &mut PhysicalKey,
|
||||
) {
|
||||
use winit::keyboard::{Key, NamedKey};
|
||||
if let Key::Named(named) = logical_key {
|
||||
let code = match named {
|
||||
NamedKey::Escape => KeyCode::Escape,
|
||||
NamedKey::CapsLock => KeyCode::CapsLock,
|
||||
NamedKey::F1 => KeyCode::F1,
|
||||
NamedKey::F2 => KeyCode::F2,
|
||||
NamedKey::F3 => KeyCode::F3,
|
||||
NamedKey::F4 => KeyCode::F4,
|
||||
NamedKey::F5 => KeyCode::F5,
|
||||
NamedKey::F6 => KeyCode::F6,
|
||||
NamedKey::F7 => KeyCode::F7,
|
||||
NamedKey::F8 => KeyCode::F8,
|
||||
NamedKey::F9 => KeyCode::F9,
|
||||
NamedKey::F10 => KeyCode::F10,
|
||||
NamedKey::F11 => KeyCode::F11,
|
||||
NamedKey::F12 => KeyCode::F12,
|
||||
NamedKey::F13 => KeyCode::F13,
|
||||
NamedKey::F14 => KeyCode::F14,
|
||||
NamedKey::F15 => KeyCode::F15,
|
||||
NamedKey::F16 => KeyCode::F16,
|
||||
NamedKey::F17 => KeyCode::F17,
|
||||
NamedKey::F18 => KeyCode::F18,
|
||||
NamedKey::F19 => KeyCode::F19,
|
||||
NamedKey::F20 => KeyCode::F20,
|
||||
NamedKey::F21 => KeyCode::F21,
|
||||
NamedKey::F22 => KeyCode::F22,
|
||||
NamedKey::F23 => KeyCode::F23,
|
||||
NamedKey::F24 => KeyCode::F24,
|
||||
NamedKey::F25 => KeyCode::F25,
|
||||
NamedKey::F26 => KeyCode::F26,
|
||||
NamedKey::F27 => KeyCode::F27,
|
||||
NamedKey::F28 => KeyCode::F28,
|
||||
NamedKey::F29 => KeyCode::F29,
|
||||
NamedKey::F30 => KeyCode::F30,
|
||||
NamedKey::F31 => KeyCode::F31,
|
||||
NamedKey::F32 => KeyCode::F32,
|
||||
NamedKey::F33 => KeyCode::F33,
|
||||
NamedKey::F34 => KeyCode::F34,
|
||||
NamedKey::F35 => KeyCode::F35,
|
||||
_ => return,
|
||||
};
|
||||
*physical_key = PhysicalKey::Code(code);
|
||||
}
|
||||
}
|
||||
|
||||
/// The modifier `modifiers` stands for, as GLFW words it in the native mods'
|
||||
/// input hooks: `GLFW_MOD_SHIFT` 1, `GLFW_MOD_CONTROL` 2, `GLFW_MOD_ALT` 4,
|
||||
/// `GLFW_MOD_SUPER` 8 — the word a NeoForge listener reads off its event
|
||||
/// (`getModifiers`). The lock keys are not modifiers there.
|
||||
pub(super) fn glfw_modifiers(modifiers: winit::keyboard::ModifiersState) -> u32 {
|
||||
let mut bits = 0;
|
||||
if modifiers.shift_key() {
|
||||
bits |= 1;
|
||||
}
|
||||
if modifiers.control_key() {
|
||||
bits |= 2;
|
||||
}
|
||||
if modifiers.alt_key() {
|
||||
bits |= 4;
|
||||
}
|
||||
if modifiers.super_key() {
|
||||
bits |= 8;
|
||||
}
|
||||
bits
|
||||
}
|
||||
|
||||
/// The GLFW mouse button index `button` is, as the native mods' mouse hooks
|
||||
/// hand it over: 0 left, 1 right, 2 middle, 3 back, 4 forward. `Other(n)` is
|
||||
/// winit's n, the platform's own number (X11 or evdev on Linux), so a button
|
||||
/// past 4 is not GLFW's index everywhere. Buttons 0–4 are the numbering
|
||||
/// `keybinds::mouse_name` names (`key.mouse.<index + 1>`) and vanilla's
|
||||
/// `InputConstants` reads.
|
||||
pub(super) fn glfw_button(button: MouseButton) -> i32 {
|
||||
match button {
|
||||
MouseButton::Left => 0,
|
||||
MouseButton::Right => 1,
|
||||
MouseButton::Middle => 2,
|
||||
MouseButton::Back => 3,
|
||||
MouseButton::Forward => 4,
|
||||
MouseButton::Other(n) => i32::from(n),
|
||||
}
|
||||
}
|
||||
|
||||
/// `KeyboardHandler.keyPress` for a key bound to a mouse mapping (key.attack,
|
||||
/// key.use, key.pickItem): in game, a press and every repeat of the held key
|
||||
/// reach `KeyMapping.set(key, true)` and `KeyMapping.click` alike, so a held
|
||||
/// key keeps attacking; a release lets go. `None` behind a screen, which takes
|
||||
/// the key.
|
||||
pub(super) fn keyboard_button(playing: bool, action: KeyAction) -> Option<bool> {
|
||||
playing.then_some(action != KeyAction::Release)
|
||||
}
|
||||
|
||||
/// Gameplay owns the keyboard: `Screen::Playing`, no title, and nothing open
|
||||
/// over the world (`screen_over_playing`: a server dialog, a book or lectern,
|
||||
/// a sign editor, a prompt). Each is a `Screen` in vanilla, so `Gui.setScreen`
|
||||
/// runs `KeyMapping.releaseAll` and `Minecraft.tick` skips `handleKeybinds`
|
||||
/// while it is up: a mouse button held into it is let go of (#9729).
|
||||
pub(super) fn gameplay_has_keyboard(screen: Screen, title: bool, screen_over: bool) -> bool {
|
||||
screen == Screen::Playing && !title && !screen_over
|
||||
}
|
||||
|
||||
/// A dialog drops held keys and mouse buttons it does not see (#4974).
|
||||
pub(super) fn drop_held_behind_dialog(
|
||||
dialog: bool,
|
||||
keys: &mut horizon_client::held::HeldKeys,
|
||||
left: &mut bool,
|
||||
right: &mut bool,
|
||||
) {
|
||||
if dialog {
|
||||
keys.clear();
|
||||
*left = false;
|
||||
*right = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// `AbstractContainerScreen.keyPressed` for a key bound to a mouse mapping: a press or repeat of
|
||||
/// key.pickItem's key clones the hovered slot; keys bound to key.attack and key.use do nothing
|
||||
/// there (#5378).
|
||||
pub(super) fn container_key_clones(button: MouseButton, action: KeyAction) -> bool {
|
||||
button == MouseButton::Middle && action != KeyAction::Release
|
||||
}
|
||||
@@ -10,7 +10,10 @@ use horizon_core::{
|
||||
random::RandomSource,
|
||||
};
|
||||
use horizon_protocol::{
|
||||
entity_data::{MOD_PARTICLE_ID, Particle, ParticleData, PositionSource},
|
||||
entity_data::{
|
||||
LEGACY_AMBIENT_ENTITY_EFFECT_PARTICLE_ID, LEGACY_ENTITY_EFFECT_PARTICLE_ID,
|
||||
MOD_PARTICLE_ID, Particle, ParticleData, PositionSource,
|
||||
},
|
||||
packets::play::{self, Clientbound as C},
|
||||
};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
@@ -105,6 +108,17 @@ fn rgb_int(c: [f32; 3]) -> i32 {
|
||||
fn rgb(c: [f32; 4]) -> [f32; 3] {
|
||||
[c[0], c[1], c[2]]
|
||||
}
|
||||
fn legacy_effect_options(particle: &Particle) -> Option<ParticleOptions> {
|
||||
let ambient = match particle.id {
|
||||
LEGACY_ENTITY_EFFECT_PARTICLE_ID => false,
|
||||
LEGACY_AMBIENT_ENTITY_EFFECT_PARTICLE_ID => true,
|
||||
_ => return None,
|
||||
};
|
||||
Some(ParticleOptions {
|
||||
legacy_entity_effect: Some(ambient),
|
||||
..ParticleOptions::simple(ParticleType::EntityEffect)
|
||||
})
|
||||
}
|
||||
|
||||
/// `ServerLevel.sendParticles` of one soul particle moving by `velocity`
|
||||
/// (Soul Speed's `spawn_particles`): sent to the players within 32 blocks.
|
||||
@@ -449,6 +463,9 @@ impl Game<'_> {
|
||||
p: &Particle,
|
||||
at: [f64; 3],
|
||||
) -> Option<ParticleOptions> {
|
||||
if let Some(options) = legacy_effect_options(p) {
|
||||
return Some(options);
|
||||
}
|
||||
if p.id == MOD_PARTICLE_ID
|
||||
&& let Some(ParticleData::Mod(name)) = &p.data
|
||||
{
|
||||
@@ -870,6 +887,32 @@ mod tests {
|
||||
use horizon_core::random::LegacyRandom;
|
||||
use horizon_protocol::packets::play;
|
||||
|
||||
#[test]
|
||||
fn legacy_effect_markers_select_providers_without_a_registry_lookup() {
|
||||
for (id, ambient) in [
|
||||
(LEGACY_ENTITY_EFFECT_PARTICLE_ID, false),
|
||||
(LEGACY_AMBIENT_ENTITY_EFFECT_PARTICLE_ID, true),
|
||||
] {
|
||||
let options = legacy_effect_options(&Particle { id, data: None }).unwrap();
|
||||
assert_eq!(options.ty, ParticleType::EntityEffect);
|
||||
assert_eq!(options.legacy_entity_effect, Some(ambient));
|
||||
}
|
||||
assert!(
|
||||
legacy_effect_options(&Particle {
|
||||
id: MOD_PARTICLE_ID,
|
||||
data: None
|
||||
})
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
legacy_effect_options(&Particle {
|
||||
id: 28,
|
||||
data: Some(ParticleData::Color(-1))
|
||||
})
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
/// The Nether's evaporation smoke (ALTERNATIVE, dist 1, speed 0) lands
|
||||
/// inside the block from its corner and stands still; DEFAULT spreads
|
||||
/// both ways.
|
||||
|
||||
@@ -427,8 +427,12 @@ impl Game<'_> {
|
||||
self.break_leash_used(id, true);
|
||||
}
|
||||
let holder = self.acting_player_uuid();
|
||||
self.mobs[index].extra.leash = Some(Holder::Player);
|
||||
self.mobs[index].extra.leash_player = Some(holder);
|
||||
// LeashData.setLeashHolder: a holder still waiting in the save
|
||||
// is forgotten, so it can neither take over nor drop a lead.
|
||||
let extra = &mut self.mobs[index].extra;
|
||||
extra.leash = Some(Holder::Player);
|
||||
extra.leash_pending = None;
|
||||
extra.leash_player = Some(holder);
|
||||
self.lead_sound("tied", at);
|
||||
// `Player.interactOn` restores the count of a player with
|
||||
// infinite materials (creative); others spend the lead.
|
||||
@@ -910,3 +914,79 @@ mod rope_hold_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod attach_tests {
|
||||
/// `Entity.interact` with a lead: `setLeashedTo` → `LeashData.setLeashHolder`
|
||||
/// forgets the holder a save named. A mob whose saved holder never loaded
|
||||
/// keeps the player's lead past the 100-tick deadline, and no second lead
|
||||
/// falls from the forgotten one.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_player_lead_replaces_a_saved_holder_that_never_loaded() {
|
||||
use crate::game::{Audio, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::mobs::{
|
||||
Mob,
|
||||
leash::{Holder, Pending},
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
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 res: &'static _ = Box::leak(Box::new(loaded.resources));
|
||||
let mut game = super::Game::new(
|
||||
res,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.creative = false;
|
||||
game.player = Some(
|
||||
horizon_client::motion::PlayerMotion::new(game.motion, Vec3::new(0.5, 64.0, 0.5))
|
||||
.unwrap(),
|
||||
);
|
||||
let kind = game
|
||||
.res
|
||||
.mob_types()
|
||||
.get("minecraft:cow")
|
||||
.expect("cow")
|
||||
.clone();
|
||||
let mut cow = Mob::new(1, kind, Vec3::new(1.5, 64.0, 0.5), 0.0);
|
||||
cow.extra.leash_pending = Some(Pending::Uuid([9, 9, 9, 9]));
|
||||
game.mobs.push(cow);
|
||||
game.inventory[game.selected] = Some(super::ItemStack::new("minecraft:lead", 1));
|
||||
|
||||
assert!(game.interact_leash(0, "minecraft:lead"));
|
||||
assert_eq!(game.mobs[0].extra.leash, Some(Holder::Player));
|
||||
assert!(game.inventory[game.selected].is_none(), "the lead is spent");
|
||||
game.mobs[0].age = 101;
|
||||
game.tick_leads();
|
||||
assert_eq!(game.mobs[0].extra.leash, Some(Holder::Player));
|
||||
assert!(
|
||||
!game
|
||||
.dropped
|
||||
.iter()
|
||||
.any(|d| &*d.stack.item == "minecraft:lead"),
|
||||
"the forgotten holder drops no second lead"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -496,12 +496,16 @@ impl Game<'_> {
|
||||
let running = (!paused && !self.agent.hold_server).then(|| self.clock.world_runs());
|
||||
let server = self.serving.is_some() || self.options.dedicated.is_some();
|
||||
// A config edited while the server is paused (empty) still reloads.
|
||||
// The parts outside every level get spans of their own, so that a slow
|
||||
// tick's line and `horizon-report` say when a mod's tick hook took it.
|
||||
if server {
|
||||
let _span = horizon_client::spans::guard("mod_configs_poll");
|
||||
self.native_configs_poll();
|
||||
}
|
||||
if let Some(running) = running
|
||||
&& server
|
||||
{
|
||||
let _span = horizon_client::spans::guard("mods_server_tick_start");
|
||||
self.native_lifecycle_server_tick_start(running);
|
||||
}
|
||||
let observe_level =
|
||||
@@ -578,13 +582,20 @@ impl Game<'_> {
|
||||
self.native_lifecycle.cancel_server_tick();
|
||||
}
|
||||
result?;
|
||||
if let Err(error) = self.close_idle_levels() {
|
||||
let closed = {
|
||||
let _span = horizon_client::spans::guard("close_idle_levels");
|
||||
self.close_idle_levels()
|
||||
};
|
||||
if let Err(error) = closed {
|
||||
self.native_lifecycle.cancel_server_tick();
|
||||
return Err(error);
|
||||
}
|
||||
// `ServerTickEnd`: an errored tick or an idle-close failure (at
|
||||
// the `?` above) never reaches this site.
|
||||
self.native_lifecycle_server_tick_end(paused);
|
||||
{
|
||||
let _span = horizon_client::spans::guard("mods_server_tick_end");
|
||||
self.native_lifecycle_server_tick_end(paused);
|
||||
}
|
||||
// The packets counted for `native_server_packets` (only while a mod counts them).
|
||||
if server {
|
||||
self.diagnostics_packets_tick();
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
//! The loading screen while the resources load (`Loading`), the window's app before the game
|
||||
//! takes over (`App`), and the checks a start makes first: missing or damaged prepared files,
|
||||
//! an unclean last exit, a fullscreen the window system could not show.
|
||||
use super::*;
|
||||
|
||||
/// The window system ended the event loop with a failure (on Linux, a compositor dropping the
|
||||
/// connection after a protocol error) while fullscreen was on: the next start opens windowed, so a
|
||||
/// fullscreen the system can't show doesn't crash every launch (#2904).
|
||||
pub(super) fn leave_fullscreen_after(
|
||||
result: &std::result::Result<(), EventLoopError>,
|
||||
settings: &mut Settings,
|
||||
) {
|
||||
if matches!(result, Err(EventLoopError::ExitFailure(_))) && settings.fullscreen {
|
||||
settings.fullscreen = false;
|
||||
settings.save(&Game::options_path());
|
||||
log::warn!(
|
||||
"the window system closed the game in fullscreen; the next start opens windowed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn run_event_loop(mut game: Game<'_>) -> Result<()> {
|
||||
if game.options.headless {
|
||||
// No display to open winit's loop on: the game's own loop.
|
||||
game.run_headless();
|
||||
// narrator::start ran for it too (options.txt's narrator setting).
|
||||
narrator::stop();
|
||||
write_report(&game)?;
|
||||
return game.error.take().map_or(Ok(()), Err);
|
||||
}
|
||||
let mut split = split::Split::new(game);
|
||||
let result = EventLoop::new()?.run_app(&mut split);
|
||||
game = split.host;
|
||||
narrator::stop();
|
||||
leave_fullscreen_after(&result, &mut game.settings);
|
||||
result?;
|
||||
#[cfg(feature = "discord")]
|
||||
horizon_client::discord_presence::stop();
|
||||
write_report(&game)?;
|
||||
if let Some(e) = game.error.take() {
|
||||
return Err(e);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The loading screen's state while the worker loads the resources.
|
||||
pub(super) struct Loading {
|
||||
pub(super) window: Option<Arc<Window>>,
|
||||
pub(super) renderer: Option<Renderer>,
|
||||
/// The boot screen's atlas, until the pack's GUI atlas replaces it.
|
||||
pub(super) boot: horizon_client::hud::HudAtlas,
|
||||
pub(super) gui: Option<UiResources>,
|
||||
pub(super) settings: Settings,
|
||||
pub(super) fullscreen: horizon_client::window::FullscreenState,
|
||||
pub(super) video_modes: horizon_client::window::VideoModeList,
|
||||
pub(super) options: Option<Options>,
|
||||
pub(super) vanilla_root: PathBuf,
|
||||
pub(super) pack_error: Option<String>,
|
||||
pub(super) started: Instant,
|
||||
pub(super) panorama_rotation: horizon_client::gfx::panorama::Rotation,
|
||||
pub(super) progress: Progress,
|
||||
pub(super) receiver: Receiver<LoaderMsg>,
|
||||
pub(super) loaded: Option<std::result::Result<Loaded, String>>,
|
||||
/// The loader's `Done` arrived: its channel closing afterwards is expected.
|
||||
pub(super) delivered: bool,
|
||||
pub(super) handle: Option<JoinHandle<()>>,
|
||||
pub(super) shown: bool,
|
||||
/// When the menu bundle arrived (`set_ui`), and whether a title frame with
|
||||
/// it was presented since: the world renderer waits for that frame
|
||||
/// (`ready_for_handoff`).
|
||||
pub(super) ui_at: Option<Instant>,
|
||||
pub(super) title_drawn: bool,
|
||||
pub(super) menu: startup_menu::StartupMenu,
|
||||
pub(super) error: Option<Box<dyn std::error::Error>>,
|
||||
}
|
||||
/// How long the handoff waits for the first title frame before building the
|
||||
/// world renderer anyway (a hidden window may never be asked to redraw).
|
||||
pub(super) const TITLE_FRAME_WAIT: Duration = Duration::from_secs(1);
|
||||
impl Loading {
|
||||
/// The world renderer is built on the main thread, so building it before
|
||||
/// the title is on the screen holds the title back by that build. Wait for
|
||||
/// one title frame first, unless a world is waiting to open.
|
||||
pub(super) fn ready_for_handoff(&self) -> bool {
|
||||
self.title_drawn
|
||||
|| self.menu.pending.is_some()
|
||||
|| self.ui_at.is_none_or(|at| at.elapsed() >= TITLE_FRAME_WAIT)
|
||||
}
|
||||
pub(super) fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||
if self.window.is_some() {
|
||||
return;
|
||||
}
|
||||
// The Window constructor: created fullscreen when options.txt or --fullscreen says so.
|
||||
let fullscreen =
|
||||
self.settings.fullscreen || self.options.as_ref().is_some_and(|o| o.fullscreen);
|
||||
let size = self
|
||||
.options
|
||||
.as_ref()
|
||||
.map_or([1280, 720], |o| o.window_size)
|
||||
.map(f64::from);
|
||||
self.fullscreen = horizon_client::window::FullscreenState::new(fullscreen);
|
||||
let attributes = horizon_client::window::with_horizon_icon(
|
||||
Window::default_attributes().with_title("Horizon"),
|
||||
);
|
||||
// Wayland names the window by its app id, which the horizon.desktop entry matches.
|
||||
#[cfg(target_os = "linux")]
|
||||
let attributes = winit::platform::wayland::WindowAttributesExtWayland::with_name(
|
||||
attributes, "horizon", "horizon",
|
||||
);
|
||||
// `--offscreen` renders into a texture: its window would only show white.
|
||||
let window_step = startup::step("window.create");
|
||||
let window = match event_loop.create_window(
|
||||
attributes
|
||||
.with_inner_size(winit::dpi::LogicalSize::new(size[0], size[1]))
|
||||
.with_fullscreen(fullscreen.then_some(winit::window::Fullscreen::Borderless(None)))
|
||||
.with_visible(!self.options.as_ref().is_some_and(|o| o.offscreen)),
|
||||
) {
|
||||
Ok(w) => Arc::new(w),
|
||||
Err(e) => {
|
||||
self.error = Some(e.into());
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
};
|
||||
horizon_client::window::settle_created_fullscreen(&window, fullscreen);
|
||||
drop(window_step);
|
||||
horizon_client::telemetry::note_display(&window);
|
||||
// The renderer takes seconds to build and winit's window sits white
|
||||
// until the first frame presents, so paint the boot screen on it and
|
||||
// keep repainting it as the console's lines arrive; the wgpu frames
|
||||
// redraw the same design when they start. GDI is unsafe:
|
||||
// horizon-threads holds it (this crate forbids unsafe code).
|
||||
#[cfg(target_os = "windows")]
|
||||
let boot_paint_stop = hwnd(&window).map(|hwnd| {
|
||||
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let repaint_stop = stop.clone();
|
||||
std::thread::spawn(move || {
|
||||
while !repaint_stop.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
horizon_threads::boot_paint::paint(
|
||||
hwnd,
|
||||
horizon_theme::fonts::GEIST_SEMIBOLD,
|
||||
horizon_theme::fonts::GEIST_MONO,
|
||||
&horizon_client::logging::recent_lines(),
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
}
|
||||
});
|
||||
stop
|
||||
});
|
||||
let offscreen = self.options.as_ref().is_some_and(|o| o.offscreen);
|
||||
let boot_step = startup::step("renderer.boot");
|
||||
match loading_renderer(
|
||||
window.clone(),
|
||||
offscreen,
|
||||
Some(&self.boot),
|
||||
None,
|
||||
None,
|
||||
self.settings.horizon.api,
|
||||
) {
|
||||
Ok(r) => self.renderer = Some(r),
|
||||
Err(e) => {
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(stop) = &boot_paint_stop {
|
||||
stop.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
self.error = Some(e);
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
}
|
||||
drop(boot_step);
|
||||
// The wgpu frames take the window over: stop the boot paint thread
|
||||
// before the first one presents.
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(stop) = &boot_paint_stop {
|
||||
stop.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
horizon_client::hud::GUI_SCALE.store(self.settings.gui_scale, Ordering::Relaxed);
|
||||
self.window = Some(window);
|
||||
}
|
||||
pub(super) fn window_event(
|
||||
&mut self,
|
||||
event_loop: &ActiveEventLoop,
|
||||
id: WindowId,
|
||||
mut event: WindowEvent,
|
||||
) {
|
||||
if self.window.as_ref().is_none_or(|w| w.id() != id) {
|
||||
return;
|
||||
}
|
||||
follow_keymap(&mut event);
|
||||
if let Err(error) = self.menu_event(event_loop, event) {
|
||||
self.error = Some(error);
|
||||
event_loop.exit();
|
||||
}
|
||||
}
|
||||
/// The menu bundle arrived: rebuild the loading screen with the real atlas
|
||||
/// and title panorama.
|
||||
pub(super) fn set_ui(&mut self, gui: UiResources, root: PathBuf) {
|
||||
log::info!(
|
||||
"Horizon: loading the title artwork after {} ms",
|
||||
self.started.elapsed().as_millis()
|
||||
);
|
||||
self.menu.set_root(root, &self.settings);
|
||||
self.gui = Some(gui);
|
||||
self.title_drawn = false;
|
||||
horizon_client::ui_theme::reset_atlas();
|
||||
self.apply_menu_settings();
|
||||
let Some(window) = self.window.clone() else {
|
||||
return;
|
||||
};
|
||||
let offscreen = self.options.as_ref().is_some_and(|o| o.offscreen);
|
||||
if let Some(renderer) = &mut self.renderer {
|
||||
renderer.release_surface();
|
||||
}
|
||||
let bundle = self.gui.as_ref().expect("the menu bundle was just set");
|
||||
let _step = startup::step("renderer.title_artwork");
|
||||
match loading_renderer(
|
||||
window,
|
||||
offscreen,
|
||||
Some(&bundle.atlas),
|
||||
Some(&bundle.panorama),
|
||||
self.renderer.as_ref(),
|
||||
self.settings.horizon.api,
|
||||
) {
|
||||
Ok(r) => {
|
||||
horizon_client::hud::GUI_SCALE.store(self.settings.gui_scale, Ordering::Relaxed);
|
||||
self.renderer = Some(r);
|
||||
}
|
||||
Err(e) => self.error = Some(e),
|
||||
}
|
||||
// The title frame's wait starts once its renderer is built.
|
||||
self.ui_at = Some(Instant::now());
|
||||
}
|
||||
pub(super) fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
||||
// The loop may run once more after exit(): keep the first error, the
|
||||
// one that ended loading.
|
||||
if self.error.is_some() {
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
if horizon_threads::stop_requests() > 0 {
|
||||
// No world is open yet: nothing to save.
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
if let Err(error) = self.poll_menu_resources() {
|
||||
self.error = Some(error);
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
self.poll_menu_realms(event_loop);
|
||||
if let Some(w) = &self.window {
|
||||
if self.options.as_ref().is_some_and(|o| o.offscreen) {
|
||||
self.draw();
|
||||
} else {
|
||||
w.request_redraw();
|
||||
}
|
||||
}
|
||||
event_loop.set_control_flow(ControlFlow::Poll);
|
||||
}
|
||||
pub(super) fn draw(&mut self) {
|
||||
let Some(size) = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.map(|r| [r.config.width, r.config.height])
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let mut batch = HudBatch::new(size);
|
||||
self.draw_menu(&mut batch);
|
||||
self.menu.frame_drawn((batch.width, batch.height));
|
||||
let mut frame = loading_frame(&batch);
|
||||
frame.yaw = self.panorama_rotation.update(
|
||||
self.started.elapsed().as_secs_f32(),
|
||||
self.settings.file.double("panoramaScrollSpeed") as f32,
|
||||
);
|
||||
if let Some(renderer) = &mut self.renderer {
|
||||
match renderer.render(&frame) {
|
||||
Ok(true) if self.gui.is_some() && self.menu.pending.is_none() => {
|
||||
self.title_drawn = true;
|
||||
self.menu.perf.title_shown()
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => log::warn!("Horizon: loading screen frame failed: {e}"),
|
||||
}
|
||||
}
|
||||
if !self.shown {
|
||||
self.shown = true;
|
||||
startup::mark("loading_screen.shown");
|
||||
log::info!(
|
||||
"Horizon: loading screen shown after {} ms",
|
||||
self.started.elapsed().as_millis()
|
||||
);
|
||||
}
|
||||
}
|
||||
pub(super) fn finish(&mut self, loaded: Loaded) -> Result<Option<split::Split<'static>>> {
|
||||
let handoff_at = Instant::now();
|
||||
let mut options = self.options.take().ok_or("loading already finished")?;
|
||||
options.resources = loaded.root.clone();
|
||||
// StartupMenu owns world selection until it can hand it to Game.
|
||||
options.world = None;
|
||||
options.server = None;
|
||||
let gui = self.gui.take().ok_or("missing menu bundle")?;
|
||||
let window = self.window.clone().ok_or("window closed")?;
|
||||
// Keep the loader's device and swap in the real textures in one step.
|
||||
if let Some(renderer) = &mut self.renderer {
|
||||
renderer.release_surface();
|
||||
}
|
||||
let renderer_at = Instant::now();
|
||||
let renderer = startup::time("renderer.world", || {
|
||||
build_renderer(
|
||||
window.clone(),
|
||||
options.offscreen,
|
||||
&loaded.pack,
|
||||
&gui,
|
||||
self.renderer.as_ref(),
|
||||
loaded.renderer_cpu,
|
||||
self.settings.horizon.api,
|
||||
self.settings.horizon.translucent_batching,
|
||||
self.settings.horizon.wide_frame(),
|
||||
shader_packs::picked_pack_shape(&self.settings),
|
||||
(
|
||||
self.settings.file.int("textureFiltering"),
|
||||
self.settings.file.int("maxAnisotropyBit"),
|
||||
),
|
||||
)
|
||||
})?;
|
||||
log::info!(
|
||||
"Horizon: world renderer ready after {} ms",
|
||||
self.started.elapsed().as_millis()
|
||||
);
|
||||
let renderer_ms = renderer_at.elapsed().as_millis();
|
||||
// Audio is created on the main thread, after the core is ready.
|
||||
let audio = if !options.execution_role.is_client() {
|
||||
Audio::silent(&options.resources)
|
||||
} else {
|
||||
Audio::with_device(&options.resources, &self.settings.sound_device)
|
||||
};
|
||||
let pack_error = loaded.pack_error.or_else(|| self.pack_error.take());
|
||||
// The game borrows its shared resources for the process's lifetime,
|
||||
// like the single copy an integrated server would share.
|
||||
let res: &'static Arc<Resources> = Box::leak(Box::new(loaded.resources));
|
||||
let game_at = Instant::now();
|
||||
let game_step = startup::step("handoff.start_game");
|
||||
let Some(mut game) = start_game(
|
||||
res,
|
||||
loaded.pack,
|
||||
gui,
|
||||
options,
|
||||
pack_error,
|
||||
audio,
|
||||
self.vanilla_root.clone(),
|
||||
self.started,
|
||||
loaded.rest_loader,
|
||||
Some(self.settings.clone()),
|
||||
loaded.game_cpu,
|
||||
loaded.entity_models,
|
||||
)?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
drop(game_step);
|
||||
let game_ms = game_at.elapsed().as_millis();
|
||||
game.window = Some(window);
|
||||
game.renderer = Some(renderer);
|
||||
game.world_renderer_ready = true;
|
||||
game.fullscreen = self.fullscreen;
|
||||
game.panorama_rotation = self.panorama_rotation;
|
||||
self.menu.handoff(&mut game);
|
||||
game.apply_settings();
|
||||
game.previous = Instant::now();
|
||||
// Minecraft.onGameLoadFinished: a start that made it this far is clean.
|
||||
game.settings.file.set(
|
||||
"startedCleanly",
|
||||
horizon_client::options_file::V::Bool(true),
|
||||
);
|
||||
game.settings.save(&Game::options_path());
|
||||
log::info!(
|
||||
"Horizon: startup handoff {} ms (renderer {} ms, start_game {} ms)",
|
||||
handoff_at.elapsed().as_millis(),
|
||||
renderer_ms,
|
||||
game_ms
|
||||
);
|
||||
Ok(Some(split::Split::new(game)))
|
||||
}
|
||||
}
|
||||
|
||||
/// The application: the loading screen until the resources are ready, then
|
||||
/// the game itself, on the same event loop and window.
|
||||
pub(super) enum App {
|
||||
Loading(Box<Loading>),
|
||||
Ready(Box<split::Split<'static>>),
|
||||
}
|
||||
impl ApplicationHandler for App {
|
||||
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||
match self {
|
||||
App::Loading(loading) => loading.resumed(event_loop),
|
||||
App::Ready(game) => game.resumed(event_loop),
|
||||
}
|
||||
}
|
||||
fn window_event(&mut self, event_loop: &ActiveEventLoop, id: WindowId, event: WindowEvent) {
|
||||
match self {
|
||||
App::Loading(loading) => loading.window_event(event_loop, id, event),
|
||||
App::Ready(game) => game.window_event(event_loop, id, event),
|
||||
}
|
||||
}
|
||||
fn device_event(&mut self, event_loop: &ActiveEventLoop, id: DeviceId, event: DeviceEvent) {
|
||||
if let App::Ready(game) = self {
|
||||
game.device_event(event_loop, id, event);
|
||||
}
|
||||
}
|
||||
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
||||
enum Step {
|
||||
Waiting,
|
||||
Ready(Box<split::Split<'static>>),
|
||||
Done,
|
||||
Failed(Box<dyn std::error::Error>),
|
||||
}
|
||||
let step = match self {
|
||||
App::Ready(game) => {
|
||||
game.about_to_wait(event_loop);
|
||||
return;
|
||||
}
|
||||
App::Loading(loading) => {
|
||||
loading.about_to_wait(event_loop);
|
||||
if loading.error.is_some() {
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
match if loading.menu.reload_packs || !loading.ready_for_handoff() {
|
||||
None
|
||||
} else {
|
||||
loading.loaded.take()
|
||||
} {
|
||||
None => Step::Waiting,
|
||||
Some(Err(e)) => Step::Failed(e.into()),
|
||||
Some(Ok(loaded)) => match loading.finish(loaded) {
|
||||
Ok(Some(game)) => Step::Ready(Box::new(game)),
|
||||
Ok(None) => Step::Done,
|
||||
Err(e) => Step::Failed(e),
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
match step {
|
||||
Step::Waiting => {}
|
||||
Step::Ready(game) => *self = App::Ready(game),
|
||||
Step::Done => event_loop.exit(),
|
||||
Step::Failed(e) => {
|
||||
if let App::Loading(loading) = self {
|
||||
loading.error = Some(e);
|
||||
}
|
||||
event_loop.exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
fn exiting(&mut self, event_loop: &ActiveEventLoop) {
|
||||
if let App::Ready(game) = self {
|
||||
game.exiting(event_loop);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every launch reads the prepared game files (`horizon-prepare`'s output, whose
|
||||
/// manifest is `horizon-resources.json`): without them, say where they were
|
||||
/// looked for and how to get them, before a window or a loader starts.
|
||||
pub(super) fn missing_resources(root: &std::path::Path) -> Option<String> {
|
||||
let shown = std::path::absolute(root).unwrap_or_else(|_| root.to_path_buf());
|
||||
if !root.join("horizon-resources.json").is_file() {
|
||||
return Some(format!(
|
||||
"Horizon's prepared game files are missing: no horizon-resources.json in {}\n\
|
||||
Horizon is started from its launcher: download it at {} (Download). It needs your \
|
||||
own Minecraft: Java Edition; the launcher signs you in and prepares the game files.\n\
|
||||
Developers: horizon-prepare --prepare, or pass --resources <folder>",
|
||||
shown.display(),
|
||||
horizon_client::no_resources::DOWNLOAD_URL
|
||||
));
|
||||
}
|
||||
damaged_prepared(root).map(|problem| {
|
||||
format!(
|
||||
"Horizon's prepared game files are damaged: {problem} in {}\n\
|
||||
Prepare them again with the launcher, or: horizon-prepare --prepare",
|
||||
shown.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Which top-level prepared file a killed preparation or an interrupted copy
|
||||
/// left unusable. The loader parses these before it can start; naming one
|
||||
/// beats serde's bare "expected value at line 1 column 1".
|
||||
pub(super) fn damaged_prepared(root: &std::path::Path) -> Option<String> {
|
||||
let manifest = root.join("horizon-resources.json");
|
||||
let bytes = std::fs::read(&manifest).ok()?;
|
||||
if bytes.iter().all(u8::is_ascii_whitespace) {
|
||||
return Some("horizon-resources.json is empty".into());
|
||||
}
|
||||
if serde_json::from_slice::<serde_json::Value>(&bytes).is_err() {
|
||||
return Some("horizon-resources.json is not valid JSON".into());
|
||||
}
|
||||
for entry in std::fs::read_dir(root).ok()?.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension() != Some(std::ffi::OsStr::new("json")) {
|
||||
continue;
|
||||
}
|
||||
if std::fs::metadata(&path).is_ok_and(|meta| meta.len() == 0) {
|
||||
return Some(format!("{} is empty", entry.file_name().to_string_lossy()));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `Minecraft.<init>`: `startedCleanly` left false means the previous start
|
||||
/// died before `onGameLoadFinished`. Vanilla then leaves fullscreen
|
||||
/// ("resetting fullscreen mode") so a start that died on the display mode can
|
||||
/// come back; the resource packs are rolled back only when their load fails
|
||||
/// (`rollbackResourcePacks`), not here. True when the last start was unclean.
|
||||
pub(super) fn unclean_start_recovery(settings: &mut Settings) -> bool {
|
||||
// Vanilla's field defaults true: only an explicit `false` is an unclean
|
||||
// start; an options file that never had the key is not one.
|
||||
let unclean = matches!(
|
||||
settings.file.get("startedCleanly"),
|
||||
Some(horizon_client::options_file::V::Bool(false))
|
||||
);
|
||||
if unclean {
|
||||
settings.fullscreen = false;
|
||||
settings.file.fullscreen_resolution = None;
|
||||
}
|
||||
unclean
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
//! The window's first renderer and frames: the renderer built from the prepared textures, the
|
||||
//! boot screen's frame, and `start_game`, which turns the loaded resources into a `Game`.
|
||||
use super::*;
|
||||
|
||||
/// CPU mip chains for the block sprites and world border.
|
||||
pub(super) fn prepare_renderer_textures(
|
||||
pack: &PackResources,
|
||||
) -> horizon_client::gfx::PreparedTextures {
|
||||
let assets = &pack.render;
|
||||
horizon_client::gfx::PreparedTextures::new(
|
||||
&assets.pixels.get(),
|
||||
assets.sprite_size,
|
||||
assets.texture_layers,
|
||||
&assets.sprite_mips,
|
||||
(&pack.forcefield.2, pack.forcefield.0),
|
||||
)
|
||||
.with_pbr(
|
||||
&assets.pbr_normals,
|
||||
&assets.pbr_specular,
|
||||
assets.pbr_size,
|
||||
assets.texture_layers,
|
||||
)
|
||||
}
|
||||
|
||||
/// Upload pack textures and create the renderer on the window's device.
|
||||
pub(super) fn build_renderer(
|
||||
window: Arc<Window>,
|
||||
offscreen: bool,
|
||||
pack: &PackResources,
|
||||
gui: &UiResources,
|
||||
previous: Option<&Renderer>,
|
||||
prepared: Option<horizon_client::gfx::PreparedTextures>,
|
||||
api: horizon_client::graphics::GraphicsApi,
|
||||
translucent_batching: bool,
|
||||
// HDR with Smooth Gradients (`Graphics::wide_frame`): a 16-bit frame where HDR output is
|
||||
// offered (`Renderer::new`).
|
||||
wide_frame: bool,
|
||||
shader_pack: shader_packs::PickedPack,
|
||||
filtering: (i32, i32),
|
||||
) -> Result<Renderer> {
|
||||
let assets = &pack.render;
|
||||
let atlas = &gui.atlas;
|
||||
// The new renderer's atlas texture starts from `HudAtlas.pixels`, which
|
||||
// holds the textures but never a glyph rasterized at run time. The font's
|
||||
// cache still answers with the rects it drew into the previous renderer's
|
||||
// texture, so forget them: the glyphs that are drawn again are placed into
|
||||
// this texture (as `ui_theme` does when its atlas changes). Without this,
|
||||
// Chinese and Japanese menu labels go blank when the loading screen hands
|
||||
// over to the game.
|
||||
atlas.font.reset_cache();
|
||||
// Made here when the worker did not: the renderer's own fallback has no LabPBR maps.
|
||||
let prepared = Some(prepared.unwrap_or_else(|| prepare_renderer_textures(pack)));
|
||||
let atlas_pixels = atlas.pixels();
|
||||
let mut renderer = pollster::block_on(Renderer::new(
|
||||
window,
|
||||
offscreen,
|
||||
&assets.pixels.get(),
|
||||
assets.sprite_size,
|
||||
assets.sprite_minimum_size,
|
||||
assets.texture_layers,
|
||||
&assets.sprite_mips,
|
||||
&assets.animations,
|
||||
(&atlas_pixels, atlas.width, atlas.height),
|
||||
(&atlas.glint.0, atlas.glint.1, atlas.glint.2),
|
||||
(
|
||||
&atlas.glint_armor.0,
|
||||
atlas.glint_armor.1,
|
||||
atlas.glint_armor.2,
|
||||
),
|
||||
(&pack.forcefield.2, pack.forcefield.0),
|
||||
gui.panorama.clone(),
|
||||
prepared,
|
||||
previous,
|
||||
api,
|
||||
translucent_batching,
|
||||
wide_frame,
|
||||
shader_pack.draws_flat,
|
||||
shader_pack.linear,
|
||||
shader_pack.hdr_float,
|
||||
))?;
|
||||
renderer.hud.sprite_animations = atlas
|
||||
.sprite_animations
|
||||
.iter()
|
||||
.map(|(rect, animation)| (*rect, animation.clone(), (usize::MAX, 0, 0)))
|
||||
.collect();
|
||||
renderer
|
||||
.hud
|
||||
.set_text_shader(&renderer.device, atlas.text_shader.as_deref());
|
||||
pack.item_renderer
|
||||
.prepare_glint_atlas(renderer.device.limits().max_texture_dimension_2d, filtering)?;
|
||||
renderer.set_lines_shader(atlas.lines_shader.as_deref());
|
||||
renderer.set_custom_sky(&pack.custom_sky);
|
||||
let cit_glints = pack.item_renderer.cit.as_ref().map(|c| &c.glint_textures);
|
||||
renderer
|
||||
.hud
|
||||
.set_cit_glints(&renderer.device, &renderer.queue, cit_glints);
|
||||
Ok(renderer)
|
||||
}
|
||||
|
||||
/// The loading screen's renderer: one atlas (the boot screen's until the
|
||||
/// pack's GUI atlas is ready) over one opaque white block-sprite layer, an
|
||||
/// empty border texture and the title panorama once the menu bundle is ready.
|
||||
pub(super) fn loading_renderer(
|
||||
window: Arc<Window>,
|
||||
offscreen: bool,
|
||||
atlas: Option<&horizon_client::hud::HudAtlas>,
|
||||
panorama: Option<&Arc<horizon_client::gfx::panorama::Faces>>,
|
||||
previous: Option<&Renderer>,
|
||||
api: horizon_client::graphics::GraphicsApi,
|
||||
) -> Result<Renderer> {
|
||||
static FORCEFIELD: [u8; 4] = [0; 4];
|
||||
static EMPTY_HUD: [u8; 4] = [0, 0, 0, 255];
|
||||
static EMPTY_GLINT: [u8; 4] = [0; 4];
|
||||
let panorama = panorama
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Arc::new(horizon_client::gfx::panorama::Faces::colour([0; 4])));
|
||||
let (pixels, width, height) = atlas.map_or((EMPTY_HUD.as_slice(), 1, 1), |a| {
|
||||
(a.pixels.as_slice(), a.width, a.height)
|
||||
});
|
||||
let (glint_pixels, glint_width, glint_height) = atlas
|
||||
.map_or((EMPTY_GLINT.as_slice(), 1, 1), |a| {
|
||||
(a.glint.0.as_slice(), a.glint.1, a.glint.2)
|
||||
});
|
||||
// A glyph rasterized into an earlier texture is not in these pixels; the
|
||||
// new texture gets it again when the character is drawn (see
|
||||
// `build_renderer`).
|
||||
if let Some(atlas) = atlas {
|
||||
atlas.font.reset_cache();
|
||||
}
|
||||
let mut renderer = pollster::block_on(Renderer::new(
|
||||
window,
|
||||
offscreen,
|
||||
&[255, 255, 255, 255],
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
&[],
|
||||
&[],
|
||||
(pixels, width, height),
|
||||
(glint_pixels, glint_width, glint_height),
|
||||
(glint_pixels, glint_width, glint_height),
|
||||
(&FORCEFIELD[..], 1),
|
||||
panorama,
|
||||
None,
|
||||
previous,
|
||||
api,
|
||||
// It draws no terrain.
|
||||
false,
|
||||
// An 8-bit frame, which HDR output takes as well.
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
))?;
|
||||
if let Some(atlas) = atlas {
|
||||
renderer.hud.sprite_animations = atlas
|
||||
.sprite_animations
|
||||
.iter()
|
||||
.map(|(rect, animation)| (*rect, animation.clone(), (usize::MAX, 0, 0)))
|
||||
.collect();
|
||||
}
|
||||
Ok(renderer)
|
||||
}
|
||||
|
||||
pub(super) fn loading_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
Frame {
|
||||
weather: &[],
|
||||
weather_time: horizon_client::weather::Time::new(0, 0.0),
|
||||
camera: glam::DVec3::new(0.5, 100.0, 0.5),
|
||||
yaw: 0.0,
|
||||
pitch: 10.0,
|
||||
fov_degrees: 85.0,
|
||||
eye: glam::DVec3::new(0.5, 100.0, 0.5),
|
||||
eye_rotation: [0.0, 10.0],
|
||||
first_person: true,
|
||||
bob: glam::Mat4::IDENTITY,
|
||||
spin: glam::Mat4::IDENTITY,
|
||||
sky: horizon_client::environment::Sky::default(),
|
||||
shadow_scene_revision: (0, 0),
|
||||
shadow_cache: false,
|
||||
distance: 12.0,
|
||||
detail_distance: 12.0,
|
||||
depth_far: 1000.0,
|
||||
underwater: false,
|
||||
water_plane: None,
|
||||
cave_culling: false,
|
||||
title: true,
|
||||
sky_blocked: false,
|
||||
skybox: false,
|
||||
end_sky: false,
|
||||
end_sky_sprite: None,
|
||||
boss_world_fog: false,
|
||||
environment_fog: None,
|
||||
water_fog: [0.0; 3],
|
||||
fog: None,
|
||||
dark_disc: false,
|
||||
outline: Vec::new(),
|
||||
outline_after_terrain: false,
|
||||
outline_high_contrast: false,
|
||||
pvp: Default::default(),
|
||||
debug_lines: &[],
|
||||
debug_lines_on_top: &[],
|
||||
debug_crosshair: &[],
|
||||
hud,
|
||||
glint_alpha: 0.75,
|
||||
glint_ticks: 0.0,
|
||||
nether_lighting: false,
|
||||
entities: &[],
|
||||
static_block_entities: &[],
|
||||
visible_static_block_entity_sections: &[],
|
||||
static_block_entity_stats: Default::default(),
|
||||
entity_motion: &[],
|
||||
entity_motion_posed: &[],
|
||||
entity_spans: &[],
|
||||
skin: None,
|
||||
glowing: &[],
|
||||
dynamic_lights: &[],
|
||||
colored_lights: &[],
|
||||
emissive_layers: &[],
|
||||
clouds: None,
|
||||
lightning: Default::default(),
|
||||
additive: &[],
|
||||
see_through: &[],
|
||||
world_primitives: Default::default(),
|
||||
shadow_only: &[],
|
||||
celestial: &[],
|
||||
stars: None,
|
||||
hand: &[],
|
||||
hand_items: &[],
|
||||
hand_fov_degrees: 70.0,
|
||||
screen: &[],
|
||||
pack: Default::default(),
|
||||
border: None,
|
||||
game_time: 0.0,
|
||||
shader_game_time: 0.0,
|
||||
end_portal_sprites: [[0.0; 4]; 2],
|
||||
crumbling_sprite: [0.0; 4],
|
||||
ticks: 0,
|
||||
darkness: (0.0, 0.0),
|
||||
night_vision: 0.0,
|
||||
block_flicker: 0.0,
|
||||
boss_darken: 0.0,
|
||||
chunk_fade: 0.0,
|
||||
post_effects: horizon_client::post_chain::Request::none(),
|
||||
menu_blur: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The post-load setup once the resources are ready: `Game::new` and the
|
||||
/// options, packs, mods, control socket, benchmark and world selection the
|
||||
/// old `run` did after loading. `None` means a harness or dedicated run
|
||||
/// finished here instead.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn start_game<'a>(
|
||||
res: &'a Arc<Resources>,
|
||||
pack: PackResources,
|
||||
gui: UiResources,
|
||||
options: Options,
|
||||
pack_error: Option<String>,
|
||||
audio: Audio,
|
||||
vanilla_root: PathBuf,
|
||||
started: Instant,
|
||||
rest_loader: Option<WorldRestLoader>,
|
||||
settings: Option<Settings>,
|
||||
prepared: Option<GameCpuResources>,
|
||||
entity_models: Option<Arc<horizon_client::mobs::EntityModelDocument>>,
|
||||
) -> Result<Option<Game<'a>>> {
|
||||
let handoff = prepared.is_some();
|
||||
let mut settings = match settings {
|
||||
Some(settings) => settings,
|
||||
None => Settings::load(&Game::options_path()),
|
||||
};
|
||||
if let Some(d) = options.distance_override {
|
||||
settings.render_distance = d;
|
||||
}
|
||||
// A synchronous launch has no handoff worker: build its CPU resources
|
||||
// here, from the same entity-model document the rest worker read.
|
||||
let prepared = match prepared {
|
||||
Some(prepared) => Some(prepared),
|
||||
None => Some(GameCpuResources::load(
|
||||
&options.resources,
|
||||
entity_models.as_deref(),
|
||||
)?),
|
||||
};
|
||||
let mut game = Game::new(res, pack, gui, options, settings, audio, prepared)?;
|
||||
game.rest_loader = rest_loader;
|
||||
game.ui.vanilla_root = vanilla_root;
|
||||
if let Some(error) = pack_error {
|
||||
// Minecraft.clearResourcePacksOnError: the selection is cleared and
|
||||
// saved, so the next launch starts without the packs.
|
||||
game.settings.resource_packs.clear();
|
||||
game.settings.save(&Game::options_path());
|
||||
game.open_options_at(true, horizon_client::options_ui::Page::Packs);
|
||||
game.ui.options.resource_error = Some(error);
|
||||
}
|
||||
if game.options.benchmark.is_some() {
|
||||
// The same conditions on every machine: no VSync, no frame cap, no
|
||||
// pause or idle limit without focus. In memory only, never saved.
|
||||
game.settings.vsync = false;
|
||||
game.settings.max_fps = 260;
|
||||
game.settings.inactivity_fps_limit = "minimized".into();
|
||||
game.options.no_pause = true;
|
||||
}
|
||||
game.apply_settings();
|
||||
// Benchmarks measure the game alone.
|
||||
if game.options.benchmark.is_none() {
|
||||
game.mods.dev = game.options.dev_mods.clone();
|
||||
game.start_mods();
|
||||
}
|
||||
// After the mods: their templates are among the ids it lists.
|
||||
game.preload_registries();
|
||||
game.start_agent_control()?;
|
||||
if let Some(scenario) = game.options.benchmark.clone() {
|
||||
let control = game
|
||||
.control
|
||||
.get_or_insert_with(horizon_client::control::Server::local);
|
||||
horizon_client::benchmark::start(
|
||||
control.sender(),
|
||||
horizon_client::benchmark::Setup {
|
||||
scenario,
|
||||
started,
|
||||
app: horizon_bench::App {
|
||||
name: "horizon-client".into(),
|
||||
version: horizon_client::crash::version(),
|
||||
commit: option_env!("HORIZON_COMMIT").map(str::to_owned),
|
||||
mods: Vec::new(),
|
||||
},
|
||||
simulation_distance: game.settings.simulation_distance as u32,
|
||||
vsync: false,
|
||||
fps_cap: None,
|
||||
offscreen: game.options.offscreen,
|
||||
game_dir: std::env::current_dir()?,
|
||||
},
|
||||
);
|
||||
}
|
||||
if let Some(directory) = std::env::var_os("HORIZON_NATIVE_CLIENT_PARTICLES_FIXTURE") {
|
||||
game.run_native_client_particles_fixture(std::path::Path::new(&directory))?;
|
||||
return Ok(None);
|
||||
}
|
||||
if let Some(directory) = std::env::var_os("HORIZON_BOAT_RIDER_FIXTURE") {
|
||||
game.run_boat_rider_fixture(std::path::Path::new(&directory))?;
|
||||
return Ok(None);
|
||||
}
|
||||
if let Some(directory) = std::env::var_os("HORIZON_BOAT_AUTHORITY_FIXTURE") {
|
||||
game.run_boat_authority_fixture(std::path::Path::new(&directory))?;
|
||||
return Ok(None);
|
||||
}
|
||||
if let Ok(spec) = std::env::var("HORIZON_DISPENSE_FIXTURE") {
|
||||
game.run_component_harness(integrated::ComponentHarness::Dispenser(spec))?;
|
||||
return Ok(None);
|
||||
}
|
||||
if let Ok(spec) = std::env::var("HORIZON_SCULK_FIXTURE") {
|
||||
game.run_component_harness(integrated::ComponentHarness::Sculk(spec))?;
|
||||
return Ok(None);
|
||||
}
|
||||
if game.options.execution_role == execution_role::ExecutionRole::DedicatedServer {
|
||||
game.run_dedicated()?;
|
||||
return Ok(None);
|
||||
}
|
||||
match game.options.world.clone() {
|
||||
_ if game.options.server.is_some() => {
|
||||
let (address, name) = (
|
||||
game.options.server.clone().unwrap_or_default(),
|
||||
game.options.player_name(),
|
||||
);
|
||||
game.connect_server(&address, &name)?;
|
||||
if let Some(report) = std::env::var_os("HORIZON_NET_REPORT") {
|
||||
game.run_net_harness(report.as_ref())?;
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
Some(folder) => {
|
||||
let (seed, creative) = (game.options.seed, game.options.creative);
|
||||
if std::env::var_os("HORIZON_COMMAND_SCRIPT").is_some() {
|
||||
// The loaded-command fixture pins the server's valid minimum.
|
||||
// Client options reject simulationDistance2 (their UI starts at5),
|
||||
// so an options.txt value cannot establish this prerequisite.
|
||||
if std::env::var_os("HORIZON_HARNESS_LOADED_FIXTURE").is_some() {
|
||||
game.settings.simulation_distance = 2;
|
||||
}
|
||||
// Headless, but with the same server and memory connection as play.
|
||||
game.open_integrated(&folder, None, Some(seed), creative, None)?;
|
||||
} else {
|
||||
// Start after the window is handed over, so --world uses the
|
||||
// same responsive initialization path as the title screen.
|
||||
game.title = Some(
|
||||
Title::new(
|
||||
std::env::current_dir()?.join("saves"),
|
||||
&game.options.resources,
|
||||
)
|
||||
.with_user(|| game.options.player_name()),
|
||||
);
|
||||
game.pending_entry = Some(PendingWorldEntry::Local {
|
||||
folder,
|
||||
name: None,
|
||||
seed: Some(seed),
|
||||
creative,
|
||||
create: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
None if handoff => {}
|
||||
None => {
|
||||
let mut title = Title::new(
|
||||
std::env::current_dir()?.join("saves"),
|
||||
&game.options.resources,
|
||||
)
|
||||
.with_user(|| game.options.player_name());
|
||||
title.last_server = game.settings.file.string("lastServer");
|
||||
title.skip_multiplayer_warning = game.settings.file.bool("skipMultiplayerWarning");
|
||||
// Gui.addInitialScreens: the accessibility onboarding comes first.
|
||||
if game.settings.file.bool("onboardAccessibility") {
|
||||
title.start_onboarding();
|
||||
}
|
||||
game.title = Some(title);
|
||||
}
|
||||
}
|
||||
if std::env::var_os("HORIZON_COMMAND_SCRIPT").is_some()
|
||||
&& std::env::var_os("HORIZON_COMMAND_OUTPUT").is_some()
|
||||
{
|
||||
game.wait_command_harness()?;
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(game))
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
//! A player's melee attack (`Player.attack`): the attack strength, critical and sweep attacks, and
|
||||
//! what a hit on a target that is not a mob does.
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Player.getAttackStrengthScale(0.5)`.
|
||||
pub(super) fn attack_strength_scale(&self) -> f32 {
|
||||
// 1.8.9 had no attack cooldown.
|
||||
if self.legacy_rules() {
|
||||
return 1.0;
|
||||
}
|
||||
let delay = horizon_client::attack_strength::delay(self.main_hand_attack_speed());
|
||||
((self.attack_ticks as f32 + 0.5) / delay).clamp(0.0, 1.0)
|
||||
}
|
||||
/// `Player.isSweepAttack`: a charged, grounded sword hit below the movement limit.
|
||||
pub(super) fn sweep_attack_position(&mut self, scale: f32, crit: bool) -> Option<[f64; 3]> {
|
||||
// The sweep came with 1.9.
|
||||
if self.legacy_rules() {
|
||||
return None;
|
||||
}
|
||||
// Player.getSpeed: the movement speed attribute as it is now (the
|
||||
// field is only refreshed by the local movement step, never for a
|
||||
// server's player).
|
||||
let p = self.player.as_mut()?;
|
||||
if let Ok(speed) = p.attribute("minecraft:movement_speed") {
|
||||
p.speed = speed as f32;
|
||||
}
|
||||
let p = self.player.as_ref()?;
|
||||
let sword = self.held().is_some_and(|s| {
|
||||
self.res
|
||||
.items()
|
||||
.item_tags
|
||||
.get("minecraft:swords")
|
||||
.is_some_and(|tag| tag.contains(&*s.item))
|
||||
});
|
||||
if !p.is_sweep_attack(scale, crit, sword) {
|
||||
return None;
|
||||
}
|
||||
// Mth.sin / Mth.cos of yRot * (PI / 180), from its sine table.
|
||||
use horizon_core::geometry::trig;
|
||||
let yaw = f64::from(self.yaw * trig::DEG_TO_RAD);
|
||||
Some([
|
||||
p.position.x - f64::from(trig::sin(yaw)),
|
||||
p.position.y + f64::from(p.dimensions.height) * 0.5,
|
||||
p.position.z + f64::from(trig::cos(yaw)),
|
||||
])
|
||||
}
|
||||
/// `Player.doSweepAttack` after a successful hit, in vanilla's order: the
|
||||
/// sweep sound, the victims around `target` (its box, and the mob that
|
||||
/// was hit, when there is one) and then the arc.
|
||||
pub(super) fn sweep_attack(
|
||||
&mut self,
|
||||
at: [f64; 3],
|
||||
from: Option<[f64; 3]>,
|
||||
target: Option<(Aabb, mace::Spared)>,
|
||||
scale: f32,
|
||||
) {
|
||||
self.audio
|
||||
.play("minecraft:entity.player.attack.sweep", from, 1.0, 1.0);
|
||||
if let Some((area, mob)) = target {
|
||||
self.sweep_damage(area, mob, scale);
|
||||
}
|
||||
let arc = horizon_client::particles::ParticleOptions::simple(
|
||||
horizon_client::particles::ParticleType::SweepAttack,
|
||||
);
|
||||
self.particles.add(&arc, at, [0.0; 3]);
|
||||
// Player.sweepAttack's `sendParticles(SWEEP_ATTACK, ..., 0, dx, 0, dz, 0)`:
|
||||
// the offset is the one from the player to the particle.
|
||||
let origin = from.unwrap_or(at);
|
||||
let spread = [at[0] - origin[0], 0.0, at[2] - origin[2]];
|
||||
self.lan_send_particles(lan_particles::QueuedParticle {
|
||||
dimension: self.dim.id(),
|
||||
options: arc,
|
||||
state: None,
|
||||
at,
|
||||
spread,
|
||||
speed: [0.0; 3],
|
||||
count: 0,
|
||||
randomization: 0,
|
||||
});
|
||||
}
|
||||
|
||||
/// `Player.attack` after a target that is not a mob took the hit:
|
||||
/// the sprint knockback sound, a living target's critical hit (`living`:
|
||||
/// its entity id; `crit`: whether `melee_damage` decided the hit critical,
|
||||
/// mods' `critical_hit` included), the sweep around the target's box `area`
|
||||
/// and the strong or weak sound.
|
||||
pub(super) fn attack_effects(
|
||||
&mut self,
|
||||
area: Aabb,
|
||||
living: Option<i32>,
|
||||
crit: bool,
|
||||
spared: mace::Spared,
|
||||
magic: Option<i32>,
|
||||
) {
|
||||
let scale = self.attack_strength_scale();
|
||||
let full = scale > 0.9;
|
||||
let player = self.player.as_ref();
|
||||
let from = player.map(|p| [p.position.x, p.position.y, p.position.z]);
|
||||
if full && player.is_some_and(|p| p.sprinting) {
|
||||
self.audio
|
||||
.play("minecraft:entity.player.attack.knockback", from, 1.0, 1.0);
|
||||
}
|
||||
let crit = living.is_some() && crit;
|
||||
let sweep = self.sweep_attack_position(scale, crit);
|
||||
if let Some(at) = sweep {
|
||||
self.sweep_attack(at, from, Some((area, spared)), scale);
|
||||
}
|
||||
if crit {
|
||||
self.audio
|
||||
.play("minecraft:entity.player.attack.crit", from, 1.0, 1.0);
|
||||
if let Some(id) = living {
|
||||
self.area_critical_hit(id, area, false);
|
||||
}
|
||||
} else if sweep.is_none() {
|
||||
let sound = if full {
|
||||
"minecraft:entity.player.attack.strong"
|
||||
} else {
|
||||
"minecraft:entity.player.attack.weak"
|
||||
};
|
||||
self.audio.play(sound, from, 1.0, 1.0);
|
||||
}
|
||||
// Player.magicCrit: the enchantments added damage.
|
||||
if let Some(id) = magic {
|
||||
self.area_critical_hit(id, area, true);
|
||||
}
|
||||
}
|
||||
/// `critical_hit` around the box `area` of entity `id` (`magic`: the
|
||||
/// enchanted hit instead of the crit).
|
||||
pub(super) fn area_critical_hit(&mut self, id: i32, area: Aabb, magic: bool) {
|
||||
let (feet, size) = (area.bottom_center(), area.size());
|
||||
self.critical_hit(
|
||||
self.lan_acting,
|
||||
id,
|
||||
[feet.x, feet.y, feet.z],
|
||||
size.x,
|
||||
size.y,
|
||||
magic,
|
||||
);
|
||||
}
|
||||
/// `Player.canCriticalAttack` for a living target at attack strength
|
||||
/// `scale`: falling, off the ground, not in water, not on a ladder, not blind (mobility
|
||||
/// restricted), not riding, not sprinting.
|
||||
pub(super) fn critical_attack(&self, scale: f32) -> bool {
|
||||
let player = self.player.as_ref();
|
||||
let falling = player.is_some_and(|p| {
|
||||
!p.ground.on_ground
|
||||
&& p.fall_distance > 0.0
|
||||
&& !p.fluids.was_touching_water
|
||||
&& !p.on_climbable
|
||||
&& !p.sprinting
|
||||
});
|
||||
let restricted = self.potions.effects.get("blindness").is_some() || self.riding.is_some();
|
||||
scale > 0.9 && falling && !restricted
|
||||
}
|
||||
/// Player.attack with the attack-strength charge, crits and knockback.
|
||||
pub(super) fn attack(&mut self) -> bool {
|
||||
if self.spectator() {
|
||||
return false;
|
||||
}
|
||||
// A painting nearer than the targeted block and mob breaks instead.
|
||||
if let Some((i, d)) = self.painting_hit()
|
||||
&& self.mob_hit().is_none_or(|m| d < m.1)
|
||||
&& self.before_block(d)
|
||||
{
|
||||
self.break_painting(i, true);
|
||||
return true;
|
||||
}
|
||||
// A ghast's fireball or a wind charge in front: turned back.
|
||||
if self.attack_redirectable() {
|
||||
return true;
|
||||
}
|
||||
if self.attack_frame()
|
||||
|| self.attack_armor_stand()
|
||||
|| self.attack_cushion()
|
||||
|| self.attack_boat()
|
||||
|| self.attack_cart()
|
||||
|| self.attack_dragon()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let Some((index, distance, _)) = self.mob_hit() else {
|
||||
return false;
|
||||
};
|
||||
if !self.before_block(distance) || !self.held_range_allows(distance) {
|
||||
return false;
|
||||
}
|
||||
self.attack_mob(index);
|
||||
// Minecraft.startAttack swings after the attack.
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -2866,23 +2866,30 @@ impl Minecart {
|
||||
c.no_gravity = horizon_client::entity_nbt::flag(tag, "NoGravity");
|
||||
c.pitch = horizon_client::entity_nbt::rotation(tag)[1];
|
||||
c.previous_pitch = c.pitch;
|
||||
let int = |k: &str| match tag.get(k) {
|
||||
Some(Tag::Byte(v)) => Some(i32::from(*v)),
|
||||
Some(Tag::Short(v)) => Some(i32::from(*v)),
|
||||
Some(Tag::Int(v)) => Some(*v),
|
||||
_ => None,
|
||||
// ValueInput's numeric getters take any NumericTag: getIntOr is its
|
||||
// intValue, getShortOr the low 16 bits of it, getBooleanOr
|
||||
// `byteValue() != 0` (the low 8), getDoubleOr its doubleValue.
|
||||
let int = |k: &str| {
|
||||
tag.get(k)
|
||||
.and_then(horizon_client_core::nbt_path::int_value)
|
||||
};
|
||||
let flag = |k: &str, default: bool| int(k).map_or(default, |v| v as i8 != 0);
|
||||
let double = |k: &str| match tag.get(k) {
|
||||
Some(Tag::Byte(v)) => f64::from(*v),
|
||||
Some(Tag::Short(v)) => f64::from(*v),
|
||||
Some(Tag::Int(v)) => f64::from(*v),
|
||||
Some(Tag::Long(v)) => *v as f64,
|
||||
Some(Tag::Float(v)) => f64::from(f32::from_bits(*v)),
|
||||
Some(Tag::Double(v)) => f64::from_bits(*v),
|
||||
_ => 0.0,
|
||||
};
|
||||
c.flipped = int("FlippedRotation").unwrap_or(0) != 0;
|
||||
c.first_tick = int("HasTicked").unwrap_or(0) != 0;
|
||||
c.flipped = flag("FlippedRotation", false);
|
||||
c.first_tick = flag("HasTicked", false);
|
||||
if kind.slots() > 0 {
|
||||
c.items = horizon_client::save::decode_items(tag.get("Items"), kind.slots());
|
||||
}
|
||||
c.enabled = int("Enabled").unwrap_or(1) != 0;
|
||||
c.fuel = int("Fuel").unwrap_or(0);
|
||||
c.enabled = flag("Enabled", true);
|
||||
c.fuel = int("Fuel").map_or(0, |v| i32::from(v as i16));
|
||||
// readAdditionalSaveData: the saved push has no y.
|
||||
c.push = Vec3::new(double("PushX"), 0.0, double("PushZ"));
|
||||
c.fuse = int("fuse").unwrap_or(-1);
|
||||
|
||||
@@ -18,7 +18,7 @@ impl Game<'_> {
|
||||
/// or the server stops, not saved: keep what you hold (a level
|
||||
/// attachment) and hold it again in `native_level_load`. True when the
|
||||
/// set of owners changed; false for an owner without a namespace, a chunk
|
||||
/// past the world's edge (as `/forceload` refuses it) and off a server. API @API:chunk_tickets@.
|
||||
/// past the world's edge (as `/forceload` refuses it) and off a server. API 412.
|
||||
pub(super) fn native_force_chunk(&mut self, owner: &str, chunk: [i32; 2], hold: bool) -> bool {
|
||||
self.native_chunk_ticket(self.dim, owner, chunk, hold)
|
||||
}
|
||||
@@ -28,7 +28,7 @@ impl Game<'_> {
|
||||
/// level held this way opens at the next server tick and ticks from then
|
||||
/// on, so a mod can generate or run the Nether and the End from any
|
||||
/// server hook. False also for a dimension other than the Overworld, the
|
||||
/// Nether and the End. API @API:chunk_tickets_in_level@.
|
||||
/// Nether and the End. API 413.
|
||||
pub(super) fn native_force_chunk_in(
|
||||
&mut self,
|
||||
owner: &str,
|
||||
@@ -103,7 +103,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// The chunks of the level in place `owner` holds (`native_force_chunk`),
|
||||
/// sorted. API @API:chunk_tickets@.
|
||||
/// sorted. API 412.
|
||||
pub(super) fn native_forced_chunks(&self, owner: &str) -> Vec<[i32; 2]> {
|
||||
let mut held: Vec<[i32; 2]> = self
|
||||
.mod_chunk_tickets
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
//! Horizon's own HUD extras do, and keep a mod's settings where the
|
||||
//! WebAssembly mod of the same id kept them.
|
||||
//!
|
||||
//! API 413 adds chunk tickets in a level nobody is in (`native_force_chunk_in`).
|
||||
//!
|
||||
//! API 412 adds chunks a mod keeps loaded and ticking (`native_force_chunk`, `native_forced_chunks`).
|
||||
//!
|
||||
//! API 411 adds client Host services, GUI painting, model-head snapshots and client lifecycle notifications.
|
||||
//!
|
||||
//! API 410 adds a final foreground GUI overlay (`native_gui_render_post`) and the current frame's camera during GUI rendering.
|
||||
@@ -1167,7 +1171,7 @@ pub(super) use horizon_world::level::flags;
|
||||
|
||||
/// The hook points' API version. The launcher reads this line from the
|
||||
/// source it builds; keep its form.
|
||||
pub(crate) const NATIVE_HOOKS_API: u32 = 411;
|
||||
pub(crate) const NATIVE_HOOKS_API: u32 = 413;
|
||||
|
||||
impl GameLevel<'_> {
|
||||
/// A hopper at `pos` is about to pick up `count` of `item`. Set `cancel` to leave the dropped stack in the world. Server-side. 232.
|
||||
|
||||
@@ -654,9 +654,21 @@ impl Game<'_> {
|
||||
before.clear();
|
||||
}
|
||||
let mut ids: Vec<(u32, bool)> = Vec::new();
|
||||
// ServerLevel.tick ticks only the entities in entity-ticking chunks,
|
||||
// and handlePortal runs in their tick: past the simulation distance an
|
||||
// entity's portal time and cooldown wait, kept as they are.
|
||||
let entity_ticking = self.entity_ticking_area();
|
||||
let mut waiting: Vec<u32> = Vec::new();
|
||||
let tracked = &self.portals.entities;
|
||||
for_each_entity!(entity_lists_of!(self), entity => {
|
||||
if entity.portal_size().is_some() && entity.is_vehicle() == vehicles {
|
||||
let at = entity.position();
|
||||
if !entity_ticking(at.x.floor() as i32, at.z.floor() as i32) {
|
||||
if tracked.contains_key(&entity.id()) {
|
||||
waiting.push(entity.id());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if entity.portal_from().is_none() {
|
||||
before.insert(entity.id(), entity.position());
|
||||
}
|
||||
@@ -666,7 +678,11 @@ impl Game<'_> {
|
||||
}
|
||||
});
|
||||
self.portals.before = before;
|
||||
let present: std::collections::HashSet<u32> = ids.iter().map(|(id, _)| *id).collect();
|
||||
let present: std::collections::HashSet<u32> = ids
|
||||
.iter()
|
||||
.map(|(id, _)| *id)
|
||||
.chain(waiting.iter().copied())
|
||||
.collect();
|
||||
// Each pass keeps the other group's states.
|
||||
let (boats, carts) = (&self.boats, &self.carts);
|
||||
self.portals.entities.retain(|id, _| {
|
||||
@@ -840,16 +856,22 @@ impl Game<'_> {
|
||||
}
|
||||
let mut inside: Vec<(u32, BlockPos)> = Vec::new();
|
||||
let mut portal_sections = std::mem::take(&mut self.portals.sections);
|
||||
// Only the entities that tick (in entity-ticking chunks) move, so only
|
||||
// they can have swept into a portal.
|
||||
let entity_ticking = self.entity_ticking_area();
|
||||
// The first portal block each entity's box swept through since the
|
||||
// start of its tick (a stuck arrow that did not move enters none).
|
||||
for_each_entity!(entity_lists_of!(self), entity => {
|
||||
let Some((width, height)) = entity.portal_size() else {
|
||||
continue;
|
||||
};
|
||||
let (id, to) = (entity.id(), entity.position());
|
||||
if !entity_ticking(to.x.floor() as i32, to.z.floor() as i32) {
|
||||
continue;
|
||||
}
|
||||
if !entity.portal_sweeps() || !entity.can_use_portal(self) {
|
||||
continue;
|
||||
}
|
||||
let (id, to) = (entity.id(), entity.position());
|
||||
let swept_later = match only {
|
||||
Some(ids) => !ids.contains(&id),
|
||||
None => ridden.contains(&id),
|
||||
|
||||
@@ -790,7 +790,7 @@ impl Game<'_> {
|
||||
packed,
|
||||
out,
|
||||
);
|
||||
if horizon_cosmetics::enabled() {
|
||||
if super::cosmetics::enabled() {
|
||||
let base = horizon_client::mobs::render::base_transform(m, alpha as f32, camera);
|
||||
self.cosmetics_geometry(
|
||||
m,
|
||||
@@ -916,7 +916,7 @@ impl Game<'_> {
|
||||
240 | 240 << 8,
|
||||
&mut verts,
|
||||
);
|
||||
if horizon_cosmetics::enabled() {
|
||||
if super::cosmetics::enabled() {
|
||||
let base = horizon_client::mobs::render::base_transform(&m, 1.0, camera);
|
||||
self.cosmetics_geometry(&m, true, base, None, 1.0, 240 | 240 << 8, false, &mut verts);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
//! Key and mouse mappings in play: toggled sneak and sprint, keys after rebinding, keys held
|
||||
//! across screens, the hotbar's wheel and bumper steps, and mouse buttons while playing.
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
/// Keep the held-key state of toggled sneak and sprint in step with their mapping.
|
||||
/// `KeyMapping.resetToggleKeys`: every toggle mapping up, and the latched sneak and sprint
|
||||
/// keys with it.
|
||||
pub(super) fn reset_toggle_keys(&mut self) {
|
||||
for key in &mut self.toggles.0 {
|
||||
key.reset();
|
||||
}
|
||||
self.sync_toggle(0);
|
||||
self.sync_toggle(1);
|
||||
self.left = false;
|
||||
self.right = false;
|
||||
}
|
||||
/// key.smoothCamera from any device toggles the cinematic camera in play.
|
||||
pub(super) fn smooth_camera_action(&mut self) {
|
||||
if self.screen == Screen::Playing {
|
||||
self.smooth_camera = !self.smooth_camera;
|
||||
}
|
||||
}
|
||||
pub(super) fn sync_toggle(&mut self, slot: usize) {
|
||||
let key = [KeyCode::ShiftLeft, KeyCode::ControlLeft][slot];
|
||||
if self.toggles.is_down(slot) {
|
||||
self.keys.insert(key);
|
||||
} else {
|
||||
self.keys.remove(&key);
|
||||
}
|
||||
}
|
||||
/// Let a held sneak, sprint or modifier key go when the keyboard reports
|
||||
/// its modifier up. A release does not always reach the mapping: a screen
|
||||
/// reads presses only, and an unfocused window is given no release at all,
|
||||
/// so the latch would keep the player sneaking or sprinting until the key
|
||||
/// is tapped again. The modifier state is what the keyboard itself
|
||||
/// asserts, so it settles the question. A mapping that toggles, or one a
|
||||
/// pad latched, keeps its state: `set_down(false, needs_toggle)` leaves
|
||||
/// those alone, and a toggle the player asked for is meant to outlive the
|
||||
/// key. A mapping rebound away from its modifier is left alone too: the
|
||||
/// modifier says nothing about it, and so is one a controller holds.
|
||||
///
|
||||
/// Only `physical_keys` is cleared directly here; the logical `self.keys`
|
||||
/// entries for sneak and sprint are left to the toggle loop below, because
|
||||
/// they stand for the mapping and not the physical Shift or Control.
|
||||
/// `Screen.hasShiftDown` and `hasControlDown` read the physical state
|
||||
/// (vanilla's `InputConstants.isKeyDown`), so a Shift no one is holding
|
||||
/// makes every inventory click a shift-click and every throw a whole-stack
|
||||
/// drop. Those do not care what sneak and sprint are bound to. A modifier
|
||||
/// says nothing about which side is down, so one being up means both
|
||||
/// Shifts, or both Controls, are.
|
||||
pub(super) fn heal_hold_latches(&mut self) {
|
||||
let (shift, control) = (self.modifiers.shift_key(), self.modifiers.control_key());
|
||||
if !shift {
|
||||
for key in [KeyCode::ShiftLeft, KeyCode::ShiftRight] {
|
||||
self.physical_keys.remove(&key);
|
||||
}
|
||||
}
|
||||
if !control {
|
||||
for key in [KeyCode::ControlLeft, KeyCode::ControlRight] {
|
||||
self.physical_keys.remove(&key);
|
||||
}
|
||||
}
|
||||
for (slot, mapping, needs_toggle) in [
|
||||
(0, "key.sneak", self.settings.toggle_crouch),
|
||||
(1, "key.sprint", self.settings.toggle_sprint),
|
||||
] {
|
||||
let held = match horizon_client::keybinds::key_code(self.settings.keys.get(mapping)) {
|
||||
Some(KeyCode::ShiftLeft | KeyCode::ShiftRight) => shift,
|
||||
Some(KeyCode::ControlLeft | KeyCode::ControlRight) => control,
|
||||
_ => continue,
|
||||
};
|
||||
// A controller's stick click acts as the key without setting the modifier: it is held
|
||||
// while the pad holds it (or its release waits for a tick), not when the keyboard says.
|
||||
let pad = self.pad.holds_action(&self.settings.controller, mapping);
|
||||
if !held && !pad {
|
||||
self.toggles.0[slot].set_down(false, needs_toggle);
|
||||
self.sync_toggle(slot);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Whether the other device still holds the sneak or sprint mapping `source` released.
|
||||
pub(super) fn toggle_held_elsewhere(&self, slot: usize, key: KeyCode, source: Source) -> bool {
|
||||
match source {
|
||||
// A pad's toggle-sneak isn't holding it: it has no release that would end it.
|
||||
Source::Keyboard => {
|
||||
!(slot == SNEAK && self.settings.controller.toggle_sneak)
|
||||
&& self
|
||||
.pad
|
||||
.holds_action(&self.settings.controller, ["key.sneak", "key.sprint"][slot])
|
||||
}
|
||||
Source::Pad => self.physical_keys.iter().any(|physical| {
|
||||
self.settings
|
||||
.keys
|
||||
.translate_all(*physical)
|
||||
.contains(&Ok(key))
|
||||
}),
|
||||
Source::Agent => false,
|
||||
}
|
||||
}
|
||||
/// Apply a physical key to every mapping bound to it.
|
||||
pub(super) fn mapped_key_event(
|
||||
&mut self,
|
||||
key: KeyCode,
|
||||
pressed: bool,
|
||||
repeat: bool,
|
||||
) -> Result<()> {
|
||||
for mapped in self.settings.keys.translate_all(key) {
|
||||
match mapped {
|
||||
Ok(k) => {
|
||||
self.mapped_key(k, pressed, repeat, key, Source::Keyboard)?;
|
||||
}
|
||||
Err(button) => {
|
||||
let action = match (pressed, repeat) {
|
||||
(false, _) => KeyAction::Release,
|
||||
(_, false) => KeyAction::Press,
|
||||
(_, true) => KeyAction::Repeat,
|
||||
};
|
||||
if let Some(pressed) = keyboard_button(
|
||||
gameplay_has_keyboard(
|
||||
self.screen,
|
||||
self.title.is_some(),
|
||||
self.screen_over_playing(),
|
||||
),
|
||||
action,
|
||||
) {
|
||||
if self.shared_default_acts(button, horizon_client::keybinds::key_name(key))
|
||||
{
|
||||
self.mouse_mapping(button, pressed, Source::Keyboard)?;
|
||||
}
|
||||
} else if container_key_clones(button, action) {
|
||||
self.inventory_pick_key();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// A key event after rebinding, as the default key of its mapping (the
|
||||
/// keyboard and controllers both end here). `raw` is the physical key
|
||||
/// the event started from (`key` where the caller started from no window
|
||||
/// key): the screens act in that keyspace (`Screen.keyPressed`), where
|
||||
/// `key`, the vanilla default the mapping translated to, may itself be
|
||||
/// another mapping's rebound key. `source` is the device the event came
|
||||
/// from: a key stays held while any device holds it.
|
||||
pub(super) fn mapped_key(
|
||||
&mut self,
|
||||
key: KeyCode,
|
||||
pressed: bool,
|
||||
repeat: bool,
|
||||
raw: KeyCode,
|
||||
source: Source,
|
||||
) -> Result<()> {
|
||||
// Sneak and sprint are ToggleKeyMappings: a press (or a key
|
||||
// repeat) flips them when toggling; only releases reach them
|
||||
// behind a screen (KeyboardHandler.keyPress).
|
||||
let toggle = match key {
|
||||
KeyCode::ShiftLeft => Some((0, self.settings.toggle_crouch)),
|
||||
KeyCode::ControlLeft => Some((1, self.settings.toggle_sprint)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some((slot, option)) = toggle {
|
||||
let action = match (pressed, repeat) {
|
||||
(false, _) => KeyAction::Release,
|
||||
(_, false) => KeyAction::Press,
|
||||
(_, true) => KeyAction::Repeat,
|
||||
};
|
||||
// One device letting go doesn't let go for the other still holding it.
|
||||
if !pressed && self.toggle_held_elsewhere(slot, key, source) {
|
||||
return Ok(());
|
||||
}
|
||||
let screen_open = !gameplay_has_keyboard(
|
||||
self.screen,
|
||||
self.title.is_some(),
|
||||
self.screen_over_playing(),
|
||||
);
|
||||
self.toggles.key(slot, action, screen_open, option);
|
||||
self.sync_toggle(slot);
|
||||
if !pressed {
|
||||
return Ok(());
|
||||
}
|
||||
if self.keys.raw(&key) {
|
||||
self.keys.press(key, source);
|
||||
}
|
||||
} else if !pressed {
|
||||
self.keys.release(&key, source);
|
||||
return Ok(());
|
||||
} else {
|
||||
self.keys.press(key, source);
|
||||
}
|
||||
// KeyboardHandler.keyPress: a repeat (InputConstants.REPEAT) reaches
|
||||
// Screen.keyPressed and KeyMapping.click like a press, so holding
|
||||
// key.drop keeps throwing, in game and over a menu slot.
|
||||
if repeat && key != KeyCode::KeyQ {
|
||||
return Ok(());
|
||||
}
|
||||
self.key_pressed(key, raw)
|
||||
}
|
||||
/// `KeyMapping.releaseAll` when a screen opens and
|
||||
/// `restoreToggleStatesOnScreenClosed` when it closes.
|
||||
pub(super) fn track_toggle_screens(&mut self) {
|
||||
let dialog = self.dialog_open();
|
||||
drop_held_behind_dialog(dialog, &mut self.keys, &mut self.left, &mut self.right);
|
||||
let playing = gameplay_has_keyboard(
|
||||
self.screen,
|
||||
self.title.is_some(),
|
||||
self.screen_over_playing(),
|
||||
);
|
||||
if playing == self.was_playing {
|
||||
return;
|
||||
}
|
||||
self.was_playing = playing;
|
||||
let options = [
|
||||
self.settings.toggle_crouch,
|
||||
self.settings.toggle_sprint,
|
||||
self.settings.toggle_attack,
|
||||
self.settings.toggle_use,
|
||||
];
|
||||
if playing {
|
||||
self.toggles.screen_closed(options);
|
||||
} else {
|
||||
self.toggles.screen_opened(options);
|
||||
}
|
||||
self.sync_toggle(0);
|
||||
self.sync_toggle(1);
|
||||
self.left = self.toggles.is_down(2);
|
||||
self.right = self.toggles.is_down(3);
|
||||
}
|
||||
|
||||
/// `MouseHandler.onScroll`: the hotbar (or the spectator menu) takes the wheel only when no
|
||||
/// screen is open; a book, lectern, sign or prompt drawn over gameplay is one.
|
||||
pub(super) fn hotbar_takes_wheel(&self) -> bool {
|
||||
self.screen == Screen::Playing
|
||||
&& !self.screen_over_playing()
|
||||
&& self.sleeping.is_none()
|
||||
&& self.player.is_some()
|
||||
}
|
||||
/// A mod's `MouseScrolling` cancels the step about to turn the hotbar.
|
||||
pub(super) fn hotbar_scroll_canceled(&mut self, scroll: [f64; 2], steps: [i32; 2]) -> bool {
|
||||
let mut canceled = false;
|
||||
self.native_mouse_scrolling(
|
||||
scroll,
|
||||
steps,
|
||||
[
|
||||
self.physical_buttons.contains(&MouseButton::Left),
|
||||
self.physical_buttons.contains(&MouseButton::Middle),
|
||||
self.physical_buttons.contains(&MouseButton::Right),
|
||||
],
|
||||
[self.cursor.0, self.cursor.1],
|
||||
glfw_modifiers(self.modifiers),
|
||||
&mut canceled,
|
||||
);
|
||||
canceled
|
||||
}
|
||||
/// What one whole wheel step does in play (`MouseHandler.onScroll`): a spectator's menu or
|
||||
/// flying speed, else the selected hotbar slot.
|
||||
pub(super) fn hotbar_wheel_step(&mut self, wheel: i32, vertical_steps: i32) {
|
||||
if self.spectator() && self.spectator_gui.is_menu_active() {
|
||||
self.spectator_scroll(-wheel);
|
||||
} else if self.spectator() {
|
||||
if let Some(player) = &mut self.player {
|
||||
player.flying_speed = horizon_client::mouse::spectator_flying_speed(
|
||||
player.flying_speed,
|
||||
vertical_steps,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
self.selected = horizon_client::mouse::ScrollWheelHandler::next_selection(
|
||||
f64::from(wheel),
|
||||
self.selected as i32,
|
||||
9,
|
||||
) as usize;
|
||||
}
|
||||
}
|
||||
/// A step of the hotbar from a controller's bumper: one wheel notch, down for the next slot,
|
||||
/// through everything the wheel goes through.
|
||||
pub(super) fn hotbar_step(&mut self, next: bool) {
|
||||
let wheel = if next { -1 } else { 1 };
|
||||
if self.hotbar_takes_wheel()
|
||||
&& !self.native_wheel_event(wheel as f32)
|
||||
&& !self.hotbar_scroll_canceled([0.0, f64::from(wheel)], [0, wheel])
|
||||
{
|
||||
self.hotbar_wheel_step(wheel, wheel);
|
||||
}
|
||||
}
|
||||
/// A mouse mapping pressed or let go by `source`. A release waits while another device holds
|
||||
/// the mapping, so letting go of one can't end what the other still presses, as for keys
|
||||
/// (`HeldKeys`).
|
||||
pub(super) fn mouse_mapping(
|
||||
&mut self,
|
||||
button: MouseButton,
|
||||
pressed: bool,
|
||||
source: Source,
|
||||
) -> Result<()> {
|
||||
if !pressed && self.mouse_mapping_held_elsewhere(button, source) {
|
||||
return Ok(());
|
||||
}
|
||||
self.play_mouse(button, pressed)
|
||||
}
|
||||
/// Whether a device other than `source` holds what `button` stands for. Asks each device's own
|
||||
/// live state rather than keeping a count: a screen takes a mouse release without telling the
|
||||
/// mappings, and a count would then hold the attack for good. A script keeps no such state, so
|
||||
/// nothing waits for a script's mouse hold.
|
||||
pub(super) fn mouse_mapping_held_elsewhere(&self, button: MouseButton, source: Source) -> bool {
|
||||
let mapping = match button {
|
||||
MouseButton::Left => "key.attack",
|
||||
MouseButton::Right => "key.use",
|
||||
_ => return false,
|
||||
};
|
||||
match source {
|
||||
Source::Keyboard | Source::Agent => {
|
||||
self.pad.holds_action(&self.settings.controller, mapping)
|
||||
}
|
||||
Source::Pad => {
|
||||
let keys = &self.settings.keys;
|
||||
self.physical_buttons
|
||||
.iter()
|
||||
.any(|held| keys.translate_mouse_all(*held).contains(&Err(button)))
|
||||
|| self
|
||||
.physical_keys
|
||||
.iter()
|
||||
.any(|held| keys.translate_all(*held).contains(&Err(button)))
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Two mappings with one default button are separate mappings all the same
|
||||
/// (`Minecraft.handleKeybinds`): whether the middle-button action a physical `input` translated
|
||||
/// to is one of those it is bound to, for this player now.
|
||||
pub(super) fn shared_default_acts(&self, button: MouseButton, input: Option<&str>) -> bool {
|
||||
button != MouseButton::Middle
|
||||
|| input.is_none_or(|input| self.settings.keys.middle_acts(input, self.spectator()))
|
||||
}
|
||||
/// A mouse button while playing (window or control socket).
|
||||
pub(super) fn play_mouse(&mut self, button: MouseButton, pressed: bool) -> Result<()> {
|
||||
if pressed {
|
||||
self.pvp_click(button);
|
||||
}
|
||||
if self.sleeping.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
// key.spectatorHotbar (the middle button) opens the spectator menu
|
||||
// and uses its selected item.
|
||||
if button == MouseButton::Middle && self.spectator() {
|
||||
if pressed {
|
||||
self.spectator_action_key();
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if self.remote.is_some() {
|
||||
return self.remote_mouse(button, pressed);
|
||||
}
|
||||
match button {
|
||||
MouseButton::Left => {
|
||||
// KeyMapping.click: Minecraft.tick starts the attack.
|
||||
if pressed {
|
||||
self.attack_clicks += 1;
|
||||
}
|
||||
self.toggles
|
||||
.button(2, pressed, false, self.settings.toggle_attack);
|
||||
self.left = self.toggles.is_down(2);
|
||||
}
|
||||
MouseButton::Right => {
|
||||
self.toggles
|
||||
.button(3, pressed, false, self.settings.toggle_use);
|
||||
self.right = self.toggles.is_down(3);
|
||||
// handleKeybinds consumes use clicks without acting while an
|
||||
// item is in use. Minecraft.tick runs them after the attack
|
||||
// clicks, so a place and a break in one tick replace the block.
|
||||
if pressed && self.using.is_none() {
|
||||
self.use_clicks += 1;
|
||||
}
|
||||
}
|
||||
MouseButton::Middle if pressed => {
|
||||
// handleKeybinds: the number keys select before key.pickItem.
|
||||
self.select_queued_hotbar();
|
||||
// Minecraft.pickBlockOrEntity -> the server's handlePickItemFromBlock:
|
||||
// the block's clone item, with its block entity's data on Ctrl in
|
||||
// creative, picked from the inventory (`pick_item`).
|
||||
if let Some(entity) = self.pick_entity_hit() {
|
||||
// handlePickItemFromEntity: the entity's pick result.
|
||||
if let Some(stack) = entity.and_then(|id| self.entity_pick_result(id)) {
|
||||
let infinite = self.creative;
|
||||
horizon_client::pick_item::pick(
|
||||
&mut self.inventory[..36],
|
||||
&mut self.selected,
|
||||
&stack,
|
||||
infinite,
|
||||
);
|
||||
}
|
||||
} else if let Some(hit) = self.pick() {
|
||||
let include_data = self.creative && self.has_control_down();
|
||||
if let Some(stack) = self.picked_stack(hit.pos, include_data) {
|
||||
let infinite = self.creative;
|
||||
horizon_client::pick_item::pick(
|
||||
&mut self.inventory[..36],
|
||||
&mut self.selected,
|
||||
&stack,
|
||||
infinite,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
//! A key press while a world is open (`KeyboardHandler.keyPress` and the gameplay mappings).
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
/// A key press while a world is open (keyboard or control socket). `key`
|
||||
/// is the vanilla default the pressed mapping translates to, `raw` the
|
||||
/// physical key as pressed (the same where the caller had no window key):
|
||||
/// `Screen.keyPressed` hears the raw one, and only a raw key settles what
|
||||
/// the container's hotbar keys are now (`checkHotbarKeyPressed`).
|
||||
pub(super) fn key_pressed(&mut self, key: KeyCode, raw: KeyCode) -> Result<()> {
|
||||
// `CreativeModeInventoryScreen.keyPressed` clears `ignoreTextInput` on
|
||||
// every key, before it decides anything.
|
||||
self.creative_ignore_text = false;
|
||||
if self.sleeping.is_some()
|
||||
&& self.screen == Screen::Playing
|
||||
&& !matches!(
|
||||
key,
|
||||
KeyCode::F1 | KeyCode::F2 | KeyCode::F11 | KeyCode::KeyT | KeyCode::Slash
|
||||
)
|
||||
{
|
||||
if matches!(key, KeyCode::Escape | KeyCode::ShiftLeft) {
|
||||
self.wake_up();
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if self.sort_key_press(key)
|
||||
|| self.hdr_key_press(key)
|
||||
|| horizon_client::keybinds::key_name(key)
|
||||
.is_some_and(|name| self.hud_editor_key_press(name))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if key == KeyCode::Escape && self.mod_browser_back() {
|
||||
return Ok(());
|
||||
}
|
||||
if self.remote.is_some() && self.remote_key(key)? {
|
||||
return Ok(());
|
||||
}
|
||||
match key {
|
||||
KeyCode::Escape => match self.screen {
|
||||
Screen::Options => self.options_key(KeyCode::Escape, None)?,
|
||||
Screen::Playing => {
|
||||
if self.studio_replay.is_some() && self.captured {
|
||||
self.capture(false);
|
||||
return Ok(());
|
||||
}
|
||||
self.pause_focus = None;
|
||||
self.screen = Screen::Paused;
|
||||
self.pause_menu = true;
|
||||
self.capture(false);
|
||||
}
|
||||
Screen::Creative => {
|
||||
self.pad_clear_focus();
|
||||
self.mod_browser_closed();
|
||||
self.carried = None;
|
||||
self.screen = Screen::Playing;
|
||||
self.capture(true);
|
||||
}
|
||||
Screen::Paused | Screen::Inventory => {
|
||||
self.close_container();
|
||||
self.pause_focus = None;
|
||||
self.screen = Screen::Playing;
|
||||
self.pause_menu = true;
|
||||
self.capture(true);
|
||||
}
|
||||
Screen::GameModeSwitcher => self.close_gamemode_switcher(false),
|
||||
Screen::EmoteWheel => self.close_emote_wheel(None),
|
||||
Screen::Dead => {
|
||||
if self.ui.death_confirm.is_some() {
|
||||
self.death_confirm_answer(false)?;
|
||||
}
|
||||
}
|
||||
Screen::Stats | Screen::Friends => self.screen = Screen::Paused,
|
||||
Screen::Social => self.close_social(),
|
||||
Screen::ServerLinks => self.screen = Screen::Paused,
|
||||
// ConfirmScreen.keyPressed: Escape answers no; both return to
|
||||
// the Server Links screen they came from.
|
||||
Screen::ConfirmLink => self.screen = self.confirm_link_back(),
|
||||
Screen::DialogWarning => self.screen = Screen::ServerLinks,
|
||||
Screen::Advancements => self.close_advancements(),
|
||||
Screen::WorldOptions => self.world_options_escape(),
|
||||
Screen::CommandBlock => self.close_command_block(),
|
||||
Screen::BlockEditor => self.close_block_editor(),
|
||||
Screen::Chat => self.close_chat(chat::ChatExit::Intentional),
|
||||
Screen::Win => self.close_win_screen()?,
|
||||
Screen::DebugOptions => self.close_debug_options(),
|
||||
Screen::Mod => self.close_mod_screen(),
|
||||
Screen::HudEditor => self.close_hud_editor(),
|
||||
},
|
||||
// `checkHotbarKeyPressed`: the keys the controls bind to the off
|
||||
// hand and the hotbar slots, so a rebound key swaps instead of
|
||||
// reaching the screen. The list below keeps Q and the defaults.
|
||||
// The raw key asks what the bindings are now: the translated
|
||||
// `key` is a vanilla default and may itself be another mapping's
|
||||
// rebound key (the off hand on Left Alt while hotbar 7 is on F
|
||||
// would find that F a second time and swap with slot 7).
|
||||
_ if (self.screen == Screen::Inventory
|
||||
|| (self.screen == Screen::Creative && !self.creative_screen.tab().search))
|
||||
&& self
|
||||
.settings
|
||||
.keys
|
||||
.container_hotbar_key(raw)
|
||||
.is_some_and(|key| self.inventory_hotkey(key)) => {}
|
||||
KeyCode::Digit1
|
||||
| KeyCode::Digit2
|
||||
| KeyCode::Digit3
|
||||
| KeyCode::Digit4
|
||||
| KeyCode::Digit5
|
||||
| KeyCode::Digit6
|
||||
| KeyCode::Digit7
|
||||
| KeyCode::Digit8
|
||||
| KeyCode::Digit9
|
||||
| KeyCode::KeyF
|
||||
| KeyCode::KeyQ
|
||||
if (self.screen == Screen::Inventory
|
||||
|| (self.screen == Screen::Creative && !self.creative_screen.tab().search))
|
||||
&& self.inventory_hotkey(key) => {}
|
||||
// The search tab's number keys (scripted presses; the window's
|
||||
// keys reach search_tab_hotbar_key before the search box).
|
||||
_ if self.screen == Screen::Creative
|
||||
&& self.creative_screen.tab().search
|
||||
&& self.search_tab_hotbar_key(key) => {}
|
||||
KeyCode::KeyT if self.screen == Screen::Inventory => self.recipe_focus_search(),
|
||||
KeyCode::KeyT if self.screen == Screen::Playing => self.open_chat_method(""),
|
||||
// CreativeModeInventoryScreen.keyPressed: the chat key opens the
|
||||
// search tab from another tab, and `ignoreTextInput` drops the
|
||||
// letter it would have typed.
|
||||
KeyCode::KeyT
|
||||
if self.screen == Screen::Creative && !self.creative_screen.tab().search =>
|
||||
{
|
||||
if let Some(tab) = self.creative_screen.search_tab() {
|
||||
select_creative_tab(&mut self.creative_screen, &mut self.inv_clicks, tab);
|
||||
self.creative_ignore_text = true;
|
||||
}
|
||||
}
|
||||
// key.advancements.
|
||||
KeyCode::KeyL if self.screen == Screen::Playing => {
|
||||
self.open_advancements(false);
|
||||
// Opened from the keybinds, like the inventory: vanilla releases
|
||||
// an active use in the same tick.
|
||||
self.release_use_for_keybind_screen();
|
||||
}
|
||||
// key.socialInteractions.
|
||||
KeyCode::KeyP if self.screen == Screen::Playing => {
|
||||
self.open_social(false);
|
||||
self.release_use_for_keybind_screen();
|
||||
}
|
||||
KeyCode::KeyL if self.screen == Screen::Advancements => self.close_advancements(),
|
||||
// key.emote (Horizon's): the emote wheel, a tap again closes it.
|
||||
KeyCode::KeyB if self.screen == Screen::Playing && super::cosmetics::enabled() => {
|
||||
self.open_emote_wheel();
|
||||
}
|
||||
KeyCode::KeyB if self.screen == Screen::EmoteWheel => self.close_emote_wheel(None),
|
||||
KeyCode::Digit1
|
||||
| KeyCode::Digit2
|
||||
| KeyCode::Digit3
|
||||
| KeyCode::Digit4
|
||||
| KeyCode::Digit5
|
||||
| KeyCode::Digit6
|
||||
| KeyCode::Digit7
|
||||
| KeyCode::Digit8
|
||||
if self.screen == Screen::EmoteWheel =>
|
||||
{
|
||||
let slot = (key as u32).wrapping_sub(KeyCode::Digit1 as u32) as usize;
|
||||
self.close_emote_wheel(Some(slot));
|
||||
}
|
||||
// key.quickActions: only open if a datapack provides dialog actions.
|
||||
KeyCode::KeyG if self.screen == Screen::Playing => {
|
||||
if self.has_quick_actions() {
|
||||
self.open_quick_actions();
|
||||
}
|
||||
}
|
||||
KeyCode::Slash if self.screen == Screen::Playing => self.open_chat_method("/"),
|
||||
// key.toggleGui is a KeyMapping: it clicks only with no screen open.
|
||||
KeyCode::F1 if self.screen == Screen::Playing => self.toggle_hide_gui(),
|
||||
KeyCode::F11 => self.toggle_fullscreen(),
|
||||
KeyCode::F5 if self.screen == Screen::Playing && self.studio_replay.is_none() => {
|
||||
self.cycle_camera()
|
||||
}
|
||||
KeyCode::KeyE
|
||||
if self.screen == Screen::Creative && self.creative_screen.tab().search => {}
|
||||
KeyCode::KeyE => match self.screen {
|
||||
Screen::Playing if self.spectator() || self.in_portal_transition() => {}
|
||||
Screen::Playing => {
|
||||
// handleKeybinds: the number keys select before key.inventory.
|
||||
self.select_queued_hotbar();
|
||||
// key.inventory while riding a tamed mount opens its inventory.
|
||||
if !self.open_ridden_mount_menu() {
|
||||
if self.tutorial.open_inventory() {
|
||||
self.persist_tutorial_step();
|
||||
}
|
||||
if self.creative {
|
||||
self.screen = Screen::Creative;
|
||||
self.refresh_creative_tabs();
|
||||
self.creative_screen.reopen();
|
||||
self.refresh_saved_hotbars();
|
||||
} else {
|
||||
self.screen = Screen::Inventory;
|
||||
}
|
||||
self.capture(false);
|
||||
}
|
||||
}
|
||||
Screen::Creative => {
|
||||
self.pad_clear_focus();
|
||||
if let Some(c) = self.carried.take() {
|
||||
let _ = self.give(c);
|
||||
}
|
||||
self.screen = Screen::Playing;
|
||||
self.capture(true);
|
||||
}
|
||||
Screen::Inventory => {
|
||||
self.close_container();
|
||||
self.screen = Screen::Playing;
|
||||
self.capture(true);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
// key.swapOffhand: on a server, ServerboundPlayerActionPacket SWAP_ITEM_WITH_OFFHAND
|
||||
// (the server swaps the hands and sends both slots back; spectators can't).
|
||||
KeyCode::KeyF if self.screen == Screen::Playing && self.remote.is_some() => {
|
||||
if !self.spectator()
|
||||
&& let Some(r) = self.remote.as_mut()
|
||||
{
|
||||
r.pending_keys.push(key);
|
||||
}
|
||||
}
|
||||
KeyCode::KeyF if self.screen == Screen::Playing => {
|
||||
// handleKeybinds: the number keys select before key.swapOffhand.
|
||||
self.select_queued_hotbar();
|
||||
// handlePlayerAction SWAP_ITEM_WITH_OFFHAND: setItemInHand for
|
||||
// the off hand, then the main hand.
|
||||
std::mem::swap(&mut self.offhand, &mut self.inventory[self.selected]);
|
||||
self.hand_stack_replaced(self.selected);
|
||||
self.hand_stack_replaced(shield::OFFHAND_SLOT);
|
||||
let (offhand, main) = (self.offhand.clone(), self.inventory[self.selected].clone());
|
||||
self.player_on_equip("offhand", main.as_ref(), offhand.as_ref());
|
||||
self.player_on_equip("mainhand", offhand.as_ref(), main.as_ref());
|
||||
self.using = None;
|
||||
}
|
||||
KeyCode::KeyQ if self.screen == Screen::Playing => {
|
||||
// handleKeybinds: the number keys select before key.drop.
|
||||
self.select_queued_hotbar();
|
||||
let all =
|
||||
drop_whole_stack(self.modifiers) || std::mem::take(&mut self.pad.drop_stack);
|
||||
self.drop_held(all);
|
||||
}
|
||||
KeyCode::Digit1
|
||||
| KeyCode::Digit2
|
||||
| KeyCode::Digit3
|
||||
| KeyCode::Digit4
|
||||
| KeyCode::Digit5
|
||||
| KeyCode::Digit6
|
||||
| KeyCode::Digit7
|
||||
| KeyCode::Digit8
|
||||
| KeyCode::Digit9
|
||||
if self.screen == Screen::Playing =>
|
||||
{
|
||||
let slot = match key {
|
||||
KeyCode::Digit1 => 0,
|
||||
KeyCode::Digit2 => 1,
|
||||
KeyCode::Digit3 => 2,
|
||||
KeyCode::Digit4 => 3,
|
||||
KeyCode::Digit5 => 4,
|
||||
KeyCode::Digit6 => 5,
|
||||
KeyCode::Digit7 => 6,
|
||||
KeyCode::Digit8 => 7,
|
||||
_ => 8,
|
||||
};
|
||||
// Minecraft.handleKeybinds: a spectator's number keys drive
|
||||
// the spectator menu, not the hotbar; a creative player
|
||||
// holding the load or save toolbar key loads or saves a
|
||||
// toolbar instead (load first when both are held).
|
||||
let load = self.mapped_key_down("key.loadToolbarActivator");
|
||||
if self.spectator() {
|
||||
self.spectator_hotbar_key(slot as i32);
|
||||
} else if self.creative
|
||||
&& (load || self.mapped_key_down("key.saveToolbarActivator"))
|
||||
{
|
||||
self.hotbar_load_or_save(slot, load);
|
||||
} else {
|
||||
self.hotbar_presses.push(slot);
|
||||
}
|
||||
}
|
||||
_ if self.screen == Screen::Paused && self.pause_menu => {
|
||||
self.pause_key(key)?;
|
||||
}
|
||||
_ if self.screen == Screen::Dead => {
|
||||
self.death_key(key)?;
|
||||
}
|
||||
_ if self.screen == Screen::Stats => self.stats_key(raw),
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//! Placing the local player in a world it opened: at its saved position once the chunks around it
|
||||
//! are lit, or at the world spawn, then capturing the mouse.
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn spawn_player(&mut self) -> Result<()> {
|
||||
if self.player.is_some() || self.remote.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let saved = self.level.position.map(|p| Vec3::new(p[0], p[1], p[2]));
|
||||
if self.spawn.is_none() {
|
||||
self.spawn = self
|
||||
.level
|
||||
.spawn
|
||||
.map(|s| Vec3::new(s[0] + 0.5, s[1], s[2] + 0.5));
|
||||
}
|
||||
let feet = if let Some(p) = saved {
|
||||
let (cx, cz) = ((p.x.floor() as i32) >> 4, (p.z.floor() as i32) >> 4);
|
||||
self.streaming.center = (cx, cz);
|
||||
if !(-1..=1).all(|dz| (-1..=1).all(|dx| self.streaming.is_lit(cx + dx, cz + dz))) {
|
||||
return Ok(());
|
||||
}
|
||||
self.level.position = None;
|
||||
p
|
||||
} else if self.spawn.is_none() || self.player_spawn.is_some() {
|
||||
// A new world: its spawn, then the first player around it.
|
||||
if self.spawn.is_none() {
|
||||
let Some([x, y, z]) = self.find_world_spawn()? else {
|
||||
return Ok(());
|
||||
};
|
||||
self.spawn = Some(Vec3::new(
|
||||
f64::from(x) + 0.5,
|
||||
f64::from(y),
|
||||
f64::from(z) + 0.5,
|
||||
));
|
||||
self.level.spawn = Some([f64::from(x), f64::from(y), f64::from(z)]);
|
||||
// The world's spawn from the start, not only once saved: a bed set
|
||||
// before the first save must not become it.
|
||||
self.level.world_spawn.get_or_insert([x, y, z]);
|
||||
// The "Bonus Chest" create-world option, placed once with the spawn.
|
||||
if horizon_client::save::WorldGen::read(&self.world_root)
|
||||
.is_some_and(|g| g.bonus_chest)
|
||||
{
|
||||
self.place_bonus_chest([x, y, z])?;
|
||||
}
|
||||
}
|
||||
let s = self
|
||||
.level
|
||||
.spawn
|
||||
.unwrap_or_default()
|
||||
.map(|v| v.floor() as i32);
|
||||
let Some(feet) = self.find_player_spawn(s)? else {
|
||||
return Ok(());
|
||||
};
|
||||
feet
|
||||
} else {
|
||||
let s = self.spawn.unwrap_or_default();
|
||||
let (cx, cz) = ((s.x.floor() as i32) >> 4, (s.z.floor() as i32) >> 4);
|
||||
self.streaming.center = (cx, cz);
|
||||
if !(-1..=1).all(|dz| (-1..=1).all(|dx| self.streaming.is_lit(cx + dx, cz + dz))) {
|
||||
return Ok(());
|
||||
}
|
||||
let column = [s.x.floor() as i32, s.y.floor() as i32, s.z.floor() as i32];
|
||||
if self.level.world_spawn == Some(column) {
|
||||
// TeleportTransition.missingRespawnBlock: PlayerSpawnFinder
|
||||
// around the world spawn, not its exact column; its candidates'
|
||||
// chunks load first.
|
||||
let Some(feet) = self.find_player_spawn(column)? else {
|
||||
return Ok(());
|
||||
};
|
||||
feet
|
||||
} else {
|
||||
// A spawnpoint: the stored column, lifted onto its current surface.
|
||||
let y = self
|
||||
.land_column(column[0], column[2])
|
||||
.map_or(s.y, f64::from);
|
||||
Vec3::new(s.x, y.max(s.y), s.z)
|
||||
}
|
||||
};
|
||||
let mut player = PlayerMotion::new(self.motion, feet)?;
|
||||
player.yaw = self.yaw;
|
||||
let entering = self.restore_player_state;
|
||||
player.load_saved_state_once(&mut self.restore_player_state, &self.level)?;
|
||||
let spectator = self.mode == horizon_client::command::args::GameMode::Spectator;
|
||||
player.may_fly = self.creative || spectator;
|
||||
player.invulnerable = self.creative || spectator;
|
||||
player.spectator = spectator;
|
||||
if spectator {
|
||||
player.flying = true;
|
||||
} else if !player.may_fly {
|
||||
player.flying = false;
|
||||
}
|
||||
player.respiration = self.armor[0]
|
||||
.as_ref()
|
||||
.map_or(0, |s| s.enchantment_level("minecraft:respiration") as i32);
|
||||
player.pitch = self.pitch;
|
||||
player.glider = self.glider_worn();
|
||||
// A saved death already dropped inventory/XP and counted the death.
|
||||
if player.health.dead_or_dying() {
|
||||
self.screen = Screen::Dead;
|
||||
self.death_focus = None;
|
||||
self.ui.died_at = Some(Instant::now());
|
||||
self.ui.death_text = None;
|
||||
self.ui.death_confirm = None;
|
||||
self.capture(false);
|
||||
}
|
||||
self.previous_position = feet;
|
||||
if entering {
|
||||
// The world's first player: a server's client gets it with its
|
||||
// level (Minecraft.setLevel), which stops every sound.
|
||||
self.stop_sounds_for_new_level();
|
||||
}
|
||||
self.player = Some(player);
|
||||
self.pad.player_replaced();
|
||||
self.sway = Default::default();
|
||||
self.apply_equipment();
|
||||
self.eye = 1.62;
|
||||
self.previous_eye = 1.62;
|
||||
self.clock.reset();
|
||||
self.capture_loaded_world();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
//! The mouse in play: the look it turns, the pointer grab (`MouseHandler.grabMouse` /
|
||||
//! `releaseMouse`), the cursor icon, and pausing a world whose window loses focus.
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
/// The options `MouseHandler.turnPlayer` reads.
|
||||
pub(super) fn look_options(&self) -> LookOptions {
|
||||
LookOptions {
|
||||
sensitivity: f64::from(self.settings.sensitivity),
|
||||
smooth_camera: self.smooth_camera,
|
||||
scoping: self.scoping(),
|
||||
invert_x: self.settings.invert_x,
|
||||
invert_y: self.settings.invert_y,
|
||||
}
|
||||
}
|
||||
/// `MouseHandler.handleAccumulatedMovement` and `Entity.turn`, once per frame.
|
||||
/// An agent moving the mouse (`mouse-move`) counts as a grabbed pointer in
|
||||
/// an active window, as it cannot grab the real one.
|
||||
pub(super) fn apply_mouse_look(&mut self) {
|
||||
let look = self.look_options();
|
||||
let time = self.started.elapsed().as_secs_f64();
|
||||
let pointer = self.captured || self.agent_pointer;
|
||||
let grabbed = pointer && self.screen == Screen::Playing && self.player.is_some();
|
||||
let active = self.focused || self.agent_pointer;
|
||||
if let Some((xo, yo)) = self
|
||||
.mouse
|
||||
.handle_accumulated_movement(time, active, grabbed, look)
|
||||
{
|
||||
self.look_turn(xo, yo);
|
||||
}
|
||||
}
|
||||
/// A look from any device, in `Entity.turn` units: the tutorial's "look
|
||||
/// around" step hears it and Horizon's zoom slows it, for the mouse and a
|
||||
/// controller alike.
|
||||
pub(super) fn look_turn(&mut self, x: f64, y: f64) {
|
||||
self.tutorial.mouse(x, y);
|
||||
let zoom = self.zoom_turn_scale();
|
||||
self.turn_player(x * zoom, y * zoom);
|
||||
}
|
||||
/// `Minecraft.pause`: a single-player game with a pausing screen open.
|
||||
pub(super) fn pauses_game(&self) -> bool {
|
||||
self.has_singleplayer_server()
|
||||
&& !self.is_published()
|
||||
&& !self.has_split_players()
|
||||
&& self.title.is_none()
|
||||
&& (matches!(
|
||||
self.screen,
|
||||
Screen::Paused
|
||||
| Screen::Options
|
||||
| Screen::Stats
|
||||
| Screen::Advancements
|
||||
| Screen::WorldOptions
|
||||
| Screen::Friends
|
||||
| Screen::Social
|
||||
| Screen::ServerLinks
|
||||
| Screen::ConfirmLink
|
||||
| Screen::DialogWarning
|
||||
| Screen::CommandBlock
|
||||
| Screen::DebugOptions
|
||||
| Screen::HudEditor
|
||||
) || self.book.is_some()
|
||||
|| self.win_screen_pauses())
|
||||
}
|
||||
/// A screen open over gameplay while `screen` stays `Playing`: a book or
|
||||
/// lectern, a sign, a server dialog, the code of conduct, a link or
|
||||
/// server pack prompt (vanilla's `Minecraft.screen` is set for each).
|
||||
pub(super) fn screen_over_playing(&self) -> bool {
|
||||
self.book.is_some() || self.sign_edit.is_some() || self.prompt_open()
|
||||
}
|
||||
/// A prompt is up: over the world or over another open screen, it takes the input and is
|
||||
/// answered first, in the order of `handle_window_event`'s arms and of what draws on top.
|
||||
pub(super) fn prompt_open(&self) -> bool {
|
||||
self.dialog_open()
|
||||
|| self.code_of_conduct.is_some()
|
||||
|| self.link_prompt.is_some()
|
||||
|| self.command_prompt.is_some()
|
||||
|| self.server_packs.prompt.is_some()
|
||||
}
|
||||
/// `Minecraft.pauseIfInactive`: a window without focus for more than 500 ms
|
||||
/// pauses the game. An open screen (books, signs, the pack prompt, a server
|
||||
/// dialog) stays instead of pausing (`Gui.setPauseScreen`).
|
||||
pub(super) fn pause_if_inactive(&mut self) {
|
||||
if self
|
||||
.focus_lost
|
||||
.is_some_and(|t| t.elapsed() > Duration::from_millis(500))
|
||||
&& self.control.is_none()
|
||||
&& self.screen == Screen::Playing
|
||||
&& self.player.is_some()
|
||||
&& !self.options.no_pause
|
||||
&& self.settings.file.bool("pauseOnLostFocus")
|
||||
&& !self.pad_keeps_playing_unfocused()
|
||||
&& self.book.is_none()
|
||||
&& self.sign_edit.is_none()
|
||||
&& self.server_packs.prompt.is_none()
|
||||
&& !self.dialog_open()
|
||||
{
|
||||
self.pause_focus = None;
|
||||
self.screen = Screen::Paused;
|
||||
}
|
||||
}
|
||||
/// `KeyMapping.setAll`: every key physically held is held again, as the
|
||||
/// mapping it is bound to, so a key pressed or kept down behind a screen
|
||||
/// counts once the game has the keyboard back. A sneak or sprint that
|
||||
/// toggles keeps its toggle state (`shouldSetOnIngameFocus`).
|
||||
pub(super) fn restore_held_keys(&mut self) {
|
||||
let held: Vec<KeyCode> = self.physical_keys.iter().copied().collect();
|
||||
let toggles = [self.settings.toggle_crouch, self.settings.toggle_sprint];
|
||||
for key in held {
|
||||
for mapped in self.settings.keys.translate_all(key) {
|
||||
match mapped {
|
||||
Ok(slot_key @ (KeyCode::ShiftLeft | KeyCode::ControlLeft)) => {
|
||||
let slot = usize::from(slot_key == KeyCode::ControlLeft);
|
||||
if !toggles[slot] {
|
||||
self.toggles.0[slot].set_down(true, false);
|
||||
self.sync_toggle(slot);
|
||||
}
|
||||
}
|
||||
Ok(other) => {
|
||||
self.keys.insert(other);
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `MouseHandler.grabMouse` / `releaseMouse`. Both put the pointer at
|
||||
/// the window's centre, so a screen opened from gameplay (inventory,
|
||||
/// chest, pause, chat, sign, book, death) starts with the cursor in the
|
||||
/// middle instead of wherever the system last left it. Releasing when
|
||||
/// already released does nothing, so moving from screen to screen keeps
|
||||
/// the cursor where it is. Like GLFW's `glfwSetCursorPos`, nothing warps
|
||||
/// while the window is unfocused (losing focus releases the pointer).
|
||||
pub(super) fn capture(&mut self, capture: bool) {
|
||||
// A guest's action (`as_guest`) opens and closes screens on the
|
||||
// guest's own client: the window's pointer stays the host's (#687).
|
||||
if self.lan_acting.is_some() {
|
||||
return;
|
||||
}
|
||||
// MouseHandler.grabMouse never captures an inactive window, and vanilla
|
||||
// grabs only on `setScreen(null)`: the pack prompt and server dialogs
|
||||
// are screens over the world that keep the pointer.
|
||||
if capture
|
||||
&& (!self.focused
|
||||
|| self.server_packs.prompt.is_some()
|
||||
|| self.dialog_open()
|
||||
|| self.command_prompt.is_some())
|
||||
{
|
||||
return;
|
||||
}
|
||||
// The container screen's press state belongs to the screen: a screen
|
||||
// that takes the pointer over starts with its own state, as
|
||||
// `AbstractContainerScreen` does, so a press whose release never
|
||||
// arrived (the pointer left the window) cannot wedge the next
|
||||
// screen's quick move and clicks.
|
||||
self.reset_inventory_clicks();
|
||||
if !capture {
|
||||
self.stop_mining();
|
||||
}
|
||||
// The real pointer takes over from an agent's.
|
||||
self.agent_pointer = false;
|
||||
// Splitscreen: another player has the pointer; this one's menus leave it alone.
|
||||
if !self.keyboard_mouse {
|
||||
self.captured = false;
|
||||
self.capture_mode = None;
|
||||
return;
|
||||
}
|
||||
if capture && !self.captured && !cfg!(target_os = "macos") {
|
||||
// MouseHandler.grabMouse: KeyMapping.setAll unless
|
||||
// InputQuirks.RESTORE_KEY_STATE_AFTER_MOUSE_GRAB is off (macOS).
|
||||
self.restore_held_keys();
|
||||
}
|
||||
let Some(window) = &self.window else { return };
|
||||
let size = self.renderer.as_ref().map_or_else(
|
||||
|| window.inner_size().into(),
|
||||
|r| [r.config.width, r.config.height],
|
||||
);
|
||||
let [vx, vy] = self.view_at.map(f64::from);
|
||||
let centre = winit::dpi::PhysicalPosition::new(
|
||||
vx + f64::from(size[0]) / 2.0,
|
||||
vy + f64::from(size[1]) / 2.0,
|
||||
);
|
||||
let warp = |window: &Window| {
|
||||
let _ = window.set_cursor_position(centre);
|
||||
};
|
||||
let scale = HudBatch::new(size).scale;
|
||||
let was_grabbed = self.captured;
|
||||
if self.options.offscreen && !self.split_window {
|
||||
// A genuinely hidden `--offscreen` run leaves the system pointer where
|
||||
// the person at the computer has it. Its grab is internal (an
|
||||
// agent turns the view with `mouse-move`).
|
||||
if capture {
|
||||
self.mouse.set_ignore_first_move();
|
||||
}
|
||||
self.captured = capture;
|
||||
self.capture_mode = None;
|
||||
} else if capture {
|
||||
self.mouse.set_ignore_first_move();
|
||||
// macOS warps by re-associating the mouse with the pointer,
|
||||
// which undoes a Locked grab and leaves the pointer free: warp
|
||||
// first there. Wayland moves a pointer only while it is locked,
|
||||
// so everywhere else warp after grabbing (Windows does both).
|
||||
#[cfg(target_os = "macos")]
|
||||
warp(window);
|
||||
// Windows clips a Locked pointer to a 1x1 rectangle where it is, and `SetCursorPos`
|
||||
// stays inside that clip: warp first, or the pointer stays frozen wherever it was
|
||||
// (over another window, on another monitor) instead of the window's centre (#6076).
|
||||
#[cfg(windows)]
|
||||
if self.focused {
|
||||
warp(window);
|
||||
}
|
||||
self.capture_mode = if window.set_cursor_grab(CursorGrabMode::Locked).is_ok() {
|
||||
Some(CursorGrabMode::Locked)
|
||||
} else if window.set_cursor_grab(CursorGrabMode::Confined).is_ok() {
|
||||
Some(CursorGrabMode::Confined)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.captured = self.capture_mode.is_some();
|
||||
// Windows already warped above, inside the clip's own position.
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
if self.captured && self.focused {
|
||||
warp(window);
|
||||
}
|
||||
} else {
|
||||
if self.captured {
|
||||
// Wayland moves a pointer only as the locked pointer's
|
||||
// position hint, which the compositor applies as the lock
|
||||
// goes (sway and Hyprland do): set it before ungrabbing.
|
||||
// X11, Windows and macOS warp, so warp again once free
|
||||
// (Wayland refuses that one).
|
||||
if self.focused {
|
||||
warp(window);
|
||||
}
|
||||
let _ = window.set_cursor_grab(CursorGrabMode::None);
|
||||
if self.focused {
|
||||
warp(window);
|
||||
}
|
||||
} else {
|
||||
let _ = window.set_cursor_grab(CursorGrabMode::None);
|
||||
}
|
||||
self.captured = false;
|
||||
self.capture_mode = None;
|
||||
}
|
||||
// MouseHandler.grabMouse and releaseMouse: the mouse position is
|
||||
// the window's centre whenever the pointer's grab state changes, so
|
||||
// a screen opened from gameplay starts with the cursor in the middle
|
||||
// and the game starts with it away from the screen's last spot.
|
||||
if let Some(pos) = horizon_client::mouse::mouse_position_after_grab_change(
|
||||
capture,
|
||||
was_grabbed,
|
||||
size,
|
||||
scale,
|
||||
) {
|
||||
self.cursor = pos;
|
||||
}
|
||||
if !self.options.offscreen || self.split_window {
|
||||
window.set_cursor_visible(!self.captured);
|
||||
}
|
||||
}
|
||||
/// A Linux window manager can release the native pointer grab while the
|
||||
/// window remains focused. Reapply the recorded mode on the next gameplay
|
||||
/// click or wheel event, without warping the pointer or consuming input.
|
||||
pub(super) fn reassert_pointer_capture(&mut self) {
|
||||
#[cfg(target_os = "linux")]
|
||||
if self.screen == Screen::Playing
|
||||
&& self.focused
|
||||
&& self.captured
|
||||
&& !self.options.offscreen
|
||||
&& let (Some(window), Some(mode)) = (&self.window, self.capture_mode)
|
||||
{
|
||||
let other = match mode {
|
||||
CursorGrabMode::Locked => CursorGrabMode::Confined,
|
||||
CursorGrabMode::Confined => CursorGrabMode::Locked,
|
||||
CursorGrabMode::None => return,
|
||||
};
|
||||
self.capture_mode = if window.set_cursor_grab(mode).is_ok() {
|
||||
Some(mode)
|
||||
} else if window.set_cursor_grab(other).is_ok() {
|
||||
Some(other)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.captured = self.capture_mode.is_some();
|
||||
window.set_cursor_visible(!self.captured);
|
||||
}
|
||||
}
|
||||
/// `GuiGraphicsExtractor.applyCursor` -> `Window.selectCursor` at the end
|
||||
/// of a frame: the cursor the frame's widgets asked for, set on the window
|
||||
/// only when it changes.
|
||||
pub(super) fn apply_cursor_icon(&mut self) {
|
||||
use horizon_client::widgets::Cursor;
|
||||
let wanted = horizon_client::widgets::take_cursor();
|
||||
// The widgets under the keyboard's hidden pointer aren't under the
|
||||
// mouse (`handleCursor` asks only for a hovered widget).
|
||||
let effective = if self.settings.allow_cursor_changes && !self.key_focus_driving() {
|
||||
wanted
|
||||
} else {
|
||||
Cursor::Arrow
|
||||
};
|
||||
if effective == self.cursor_icon || !self.keyboard_mouse {
|
||||
return;
|
||||
}
|
||||
if let Some(window) = &self.window {
|
||||
window.set_cursor(match effective {
|
||||
Cursor::Arrow => winit::window::CursorIcon::Default,
|
||||
Cursor::PointingHand => winit::window::CursorIcon::Pointer,
|
||||
Cursor::NotAllowed => winit::window::CursorIcon::NotAllowed,
|
||||
});
|
||||
}
|
||||
self.cursor_icon = effective;
|
||||
}
|
||||
/// LevelLoadingScreen.onClose -> Gui.setScreen(null) -> grabMouse.
|
||||
pub(super) fn capture_loaded_world(&mut self) {
|
||||
// Loading may finish after focus was lost or another screen opened.
|
||||
if self.title.is_none() && self.screen == Screen::Playing {
|
||||
self.capture(true);
|
||||
}
|
||||
}
|
||||
/// The window came back into focus after holding the pointer: the system
|
||||
/// dropped the grab with the focus, and vanilla's grab outlives it
|
||||
/// (`MouseHandler.mouseGrabbed` stays true while GLFW keeps the cursor
|
||||
/// mode), so the game takes the pointer again without waiting for a
|
||||
/// click. A screen that is open keeps it free, as releasing the pointer
|
||||
/// for that screen did: it is the screen, not the focus, that shows it.
|
||||
pub(super) fn regrab_on_refocus(&mut self) {
|
||||
if std::mem::take(&mut self.regrab_on_refocus) && self.screen == Screen::Playing {
|
||||
self.capture(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//! `Entity.handlePortal` runs in the entity's own tick, and `ServerLevel.tick`
|
||||
//! ticks only the entities in entity-ticking chunks: past the simulation
|
||||
//! distance a mob in a nether portal doesn't cross, and a mob cooling down
|
||||
//! from one keeps its cooldown until it ticks again.
|
||||
|
||||
use super::native_machines::tests::server_game;
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_client::mobs::Mob;
|
||||
use horizon_client::nether_portal::ENTITY_DELAY;
|
||||
use horizon_core::geometry::Vec3;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn mobs_past_the_simulation_distance_neither_enter_portals_nor_cool_down() {
|
||||
let mut game = server_game();
|
||||
game.settings.simulation_distance = 4;
|
||||
game.settings.render_distance = 12;
|
||||
let registry = game.pack.assets.registry;
|
||||
let stone = registry.default_state("minecraft:stone").unwrap();
|
||||
let portal = registry.default_state("minecraft:nether_portal").unwrap();
|
||||
let zombie = game
|
||||
.res
|
||||
.mob_types()
|
||||
.get("minecraft:zombie")
|
||||
.unwrap()
|
||||
.clone();
|
||||
// The player in chunk (0, 0); chunk (2, 0) is inside the simulation
|
||||
// distance, chunk (8, 0) loaded and in view but past it. In each, one
|
||||
// zombie stands in a portal (z 8) and one on stone, cooling down (z 12).
|
||||
game.player = Some(
|
||||
horizon_client::motion::PlayerMotion::new(game.motion, Vec3::new(8.5, 65.0, 8.5)).unwrap(),
|
||||
);
|
||||
for (cx, x, in_portal, cooling) in [(2, 40, 1, 3), (8, 136, 2, 4)] {
|
||||
game.world
|
||||
.chunks
|
||||
.insert((cx, 0), Arc::new(Chunk::empty(cx, 0)));
|
||||
for z in [8, 12] {
|
||||
game.world.set(x, 63, z, stone).unwrap();
|
||||
}
|
||||
game.world.set(x, 64, 8, portal).unwrap();
|
||||
game.world.set(x, 65, 8, portal).unwrap();
|
||||
let at = |z: f64| Vec3::new(f64::from(x) + 0.5, 64.0, z);
|
||||
game.mobs
|
||||
.push(Mob::new(in_portal, zombie.clone(), at(8.5), 0.0));
|
||||
game.mobs
|
||||
.push(Mob::new(cooling, zombie.clone(), at(12.5), 0.0));
|
||||
game.set_portal_cooldown(cooling);
|
||||
}
|
||||
game.update_chunk_tickets();
|
||||
let ticking = game.entity_ticking_area();
|
||||
assert!(ticking(40, 8), "chunk (2, 0) ticks entities");
|
||||
assert!(
|
||||
!ticking(136, 8),
|
||||
"chunk (8, 0) is past the simulation distance"
|
||||
);
|
||||
for _ in 0..3 {
|
||||
game.handle_entity_portals();
|
||||
game.find_entity_portals();
|
||||
}
|
||||
let state = |id| game.portals.entities.get(&id);
|
||||
let here = |id| game.mobs.iter().any(|m| m.id == id);
|
||||
// A mob crosses a nether portal at once: the ticking zombie has gone.
|
||||
assert!(!here(1), "the ticking zombie went through its portal");
|
||||
assert!(here(2), "the far zombie is still here");
|
||||
assert!(
|
||||
state(2).is_none(),
|
||||
"the far zombie never entered its portal"
|
||||
);
|
||||
assert_eq!(state(3).map(|s| s.cooldown), Some(ENTITY_DELAY - 3));
|
||||
assert_eq!(state(4).map(|s| s.cooldown), Some(ENTITY_DELAY));
|
||||
}
|
||||
@@ -1135,8 +1135,19 @@ impl Game<'_> {
|
||||
/// The effects' attribute modifiers, armor protection and status flags
|
||||
/// on `self.player`.
|
||||
pub(super) fn apply_effect_status(&mut self) {
|
||||
// LivingEntity.onEffectAdded, onEffectUpdated and onEffectsRemoved
|
||||
// change the attributes on a server only. A server's client has the
|
||||
// modifiers its ClientboundUpdateAttributesPacket sends, which comes a
|
||||
// tick after the effect's own packet when the effect starts or ends in
|
||||
// the player's tick (ServerPlayer.doTick runs after the tick's
|
||||
// sendDirtyEntityData). Its own would slow it a tick before the
|
||||
// server's player when Speed runs out (#12040).
|
||||
if self.remote.is_some() {
|
||||
self.potions.applied.clear();
|
||||
}
|
||||
let modifiers = self.potions.effects.modifiers();
|
||||
if modifiers != self.potions.applied
|
||||
if self.remote.is_none()
|
||||
&& modifiers != self.potions.applied
|
||||
&& let Some(p) = self.player.as_mut()
|
||||
{
|
||||
for (attr, id, _, op) in &self.potions.applied {
|
||||
@@ -1155,6 +1166,16 @@ impl Game<'_> {
|
||||
{
|
||||
let _ = p.set_health(0.0, true);
|
||||
}
|
||||
self.refresh_armor_protection();
|
||||
let mut status = self.potions.effects.status();
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
status.protection = std::mem::take(&mut p.status.protection);
|
||||
p.status = status;
|
||||
}
|
||||
}
|
||||
/// `EnchantmentHelper.getDamageProtection` after equipment changes. This
|
||||
/// does not tick effects or clear absorption while a hit is in progress.
|
||||
pub(super) fn refresh_armor_protection(&mut self) {
|
||||
// EnchantmentHelper.getDamageProtection per damage type, recomputed on armor change.
|
||||
let armor: Vec<Option<ItemStack>> = self.armor.to_vec();
|
||||
if !self
|
||||
@@ -1197,11 +1218,6 @@ impl Game<'_> {
|
||||
p.status.protection = table;
|
||||
}
|
||||
}
|
||||
let mut status = self.potions.effects.status();
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
status.protection = std::mem::take(&mut p.status.protection);
|
||||
p.status = status;
|
||||
}
|
||||
}
|
||||
/// The players a thrown potion can hit (`Projectile.canHitEntity`): the
|
||||
/// host and the guests, but not a spectator or a dead one.
|
||||
|
||||
@@ -5481,8 +5481,26 @@ impl Game<'_> {
|
||||
let [x, y, z] = p.pos.pos;
|
||||
self.spawn = (p.pos.dimension == "minecraft:overworld")
|
||||
.then(|| Vec3::new(f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5));
|
||||
// 1.20.2 handleSetSpawn notified ReceivingLevelScreen here; the later
|
||||
// LEVEL_CHUNKS_LOAD_START game event did not exist yet.
|
||||
if let Some(r) = self.remote.as_mut()
|
||||
&& r.session
|
||||
.server_version()
|
||||
.at_most(horizon_protocol::adapter::ServerVersion::V1_20_2)
|
||||
{
|
||||
r.level_load.loading_packets_received();
|
||||
}
|
||||
}
|
||||
C::ServerData(p) => {
|
||||
if let Some(r) = self.remote.as_mut()
|
||||
&& let Some(enforced) = r.session.legacy_secure_chat_enforcement()
|
||||
&& r.enforces_secure_chat != enforced
|
||||
{
|
||||
r.enforces_secure_chat = enforced;
|
||||
r.chat_validators.clear();
|
||||
}
|
||||
self.remote_server_data(p.icon.as_deref());
|
||||
}
|
||||
C::ServerData(p) => self.remote_server_data(p.icon.as_deref()),
|
||||
C::MapItemData(p) => self.remote_map_item_data(&p),
|
||||
// handleTickingState / handleTickingStep: the level's TickRateManager.
|
||||
C::TickingState(p) => {
|
||||
@@ -11751,6 +11769,118 @@ mod tests {
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
/// #12040: a server's client moves at the speed the server's attributes give it, never its
|
||||
/// effects' own. Vanilla's server removes a Speed that runs out in ServerPlayer.doTick, after
|
||||
/// the tick's sendDirtyEntityData: ClientboundRemoveMobEffectPacket goes out at once and the
|
||||
/// movement speed without the effect's modifier a tick later, so for that tick the client still
|
||||
/// runs fast (a potion drunk starts the same way). A client a tick off is what GrimAC's
|
||||
/// Simulation flagged.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_servers_client_takes_its_speed_from_the_attributes_not_the_effect() {
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::motion::PlayerMotion;
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_protocol::packets::play::{
|
||||
AttributeModifier, AttributeSnapshot, Clientbound as C, RemoveMobEffect,
|
||||
UpdateAttributes, UpdateMobEffect,
|
||||
};
|
||||
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();
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.init_remote_for_replay().unwrap();
|
||||
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(0.5, 64.0, 0.5)).unwrap());
|
||||
let me = game.remote.as_ref().unwrap().entity_id;
|
||||
let speed = game.remote_effect_network_id("speed").unwrap();
|
||||
let attribute = {
|
||||
let p = game.player.as_ref().unwrap();
|
||||
let key = p.attributes.key("minecraft:movement_speed").unwrap();
|
||||
key.attribute().registry_id()
|
||||
};
|
||||
// Player.createAttributes: 0.1F.
|
||||
let walking = f64::from(0.1f32);
|
||||
// Speed II: 0.2 (a float) a level, multiplying the total.
|
||||
let amount = f64::from(0.2f32) * 2.0;
|
||||
let attributes = |sped: bool| {
|
||||
C::UpdateAttributes(UpdateAttributes {
|
||||
id: me,
|
||||
values: vec![AttributeSnapshot {
|
||||
attribute,
|
||||
base: walking,
|
||||
modifiers: sped
|
||||
.then(|| AttributeModifier {
|
||||
id: "minecraft:effect.speed".into(),
|
||||
amount,
|
||||
operation: 2,
|
||||
})
|
||||
.into_iter()
|
||||
.collect(),
|
||||
}],
|
||||
})
|
||||
};
|
||||
// Each step: the packets of one server tick, then a client tick.
|
||||
let mut step = |packets: Vec<C>| {
|
||||
for packet in packets {
|
||||
game.handle_packet(packet).unwrap();
|
||||
}
|
||||
game.tick_client_effects();
|
||||
game.player
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.attribute("minecraft:movement_speed")
|
||||
.unwrap()
|
||||
};
|
||||
let effect = C::UpdateMobEffect(UpdateMobEffect {
|
||||
id: me,
|
||||
effect: speed,
|
||||
amplifier: 1,
|
||||
duration: 60,
|
||||
flags: 2 | 4,
|
||||
});
|
||||
let fast = walking * (1.0 + amount);
|
||||
let close = |a: f64, b: f64| (a - b).abs() < 1e-9;
|
||||
let seen = [
|
||||
step(vec![effect]),
|
||||
step(vec![attributes(true)]),
|
||||
step(Vec::new()),
|
||||
step(vec![C::RemoveMobEffect(RemoveMobEffect {
|
||||
id: me,
|
||||
effect: speed,
|
||||
})]),
|
||||
step(vec![attributes(false)]),
|
||||
];
|
||||
let expected = [walking, fast, fast, fast, walking];
|
||||
assert!(
|
||||
seen.iter().zip(expected).all(|(&s, e)| close(s, e)),
|
||||
"speed on each tick: {seen:?}, vanilla's client: {expected:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `ItemFrame.interact`, `AbstractBoat.interact`, and `Minecart.interact`:
|
||||
/// interactions that pass leave the click to the held item without swinging;
|
||||
/// vehicle boarding answers CONSUME and ends the click without a predicted swing (#9120).
|
||||
|
||||
@@ -249,7 +249,8 @@ impl Game<'_> {
|
||||
/// (SignableCommand.of over the chat's tree, the server's once it sent one) has each of them signed,
|
||||
/// all with the same time, salt and last seen (without a chat session the
|
||||
/// signature list is empty, as in vanilla); any other command goes as the
|
||||
/// unsigned packet.
|
||||
/// unsigned packet on releases that have one. Before 1.20.5 even a command
|
||||
/// without message arguments carries the real last-seen acknowledgement.
|
||||
pub(super) fn send_command(&mut self, command: &str) {
|
||||
let Some(r) = self.remote.as_mut() else {
|
||||
return;
|
||||
@@ -261,7 +262,12 @@ impl Game<'_> {
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
if arguments.is_empty() {
|
||||
if arguments.is_empty()
|
||||
&& !r
|
||||
.session
|
||||
.server_version()
|
||||
.at_most(horizon_protocol::adapter::ServerVersion::V1_20_3)
|
||||
{
|
||||
self.send(S::ChatCommand(play::ChatCommand {
|
||||
command: command.to_owned(),
|
||||
}));
|
||||
@@ -318,9 +324,9 @@ impl Game<'_> {
|
||||
let over_screen = self.screen != Screen::Playing || self.dialog_open();
|
||||
let (message, accept) = match check {
|
||||
CommandCheck::NoIssues => {
|
||||
self.send(S::ChatCommand(play::ChatCommand {
|
||||
command: command.to_owned(),
|
||||
}));
|
||||
// Verification above excludes message arguments. Use the ordinary sender so
|
||||
// releases without an unsigned command packet still acknowledge seen chat.
|
||||
self.send_command(command);
|
||||
return;
|
||||
}
|
||||
CommandCheck::ParseErrors => (
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
//! The renderer and its settings: building it for the window, applying the video settings and a
|
||||
//! shader pack's translucent layers, and the far terrain (LOD) distances.
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn renderer_for(
|
||||
&self,
|
||||
window: Arc<Window>,
|
||||
pack: &PackResources,
|
||||
gui: &UiResources,
|
||||
) -> Result<Renderer> {
|
||||
build_renderer(
|
||||
window,
|
||||
self.options.offscreen,
|
||||
pack,
|
||||
gui,
|
||||
self.renderer.as_ref(),
|
||||
None,
|
||||
self.settings.horizon.api,
|
||||
self.settings.horizon.translucent_batching,
|
||||
self.settings.horizon.wide_frame(),
|
||||
shader_packs::picked_pack_shape(&self.settings),
|
||||
(
|
||||
self.settings.file.int("textureFiltering"),
|
||||
self.settings.file.int("maxAnisotropyBit"),
|
||||
),
|
||||
)
|
||||
}
|
||||
pub(super) fn apply_settings(&mut self) {
|
||||
// LocalPlayer.refreshChatAbilities: the chat shows what the Chat option allows.
|
||||
let abilities = self.chat_abilities();
|
||||
self.ui.chat.set_abilities(&self.gui.atlas.font, abilities);
|
||||
let render_distance_changed = self.streaming.radius != self.settings.render_distance;
|
||||
self.streaming.radius = self.settings.render_distance;
|
||||
// Options.renderDistance, biomeBlendRadius, ambientOcclusion,
|
||||
// cutoutLeaves and the leaf-culling mode: LevelExtractor.allChanged
|
||||
// when they change.
|
||||
let leaf_culling = self.effective_leaf_culling();
|
||||
let (s, st) = (&self.settings, &self.streaming);
|
||||
let reload = render_distance_changed
|
||||
|| (
|
||||
s.biome_blend,
|
||||
s.smooth_lighting,
|
||||
s.cutout_leaves,
|
||||
leaf_culling,
|
||||
) != (
|
||||
st.biome_blend,
|
||||
st.smooth_lighting,
|
||||
st.cutout_leaves,
|
||||
st.leaf_culling,
|
||||
);
|
||||
self.streaming.biome_blend = self.settings.biome_blend;
|
||||
self.streaming.smooth_lighting = self.settings.smooth_lighting;
|
||||
self.streaming.cutout_leaves = self.settings.cutout_leaves;
|
||||
self.streaming.leaf_culling = leaf_culling;
|
||||
if reload {
|
||||
self.all_sections_changed();
|
||||
}
|
||||
self.audio.master = self.settings.master_volume;
|
||||
if self.settings.sound_device != self.audio.device() {
|
||||
let _ = self.audio.set_device(&self.settings.sound_device);
|
||||
}
|
||||
horizon_client::hud::GUI_SCALE.store(
|
||||
self.settings.gui_scale,
|
||||
std::sync::atomic::Ordering::Relaxed,
|
||||
);
|
||||
self.apply_ui_settings();
|
||||
}
|
||||
/// The sprite layers a shader pack draws in the translucent pass: every section is meshed
|
||||
/// again when they change.
|
||||
pub(super) fn set_translucent_layers(&mut self, layers: Vec<bool>) {
|
||||
if *layers != *self.streaming.translucent_layers {
|
||||
self.streaming.translucent_layers = layers.into();
|
||||
self.all_sections_changed();
|
||||
}
|
||||
}
|
||||
|
||||
/// LOD Distance and Far Detail Reduced as the player chose them, the
|
||||
/// distance no farther than a server sampling far terrain for us allows.
|
||||
pub(super) fn far_terrain_choice(&self) -> (u32, bool) {
|
||||
let g = &self.settings.horizon;
|
||||
let reduced = g.far_detail == horizon_client::graphics::FarDetail::Reduced;
|
||||
let distance = match self.server_far_terrain() {
|
||||
Some(offer) => g.lod_distance.min(offer.distance),
|
||||
None => g.lod_distance,
|
||||
};
|
||||
(distance, reduced)
|
||||
}
|
||||
/// The far terrain another machine's server samples for us
|
||||
/// (`lod_net`): a client of a dedicated server or of a LAN world, not of
|
||||
/// its own server thread, which hands its generator over directly.
|
||||
pub(super) fn server_far_terrain(&self) -> Option<horizon_client::lod_net::Offer> {
|
||||
if self.integrated.is_some() {
|
||||
return None;
|
||||
}
|
||||
self.remote.as_ref()?.far_terrain
|
||||
}
|
||||
/// LOD Distance as far terrain uses it (`graphics::lod_distance_capped`).
|
||||
pub(super) fn lod_distance(&self) -> u32 {
|
||||
let (distance, _) = self.far_terrain_choice();
|
||||
match &self.renderer {
|
||||
Some(renderer) => {
|
||||
horizon_client::graphics::lod_distance_capped(distance, &renderer.info)
|
||||
}
|
||||
None => distance.min(horizon_client::lod::MAX_CHUNKS),
|
||||
}
|
||||
}
|
||||
/// Only enabled LOD worlds extend the renderer's range, never simulation.
|
||||
pub(super) fn terrain_distance(&self) -> i32 {
|
||||
if self.lod.is_some() {
|
||||
self.streaming.radius.max(self.lod_distance() as i32)
|
||||
} else {
|
||||
self.streaming.radius
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,628 @@
|
||||
//! The startup's resource load: the stages the loading screen shows, the
|
||||
//! scoped threads each step runs on, and the core, gameplay and pack tables
|
||||
//! read from the prepared resources.
|
||||
use super::*;
|
||||
|
||||
pub(super) const STAGE_LINES: [&str; 10] = [
|
||||
"Loading menu",
|
||||
"Loading assets",
|
||||
"Loading block facts",
|
||||
"Loading biomes",
|
||||
"Loading pack tables",
|
||||
"Loading collision",
|
||||
"Loading items",
|
||||
"Loading recipes",
|
||||
"Loading world resources",
|
||||
"World resources ready",
|
||||
];
|
||||
|
||||
/// The background loader's checkpoint, read by the loading screen each frame.
|
||||
#[derive(Clone)]
|
||||
pub(super) struct Progress {
|
||||
pub(super) value: Arc<AtomicU32>,
|
||||
pub(super) lines: &'static [&'static str],
|
||||
}
|
||||
impl Progress {
|
||||
pub(super) fn new() -> Self {
|
||||
Self::with(&STAGE_LINES)
|
||||
}
|
||||
pub(super) fn with(lines: &'static [&'static str]) -> Self {
|
||||
Self {
|
||||
value: Arc::new(AtomicU32::new(0)),
|
||||
lines,
|
||||
}
|
||||
}
|
||||
pub(super) fn set(&self, stage: u32) {
|
||||
self.value.store(stage, Ordering::Relaxed);
|
||||
}
|
||||
pub(super) fn fraction(&self) -> f32 {
|
||||
self.value.load(Ordering::Relaxed) as f32 / (self.lines.len() - 1) as f32
|
||||
}
|
||||
pub(super) fn line(&self) -> &'static str {
|
||||
self.lines[(self.value.load(Ordering::Relaxed) as usize).min(self.lines.len() - 1)]
|
||||
}
|
||||
}
|
||||
|
||||
/// Harness and dedicated runs keep the fully-synchronous startup so their
|
||||
/// output stays byte-identical; only an ordinary launch shows the window first.
|
||||
pub(super) fn synchronous_startup(options: &Options) -> bool {
|
||||
// A headless game has no window for the loading screen.
|
||||
options.headless
|
||||
|| options.frames.is_some()
|
||||
|| options.world_frames.is_some()
|
||||
|| options.benchmark.is_some()
|
||||
|| options.control.is_some()
|
||||
|| !options.execution_role.is_client()
|
||||
|| std::env::var_os("HORIZON_DISPENSE_FIXTURE").is_some()
|
||||
|| std::env::var_os("HORIZON_SCULK_FIXTURE").is_some()
|
||||
|| std::env::var_os("HORIZON_COMMAND_SCRIPT").is_some()
|
||||
|| std::env::var_os("HORIZON_NET_REPORT").is_some()
|
||||
}
|
||||
|
||||
/// The local world a client's launch opens at once (`--world`,
|
||||
/// `--quickPlaySingleplayer`), whose generator data can load with the game's.
|
||||
pub(super) fn launch_world(options: &Options) -> Option<&str> {
|
||||
(options.execution_role.is_client() && options.server.is_none() && !options.test_terrain)
|
||||
.then_some(options.world.as_deref())
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// A scoped thread with a name, for one step of the resource load.
|
||||
///
|
||||
/// Named because the heap census and the startup trace both fall back to the
|
||||
/// OS thread name: unnamed, every loader's retained memory and every span it
|
||||
/// ran landed in one `unnamed thread` bucket. That bucket was 330 MiB of a
|
||||
/// playing game's resident set -- the largest of all of them -- with nothing
|
||||
/// to say which loader held it, and 13 of the trace's 17 lanes were
|
||||
/// indistinguishable.
|
||||
pub(super) fn named_scope<'scope, T: Send + 'scope>(
|
||||
scope: &'scope std::thread::Scope<'scope, '_>,
|
||||
name: &str,
|
||||
f: impl FnOnce() -> T + Send + 'scope,
|
||||
) -> std::thread::ScopedJoinHandle<'scope, T> {
|
||||
std::thread::Builder::new()
|
||||
// Linux keeps 15 bytes of a thread name: a short prefix keeps them apart
|
||||
// ("horizon-load-core" and "horizon-load-collision" would both read
|
||||
// "horizon-load-co").
|
||||
.name(format!("load-{name}"))
|
||||
.spawn_scoped(scope, f)
|
||||
// `Scope::spawn`, which this replaces, panics when the OS refuses a
|
||||
// thread. Keeping that: a resource load cannot go on without it.
|
||||
.expect("resource loader thread")
|
||||
}
|
||||
|
||||
/// A step of the resource load: on its own scoped thread, or, when the
|
||||
/// settings allow a single worker, run where it is joined.
|
||||
pub(super) enum Step<'scope, T> {
|
||||
Thread(std::thread::ScopedJoinHandle<'scope, T>),
|
||||
Later(Box<dyn FnOnce() -> T + Send + 'scope>),
|
||||
}
|
||||
impl<'scope, T: Send + 'scope> Step<'scope, T> {
|
||||
/// `name` names the thread when one is spawned ([`named_scope`]).
|
||||
pub(super) fn spawn(
|
||||
scope: &'scope std::thread::Scope<'scope, '_>,
|
||||
parallel: bool,
|
||||
name: &str,
|
||||
f: impl FnOnce() -> T + Send + 'scope,
|
||||
) -> Self {
|
||||
if parallel {
|
||||
Self::Thread(named_scope(scope, name, f))
|
||||
} else {
|
||||
Self::Later(Box::new(f))
|
||||
}
|
||||
}
|
||||
pub(super) fn join(self) -> T {
|
||||
match self {
|
||||
Self::Thread(handle) => handle.join().unwrap(),
|
||||
Self::Later(f) => f(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The world bundle's core tables from one resource root, before
|
||||
/// `load_resources` binds them into the pack tables and `WorldCore`.
|
||||
pub(super) struct CoreTables {
|
||||
pub(super) assets: Assets,
|
||||
pub(super) item_defaults: Arc<horizon_world::item_defaults::Defaults>,
|
||||
pub(super) render: RenderAssets,
|
||||
pub(super) item_renderer: horizon_client::item_render::ItemRenderer,
|
||||
pub(super) visibility: horizon_world::block_render::FaceVisibility,
|
||||
pub(super) gameplay: GameplayTables,
|
||||
pub(super) biomes: Arc<Biomes>,
|
||||
pub(super) environment: Environment,
|
||||
pub(super) collision: Arc<BlockCollisionRegistry<'static>>,
|
||||
}
|
||||
/// The block facts and the tables read from them.
|
||||
pub(super) struct GameplayTables {
|
||||
pub(super) behaviour_json: serde_json::Value,
|
||||
pub(super) facts: BlockFacts,
|
||||
pub(super) gameplay_projection: Vec<horizon_client::blocks::GameplayProjection>,
|
||||
pub(super) light: LightTables,
|
||||
pub(super) motion: MotionResources,
|
||||
}
|
||||
|
||||
/// [`CoreTables`] at `root`. Every table reads the block registry; past it,
|
||||
/// the block models and sprites (then the item models, which share their
|
||||
/// parsed models), the face visibility, the collisions, the biomes and the
|
||||
/// environment need no block facts and load beside them. The outer error
|
||||
/// fails the load; the inner one is the packs' (the registry or the block
|
||||
/// models), which `load_resources` rolls back from.
|
||||
pub(super) fn load_core(
|
||||
root: &std::path::Path,
|
||||
headless: bool,
|
||||
parallel: bool,
|
||||
progress: &Progress,
|
||||
launch_world: Option<&str>,
|
||||
) -> std::result::Result<std::result::Result<CoreTables, String>, String> {
|
||||
// The model library reads every file under `assets/` and needs no
|
||||
// block registry, so it is parsed beside `Assets::load` rather than
|
||||
// after it. It used to sit inside `reload` behind both the registry
|
||||
// read and the block facts; on a cold cache that read was most of a
|
||||
// second on the critical path, and warm it is still ~30 ms.
|
||||
// A server draws nothing and parses no models, so it skips this.
|
||||
let (assets, parsed_models) = std::thread::scope(|scope| {
|
||||
let models = (parallel && !headless).then(|| {
|
||||
scope.spawn(|| {
|
||||
let _step = startup::step("core.models_parse");
|
||||
horizon_client::model_library::ParsedModels::load(root)
|
||||
})
|
||||
});
|
||||
let assets = startup::time("core.assets", || Assets::load(root));
|
||||
let models = models.map(|m| m.join().unwrap_or_else(|e| std::panic::resume_unwind(e)));
|
||||
(assets, models)
|
||||
});
|
||||
let assets = match assets {
|
||||
Ok(assets) => assets,
|
||||
Err(e) => return Ok(Err(e.to_string())),
|
||||
};
|
||||
progress.set(1);
|
||||
let registry = assets.registry;
|
||||
let (render, gameplay, visibility, biomes, environment, collision) =
|
||||
std::thread::scope(|scope| {
|
||||
let assets = &assets;
|
||||
let render = Step::spawn(scope, parallel, "render", move || {
|
||||
// A server reads no block sprites' pixels, which only the
|
||||
// renderer draws (32 MB).
|
||||
let parsed_models = parsed_models;
|
||||
let render = startup::time("core.block_models_sprites", || {
|
||||
if headless {
|
||||
assets.reload_for_server(root)
|
||||
} else if let Some(parsed) = parsed_models {
|
||||
assets.reload_with_models(root, parsed)
|
||||
} else {
|
||||
assets.reload(root)
|
||||
}
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
let library = render.model_library.clone();
|
||||
let item_defaults = startup::time("core.item_defaults", || {
|
||||
horizon_world::item_defaults::shared(root)
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
let item_renderer = startup::time("core.item_models", || {
|
||||
if headless {
|
||||
horizon_client::item_render::ItemRenderer::load_for_server(root, library)
|
||||
} else {
|
||||
horizon_client::item_render::ItemRenderer::load_with_library(root, library)
|
||||
}
|
||||
})
|
||||
.map_err(|e| e.to_string());
|
||||
Ok((render, item_renderer, item_defaults))
|
||||
});
|
||||
// Nor the face visibility the mesher and the block entity
|
||||
// renderer cull with (19 MB).
|
||||
let visibility = Step::spawn(scope, parallel, "visibility", || {
|
||||
if headless {
|
||||
return Ok(horizon_world::block_render::FaceVisibility::empty());
|
||||
}
|
||||
startup::time("core.visibility", || {
|
||||
models::load_visibility(root, registry)
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
});
|
||||
let biomes = Step::spawn(scope, parallel, "biomes", || {
|
||||
let biomes = Arc::new(
|
||||
startup::time("core.biomes", || Biomes::load(root))
|
||||
.map_err(|e| e.to_string())?,
|
||||
);
|
||||
// A launch that names a world: its data preload needs only the
|
||||
// block registry and the biomes, so it starts here rather than
|
||||
// after the block models and facts (~250 ms later).
|
||||
if let Some(folder) = launch_world {
|
||||
world_loading::preload_launch_world(folder, root, registry, biomes.clone());
|
||||
}
|
||||
Ok::<_, String>(biomes)
|
||||
});
|
||||
let environment = Step::spawn(scope, parallel, "environment", || {
|
||||
startup::time("core.environment", || Environment::load(root))
|
||||
.map_err(|e| e.to_string())
|
||||
});
|
||||
let collision = Step::spawn(scope, parallel, "collision", || {
|
||||
startup::time("core.collision", || collisions::load(root, registry))
|
||||
.map_err(|e| e.to_string())
|
||||
});
|
||||
let gameplay = gameplay_tables(root, assets, parallel, progress);
|
||||
(
|
||||
render.join(),
|
||||
gameplay,
|
||||
visibility.join(),
|
||||
biomes.join(),
|
||||
environment.join(),
|
||||
collision.join(),
|
||||
)
|
||||
});
|
||||
// The packs' failure first: it rolls back whatever else failed.
|
||||
let (render, item_renderer, item_defaults) = match render {
|
||||
Ok(render) => render,
|
||||
Err(e) => return Ok(Err(e)),
|
||||
};
|
||||
let gameplay = gameplay?;
|
||||
progress.set(3);
|
||||
Ok(Ok(CoreTables {
|
||||
item_defaults,
|
||||
gameplay,
|
||||
biomes: biomes?,
|
||||
environment: environment?,
|
||||
visibility: visibility?,
|
||||
item_renderer: item_renderer?,
|
||||
collision: Arc::new(collision?),
|
||||
assets,
|
||||
render,
|
||||
}))
|
||||
}
|
||||
/// The block facts, then the light tables beside the motion tables, which
|
||||
/// both read them.
|
||||
pub(super) fn gameplay_tables(
|
||||
root: &std::path::Path,
|
||||
assets: &Assets,
|
||||
parallel: bool,
|
||||
progress: &Progress,
|
||||
) -> std::result::Result<GameplayTables, String> {
|
||||
let facts_step = startup::step("core.block_facts");
|
||||
// Read whole, then parsed from the buffer: `serde_json::from_reader`
|
||||
// pulls through a `BufReader` and is measurably slower on a file this
|
||||
// size than one read plus `from_slice`, which is how `BlockFacts` next
|
||||
// door already reads its own. Same value either way.
|
||||
let behaviour_json: serde_json::Value =
|
||||
horizon_world::json_file::read(&root.join("block-behaviours.json"))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let (facts, gameplay_projection) =
|
||||
BlockFacts::load_with_projection(root, assets.registry, &behaviour_json, parallel)
|
||||
.map_err(|e| e.to_string())?;
|
||||
horizon_client::block_contact::set_mod_blocks(
|
||||
facts
|
||||
.states
|
||||
.iter()
|
||||
.any(|s| !s.name.starts_with("minecraft:")),
|
||||
);
|
||||
drop(facts_step);
|
||||
progress.set(2);
|
||||
let (light, motion) = std::thread::scope(|scope| {
|
||||
let light = Step::spawn(scope, parallel, "light", || {
|
||||
startup::time("core.light", || {
|
||||
LightTables::load(root, assets.registry, &facts)
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
});
|
||||
let motion = startup::time("core.motion", || {
|
||||
MotionResources::load_with_facts(root, assets, &facts)
|
||||
})
|
||||
.map_err(|e| e.to_string());
|
||||
(light.join(), motion)
|
||||
});
|
||||
Ok(GameplayTables {
|
||||
light: light?,
|
||||
motion: motion?,
|
||||
behaviour_json,
|
||||
facts,
|
||||
gameplay_projection,
|
||||
})
|
||||
}
|
||||
|
||||
/// The synchronous launch loads both phases; an ordinary launch returns the
|
||||
/// core bundle while the rest worker keeps loading. `launch_world`: the world
|
||||
/// the launch opens, whose data is preloaded meanwhile.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn load_resources(
|
||||
mut root: PathBuf,
|
||||
launch_world: Option<&str>,
|
||||
vanilla_root: &std::path::Path,
|
||||
settings: &Settings,
|
||||
started: Instant,
|
||||
menu: bool,
|
||||
headless: bool,
|
||||
progress: &Progress,
|
||||
publish: &mut dyn FnMut(UiResources, &std::path::Path),
|
||||
) -> Result<Loaded> {
|
||||
let threads = settings.horizon.worker_threads();
|
||||
// One pool of workers for every level's streaming, the client's and the
|
||||
// server thread's (horizon_threads::jobs), sized before any world opens.
|
||||
horizon_threads::jobs::configure(threads);
|
||||
horizon_client::sprites::set_texture_budget(settings.horizon.texture_budget_mib);
|
||||
horizon_client::sprites::set_labpbr_maps(shader_packs::picks_material_pack(settings));
|
||||
// Stage A: the menu bundle (atlas and panorama). With a loading screen it is
|
||||
// published at once so the title screen can appear while the world bundle
|
||||
// loads; the synchronous launch instead carries it in `Loaded`.
|
||||
let mut pack_error = None;
|
||||
// A dedicated server builds only the font (UiResources::server), as the
|
||||
// integrated server's game does: the full atlas and panorama are 144 MB.
|
||||
let load_ui = |root: &std::path::Path| {
|
||||
let _step = startup::step("ui.load");
|
||||
if headless {
|
||||
UiResources::server(root)
|
||||
} else {
|
||||
UiResources::load(root, vanilla_root)
|
||||
}
|
||||
};
|
||||
// The menu bundle loads on this thread while workers build the world
|
||||
// bundle's core tables from the same root (`load_core`). A pack failure
|
||||
// in either starts both again with the game's own resources
|
||||
// (Minecraft.rollbackResourcePacks).
|
||||
let parallel = threads > 1;
|
||||
let mut offer = |ui: UiResources, root: &std::path::Path| {
|
||||
if menu {
|
||||
log::info!(
|
||||
"Horizon: UI bundle ready after {} ms",
|
||||
started.elapsed().as_millis()
|
||||
);
|
||||
publish(ui, root);
|
||||
None
|
||||
} else {
|
||||
Some(ui)
|
||||
}
|
||||
};
|
||||
let (core, loaded_gui, entity_models) = loop {
|
||||
let (ui, core, entity_models) = std::thread::scope(|scope| {
|
||||
let core = Step::spawn(scope, parallel, "core", || {
|
||||
// A failed pack starts core again from the game's own
|
||||
// resources, which the world's preload must read: with packs
|
||||
// it waits for the core that loaded.
|
||||
let early = launch_world.filter(|_| root == vanilla_root);
|
||||
startup::time("core", || {
|
||||
load_core(&root, headless, parallel, progress, early)
|
||||
})
|
||||
});
|
||||
// The handoff's entity models, the rest worker's mob models and
|
||||
// its transformers all read this one document. A server draws
|
||||
// none of them.
|
||||
let entity_models = (!headless).then(|| {
|
||||
Step::spawn(scope, parallel, "entity_models", || {
|
||||
startup::time("core.entity_models", || {
|
||||
horizon_client::mobs::EntityModelDocument::load(&root).ok()
|
||||
})
|
||||
})
|
||||
});
|
||||
let ui = load_ui(&root).map(|ui| offer(ui, &root));
|
||||
(ui, core.join(), entity_models.and_then(|step| step.join()))
|
||||
});
|
||||
let packs = root != vanilla_root;
|
||||
let failure = match (ui, core) {
|
||||
(Ok(gui), Ok(Ok(core))) => break (core, gui, entity_models),
|
||||
(Err(e), _) if packs => e.to_string(),
|
||||
(_, Ok(Err(e))) if packs => e,
|
||||
(_, Ok(Err(e))) => {
|
||||
return Err(format!(
|
||||
"{e}\nPrepare resources with the launcher, or: horizon-prepare --prepare"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
(_, Err(e)) => return Err(e.into()),
|
||||
(Err(e), Ok(Ok(_))) => return Err(e),
|
||||
};
|
||||
log::warn!(
|
||||
"Horizon: resource packs failed to load ({failure}); using the default resources"
|
||||
);
|
||||
pack_error = Some(failure);
|
||||
root = vanilla_root.to_path_buf();
|
||||
};
|
||||
log::info!("load core tables: {:.2}s", started.elapsed().as_secs_f32());
|
||||
let entity_models = entity_models.map(Arc::new);
|
||||
let CoreTables {
|
||||
assets,
|
||||
item_defaults,
|
||||
render,
|
||||
item_renderer,
|
||||
visibility,
|
||||
gameplay:
|
||||
GameplayTables {
|
||||
behaviour_json,
|
||||
facts,
|
||||
gameplay_projection,
|
||||
light,
|
||||
motion,
|
||||
},
|
||||
biomes,
|
||||
environment,
|
||||
collision,
|
||||
} = core;
|
||||
let assets = Arc::new(assets);
|
||||
let render = Arc::new(render);
|
||||
if let Some(folder) = launch_world {
|
||||
world_loading::preload_launch_world(folder, &root, assets.registry, biomes.clone());
|
||||
}
|
||||
let tables = Arc::new(startup::time("pack.mesh_tables", || {
|
||||
MeshTables::new(&assets, &render, &facts, visibility)
|
||||
})?);
|
||||
let pack_step = startup::step("pack.assemble");
|
||||
let pack = PackResources::assemble(
|
||||
&root,
|
||||
assets.clone(),
|
||||
render,
|
||||
tables,
|
||||
item_renderer,
|
||||
biomes.clone(),
|
||||
Arc::new(light),
|
||||
);
|
||||
drop(pack_step);
|
||||
log::info!("load pack tables: {:.2}s", started.elapsed().as_secs_f32());
|
||||
progress.set(4);
|
||||
if !menu {
|
||||
horizon_client::ui::load_metadata(&root);
|
||||
horizon_client::lang::set(horizon_client::lang::Language::load(
|
||||
&[&root],
|
||||
&settings.language,
|
||||
));
|
||||
}
|
||||
progress.set(5);
|
||||
let suffocation =
|
||||
horizon_client::mobs::suffocation::bind(collision.clone(), &motion.causes_suffocation)?;
|
||||
let resources = Resources {
|
||||
core: WorldCore {
|
||||
facts,
|
||||
biomes: biomes.clone(),
|
||||
environment,
|
||||
collision,
|
||||
motion,
|
||||
suffocation,
|
||||
threads,
|
||||
},
|
||||
rest: std::sync::OnceLock::new(),
|
||||
};
|
||||
let resources = Arc::new(resources);
|
||||
if menu {
|
||||
horizon_client::telemetry::mark_core_ready();
|
||||
log::info!(
|
||||
"Horizon: core-ready after {} ms",
|
||||
started.elapsed().as_millis()
|
||||
);
|
||||
}
|
||||
// The block entity renderer's face visibility: the meshes' own, whose
|
||||
// shapes already hold their faces, not a second load of the same tables.
|
||||
let visibility = pack.context.tables.visibility.clone();
|
||||
let rest_loader = if menu {
|
||||
let resources = resources.clone();
|
||||
let root = root.clone();
|
||||
let vanilla_root = vanilla_root.to_owned();
|
||||
let entity_models = entity_models.clone();
|
||||
let rest_progress = progress.clone();
|
||||
let progress = progress.clone();
|
||||
let handle = std::thread::Builder::new()
|
||||
.name("world-rest-loader".into())
|
||||
.spawn(move || {
|
||||
let _item_defaults = item_defaults;
|
||||
let _step = startup::step("rest");
|
||||
let rest = load_world_rest(
|
||||
&root,
|
||||
&resources,
|
||||
&assets,
|
||||
&behaviour_json,
|
||||
&gameplay_projection,
|
||||
entity_models.as_deref(),
|
||||
visibility,
|
||||
&progress,
|
||||
started,
|
||||
true,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
resources
|
||||
.rest
|
||||
.set(rest)
|
||||
.map_err(|_| "world resources already loaded".to_owned())?;
|
||||
progress.set(9);
|
||||
horizon_client::telemetry::mark_rest_ready();
|
||||
log::info!(
|
||||
"Horizon: rest-ready after {} ms",
|
||||
started.elapsed().as_millis()
|
||||
);
|
||||
write_prepared_files(vanilla_root);
|
||||
Ok(())
|
||||
})?;
|
||||
Some(WorldRestLoader {
|
||||
progress: rest_progress,
|
||||
handle,
|
||||
})
|
||||
} else {
|
||||
let rest = load_world_rest(
|
||||
&root,
|
||||
&resources,
|
||||
&assets,
|
||||
&behaviour_json,
|
||||
&gameplay_projection,
|
||||
entity_models.as_deref(),
|
||||
visibility,
|
||||
progress,
|
||||
started,
|
||||
!headless,
|
||||
)?;
|
||||
resources
|
||||
.rest
|
||||
.set(rest)
|
||||
.map_err(|_| "world resources already loaded")?;
|
||||
None
|
||||
};
|
||||
// The world tables load (above) while the handoff's CPU work is done: the
|
||||
// world renderer's textures beside the game's own tables. A failure here
|
||||
// waits for the tail, so no tail worker outlives its pack-loader generation.
|
||||
let prepared_at = Instant::now();
|
||||
let (game_cpu, renderer_cpu) = if menu {
|
||||
let (game_cpu, renderer_cpu) = std::thread::scope(|scope| {
|
||||
let textures = Step::spawn(scope, parallel, "textures", || {
|
||||
startup::time("handoff.renderer_textures", || {
|
||||
prepare_renderer_textures(&pack)
|
||||
})
|
||||
});
|
||||
let game_cpu = startup::time("handoff.game_cpu", || {
|
||||
GameCpuResources::load(&root, entity_models.as_deref())
|
||||
});
|
||||
(game_cpu, textures.join())
|
||||
});
|
||||
let game_cpu = match game_cpu {
|
||||
Ok(game_cpu) => game_cpu,
|
||||
Err(error) => {
|
||||
if let Some(tail) = rest_loader {
|
||||
let _ = tail.handle.join();
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
log::info!(
|
||||
"Horizon: handoff CPU resources prepared on worker in {} ms",
|
||||
prepared_at.elapsed().as_millis()
|
||||
);
|
||||
(Some(game_cpu), Some(renderer_cpu))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
Ok(Loaded {
|
||||
game_cpu,
|
||||
entity_models,
|
||||
renderer_cpu,
|
||||
root,
|
||||
pack_error,
|
||||
resources,
|
||||
rest_loader,
|
||||
pack,
|
||||
gui: loaded_gui,
|
||||
})
|
||||
}
|
||||
|
||||
/// Write the prepared folder's `horizon-data.bin` when it has none the game
|
||||
/// can use: on a thread of its own, at idle priority, once the start is done.
|
||||
/// This start has just read the folder's files (they are in the disk cache);
|
||||
/// the next starts read them from one file (#1961).
|
||||
pub(super) fn write_prepared_files(root: PathBuf) {
|
||||
use horizon_core::prepared_files;
|
||||
if prepared_files::open(&root).is_some() || !root.join("horizon-resources.json").is_file() {
|
||||
return;
|
||||
}
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("Prepared data file".into())
|
||||
.spawn(move || {
|
||||
horizon_threads::idle_current();
|
||||
let started = Instant::now();
|
||||
match prepared_files::write(&root) {
|
||||
Ok(()) => log::info!(
|
||||
"Horizon: wrote {} in {} ms",
|
||||
prepared_files::FILE_NAME,
|
||||
started.elapsed().as_millis()
|
||||
),
|
||||
Err(e) => log::info!("Horizon: {} not written: {e}", prepared_files::FILE_NAME),
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
log::info!(
|
||||
"Horizon: {} not written: {e}",
|
||||
horizon_core::prepared_files::FILE_NAME
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! The game's shared read-only tables: `Resources` (blocks, items, recipes, mob types, biomes),
|
||||
//! `UiResources` and `PackResources`.
|
||||
use super::*;
|
||||
|
||||
impl Resources {
|
||||
pub(super) fn rest(&self) -> &WorldRest {
|
||||
self.rest
|
||||
.get()
|
||||
.expect("world resources joined before entry")
|
||||
}
|
||||
pub(super) fn facts(&self) -> &BlockFacts {
|
||||
&self.core.facts
|
||||
}
|
||||
pub(super) fn items(&self) -> &Items {
|
||||
&self.rest().items
|
||||
}
|
||||
pub(super) fn recipes(&self) -> &Recipes {
|
||||
&self.rest().recipes
|
||||
}
|
||||
pub(super) fn transformers(&self) -> &horizon_client::block_transform::Transformers {
|
||||
&self.rest().transformers
|
||||
}
|
||||
pub(super) fn biomes(&self) -> &Arc<Biomes> {
|
||||
&self.core.biomes
|
||||
}
|
||||
pub(super) fn environment(&self) -> &Environment {
|
||||
&self.core.environment
|
||||
}
|
||||
pub(super) fn mob_types(&self) -> &HashMap<String, Arc<MobType>> {
|
||||
&self.rest().mob_types
|
||||
}
|
||||
pub(super) fn mob_data(&self) -> &horizon_client::mobs::MobData {
|
||||
&self.rest().mob_data
|
||||
}
|
||||
pub(super) fn providers(&self) -> &Providers {
|
||||
&self.rest().providers
|
||||
}
|
||||
pub(super) fn block_entity_render(&self) -> &BlockEntityRenderer {
|
||||
&self.rest().block_entity_render
|
||||
}
|
||||
pub(super) fn animate(&self) -> &horizon_client::animate_tick::AnimateTables {
|
||||
&self.rest().animate
|
||||
}
|
||||
pub(super) fn fog(&self) -> &horizon_client::fog::FogTables {
|
||||
&self.rest().fog
|
||||
}
|
||||
pub(super) fn music(&self) -> &horizon_client::music::MusicTables {
|
||||
&self.rest().music
|
||||
}
|
||||
pub(super) fn ambient(&self) -> &horizon_client::ambience::AmbientTables {
|
||||
&self.rest().ambient
|
||||
}
|
||||
pub(super) fn nether_environment(&self) -> &Environment {
|
||||
&self.rest().nether_environment
|
||||
}
|
||||
pub(super) fn end_environment(&self) -> &Environment {
|
||||
&self.rest().end_environment
|
||||
}
|
||||
pub(super) fn behaviours(&self) -> &Behaviours {
|
||||
&self.rest().behaviours
|
||||
}
|
||||
pub(super) fn collision(&self) -> &BlockCollisionRegistry<'static> {
|
||||
&self.core.collision
|
||||
}
|
||||
pub(super) fn suffocation(&self) -> &horizon_world::suffocation::SuffocationRegistry<'static> {
|
||||
&self.core.suffocation
|
||||
}
|
||||
pub(super) fn motion(&self) -> &MotionResources {
|
||||
&self.core.motion
|
||||
}
|
||||
pub(super) fn threads(&self) -> usize {
|
||||
self.core.threads
|
||||
}
|
||||
pub(super) fn player_arm(&self, slim: bool, left: bool) -> &Option<horizon_client::mobs::Part> {
|
||||
let arms = if slim {
|
||||
&self.rest().player_arm_slim
|
||||
} else {
|
||||
&self.rest().player_arm
|
||||
};
|
||||
&arms[usize::from(left)]
|
||||
}
|
||||
pub(super) fn player_type(&self) -> &Option<Arc<horizon_client::mobs::MobType>> {
|
||||
&self.rest().player_type
|
||||
}
|
||||
pub(super) fn spin_attack(&self) -> &Option<horizon_client::mobs::Part> {
|
||||
&self.rest().spin_attack
|
||||
}
|
||||
pub(super) fn stations(&self) -> &horizon_client::stations::StationData {
|
||||
&self.rest().stations
|
||||
}
|
||||
}
|
||||
|
||||
impl UiResources {
|
||||
/// From a resource root layering packs over `defaults`, the game's own
|
||||
/// resources (`root` itself when no pack is on).
|
||||
pub fn load(root: &std::path::Path, defaults: &std::path::Path) -> Result<Self> {
|
||||
// The title screen waits for both: the panorama's six faces decode
|
||||
// beside the atlas.
|
||||
std::thread::scope(|scope| {
|
||||
let panorama = named_scope(scope, "panorama", || {
|
||||
startup::time("ui.panorama", || {
|
||||
horizon_client::gfx::panorama::Faces::load(root, defaults)
|
||||
})
|
||||
});
|
||||
let mod_image = crate::game::mods::built_with_native_mods();
|
||||
let atlas = startup::time("ui.atlas", || {
|
||||
HudAtlas::build_over_with_image(root, defaults, mod_image)
|
||||
});
|
||||
let panorama = panorama
|
||||
.join()
|
||||
.unwrap_or_else(|panic| std::panic::resume_unwind(panic));
|
||||
Ok(Self {
|
||||
atlas: atlas?,
|
||||
panorama: Arc::new(panorama),
|
||||
})
|
||||
})
|
||||
}
|
||||
/// What a server's game holds, since it draws nothing: the font, which books
|
||||
/// and chat measure with, and a one-pixel panorama.
|
||||
pub fn server(root: &std::path::Path) -> Result<Self> {
|
||||
Ok(Self {
|
||||
atlas: HudAtlas::fonts(root)?,
|
||||
panorama: Arc::new(horizon_client::gfx::panorama::Faces::colour([0; 4])),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl PackResources {
|
||||
/// Everything a pack overlay at `root` provides, over `assets` and `render`
|
||||
/// loaded from it.
|
||||
pub(super) fn load(
|
||||
root: &std::path::Path,
|
||||
assets: Arc<Assets>,
|
||||
render: Arc<RenderAssets>,
|
||||
facts: &BlockFacts,
|
||||
biomes: Arc<Biomes>,
|
||||
light: Arc<LightTables>,
|
||||
) -> Result<Self> {
|
||||
let visibility = models::load_visibility(root, assets.registry)?;
|
||||
let tables = Arc::new(MeshTables::new(&assets, &render, facts, visibility)?);
|
||||
let item_renderer = horizon_client::item_render::ItemRenderer::load_with_library(
|
||||
root,
|
||||
render.model_library.clone(),
|
||||
)?;
|
||||
Ok(Self::assemble(
|
||||
root,
|
||||
assets,
|
||||
render,
|
||||
tables,
|
||||
item_renderer,
|
||||
biomes,
|
||||
light,
|
||||
))
|
||||
}
|
||||
/// The workers' context over `biomes`: this pack's own when `biomes` is
|
||||
/// the table it was built with, else the same parts with that table.
|
||||
pub(super) fn context_with(&self, biomes: Arc<Biomes>) -> Arc<WorkerContext> {
|
||||
let old = &self.context;
|
||||
if Arc::ptr_eq(&old.biomes, &biomes) {
|
||||
return old.clone();
|
||||
}
|
||||
Arc::new(WorkerContext {
|
||||
assets: old.assets.clone(),
|
||||
render: old.render.clone(),
|
||||
tables: old.tables.clone(),
|
||||
biomes,
|
||||
light: old.light.clone(),
|
||||
})
|
||||
}
|
||||
/// The pack tables over their parts, which `load_resources` builds on
|
||||
/// separate workers.
|
||||
pub(super) fn assemble(
|
||||
root: &std::path::Path,
|
||||
assets: Arc<Assets>,
|
||||
render: Arc<RenderAssets>,
|
||||
tables: Arc<MeshTables>,
|
||||
item_renderer: horizon_client::item_render::ItemRenderer,
|
||||
biomes: Arc<Biomes>,
|
||||
light: Arc<LightTables>,
|
||||
) -> Self {
|
||||
Self {
|
||||
layer_names: Arc::new(
|
||||
render
|
||||
.sprite_layers
|
||||
.iter()
|
||||
.map(|(name, layer)| (*layer, name.trim_start_matches("minecraft:").to_owned()))
|
||||
.collect(),
|
||||
),
|
||||
context: Arc::new(WorkerContext {
|
||||
assets: assets.clone(),
|
||||
render: render.clone(),
|
||||
tables,
|
||||
biomes,
|
||||
light,
|
||||
}),
|
||||
assets,
|
||||
render,
|
||||
item_renderer: Arc::new(item_renderer),
|
||||
names: Arc::new(Names::load(root)),
|
||||
clouds: Arc::new(load_clouds(root)),
|
||||
forcefield: Arc::new(load_forcefield(root)),
|
||||
custom_sky: Arc::new(horizon_client::custom_sky::CustomSky::load(root)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
//! What a frame draws behind the screens: the title panorama, the level loading background, the
|
||||
//! menu blur and transparent background, the world border wall, and the screens' render events.
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `native_screen_render_post` (`ScreenEvent.Render.Post`) for the open
|
||||
/// screen, once a frame; the Win and Options screens call this on their
|
||||
/// own path. Reads `self.screen`, the one `hud_layers` drew. Not while
|
||||
/// playing with no screen, nor while the server pack prompt hides the
|
||||
/// open screen (`hud_layers` draws `Playing` then). Client only,
|
||||
/// read-only: what a mod draws is its own.
|
||||
pub(super) fn fire_screen_render_post(&self, batch: &mut HudBatch) {
|
||||
let screen = self.screen;
|
||||
if screen == Screen::Playing || self.server_packs.prompt.is_some() || self.book.is_some() {
|
||||
return;
|
||||
}
|
||||
// slot_count mirrors slots() without allocating; keep them in step.
|
||||
debug_assert_eq!(self.slot_count(), self.slots().len());
|
||||
let view = self.screen_view(batch, screen);
|
||||
self.native_screen_render_post(batch, &view);
|
||||
}
|
||||
/// `native_screen_render_post` (`ScreenEvent.Render.Post`) for the title's
|
||||
/// own screen (`title`, `world_select`, `world_create`, `multiplayer`…),
|
||||
/// once a frame, after it has drawn and before the toasts. Not when a
|
||||
/// screen of the game's own is over the title (Options draws and fires
|
||||
/// its own), nor while a dialog, a prompt or a connect or load hides it.
|
||||
/// Client only, read-only: what a mod draws is its own.
|
||||
pub(super) fn fire_title_screen_render_post(&self, batch: &mut HudBatch) {
|
||||
if self.screen != Screen::Playing || self.screen_over_playing() || self.remote_loading() {
|
||||
return;
|
||||
}
|
||||
let Some(view) = self.title_screen_view(batch) else {
|
||||
return;
|
||||
};
|
||||
self.native_screen_render_post(batch, &view);
|
||||
}
|
||||
|
||||
/// The frame shows the title's panorama, not a level: the title screen, or a
|
||||
/// LevelLoadingScreen (Reason.OTHER), which draws it too.
|
||||
pub(super) fn shows_panorama(&self) -> bool {
|
||||
// The two cases that always had one: the title screen and waiting on
|
||||
// a server. Left exactly as they were.
|
||||
self.title.is_some() || self.remote_loading() || self.screen_wants_panorama()
|
||||
}
|
||||
/// Whether the screen on top asked for the panorama through
|
||||
/// [`horizon_client::screen_draw::Backdrop`].
|
||||
///
|
||||
/// This is the opt-in path for Horizon's own screens: a screen sets its
|
||||
/// `backdrop` and the renderer draws the panorama behind it, instead of
|
||||
/// every such screen needing its own branch in `shows_panorama`. Vanilla
|
||||
/// screens leave the backdrop at `None` and so never reach this.
|
||||
///
|
||||
/// Only in a menu: in a world the panorama would replace the world the
|
||||
/// player is standing in, which is not what any of these screens want.
|
||||
pub(super) fn screen_wants_panorama(&self) -> bool {
|
||||
use horizon_client::screen_draw::Backdrop;
|
||||
if self.player.is_some() {
|
||||
return false;
|
||||
}
|
||||
self.ui
|
||||
.options
|
||||
.pack_screen
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.backdrop == Backdrop::Panorama)
|
||||
}
|
||||
/// `LevelLoadingScreen.extractBackground`.
|
||||
pub(super) fn draw_level_loading_background(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
reason: horizon_client::level_loading::Reason,
|
||||
) {
|
||||
use horizon_client::level_loading::Reason;
|
||||
let atlas = &self.gui.atlas;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
match reason {
|
||||
// The panorama and its blur are drawn by the renderer.
|
||||
Reason::Other => {
|
||||
horizon_client::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, false)
|
||||
}
|
||||
Reason::NetherPortal => {
|
||||
let overlay = hud_overlays::portal_overlay(w as i32, h as i32, 1.0);
|
||||
hud_layout::paint(batch, atlas, &[overlay]);
|
||||
}
|
||||
Reason::EndPortal => batch.fill(atlas, 0.0, 0.0, w, h, rgba(20, 20, 20, 255)),
|
||||
}
|
||||
}
|
||||
/// `GameRenderer.update`'s `requestedPostEffects`: `end_of_frame` (a resource
|
||||
/// pack's own chain), then the server's `LocalPlayer.postEffects`. Only the
|
||||
/// world is drawn through them.
|
||||
pub(super) fn requested_post_effects(&self) -> Vec<String> {
|
||||
if self.title.is_some() || self.player.is_none() {
|
||||
return Vec::new();
|
||||
}
|
||||
std::iter::once("minecraft:end_of_frame".to_owned())
|
||||
.chain(self.post_effects.iter().cloned())
|
||||
.chain(self.spectated_post_effect.map(str::to_owned))
|
||||
.collect()
|
||||
}
|
||||
/// `Screen.extractBlurredBackground` over the panorama or the world and
|
||||
/// HUD, when `menuBackgroundBlurriness` is at least 1.
|
||||
pub(super) fn menu_blur(&self) -> Option<f32> {
|
||||
let blurred = self.remote_loading()
|
||||
|| self.title.as_ref().is_some_and(|t| {
|
||||
matches!(self.screen, Screen::Options | Screen::Win)
|
||||
|| !matches!(t.mode, horizon_client::title::Mode::Main)
|
||||
})
|
||||
|| (self.title.is_none()
|
||||
&& self.player.is_some()
|
||||
&& (self.screen.blurs_background(self.pause_menu)
|
||||
|| self.dialog_open()
|
||||
|| (self.screen == Screen::Win && self.win_screen_pauses())))
|
||||
|| self.screen_blur_requested.get();
|
||||
let radius = self.settings.menu_blur as f32;
|
||||
let mut radius = (blurred && radius >= 1.0).then_some(radius);
|
||||
self.native_menu_background(&mut radius);
|
||||
radius.filter(|r| r.is_finite() && (1.0..=64.0).contains(r))
|
||||
}
|
||||
/// `Screen.renderTransparentBackground`: draws the translucent dark background
|
||||
/// behind an in-game screen (inventory, containers, creative, books, signs, editors),
|
||||
/// giving native mods a chance to suppress it (`Flow::Cancel`) or apply menu blur / tiled background.
|
||||
pub(super) fn render_transparent_background(&self, screen: &str, batch: &mut HudBatch) {
|
||||
let mut canceled = false;
|
||||
self.native_screen_transparent_background(screen, batch, &mut canceled);
|
||||
if !canceled {
|
||||
let atlas = &self.gui.atlas;
|
||||
batch.fill(
|
||||
atlas,
|
||||
0.0,
|
||||
0.0,
|
||||
batch.width,
|
||||
batch.height,
|
||||
rgba(16, 16, 16, 160),
|
||||
);
|
||||
}
|
||||
}
|
||||
/// `LevelExtractor` / `LevelRenderer`: the world border wall this frame.
|
||||
pub(super) fn border_draw(
|
||||
&mut self,
|
||||
camera: [f64; 3],
|
||||
partial: f32,
|
||||
) -> Option<horizon_client::gfx::BorderDraw> {
|
||||
use horizon_client::border_render::{self, BorderRenderer};
|
||||
let distance = f64::from(self.streaming.radius * 16);
|
||||
border_render::extract(
|
||||
&self.border,
|
||||
partial,
|
||||
camera,
|
||||
distance,
|
||||
&mut self.border_state,
|
||||
);
|
||||
// `LevelRenderer.repositionCamera`: the wall is rebuilt when the camera
|
||||
// enters another `SectionPos` (it only holds the stretch in range).
|
||||
let section = camera.map(|c| (c.floor() as i32) >> 4);
|
||||
if self.border_section != Some(section) {
|
||||
self.border_renderer.invalidate();
|
||||
self.border_section = Some(section);
|
||||
}
|
||||
// Camera.depthFar, as the renderer's far plane.
|
||||
let depth_far =
|
||||
horizon_client::camera::depth_far(self.terrain_distance(), self.settings.cloud_range);
|
||||
if !self
|
||||
.border_renderer
|
||||
.prepare(&self.border_state, camera, distance, depth_far)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let millis = self.visual_millis();
|
||||
Some(horizon_client::gfx::BorderDraw {
|
||||
vertices: self.border_renderer.vertices,
|
||||
rebuilt: self.border_renderer.take_rebuilt(),
|
||||
transforms: self
|
||||
.border_renderer
|
||||
.transforms(&self.border_state, camera, millis),
|
||||
sides: BorderRenderer::sides(&self.border_state, camera, distance),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
//! An item in use, tick by tick (`LivingEntity.startUsingItem`, `updatingUsingItem`,
|
||||
//! `releaseUsingItem`, `stopUsingItem`): eating and drinking, goat horns, bundles, and the
|
||||
//! vibrations a use makes.
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Consumable.startConsuming` for a held consumable: food needs
|
||||
/// `Player.canEat` (`Consumable.canConsume`), anything else is always
|
||||
/// consumed. The right click is held for `consume_seconds`.
|
||||
pub(super) fn start_eating(&mut self, info: &horizon_client::items::ItemInfo) -> bool {
|
||||
let Some(stack) = self.held().cloned() else {
|
||||
return false;
|
||||
};
|
||||
if info.consumable.is_none()
|
||||
|| (info.food.is_some()
|
||||
&& (self.player.is_none() || !self.can_eat(&stack, info.can_always_eat)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// as Consumable.startConsuming does, no time is eaten at once, without a use
|
||||
// (the server's eating reaches a client through the inventory).
|
||||
if let Some(consumable) = info.consumable.as_ref()
|
||||
&& item_use::consumes_at_once(consumable.ticks())
|
||||
{
|
||||
if self.remote.is_none() {
|
||||
// the cooldown of ItemStack.use's applyAfterUseComponentSideEffects.
|
||||
self.apply_use_cooldown(&stack);
|
||||
self.finish_consuming(info, consumable, &stack);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
self.start_using_item();
|
||||
true
|
||||
}
|
||||
/// `InstrumentItem.use` for a goat horn: the player holds it up for the
|
||||
/// instrument's `use_duration`, its sound plays at the player
|
||||
/// (`SoundSource.RECORDS`, volume `range / 16`), `INSTRUMENT_PLAY` is
|
||||
/// posted, the used statistic counts and the horn is on cooldown as long.
|
||||
/// A horn whose instrument is unknown fails, which still ends the click.
|
||||
pub(super) fn use_instrument(&mut self, stack: &ItemStack) {
|
||||
let Some(instrument) = self.res.items().instrument(stack) else {
|
||||
return;
|
||||
};
|
||||
self.start_using_item();
|
||||
self.play_player_bound_sound(&instrument.sound, 2, instrument.range / 16.0, 1.0, true);
|
||||
self.post_entity_event(
|
||||
"minecraft:instrument_play",
|
||||
horizon_client::mobs::Who::Player,
|
||||
);
|
||||
if instrument.use_duration > 0.0 {
|
||||
let group = horizon_client::cooldowns::group(stack, None);
|
||||
self.add_cooldown(group, instrument.use_ticks());
|
||||
}
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
}
|
||||
|
||||
pub(super) fn consume_held(&mut self) {
|
||||
if self.creative {
|
||||
return;
|
||||
}
|
||||
let cell = self.held_mut();
|
||||
if let Some(s) = cell.as_mut() {
|
||||
s.count -= 1;
|
||||
if s.count == 0 {
|
||||
*cell = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) fn tick_using(&mut self) {
|
||||
let Some(hand) = self.used_hand() else {
|
||||
return;
|
||||
};
|
||||
self.with_hand(hand, Self::tick_used_item);
|
||||
}
|
||||
/// `LivingEntity.updatingUsingItem` for the item in use, its hand acting.
|
||||
pub(super) fn tick_used_item(&mut self) {
|
||||
let Some((slot, ticks)) = self.using else {
|
||||
return;
|
||||
};
|
||||
let Some(stack) = self.stack_in(slot).cloned() else {
|
||||
self.using = None;
|
||||
return;
|
||||
};
|
||||
// Switching hotbar slots only ends a main-hand use.
|
||||
let hand_changed = slot != self.selected && slot != shield::OFFHAND_SLOT;
|
||||
if !self.right || hand_changed || self.screen != Screen::Playing {
|
||||
self.using = None;
|
||||
self.release_used_item(&stack, ticks, hand_changed);
|
||||
return;
|
||||
}
|
||||
// ServerPlayer.updateUsingItem: USING_ITEM every tick of the use.
|
||||
self.trigger_using_item(&stack);
|
||||
let Some(info) = self.items().get(&stack.item).cloned() else {
|
||||
self.using = None;
|
||||
return;
|
||||
};
|
||||
// a mod's tick ran the use out: finished now, or frozen until let go
|
||||
// for an item used on release (CrossbowItem.useOnRelease).
|
||||
if !self.use_item_ticked(&stack, ticks) {
|
||||
if &*stack.item != "minecraft:crossbow" {
|
||||
self.complete_using_item(slot, &stack, &info);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if self.blocks_attacks(&stack).is_some() {
|
||||
// ShieldItem: used for 72000 ticks, i.e. until released.
|
||||
self.item_on_use_tick(&stack, ticks);
|
||||
self.using = Some((slot, ticks + 1));
|
||||
return;
|
||||
}
|
||||
if self.tick_drinking(slot, ticks, &stack) {
|
||||
if self.using.is_none() {
|
||||
self.use_item_finished(&stack);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if &*stack.item == "minecraft:bow" {
|
||||
self.using = Some((slot, ticks + 1));
|
||||
return;
|
||||
}
|
||||
if &*stack.item == "minecraft:crossbow" {
|
||||
self.tick_crossbow(slot, ticks, &stack);
|
||||
return;
|
||||
}
|
||||
// TridentItem and EnderEyeItem: used until released (an eye's
|
||||
// getUseDuration 0 never counts down to finishUsingItem).
|
||||
if matches!(&*stack.item, "minecraft:trident" | "minecraft:ender_eye") {
|
||||
self.using = Some((slot, ticks + 1));
|
||||
return;
|
||||
}
|
||||
// ItemStack.onUseTick: a spear stabs what its speed allows, until released.
|
||||
if horizon_client::spear::Kinetic::of(&stack.item).is_some() {
|
||||
self.spear_tick(&stack, ticks);
|
||||
self.using = Some((slot, ticks + 1));
|
||||
return;
|
||||
}
|
||||
if &*stack.item == "minecraft:spyglass" {
|
||||
// SpyglassItem.getUseDuration (1200, as a mod left it), then
|
||||
// completeUsingItem: finishUsingItem lowers it, and the finish hook.
|
||||
match item_use::count_down(ticks, self.use_duration) {
|
||||
Some(ticks) => self.using = Some((slot, ticks)),
|
||||
None => self.complete_using_item(slot, &stack, &info),
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(duration) = self
|
||||
.res
|
||||
.items()
|
||||
.instrument(&stack)
|
||||
.map(|i| i.use_ticks().max(0) as u32)
|
||||
{
|
||||
// InstrumentItem.getUseDuration: held up that long. When it runs
|
||||
// out, LivingEntity.stopUsingItem ends the use and causes the
|
||||
// interaction vibration; letting go of the button already does.
|
||||
if ticks + 1 < duration {
|
||||
self.using = Some((slot, ticks + 1));
|
||||
} else {
|
||||
self.stop_using_item(&stack);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if &*stack.item == "minecraft:brush" {
|
||||
self.tick_brush(&stack, slot, ticks);
|
||||
return;
|
||||
}
|
||||
if info.bundle {
|
||||
self.tick_bundle(slot, ticks);
|
||||
return;
|
||||
}
|
||||
if self.mod_use(&stack) {
|
||||
self.tick_mod_use(slot, ticks, &stack);
|
||||
return;
|
||||
}
|
||||
let Some(consumable) = info.consumable.as_ref() else {
|
||||
self.using = None;
|
||||
return;
|
||||
};
|
||||
// ItemStack.onUseTick: Consumable.shouldEmitParticlesAndSounds with
|
||||
// the remaining ticks before this tick counts them down.
|
||||
// the duration is getUseDuration's when the use started, as a mod's
|
||||
// Start and Tick left it.
|
||||
let remaining = self.use_duration - ticks as i32;
|
||||
if consumable.should_emit(remaining) {
|
||||
self.emit_consume(consumable, &stack, 5);
|
||||
}
|
||||
self.item_on_use_tick(&stack, ticks);
|
||||
match item_use::count_down(ticks, self.use_duration) {
|
||||
Some(ticks) => self.using = Some((slot, ticks)),
|
||||
None => self.complete_using_item(slot, &stack, &info),
|
||||
}
|
||||
}
|
||||
/// `BundleItem.onUseTick` and `getUseDuration` (200): the first tick and
|
||||
/// then every second tick from the 11th on drop one stack out of the
|
||||
/// bundle in `slot`; the use ends after 200 ticks.
|
||||
pub(super) fn tick_bundle(&mut self, slot: usize, ticks: u32) {
|
||||
const USE_DURATION: u32 = 200;
|
||||
let remaining = USE_DURATION - ticks.min(USE_DURATION);
|
||||
if ticks == 0 || (remaining < USE_DURATION - 10 && remaining.is_multiple_of(2)) {
|
||||
self.drop_bundle_content(slot);
|
||||
}
|
||||
self.using = (ticks + 1 < USE_DURATION).then_some((slot, ticks + 1));
|
||||
}
|
||||
|
||||
/// `LivingEntity.releaseUsingItem` (`Item.releaseUsing`) for `stack`,
|
||||
/// used for `ticks`, its hand acting: a bow or a trident lets go (not
|
||||
/// when its hand changed), a spyglass stops scoping, and a mod's own
|
||||
/// release follows. A mod's `Stop` may skip the release. `stopUsingItem`
|
||||
/// ends the use after it.
|
||||
pub(super) fn release_used_item(&mut self, stack: &ItemStack, ticks: u32, hand_changed: bool) {
|
||||
let canceled = !hand_changed && !self.use_item_stopped(stack, ticks);
|
||||
let (release, spyglass) = item_use::release_parts(canceled, hand_changed);
|
||||
match &*stack.item {
|
||||
"minecraft:bow" if release => self.release_bow(stack, self.ticks_held(stack, ticks)),
|
||||
"minecraft:trident" if release => self.release_trident(self.ticks_held(stack, ticks)),
|
||||
"minecraft:spyglass" if spyglass => self.stop_spyglass(),
|
||||
_ => {}
|
||||
}
|
||||
if release {
|
||||
self.mod_release_using(stack, ticks);
|
||||
}
|
||||
self.stop_using_item(stack);
|
||||
}
|
||||
/// `LivingEntity.stopUsingItem`: the use of `stack` is over (`useItem` and
|
||||
/// its remaining ticks are cleared), and when the use effects of the stack
|
||||
/// ask for it, `ItemStack.causeUseVibration` sends an `ITEM_INTERACT_FINISH`
|
||||
/// game event from the player, at its feet.
|
||||
pub(super) fn stop_using_item(&mut self, stack: &ItemStack) {
|
||||
self.using = None;
|
||||
self.use_vibration(stack, "minecraft:item_interact_finish");
|
||||
}
|
||||
/// `ItemStack.causeUseVibration` for `stack` used by the player: the
|
||||
/// `event` game event (`ITEM_INTERACT_START` when the use begins,
|
||||
/// `ITEM_INTERACT_FINISH` when it ends) from the player, at its feet, when
|
||||
/// the stack's use effects ask for interaction vibrations (the default).
|
||||
pub(super) fn use_vibration(&mut self, stack: &ItemStack, event: &'static str) {
|
||||
let default = self.items().get(&stack.item).map(|i| i.interact_vibrations);
|
||||
if !item_use::interact_vibrations(stack, default) {
|
||||
return;
|
||||
}
|
||||
let Some(p) = self.player.as_ref().map(|p| p.position) else {
|
||||
return;
|
||||
};
|
||||
let ctx = self.player_context(None);
|
||||
self.post_game_event(event, [p.x, p.y, p.z], ctx);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
//! winit's `ApplicationHandler` for the game window: window and device events, shutting down,
|
||||
//! and the idle step (`about_to_wait`).
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
/// winit's `window_event`. The control socket and the pad menu call it too, with the
|
||||
/// input they make up.
|
||||
pub(super) fn handle_window_event(
|
||||
&mut self,
|
||||
event_loop: &dyn EventLoopCtl,
|
||||
id: WindowId,
|
||||
mut event: WindowEvent,
|
||||
origin: input_trace::InputOrigin,
|
||||
) {
|
||||
// A headless game has no window: its events (the frame, the control socket's input)
|
||||
// carry `WindowId::dummy()`.
|
||||
let ours = match &self.window {
|
||||
Some(w) => w.id() == id,
|
||||
None => self.options.headless,
|
||||
};
|
||||
if !ours {
|
||||
return;
|
||||
}
|
||||
follow_keymap(&mut event);
|
||||
// MacosUtil.setCtrlClickEmulatesRightClick: Control + left click is a right click.
|
||||
if let WindowEvent::MouseInput { state, button, .. } = &mut event
|
||||
&& *button == MouseButton::Left
|
||||
&& self.right_click_emulation.is_right_click(
|
||||
*state == ElementState::Pressed,
|
||||
self.modifiers.control_key(),
|
||||
cfg!(target_os = "macos") && self.settings.ctrl_click_emulates_right_click,
|
||||
)
|
||||
{
|
||||
*button = MouseButton::Right;
|
||||
}
|
||||
// Who caused it, before the event is handled: the trace wants the
|
||||
// window's own keys, never the ones the socket made up.
|
||||
self.trace_window_event(origin, &event);
|
||||
if let WindowEvent::KeyboardInput { event, .. } = &event
|
||||
&& let PhysicalKey::Code(key) = event.physical_key
|
||||
{
|
||||
if event.state == ElementState::Pressed {
|
||||
self.physical_keys.insert(key);
|
||||
horizon_client::keybinds::learn_layout(event);
|
||||
} else {
|
||||
self.physical_keys.remove(&key);
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
&event,
|
||||
WindowEvent::MouseInput {
|
||||
state: ElementState::Pressed,
|
||||
..
|
||||
}
|
||||
) && !self.pad.menu.synthetic
|
||||
{
|
||||
self.pad.keyboard_took_over = true;
|
||||
}
|
||||
if let WindowEvent::MouseInput { state, button, .. } = &event {
|
||||
if *state == ElementState::Pressed {
|
||||
self.physical_buttons.insert(*button);
|
||||
} else {
|
||||
self.physical_buttons.remove(button);
|
||||
}
|
||||
}
|
||||
let mouse = if !self.pad_synthetic_input()
|
||||
&& let WindowEvent::MouseInput { state, button, .. } = &event
|
||||
{
|
||||
Some((
|
||||
glfw_button(*button),
|
||||
*state == ElementState::Pressed,
|
||||
glfw_modifiers(self.modifiers),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some((button, pressed, modifiers)) = mouse {
|
||||
let mut canceled = false;
|
||||
self.native_mouse_button_pre(button, pressed, modifiers, &mut canceled);
|
||||
if canceled {
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.handle_window_event_inner(event_loop, event);
|
||||
if let Some((button, pressed, modifiers)) = mouse {
|
||||
self.native_mouse_button_post(button, pressed, modifiers);
|
||||
}
|
||||
}
|
||||
|
||||
/// The loop ended: close the pads' output, save the world, write the perf report.
|
||||
pub(super) fn shut_down(&mut self) {
|
||||
self.end_screenshot_sequence(Err("cancelled: the game is closing".into()));
|
||||
self.pad_close_output();
|
||||
if let Err(e) = self.save_for_exit() {
|
||||
log::warn!("Horizon: failed to save the world on exit: {e}");
|
||||
}
|
||||
self.finish_perf();
|
||||
}
|
||||
/// winit's `about_to_wait`: what happens between frames (stop requests, the closing
|
||||
/// save, world entry, the control socket, pads), then the next frame.
|
||||
pub(super) fn idle(&mut self, event_loop: &dyn EventLoopCtl) {
|
||||
// A splitscreen player's game has nothing to save: P1's shuts the process down.
|
||||
if horizon_threads::stop_requests() > 0
|
||||
&& !self.options.split_guest
|
||||
&& self.pending_save.as_ref().is_none_or(|save| !save.quit)
|
||||
{
|
||||
// SIGTERM, SIGINT or SIGHUP: the shutdown hook's server halt, as a window close.
|
||||
if let Err(error) = self.begin_closing_save(true) {
|
||||
self.error = Some(error);
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if self.pending_save.is_some() {
|
||||
match self.poll_closing_save() {
|
||||
Ok(true) => event_loop.exit(),
|
||||
Ok(false) => {
|
||||
if let Some(window) = &self.window {
|
||||
window.request_redraw();
|
||||
}
|
||||
// Yield between polls even when disk I/O is slow.
|
||||
event_loop.set_control_flow(ControlFlow::WaitUntil(
|
||||
Instant::now() + Duration::from_millis(4),
|
||||
));
|
||||
}
|
||||
Err(error) => {
|
||||
self.error = Some(error);
|
||||
self.exit_on_error(event_loop);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Err(error) = horizon_client::spans::time("world_entry", || self.resume_world_entry())
|
||||
{
|
||||
self.error = Some(error);
|
||||
self.exit_on_error(event_loop);
|
||||
return;
|
||||
}
|
||||
// The world a backup made before its upgrade opens (EditWorldScreen's callback).
|
||||
if let Some(folder) = self.title.as_mut().and_then(|t| t.take_open_request())
|
||||
&& let Err(error) = self.open_world(&folder, None, None, false, None)
|
||||
{
|
||||
self.error = Some(error);
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
// A Realm's address came back (GetServerDetailsTask hands over to ConnectScreen).
|
||||
if let Some(action) = self.title.as_mut().and_then(|t| t.take_connect_request())
|
||||
&& let Err(error) = self.title_action(event_loop, action)
|
||||
{
|
||||
self.error = Some(error);
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
horizon_client::spans::time("server_packs", || self.tick_server_packs());
|
||||
horizon_client::spans::time("control", || self.poll_control(event_loop));
|
||||
if self.pending_save.is_some() {
|
||||
return;
|
||||
}
|
||||
horizon_client::spans::time("pads", || self.poll_pads(event_loop));
|
||||
self.pause_if_inactive();
|
||||
if self.options.frames.is_some() && self.started.elapsed() > self.options.timeout {
|
||||
self.error = Some("frame-limited run timed out".into());
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
if let Some(w) = &self.window {
|
||||
if self.options.offscreen {
|
||||
// Offscreen frames don't wait for the window to be drawn: an
|
||||
// occluded window (a locked macOS session) gets no redraws.
|
||||
let id = w.id();
|
||||
self.handle_window_event(
|
||||
event_loop,
|
||||
id,
|
||||
WindowEvent::RedrawRequested,
|
||||
input_trace::InputOrigin::Game,
|
||||
);
|
||||
} else {
|
||||
w.request_redraw();
|
||||
}
|
||||
} else if self.options.headless {
|
||||
// The headless loop paces the frames itself (`run_headless`).
|
||||
self.handle_window_event(
|
||||
event_loop,
|
||||
WindowId::dummy(),
|
||||
WindowEvent::RedrawRequested,
|
||||
input_trace::InputOrigin::Game,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Consume the last frame's result: a hidden compositor may stop sending
|
||||
// redraws altogether, leaving a successful result stale indefinitely.
|
||||
event_loop.set_control_flow(event_loop::redraw_flow(
|
||||
&mut self.frame_drawn,
|
||||
self.options.offscreen,
|
||||
Instant::now(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
impl ApplicationHandler for Game<'_> {
|
||||
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||
if self.window.is_some() {
|
||||
return;
|
||||
}
|
||||
debug_keys::warm_debug_cpu();
|
||||
// The Window constructor: created fullscreen when options.txt or --fullscreen says so.
|
||||
let fullscreen = self.settings.fullscreen || self.options.fullscreen;
|
||||
let size = self.options.window_size.map(f64::from);
|
||||
self.fullscreen = horizon_client::window::FullscreenState::new(fullscreen);
|
||||
let attributes = horizon_client::window::with_horizon_icon(
|
||||
Window::default_attributes().with_title("Horizon"),
|
||||
);
|
||||
// Wayland names the window by its app id, which the horizon.desktop entry matches.
|
||||
#[cfg(target_os = "linux")]
|
||||
let attributes = winit::platform::wayland::WindowAttributesExtWayland::with_name(
|
||||
attributes, "horizon", "horizon",
|
||||
);
|
||||
// `--offscreen` renders into a texture: its window would only show white.
|
||||
let window_step = startup::step("window.create");
|
||||
let window = match event_loop.create_window(
|
||||
attributes
|
||||
.with_inner_size(winit::dpi::LogicalSize::new(size[0], size[1]))
|
||||
.with_fullscreen(fullscreen.then_some(winit::window::Fullscreen::Borderless(None)))
|
||||
.with_visible(!self.options.offscreen),
|
||||
) {
|
||||
Ok(w) => Arc::new(w),
|
||||
Err(e) => {
|
||||
self.error = Some(e.into());
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
};
|
||||
horizon_client::window::settle_created_fullscreen(&window, fullscreen);
|
||||
drop(window_step);
|
||||
horizon_client::telemetry::note_display(&window);
|
||||
match self.new_renderer(window.clone()) {
|
||||
Ok(r) => {
|
||||
self.renderer = Some(r);
|
||||
self.world_renderer_ready = true;
|
||||
}
|
||||
Err(e) => {
|
||||
self.error = Some(e);
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.window = Some(window);
|
||||
self.ime_allowed = false;
|
||||
self.ime_area = None;
|
||||
self.apply_settings();
|
||||
self.previous = Instant::now();
|
||||
}
|
||||
fn window_event(&mut self, event_loop: &ActiveEventLoop, id: WindowId, event: WindowEvent) {
|
||||
let key = if self.window.as_ref().is_some_and(|w| w.id() == id)
|
||||
&& let WindowEvent::KeyboardInput {
|
||||
event,
|
||||
is_synthetic: false,
|
||||
..
|
||||
} = &event
|
||||
{
|
||||
Some((event.clone(), glfw_modifiers(self.modifiers)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.handle_window_event(event_loop, id, event, input_trace::InputOrigin::User);
|
||||
if let Some((event, modifiers)) = key {
|
||||
self.native_input_key(&event, modifiers);
|
||||
}
|
||||
}
|
||||
fn device_event(&mut self, event_loop: &ActiveEventLoop, _: DeviceId, event: DeviceEvent) {
|
||||
// A composited view renders offscreen but its keyboard/mouse owner still
|
||||
// controls a visible native window. A headless offscreen run never does.
|
||||
if self.pending_save.is_none()
|
||||
&& self.captured
|
||||
&& self.focused
|
||||
&& self.keyboard_mouse
|
||||
&& (!self.options.offscreen || self.split_window)
|
||||
&& self.screen == Screen::Playing
|
||||
&& let DeviceEvent::MouseMotion { delta: (x, y) } = event
|
||||
{
|
||||
if x != 0.0 || y != 0.0 {
|
||||
self.pad_yield_input(event_loop);
|
||||
}
|
||||
// MouseHandler.onMove with the pointer grabbed; applied once per frame.
|
||||
self.note_input(
|
||||
input_trace::InputOrigin::User,
|
||||
input_trace::TraceKind::Motion { x, y },
|
||||
);
|
||||
self.mouse.on_move(0.0, 0.0, x, y, true, true);
|
||||
self.last_input = Instant::now();
|
||||
}
|
||||
}
|
||||
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
||||
self.idle(event_loop);
|
||||
}
|
||||
/// The application is ending. On macOS, Quit (Cmd+Q) ends it through
|
||||
/// applicationWillTerminate without a CloseRequested; vanilla's GLFW
|
||||
/// window turns it into a window close, so Minecraft.stop saves the world.
|
||||
fn exiting(&mut self, _event_loop: &ActiveEventLoop) {
|
||||
self.shut_down();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
//! Entering a world without blocking the window: the deferred entry, world creation, the world
|
||||
//! resources' background load, and the world's own content tables.
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn defer_world_entry(&mut self, entry: PendingWorldEntry) -> Result<bool> {
|
||||
self.poll_world_rest()?;
|
||||
if self.rest_loader.is_none() {
|
||||
return Ok(false);
|
||||
}
|
||||
self.perf.world_opening();
|
||||
if self.title.is_none() {
|
||||
self.title = Some(
|
||||
Title::new(
|
||||
std::env::current_dir()?.join("saves"),
|
||||
&self.options.resources,
|
||||
)
|
||||
.with_user(|| self.options.player_name()),
|
||||
);
|
||||
}
|
||||
self.pending_entry = Some(entry);
|
||||
log::info!("Horizon: world entry waiting for world resources");
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(super) fn resume_world_entry(&mut self) -> Result<()> {
|
||||
self.poll_integrated_start()?;
|
||||
self.poll_world_rest()?;
|
||||
self.poll_world_creation();
|
||||
if self.rest_loader.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let on_world_screens = self.title.as_ref().is_some_and(|title| {
|
||||
!matches!(
|
||||
title.mode,
|
||||
horizon_client::title::Mode::Main | horizon_client::title::Mode::Multiplayer(_)
|
||||
)
|
||||
});
|
||||
world_loading::preload_world_data(
|
||||
on_world_screens,
|
||||
&self.options.resources,
|
||||
self.pack.assets.registry,
|
||||
);
|
||||
if std::mem::take(&mut self.pending_pack_reload) {
|
||||
self.reload_packs();
|
||||
}
|
||||
match self.pending_entry.take() {
|
||||
Some(PendingWorldEntry::Local {
|
||||
folder,
|
||||
name,
|
||||
seed,
|
||||
creative,
|
||||
create,
|
||||
}) => {
|
||||
self.open_world(&folder, name.as_deref(), seed, creative, create.as_deref())?;
|
||||
}
|
||||
Some(PendingWorldEntry::Server {
|
||||
address,
|
||||
name,
|
||||
cookies,
|
||||
lan,
|
||||
}) => {
|
||||
self.connect_server_with_options(&address, &name, cookies, lan)?;
|
||||
}
|
||||
Some(PendingWorldEntry::StudioReplay(path)) => {
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
if let Err(e) = self.open_studio_replay(&path_str) {
|
||||
log::warn!("Horizon Studio: could not open {path_str}: {e}");
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `CreateWorldScreen.openCreateWorldScreen`: read the world generation
|
||||
/// data the create screen describes, on a worker, then open the screen
|
||||
/// (`title.preparing`).
|
||||
pub(super) fn poll_world_creation(&mut self) {
|
||||
let Some(title) = self.title.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if !title.preparing() {
|
||||
return;
|
||||
}
|
||||
if self.creation_job.is_none() {
|
||||
let dir = self.options.resources.join("worldgen");
|
||||
let registry = self.pack.assets.registry;
|
||||
// The chosen packs' worldgen over the game's own:
|
||||
// `applyNewPackConfig` reloads with the new pack config.
|
||||
let overrides = title
|
||||
.creation_packs()
|
||||
.map(|(dir, packs)| {
|
||||
world_loading::creation_worldgen_overrides(
|
||||
dir.as_deref(),
|
||||
&self.options.resources,
|
||||
&packs,
|
||||
self.res.biomes(),
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
self.creation_job = std::thread::Builder::new()
|
||||
.name("world-creation".into())
|
||||
.spawn(move || {
|
||||
horizon_client::vanillagen::shared_data_with(&dir, &overrides, registry)
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
if !self
|
||||
.creation_job
|
||||
.as_ref()
|
||||
.is_none_or(std::thread::JoinHandle::is_finished)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let outcome = self.creation_job.take().and_then(|job| job.join().ok());
|
||||
if let Some(title) = self.title.as_mut() {
|
||||
match outcome {
|
||||
Some(Ok(())) => title.finish_preparing(),
|
||||
// `applyNewPackConfig`'s failure: back out of the read with
|
||||
// the message, the Create screen's options intact.
|
||||
outcome => {
|
||||
let e = outcome
|
||||
.and_then(|r| r.err())
|
||||
.unwrap_or_else(|| "the read never ran".to_owned());
|
||||
log::warn!("Horizon: preparing world creation failed: {e}");
|
||||
// Clear the flag first: `failed` sets the error screen
|
||||
// with Create as its back target, which
|
||||
// `finish_preparing` would replace with a fresh world.
|
||||
title.finish_preparing();
|
||||
title.failed(format!(
|
||||
"{}: {e}",
|
||||
horizon_client::lang::get_or(
|
||||
"createWorld.preparing",
|
||||
"Preparing for world creation..."
|
||||
)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn poll_world_rest(&mut self) -> Result<()> {
|
||||
if let Some(loader) = &self.rest_loader
|
||||
&& loader.ready()?
|
||||
{
|
||||
self.rest_loader
|
||||
.take()
|
||||
.ok_or("missing world loader")?
|
||||
.finish()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- the world's data packs' content tables ---------------------------
|
||||
|
||||
/// The content tables the world plays with: its data packs' over the
|
||||
/// game's own when it has packs, `Resources.rest`'s own otherwise.
|
||||
pub(super) fn items(&self) -> &Items {
|
||||
match &self.world_tables {
|
||||
Some(t) => &t.items,
|
||||
None => self.res.items(),
|
||||
}
|
||||
}
|
||||
pub(super) fn mob_suffocation(
|
||||
&self,
|
||||
) -> &horizon_world::suffocation::SuffocationRegistry<'static> {
|
||||
self.world_tables
|
||||
.as_ref()
|
||||
.map_or_else(|| self.res.suffocation(), |t| &t.suffocation)
|
||||
}
|
||||
pub(super) fn recipes(&self) -> &Recipes {
|
||||
match &self.world_tables {
|
||||
Some(t) => &t.recipes,
|
||||
None => self.synced_recipes().unwrap_or_else(|| self.res.recipes()),
|
||||
}
|
||||
}
|
||||
pub(super) fn stations(&self) -> &horizon_client::stations::StationData {
|
||||
match &self.world_tables {
|
||||
Some(t) => &t.stations,
|
||||
None => self.res.stations(),
|
||||
}
|
||||
}
|
||||
/// The enchantments item names and tooltips read: a server's, with the
|
||||
/// entries its data packs add, else the world's.
|
||||
pub(super) fn enchantments(&self) -> &horizon_client::enchant::Enchantments {
|
||||
match self.remote.as_ref().and_then(|r| r.enchantments.as_ref()) {
|
||||
Some(e) => e,
|
||||
None => &self.stations().enchantments,
|
||||
}
|
||||
}
|
||||
/// The world's packs' content files, none without packs.
|
||||
/// The biome table the world plays with, `None` before a world opens.
|
||||
pub(super) fn packed_biomes(&self) -> Option<Arc<Biomes>> {
|
||||
let table = world_loading::packed_biomes(
|
||||
&self.world_root,
|
||||
&self.options.resources,
|
||||
self.res.biomes(),
|
||||
&self.level.enabled_features,
|
||||
);
|
||||
(!Arc::ptr_eq(&table, self.res.biomes())).then_some(table)
|
||||
}
|
||||
/// What the chunk and far-terrain workers mesh with: the pack's context,
|
||||
/// over the world's biomes when it has its own.
|
||||
pub(super) fn worker_context(&self) -> Arc<WorkerContext> {
|
||||
match &self.world_biomes {
|
||||
Some(table) => self.pack.context_with(table.clone()),
|
||||
None => self.pack.context.clone(),
|
||||
}
|
||||
}
|
||||
pub(super) fn pack_files(&self) -> horizon_world::loot::PackOverlay<'_> {
|
||||
pack_files(&self.world_tables)
|
||||
}
|
||||
/// Load the world's data packs' content tables (`None`: the world has no
|
||||
/// packs, and `Resources.rest` stays in use). This re-reads the tables,
|
||||
/// so it runs once per world open and on `/reload`.
|
||||
pub(super) fn build_world_tables(
|
||||
&self,
|
||||
reload: bool,
|
||||
enabled_features: &[String],
|
||||
) -> Result<(Option<WorldTables>, horizon_client::health::HealthResources)> {
|
||||
// The ask of this load, both here and on `/reload`: a native mod
|
||||
// recomputes the pack files it contributes every time.
|
||||
let mut contributed = Vec::new();
|
||||
self.native_data_pack_files(&mut contributed);
|
||||
let files = functions::overlay_files(
|
||||
&self.world_root,
|
||||
&self.options.resources,
|
||||
self.cmds.vanilla_disabled,
|
||||
enabled_features,
|
||||
contributed,
|
||||
);
|
||||
let overlay = horizon_world::loot::PackOverlay { files: &files };
|
||||
let root = &self.options.resources;
|
||||
let base = if reload {
|
||||
&self.health_resources
|
||||
} else {
|
||||
&self.motion.health
|
||||
};
|
||||
let health = base.with_packs(root, &overlay, reload)?;
|
||||
// With no pack, `res` holds the tables, unless content mods switched
|
||||
// live since it was loaded: their data is in the new root.
|
||||
if files.is_empty() && !self.mods.data_switched {
|
||||
return Ok((None, health));
|
||||
}
|
||||
let mut items = Items::load(root, self.pack.assets.registry, self.res.facts(), &overlay)?;
|
||||
// Item damage predicates use the same frozen registry as player hurt.
|
||||
Arc::make_mut(&mut items.enchantments).bind_damage_catalog(&health.damage);
|
||||
// The recipe tables, the stations and the recipe book read one folder:
|
||||
// hold its files so it is read once.
|
||||
let _recipe_files = horizon_client::json_folder::read(&root.join("data/minecraft/recipe"));
|
||||
let recipes = Recipes::load(root, &items, &overlay)?;
|
||||
let stations = horizon_client::stations::StationData::load(root, &items, &overlay);
|
||||
let mut data_map_types = Vec::new();
|
||||
self.native_data_map_types(&mut data_map_types);
|
||||
let data_maps = native_data_maps::DataMaps::load(
|
||||
&data_map_types,
|
||||
&files,
|
||||
&items.item_tags,
|
||||
&items.block_tags,
|
||||
);
|
||||
let causes_suffocation = items
|
||||
.block_tags
|
||||
.get("minecraft:causes_suffocation")
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.cloned()
|
||||
.collect();
|
||||
let suffocation = horizon_client::mobs::suffocation::bind(
|
||||
self.res.core.collision.clone(),
|
||||
&causes_suffocation,
|
||||
)?;
|
||||
// A registry whose pack tags don't load keeps the game's own tags
|
||||
// (`in_tag` falls back to them), rather than matching no tag at all.
|
||||
let pack_tags = WorldTables::PACK_TAG_REGISTRIES
|
||||
.into_iter()
|
||||
.filter_map(|registry| {
|
||||
match horizon_client::items::load_tags(root, registry, &overlay) {
|
||||
Ok(tags) => Some((registry, tags)),
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: the world's {registry} tags are not applied: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok((
|
||||
Some(WorldTables {
|
||||
suffocation,
|
||||
files,
|
||||
items,
|
||||
recipes,
|
||||
stations,
|
||||
data_maps,
|
||||
pack_tags,
|
||||
}),
|
||||
health,
|
||||
))
|
||||
}
|
||||
/// Reload the content tables with the world's packs, as
|
||||
/// `/reload` reloads a server's (`ReloadableServerResources`).
|
||||
pub(super) fn reload_world_tables(&mut self) {
|
||||
if self.remote.is_some() {
|
||||
return;
|
||||
}
|
||||
match self.build_world_tables(true, &self.level.enabled_features) {
|
||||
Ok((tables, health)) => {
|
||||
self.world_tables = tables;
|
||||
self.health_resources = health;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: the world's data packs are not applied: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.damage_tag_generation = self.damage_tag_generation.wrapping_add(1);
|
||||
self.recipe_tables.categories = None;
|
||||
horizon_client::structure_blocks::forget_loot_data();
|
||||
// Before the players are sent the reloaded data below.
|
||||
self.sync_recipe_content();
|
||||
// PlayerList.reloadResources: the tags and recipes go to the players
|
||||
// and their advancements reload. The synchronized registries (the
|
||||
// dialogs, and the commands' ids of them) stay the hub's.
|
||||
let kept = self.lan.as_ref().map(|host| host.hub.lan.shared.sync());
|
||||
match kept {
|
||||
Some(kept) => {
|
||||
match lan::reload_sync_data(&self.options.resources, &self.pack_files(), &kept) {
|
||||
Ok(sync) => {
|
||||
if let Some(host) = self.lan.as_mut() {
|
||||
host.hub.reload_resources(sync);
|
||||
}
|
||||
self.run_native_advancement_grants();
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: the players are not sent the reloaded data: {e}")
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if let Err(e) =
|
||||
lan::reload_cached_sync_data(&self.options.resources, &self.pack_files())
|
||||
{
|
||||
log::warn!("Horizon: the cached world data is not reloaded: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
let overlay = pack_files(&self.world_tables);
|
||||
self.adv.tree = Arc::new(horizon_client::advancements::Tree::load_with(
|
||||
&self.options.resources.join("data"),
|
||||
&overlay,
|
||||
));
|
||||
self.adv.reset_tree();
|
||||
self.native_reload_listeners();
|
||||
self.native_server_data_loaded(true);
|
||||
}
|
||||
|
||||
pub(super) fn join_world_rest(&mut self) -> Result<()> {
|
||||
if self.rest_loader.is_some() {
|
||||
let waited = Instant::now();
|
||||
log::info!("Horizon: waiting for world resources");
|
||||
while self.rest_loader.is_some() {
|
||||
self.poll_world_rest()?;
|
||||
if self.rest_loader.is_some() {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
log::info!(
|
||||
"Horizon: world resources joined after {} ms",
|
||||
waited.elapsed().as_millis()
|
||||
);
|
||||
}
|
||||
if self.res.rest.get().is_none() {
|
||||
return Err("world resources are unavailable".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
//! Opening and closing a local world from the client: the title screen's actions, entering a
|
||||
//! world on the server thread, and saving and leaving it on exit.
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `onAbort`: the confirm screen's Back or Escape reopens the Data
|
||||
/// Packs screen with the selection it had.
|
||||
pub(super) fn reopen_data_packs(&mut self, event_loop: &dyn EventLoopCtl) -> Result<()> {
|
||||
let action = self
|
||||
.title
|
||||
.as_mut()
|
||||
.and_then(|t| t.reopen_data_packs_action());
|
||||
if let Some(action) = action {
|
||||
self.title_action(event_loop, action)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// What the title screen asked for, from a click or Enter.
|
||||
pub(super) fn title_action(
|
||||
&mut self,
|
||||
event_loop: &dyn EventLoopCtl,
|
||||
action: Action,
|
||||
) -> Result<()> {
|
||||
match action {
|
||||
// A splitscreen player's title never ends the process (P1's does).
|
||||
Action::Quit if self.options.split_guest => {}
|
||||
Action::Quit => event_loop.exit(),
|
||||
Action::Options => self.open_options(true),
|
||||
Action::Accessibility => {
|
||||
self.open_options_at(true, horizon_client::options_ui::Page::Accessibility)
|
||||
}
|
||||
Action::Language => {
|
||||
self.open_options_at(true, horizon_client::options_ui::Page::Language)
|
||||
}
|
||||
Action::Sounds => self.open_options_at(true, horizon_client::options_ui::Page::Sound),
|
||||
Action::Credits => {
|
||||
self.open_options_at(true, horizon_client::options_ui::Page::Credits)
|
||||
}
|
||||
Action::OpenUri(uri) => packs::open_uri(&uri),
|
||||
Action::CopyText(text) => {
|
||||
use horizon_client::clipboard::Clipboard;
|
||||
self.clipboard.set(&text);
|
||||
}
|
||||
Action::Play { folder } => self.open_world(&folder, None, None, false, None)?,
|
||||
Action::Create {
|
||||
folder,
|
||||
name,
|
||||
seed,
|
||||
creative,
|
||||
options,
|
||||
} => self.open_world(&folder, Some(&name), seed, creative, Some(&options))?,
|
||||
Action::Connect { address, lan } => {
|
||||
self.connect_server_with_options(&address, &self.options.player_name(), None, lan)?
|
||||
}
|
||||
Action::DataPacks { dir, selected } => {
|
||||
self.keys.clear();
|
||||
self.screen = Screen::Options;
|
||||
self.options_from_title = true;
|
||||
let resources = self.vanilla_root();
|
||||
self.ui.options.open_data_packs(dir, selected, resources);
|
||||
}
|
||||
Action::None => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Enter a local world from the client: it runs on the client's server thread.
|
||||
pub(super) fn open_world(
|
||||
&mut self,
|
||||
folder: &str,
|
||||
name: Option<&str>,
|
||||
seed: Option<i64>,
|
||||
creative: bool,
|
||||
create: Option<&horizon_client::title::WorldOptions>,
|
||||
) -> Result<()> {
|
||||
// openWorldLoadLevelData: an existing world whose level data does not read opens
|
||||
// RecoverWorldDataScreen instead.
|
||||
if create.is_none()
|
||||
&& let Some(title) = self.title.as_mut()
|
||||
&& title.open_recover(folder)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
world_loading::world_data_used();
|
||||
if self.defer_world_entry(PendingWorldEntry::Local {
|
||||
folder: folder.to_owned(),
|
||||
name: name.map(str::to_owned),
|
||||
seed,
|
||||
creative,
|
||||
create: create.cloned().map(Box::new),
|
||||
})? {
|
||||
return Ok(());
|
||||
}
|
||||
self.join_world_rest()?;
|
||||
// Create World's data packs, in the world before it opens.
|
||||
if let Some(dir) = create.and_then(|o| o.pack_dir.as_deref()) {
|
||||
let world = std::env::current_dir()?.join("saves").join(folder);
|
||||
let packs = create.map_or(&[][..], |o| &o.data_packs[..]);
|
||||
if let Err(e) = horizon_client::datapack::install(dir, &world, packs) {
|
||||
log::warn!("Horizon: data packs for {folder}: {e}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
// The world's pack selection (`DataPackConfig`), for the pack stack
|
||||
// to read: the picked packs on, the feature packs left out off.
|
||||
// Only when `install` did not run (no Data Packs screen opened): it
|
||||
// writes the file itself, and its `disabled` must not be lost.
|
||||
if let Some(o) = create.filter(|o| o.pack_dir.is_none()) {
|
||||
let world = std::env::current_dir()?.join("saves").join(folder);
|
||||
if let Err(e) = std::fs::create_dir_all(&world) {
|
||||
log::warn!("Horizon: data packs for {folder}: {}: {e}", world.display());
|
||||
} else {
|
||||
let disabled: Vec<String> =
|
||||
horizon_client::datapack::feature_packs(&self.vanilla_root())
|
||||
.into_iter()
|
||||
.map(|p| p.id)
|
||||
.filter(|id| !o.data_packs.contains(id))
|
||||
.collect();
|
||||
let json = serde_json::json!({"enabled": o.data_packs, "disabled": disabled});
|
||||
let file = world.join("datapacks.json");
|
||||
if let Err(e) = std::fs::write(&file, json.to_string()) {
|
||||
log::warn!("Horizon: data packs for {folder}: {}: {e}", file.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
// IntegratedServer.initServer's line, which other launchers read in `latest.log` to
|
||||
// offer the world again.
|
||||
horizon_client::logging::write_as(
|
||||
"Server thread",
|
||||
log::Level::Info,
|
||||
&format!(
|
||||
"Starting integrated minecraft server version {}",
|
||||
horizon_core::VANILLA_VERSION
|
||||
),
|
||||
);
|
||||
self.open_integrated(folder, name, seed, creative, create)
|
||||
}
|
||||
|
||||
/// Load authoritative world state synchronously, without client entry or
|
||||
/// integrated-server selection. Server startup must use this entry point.
|
||||
pub(super) fn load_world(&mut self, request: world_loading::WorldRequest) -> Result<()> {
|
||||
self.join_world_rest()?;
|
||||
self.perf.world_opening();
|
||||
let prepared = request.prepare(
|
||||
&self.res,
|
||||
&self.pack,
|
||||
&self.options,
|
||||
&self.settings,
|
||||
&horizon_world::datafix::progress::UpgradeProgress::new(),
|
||||
)?;
|
||||
self.finish_open_world(prepared)
|
||||
}
|
||||
|
||||
/// Leaving the game with a world open (window closed, Quit, `quit`):
|
||||
/// menu items go back (Player.closeContainer), then the world is saved,
|
||||
/// once.
|
||||
/// Keeps this play session's performance summary for the launcher to send, if the player
|
||||
/// allows performance statistics (`horizon_client::telemetry`), and starts the next one.
|
||||
pub(super) fn finish_perf(&mut self) {
|
||||
// One session per computer: P1's (a splitscreen player's would count it again).
|
||||
if self.options.split_guest {
|
||||
return;
|
||||
}
|
||||
let s = &self.settings;
|
||||
let settings = horizon_telemetry::payload::Settings {
|
||||
render_distance: s.render_distance.clamp(0, 255) as u8,
|
||||
simulation_distance: s.simulation_distance.clamp(0, 255) as u8,
|
||||
// 260 is the slider's "unlimited".
|
||||
max_fps: if s.max_fps >= 260 {
|
||||
0
|
||||
} else {
|
||||
s.max_fps.min(u32::from(u16::MAX)) as u16
|
||||
},
|
||||
vsync: s.vsync,
|
||||
fullscreen: s.fullscreen,
|
||||
graphics: s.graphics_preset.clone(),
|
||||
};
|
||||
// Again now: on Wayland the window is on a screen only once it has been shown.
|
||||
if let Some(window) = &self.window {
|
||||
horizon_client::telemetry::note_display(window);
|
||||
}
|
||||
self.perf
|
||||
.finish(&horizon_client::logging::game_home(), settings);
|
||||
}
|
||||
|
||||
pub(super) fn save_for_exit(&mut self) -> Result<()> {
|
||||
if self.native_window() && !self.native_client_stopped {
|
||||
self.native_client_stopped = true;
|
||||
self.native_client_stopping();
|
||||
}
|
||||
// Native application termination can bypass CloseRequested. Finish any
|
||||
// save screen synchronously here, before the process can go away.
|
||||
while self.pending_save.is_some() {
|
||||
self.poll_closing_save()?;
|
||||
if self.pending_save.is_some() {
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
// A repro recording is finished and bundled before the process may go away.
|
||||
self.repro_on_exit();
|
||||
if self.title.is_some() || self.saved_for_exit {
|
||||
return Ok(());
|
||||
}
|
||||
// Native application termination can bypass the close request, so make the same
|
||||
// client-side worker abort explicit before the game-owned streaming state is dropped.
|
||||
self.streaming.abort_generation();
|
||||
if let Some(server) = self.integrated.take() {
|
||||
// The server thread saves the world.
|
||||
server.stop()?;
|
||||
self.saved_for_exit = true;
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(mut remote) = self.remote.take() {
|
||||
// Minecraft.disconnect closes the connection, so the host sees a clean leave.
|
||||
remote.session.disconnect();
|
||||
self.saved_for_exit = true;
|
||||
return Ok(());
|
||||
}
|
||||
self.close_container();
|
||||
if self.remote.is_none() {
|
||||
self.award_custom_stat("leave_game", 1);
|
||||
}
|
||||
self.lan_close(false);
|
||||
self.free_player_beds()?;
|
||||
self.save_game()?;
|
||||
self.saved_for_exit = true;
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn close_world(&mut self) -> Result<()> {
|
||||
// Pause-menu and death-screen exits use the normal world-close action.
|
||||
// A standalone replay owns a synthetic remote session, so it must go
|
||||
// through replay cleanup instead of being treated like a server.
|
||||
if self.studio_replay.is_some() {
|
||||
self.close_studio_replay();
|
||||
return Ok(());
|
||||
}
|
||||
// A recording cannot cross into another world, and a replay feeding the
|
||||
// title screen is nonsense.
|
||||
self.repro_world_closed();
|
||||
self.clear_waypoints();
|
||||
self.post_effects.clear();
|
||||
self.spectated_post_effect = None;
|
||||
if self.external_server() {
|
||||
return self.leave_server(None);
|
||||
}
|
||||
self.begin_closing_save(false)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,10 @@ use horizon_world::{
|
||||
synced_floats::{FloatValue, Observer, SyncedFloats},
|
||||
};
|
||||
use std::{cell::Cell, fs, io, path::Path, sync::OnceLock};
|
||||
mod equipment;
|
||||
#[doc(hidden)]
|
||||
pub use equipment::EquipmentHurt;
|
||||
|
||||
pub(crate) type Attributes = LiveAttributes<Box<dyn horizon_world::attributes::live::Identities>>;
|
||||
pub(crate) type State = HurtState<DamageTypeId>;
|
||||
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
//! Damage continuations at the two equipment boundaries. The gameplay owner
|
||||
//! keeps the complete acting player installed while running durability hooks.
|
||||
use super::*;
|
||||
use horizon_world::living_hurt::{self, PendingHurt, PreparedHurt};
|
||||
|
||||
#[doc(hidden)]
|
||||
pub struct EquipmentHurt {
|
||||
source: DamageTypeId,
|
||||
rules: HealthRules,
|
||||
amount: f32,
|
||||
blocking: Option<Blocking>,
|
||||
living_cause: bool,
|
||||
causing: i32,
|
||||
before: f32,
|
||||
fire_ticks: i32,
|
||||
prepared: Option<PreparedHurt<()>>,
|
||||
pending: Option<PendingHurt<()>>,
|
||||
inner: Option<bool>,
|
||||
blocked: f32,
|
||||
fully_blocked: bool,
|
||||
block_sound: bool,
|
||||
knocked: bool,
|
||||
totem_fired: bool,
|
||||
canceled: bool,
|
||||
damages_helmet: bool,
|
||||
}
|
||||
impl EquipmentHurt {
|
||||
pub fn damage(&self) -> Option<f32> {
|
||||
self.prepared.as_ref().map(|p| {
|
||||
if p.damage.is_finite() {
|
||||
p.damage
|
||||
} else {
|
||||
f32::MAX
|
||||
}
|
||||
})
|
||||
}
|
||||
pub fn helmet_damage(&self) -> Option<f32> {
|
||||
self.prepared
|
||||
.as_ref()
|
||||
.filter(|_| self.damages_helmet)
|
||||
.map(|p| p.damage)
|
||||
}
|
||||
pub fn helmet_worn(&mut self) {
|
||||
if let Some(prepared) = &mut self.prepared {
|
||||
prepared.damage *= 0.75;
|
||||
}
|
||||
}
|
||||
}
|
||||
impl PlayerMotion {
|
||||
fn equipment_step<T>(
|
||||
&mut self,
|
||||
resources: &HealthResources,
|
||||
hit: &mut EquipmentHurt,
|
||||
step: impl FnOnce(
|
||||
&mut State,
|
||||
&mut ServerPlayerCombat<'_, Environment<'_>, Attributes>,
|
||||
) -> Result<T>,
|
||||
) -> Result<T> {
|
||||
let source = resources
|
||||
.damage
|
||||
.source::<()>(hit.source, None, None, None)
|
||||
.ok_or("unknown resident damage source")?;
|
||||
let h = &mut self.health;
|
||||
let mut environment = Environment {
|
||||
entity: self.entity_id,
|
||||
causing: hit.causing,
|
||||
invulnerable: self.invulnerable,
|
||||
client_loaded: self.client_loaded,
|
||||
rules: hit.rules,
|
||||
source,
|
||||
living_cause: hit.living_cause,
|
||||
events: &mut self.events,
|
||||
dead: &mut h.dead,
|
||||
sync_velocity: &mut h.sync_velocity,
|
||||
game_time: h.game_time,
|
||||
status: self.status.clone(),
|
||||
blocking: hit.blocking.clone(),
|
||||
blocked: hit.blocked,
|
||||
fully_blocked: hit.fully_blocked,
|
||||
block_sound: hit.block_sound,
|
||||
knocked: hit.knocked,
|
||||
totem: h.totem_ready,
|
||||
totem_fired: hit.totem_fired,
|
||||
canceled: hit.canceled,
|
||||
};
|
||||
let mut combat = ServerPlayerCombat::new(
|
||||
&mut self.attributes,
|
||||
&mut h.data,
|
||||
&mut h.food,
|
||||
h.ids,
|
||||
&mut environment,
|
||||
)?;
|
||||
let result = step(&mut h.hurt, &mut combat);
|
||||
hit.blocked = environment.blocked;
|
||||
hit.fully_blocked = environment.fully_blocked;
|
||||
hit.block_sound = environment.block_sound;
|
||||
hit.knocked = environment.knocked;
|
||||
hit.totem_fired = environment.totem_fired;
|
||||
hit.canceled = environment.canceled;
|
||||
result
|
||||
}
|
||||
#[doc(hidden)]
|
||||
pub fn prepare_equipment_hurt(
|
||||
&mut self,
|
||||
resources: &HealthResources,
|
||||
rules: HealthRules,
|
||||
source: DamageTypeId,
|
||||
amount: f32,
|
||||
blocking: Option<Blocking>,
|
||||
living_cause: bool,
|
||||
causing: i32,
|
||||
) -> Result<EquipmentHurt> {
|
||||
let mut hit = EquipmentHurt {
|
||||
source,
|
||||
rules,
|
||||
amount,
|
||||
blocking,
|
||||
living_cause,
|
||||
causing,
|
||||
before: self.health.health() + self.health.absorption(),
|
||||
fire_ticks: self.fire_ticks,
|
||||
prepared: None,
|
||||
pending: None,
|
||||
inner: None,
|
||||
blocked: 0.0,
|
||||
fully_blocked: false,
|
||||
block_sound: false,
|
||||
knocked: false,
|
||||
totem_fired: false,
|
||||
canceled: false,
|
||||
damages_helmet: resources.damage.in_tag(source, "minecraft:damages_helmet"),
|
||||
};
|
||||
if !self.client_side {
|
||||
hit.prepared = self.equipment_step(resources, &mut hit, |state, combat| {
|
||||
Ok(combat.prepare_hurt(state, amount)?)
|
||||
})?;
|
||||
}
|
||||
Ok(hit)
|
||||
}
|
||||
#[doc(hidden)]
|
||||
pub fn select_equipment_hurt(
|
||||
&mut self,
|
||||
resources: &HealthResources,
|
||||
hit: &mut EquipmentHurt,
|
||||
) -> Result<Option<f32>> {
|
||||
let Some(prepared) = hit.prepared.take() else {
|
||||
return Ok(None);
|
||||
};
|
||||
hit.pending = self.equipment_step(resources, hit, |state, combat| {
|
||||
Ok(living_hurt::select_hurt_server(state, combat, prepared)?)
|
||||
})?;
|
||||
if let Some(pending) = &hit.pending {
|
||||
let damage = pending.damage;
|
||||
hit.inner = self.equipment_step(resources, hit, |_, combat| {
|
||||
Ok(combat.prepare_equipment_inner()?)
|
||||
})?;
|
||||
if hit.inner == Some(false) {
|
||||
return Ok(Some(damage));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
#[doc(hidden)]
|
||||
pub fn finish_equipment_hurt(
|
||||
&mut self,
|
||||
resources: &HealthResources,
|
||||
mut hit: EquipmentHurt,
|
||||
) -> Result<BlockOutcome> {
|
||||
let hurt = if let Some(pending) = hit.pending.take() {
|
||||
let inner = hit.inner;
|
||||
self.equipment_step(resources, &mut hit, |state, combat| {
|
||||
if let Some(bypass) = inner {
|
||||
combat.apply_equipment_inner(pending.damage, bypass)?;
|
||||
}
|
||||
Ok(living_hurt::finish_hurt_server(state, combat, pending)?)
|
||||
})?
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let h = &mut self.health;
|
||||
if hit.totem_fired {
|
||||
h.totem_used = true;
|
||||
h.totem_ready = false;
|
||||
}
|
||||
if hurt {
|
||||
h.hurt_animation_time = h.hurt.hurt_time;
|
||||
}
|
||||
if hit.knocked && hit.blocked <= 0.0 {
|
||||
h.indicate_damage(0.0, 0.0, self.yaw);
|
||||
}
|
||||
let after = h.health() + h.absorption();
|
||||
if self.hurt_log.len() >= HURT_LOG {
|
||||
self.hurt_log.remove(0);
|
||||
}
|
||||
self.hurt_log.push(HurtRecord {
|
||||
game_time: h.game_time,
|
||||
source: resources
|
||||
.damage
|
||||
.definition(hit.source)
|
||||
.ok_or("unknown resident damage source")?
|
||||
.name
|
||||
.clone(),
|
||||
amount: hit.amount,
|
||||
blocked: hit.blocked,
|
||||
taken: hit.before - after,
|
||||
hurt,
|
||||
armor: self
|
||||
.attributes
|
||||
.cached_value_of("minecraft:armor")
|
||||
.unwrap_or(0.0) as f32,
|
||||
toughness: self
|
||||
.attributes
|
||||
.cached_value_of("minecraft:armor_toughness")
|
||||
.unwrap_or(0.0) as f32,
|
||||
health: after,
|
||||
fire_ticks: hit.fire_ticks,
|
||||
knockback: None,
|
||||
});
|
||||
Ok(BlockOutcome {
|
||||
hurt,
|
||||
blocked: hit.blocked,
|
||||
fully_blocked: hit.fully_blocked,
|
||||
sound: hit.block_sound,
|
||||
knockback: hit.knocked,
|
||||
canceled: hit.canceled,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -457,6 +457,9 @@ fn submit_quads(
|
||||
// translucency up once.
|
||||
let mut last: Option<(&str, bool)> = None;
|
||||
let mut last_rect: Option<(&str, Option<Rect>)> = None;
|
||||
// A glint in the GUI follows the quad's sprite in the glint atlas: looked up once a sprite too.
|
||||
let gui_glint = display == DisplayContext::Gui && foil == Foil::Standard;
|
||||
let mut last_glint: Option<(&str, Option<Rect>)> = None;
|
||||
for q in &baked.quads {
|
||||
let rect = match last_rect {
|
||||
Some((sprite, rect)) if sprite == q.material.sprite => rect,
|
||||
@@ -509,8 +512,15 @@ fn submit_quads(
|
||||
pos,
|
||||
uv,
|
||||
foil_uv: q.uv,
|
||||
gui_glint_uv: (display == DisplayContext::Gui && foil == Foil::Standard)
|
||||
.then(|| atlas.get(sprite_name(&q.material.sprite)))
|
||||
gui_glint_uv: gui_glint
|
||||
.then(|| match last_glint {
|
||||
Some((sprite, rect)) if sprite == q.material.sprite => rect,
|
||||
_ => {
|
||||
let rect = atlas.get(sprite_name(&q.material.sprite)).copied();
|
||||
last_glint = Some((&q.material.sprite, rect));
|
||||
rect
|
||||
}
|
||||
})
|
||||
.flatten()
|
||||
.map(|r| {
|
||||
q.uv.map(|[u, v]| [r.u0 + (r.u1 - r.u0) * u, r.v0 + (r.v1 - r.v0) * v])
|
||||
|
||||
@@ -670,15 +670,18 @@ fn serve_connection(
|
||||
}
|
||||
|
||||
/// The older release a client's handshake states, when this server lets it join
|
||||
/// (`horizon-accept-older-clients`): 26.2 down to 1.20.5, the releases the adapter
|
||||
/// has tables for (`ServerVersion::from_protocol`).
|
||||
/// (`horizon-accept-older-clients`): 26.2 down to 1.20.5. The client-direction adapter
|
||||
/// also knows older releases whose server-direction layouts are not implemented.
|
||||
fn older_client(
|
||||
shared: &Shared,
|
||||
protocol: i32,
|
||||
) -> Option<horizon_protocol::adapter::ServerVersion> {
|
||||
use horizon_protocol::adapter::ServerVersion;
|
||||
let version = ServerVersion::from_protocol(protocol)?;
|
||||
(shared.config.accept_older_clients && version.at_most(ServerVersion::V26_2)).then_some(version)
|
||||
(shared.config.accept_older_clients
|
||||
&& version.at_most(ServerVersion::V26_2)
|
||||
&& !version.at_most(ServerVersion::V1_20_3))
|
||||
.then_some(version)
|
||||
}
|
||||
|
||||
fn serve_status(c: &mut Connection, shared: &Shared, protocol: i32) -> Result<(), String> {
|
||||
@@ -1925,6 +1928,55 @@ impl KeepAliveWatch {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Having tables for Horizon to join an old server does not make the reverse direction supported.
|
||||
#[test]
|
||||
fn nbt_item_clients_are_refused_even_when_older_clients_are_enabled() {
|
||||
let config = crate::lan::NetConfig {
|
||||
announce: false,
|
||||
listen: false,
|
||||
accept_older_clients: true,
|
||||
..crate::lan::NetConfig::loopback(0, "Older clients".into(), 8)
|
||||
};
|
||||
let lan =
|
||||
crate::lan::Lan::start_with(Arc::new(crate::lan::data::SyncData::empty()), config)
|
||||
.unwrap();
|
||||
for protocol in [764, 765] {
|
||||
let mut c = Connection::local(lan.connect_local().unwrap());
|
||||
c.send(
|
||||
&handshake::Serverbound::Intention(handshake::Intention {
|
||||
protocol,
|
||||
host: "127.0.0.1".into(),
|
||||
port: 0,
|
||||
intent: handshake::LOGIN,
|
||||
})
|
||||
.encode(),
|
||||
)
|
||||
.unwrap();
|
||||
// If this regresses to accepting an unsupported version, its hello produces a login
|
||||
// response instead of leaving the test waiting for one the server has not sent yet.
|
||||
let _ = c.send(
|
||||
&login::Serverbound::Hello(login::ServerHello {
|
||||
name: "Old".into(),
|
||||
uuid: offline_uuid("Old"),
|
||||
})
|
||||
.encode(),
|
||||
);
|
||||
let (id, payload) = c.recv().unwrap();
|
||||
let login::Clientbound::Disconnect(reason) =
|
||||
login::Clientbound::decode(id, &payload).unwrap()
|
||||
else {
|
||||
panic!("protocol {protocol} was allowed into the reverse adapter")
|
||||
};
|
||||
assert!(
|
||||
reason
|
||||
.reason_json
|
||||
.contains("multiplayer.disconnect.incompatible"),
|
||||
"protocol {protocol}: {}",
|
||||
reason.reason_json
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Logs in through `c` and answers the `LoginFinished` profile.
|
||||
fn login_profile(mut c: Connection, name: &str) -> login::GameProfile {
|
||||
c.send(
|
||||
|
||||
@@ -914,7 +914,30 @@ mod tests {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let client = Connection::connect("127.0.0.1", port, Duration::from_secs(5)).unwrap();
|
||||
let server = Connection::from_stream(listener.accept().unwrap().0).unwrap();
|
||||
let mut socket = listener.accept().unwrap().0;
|
||||
socket.set_nonblocking(true).unwrap();
|
||||
// Windows may buffer an entire large first write. Establish actual backpressure with
|
||||
// small framed writes before adoption, rather than assuming the batch below fills it.
|
||||
let fill = horizon_protocol::frame::encode(&body(0, 65535), None).unwrap();
|
||||
let mut filled = 0;
|
||||
let mut offset = 0;
|
||||
let fill_until = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
assert!(Instant::now() < fill_until, "socket prefill timed out");
|
||||
match socket.write(&fill[offset..]) {
|
||||
Ok(n) => {
|
||||
assert!(n > 0, "socket closed during prefill");
|
||||
filled += n;
|
||||
assert!(filled < MAX_PENDING, "socket did not apply backpressure");
|
||||
offset = (offset + n) % fill.len();
|
||||
}
|
||||
Err(e) if e.kind() == ErrorKind::WouldBlock => break,
|
||||
Err(e) if e.kind() == ErrorKind::Interrupted => {}
|
||||
Err(e) => panic!("socket prefill: {e}"),
|
||||
}
|
||||
}
|
||||
socket.set_nonblocking(false).unwrap();
|
||||
let server = Connection::from_stream(socket).unwrap();
|
||||
let served = serve(server, quiet, Duration::from_secs(30));
|
||||
// Fill the socket until the outbox waits. A fixed amount may all go
|
||||
// through: Windows takes a write of any size whole while its buffer
|
||||
|
||||
@@ -120,6 +120,23 @@ pub struct Session {
|
||||
pub recorder: Arc<Mutex<Option<StudioRecorder>>>,
|
||||
}
|
||||
impl Session {
|
||||
/// The negotiated wire release, once the connection has been established.
|
||||
pub fn server_version(&self) -> horizon_protocol::adapter::ServerVersion {
|
||||
self.writer.lock().unwrap().as_ref().map_or(
|
||||
horizon_protocol::adapter::ServerVersion::Current,
|
||||
ConnWriter::server_version,
|
||||
)
|
||||
}
|
||||
|
||||
/// Secure-chat enforcement synchronized later than login on older servers.
|
||||
pub fn legacy_secure_chat_enforcement(&self) -> Option<bool> {
|
||||
self.writer
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.and_then(ConnWriter::legacy_secure_chat_enforcement)
|
||||
}
|
||||
|
||||
/// Start connecting in the background, as `auth`'s account when there is one.
|
||||
pub fn connect(
|
||||
address: &str,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! resources with [`ServerPacks::active`] when it asks for a reload.
|
||||
pub mod download;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::{collections::VecDeque, path::PathBuf};
|
||||
|
||||
/// `ServerboundResourcePackPacket.Action`, by its protocol number.
|
||||
pub mod action {
|
||||
@@ -112,12 +112,14 @@ impl Removal {
|
||||
}
|
||||
}
|
||||
|
||||
/// The identity of one pack instance, independent of its wire UUID.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct Generation(u64);
|
||||
|
||||
/// `ServerPackManager.ServerPackData`.
|
||||
#[derive(Debug)]
|
||||
struct Pack {
|
||||
/// Which `ServerPackData` this is: a pushed id replaces its pack while the old
|
||||
/// one can still be active, so a reload names its packs by this, not by `id`.
|
||||
key: u64,
|
||||
generation: Generation,
|
||||
id: u128,
|
||||
url: String,
|
||||
sha1: Option<String>,
|
||||
@@ -140,8 +142,8 @@ impl Pack {
|
||||
/// `triggerReloadIfNeeded` gives `scheduleReload`).
|
||||
#[derive(Debug)]
|
||||
struct Reload {
|
||||
load: Vec<u64>,
|
||||
unload: Vec<u64>,
|
||||
load: Vec<Generation>,
|
||||
unload: Vec<Generation>,
|
||||
/// It reloads what was active before a failed one (`isRecovery`).
|
||||
recovery: bool,
|
||||
}
|
||||
@@ -150,6 +152,10 @@ struct Reload {
|
||||
#[derive(Debug)]
|
||||
pub struct ServerPacks {
|
||||
packs: Vec<Pack>,
|
||||
next_generation: u64,
|
||||
/// The native download queue completes batches in request order. Retain each batch's
|
||||
/// pack identities even after replacement/disconnect removes those packs from `packs`.
|
||||
downloads: VecDeque<Vec<(u128, Generation)>>,
|
||||
prompt: Prompt,
|
||||
reload: Option<Reload>,
|
||||
/// `registerForUpdate`: something changed, [`Self::tick`] again.
|
||||
@@ -158,22 +164,30 @@ pub struct ServerPacks {
|
||||
/// statuses are only logged, as for a local world.
|
||||
reporting: bool,
|
||||
replies: Vec<(u128, i32)>,
|
||||
next_key: u64,
|
||||
}
|
||||
impl Default for ServerPacks {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
packs: Vec::new(),
|
||||
next_generation: 0,
|
||||
downloads: VecDeque::new(),
|
||||
prompt: Prompt::Pending,
|
||||
reload: None,
|
||||
update: false,
|
||||
reporting: false,
|
||||
replies: Vec::new(),
|
||||
next_key: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl ServerPacks {
|
||||
fn generation(&mut self) -> Generation {
|
||||
let generation = Generation(self.next_generation);
|
||||
self.next_generation = self
|
||||
.next_generation
|
||||
.checked_add(1)
|
||||
.expect("resource pack generation overflow");
|
||||
generation
|
||||
}
|
||||
fn report(&mut self, id: u128, status: i32) {
|
||||
log::debug!("Horizon: server pack {id:032x} changed status to {status}");
|
||||
if self.reporting {
|
||||
@@ -221,9 +235,9 @@ impl ServerPacks {
|
||||
for pack in self.packs.iter_mut().filter(|p| p.id == id) {
|
||||
pack.remove(Removal::ServerReplaced);
|
||||
}
|
||||
self.next_key += 1;
|
||||
let generation = self.generation();
|
||||
self.packs.push(Pack {
|
||||
key: self.next_key,
|
||||
generation,
|
||||
id,
|
||||
url: url.to_owned(),
|
||||
sha1,
|
||||
@@ -248,9 +262,9 @@ impl ServerPacks {
|
||||
for pack in self.packs.iter_mut().filter(|p| p.id == id) {
|
||||
pack.remove(Removal::ServerReplaced);
|
||||
}
|
||||
self.next_key += 1;
|
||||
let generation = self.generation();
|
||||
self.packs.push(Pack {
|
||||
key: self.next_key,
|
||||
generation,
|
||||
id,
|
||||
// Never downloaded: the file is already on disk.
|
||||
url: String::new(),
|
||||
@@ -313,10 +327,12 @@ impl ServerPacks {
|
||||
self.update = false;
|
||||
let mut out = Tick::default();
|
||||
let mut downloading = false;
|
||||
let mut requested = Vec::new();
|
||||
for pack in self.packs.iter_mut().filter(|p| !p.removed() && p.accepted) {
|
||||
downloading |= pack.download != Download::Done;
|
||||
if pack.download == Download::Requested {
|
||||
pack.download = Download::Pending;
|
||||
requested.push((pack.id, pack.generation));
|
||||
out.download.push(Request {
|
||||
id: pack.id,
|
||||
url: pack.url.clone(),
|
||||
@@ -324,6 +340,9 @@ impl ServerPacks {
|
||||
});
|
||||
}
|
||||
}
|
||||
if !requested.is_empty() {
|
||||
self.downloads.push_back(requested);
|
||||
}
|
||||
if !downloading {
|
||||
out.reload = self.reload_if_needed();
|
||||
}
|
||||
@@ -355,12 +374,12 @@ impl ServerPacks {
|
||||
let wanted = pack.accepted && pack.download == Download::Done && !pack.removed();
|
||||
match pack.activation {
|
||||
Activation::Inactive if wanted => {
|
||||
load.push(pack.key);
|
||||
load.push(pack.generation);
|
||||
changed = true;
|
||||
}
|
||||
Activation::Active if wanted => load.push(pack.key),
|
||||
Activation::Active if wanted => load.push(pack.generation),
|
||||
Activation::Active => {
|
||||
unload.push(pack.key);
|
||||
unload.push(pack.generation);
|
||||
changed = true;
|
||||
}
|
||||
_ => {}
|
||||
@@ -370,8 +389,8 @@ impl ServerPacks {
|
||||
return None;
|
||||
}
|
||||
for pack in &mut self.packs {
|
||||
let loading = load.contains(&pack.key) && pack.activation != Activation::Active;
|
||||
if loading || unload.contains(&pack.key) {
|
||||
let loading = load.contains(&pack.generation) && pack.activation != Activation::Active;
|
||||
if loading || unload.contains(&pack.generation) {
|
||||
pack.activation = Activation::Pending;
|
||||
}
|
||||
}
|
||||
@@ -384,17 +403,22 @@ impl ServerPacks {
|
||||
Some(paths)
|
||||
}
|
||||
/// The packs to load (`PackReloadConfig.Callbacks.packsToLoad`), bottom first.
|
||||
fn paths(&self, keys: &[u64]) -> Vec<(u128, PathBuf)> {
|
||||
keys.iter()
|
||||
.filter_map(|key| {
|
||||
let pack = self.packs.iter().find(|p| p.key == *key)?;
|
||||
fn paths(&self, generations: &[Generation]) -> Vec<(u128, PathBuf)> {
|
||||
generations
|
||||
.iter()
|
||||
.filter_map(|generation| {
|
||||
let pack = self.packs.iter().find(|p| p.generation == *generation)?;
|
||||
Some((pack.id, pack.path.clone()?))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `ServerPackManager.onDownload`.
|
||||
/// `ServerPackManager.onDownload`. Results follow the request batches' order, as the native
|
||||
/// [`download::Queue`] guarantees, so a result can be paired with its original pack instances.
|
||||
pub fn downloaded(&mut self, batch: Batch) {
|
||||
let Some(requested) = self.downloads.pop_front() else {
|
||||
return;
|
||||
};
|
||||
if !batch.failed.is_empty() {
|
||||
for pack in self
|
||||
.packs
|
||||
@@ -410,10 +434,14 @@ impl ServerPacks {
|
||||
}
|
||||
let mut done = Vec::new();
|
||||
for (id, path) in batch.downloaded {
|
||||
let Some((_, generation)) = requested.iter().find(|(requested, _)| *requested == id)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if let Some(pack) = self
|
||||
.packs
|
||||
.iter_mut()
|
||||
.find(|p| p.id == id && p.download == Download::Pending)
|
||||
.find(|p| p.generation == *generation && p.download == Download::Pending)
|
||||
{
|
||||
pack.download = Download::Done;
|
||||
pack.path = Some(path);
|
||||
@@ -438,12 +466,12 @@ impl ServerPacks {
|
||||
if ok {
|
||||
let mut applied = Vec::new();
|
||||
for pack in &mut self.packs {
|
||||
if reload.load.contains(&pack.key) {
|
||||
if reload.load.contains(&pack.generation) {
|
||||
pack.activation = Activation::Active;
|
||||
if pack.removal.is_none() {
|
||||
applied.push(pack.id);
|
||||
}
|
||||
} else if reload.unload.contains(&pack.key) {
|
||||
} else if reload.unload.contains(&pack.generation) {
|
||||
pack.activation = Activation::Inactive;
|
||||
}
|
||||
}
|
||||
@@ -469,7 +497,7 @@ impl ServerPacks {
|
||||
pack.activation = Activation::Inactive;
|
||||
pack.remove(Removal::ActivationFailed);
|
||||
}
|
||||
Activation::Active => reload.load.push(pack.key),
|
||||
Activation::Active => reload.load.push(pack.generation),
|
||||
}
|
||||
}
|
||||
reload.recovery = true;
|
||||
@@ -641,6 +669,218 @@ mod tests {
|
||||
assert_eq!(urls, ["http://a/new.zip"]);
|
||||
}
|
||||
|
||||
fn local_active(sp: &mut ServerPacks, id: u128, path: &str) {
|
||||
sp.push_local(id, PathBuf::from(path));
|
||||
settle(sp, &mut |_| unreachable!(), &mut |_| true);
|
||||
sp.take_replies();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_an_active_uuid_loads_the_new_path_and_pop_unloads_it() {
|
||||
let mut sp = ServerPacks::default();
|
||||
sp.connect(Prompt::Allowed);
|
||||
local_active(&mut sp, 1, "/p/old");
|
||||
sp.push_local(1, PathBuf::from("/p/new"));
|
||||
assert_eq!(sp.tick().reload, Some(vec![(1, PathBuf::from("/p/new"))]));
|
||||
assert_eq!(sp.reloaded(true), None);
|
||||
assert_eq!(sp.tick().reload, None);
|
||||
assert_eq!(sp.packs.len(), 1);
|
||||
assert_eq!(sp.packs[0].path, Some(PathBuf::from("/p/new")));
|
||||
assert_eq!(sp.packs[0].activation, Activation::Active);
|
||||
assert_eq!(sp.take_replies(), [(1, ACCEPTED), (1, SUCCESSFULLY_LOADED)]);
|
||||
sp.pop(1);
|
||||
assert_eq!(sp.tick().reload, Some(vec![]));
|
||||
sp.reloaded(true);
|
||||
sp.tick();
|
||||
assert!(sp.packs.is_empty());
|
||||
assert!(sp.take_replies().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pending_reload_cannot_activate_a_later_generation_with_the_same_uuid() {
|
||||
let mut sp = ServerPacks::default();
|
||||
sp.connect(Prompt::Allowed);
|
||||
local_active(&mut sp, 1, "/p/first");
|
||||
sp.push_local(1, PathBuf::from("/p/second"));
|
||||
assert_eq!(
|
||||
sp.tick().reload,
|
||||
Some(vec![(1, PathBuf::from("/p/second"))])
|
||||
);
|
||||
sp.push_local(1, PathBuf::from("/p/third"));
|
||||
assert_eq!(sp.tick().reload, None);
|
||||
assert_eq!(sp.reloaded(true), None);
|
||||
let newest = sp.packs.iter().find(|p| !p.removed()).unwrap();
|
||||
assert_eq!(newest.path, Some(PathBuf::from("/p/third")));
|
||||
assert_eq!(newest.activation, Activation::Inactive);
|
||||
assert_eq!(sp.take_replies(), [(1, ACCEPTED), (1, ACCEPTED)]);
|
||||
assert_eq!(sp.tick().reload, Some(vec![(1, PathBuf::from("/p/third"))]));
|
||||
sp.reloaded(true);
|
||||
sp.tick();
|
||||
assert_eq!(sp.take_replies(), [(1, SUCCESSFULLY_LOADED)]);
|
||||
assert_eq!(sp.packs.len(), 1);
|
||||
assert_eq!(sp.packs[0].path, Some(PathBuf::from("/p/third")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_replaced_download_result_only_completes_its_requested_generation() {
|
||||
let mut sp = ServerPacks::default();
|
||||
sp.connect(Prompt::Allowed);
|
||||
sp.push(1, "https://example.com/old.zip", None);
|
||||
assert_eq!(sp.tick().download[0].url, "https://example.com/old.zip");
|
||||
sp.push(1, "https://example.com/new.zip", None);
|
||||
assert_eq!(sp.tick().download[0].url, "https://example.com/new.zip");
|
||||
assert_eq!(sp.packs.len(), 1); // The removed old download has already been forgotten.
|
||||
sp.downloaded(Batch {
|
||||
downloaded: vec![(1, PathBuf::from("/p/old"))],
|
||||
failed: vec![],
|
||||
});
|
||||
assert_eq!(sp.packs[0].path, None);
|
||||
assert_eq!(sp.packs[0].download, Download::Pending);
|
||||
assert_eq!(sp.take_replies(), [(1, ACCEPTED), (1, ACCEPTED)]);
|
||||
assert_eq!(sp.tick().reload, None);
|
||||
sp.downloaded(Batch {
|
||||
downloaded: vec![(1, PathBuf::from("/p/new"))],
|
||||
failed: vec![],
|
||||
});
|
||||
assert_eq!(sp.tick().reload, Some(vec![(1, PathBuf::from("/p/new"))]));
|
||||
sp.reloaded(true);
|
||||
sp.tick();
|
||||
assert_eq!(
|
||||
sp.take_replies(),
|
||||
[(1, DOWNLOADED), (1, SUCCESSFULLY_LOADED)]
|
||||
);
|
||||
assert!(sp.downloads.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disconnect_keeps_download_batch_identity_until_the_old_result_arrives() {
|
||||
let mut sp = ServerPacks::default();
|
||||
sp.connect(Prompt::Allowed);
|
||||
sp.push(0, "https://example.com/first-server.zip", None);
|
||||
assert_eq!(sp.tick().download.len(), 1);
|
||||
sp.disconnect();
|
||||
sp.tick();
|
||||
assert!(sp.packs.is_empty());
|
||||
sp.connect(Prompt::Allowed);
|
||||
sp.push(0, "https://example.com/second-server.zip", None);
|
||||
assert_eq!(sp.tick().download.len(), 1);
|
||||
sp.downloaded(Batch {
|
||||
downloaded: vec![(0, PathBuf::from("/p/first-server"))],
|
||||
failed: vec![],
|
||||
});
|
||||
assert_eq!(sp.tick().reload, None);
|
||||
assert_eq!(sp.packs[0].path, None);
|
||||
assert_eq!(sp.take_replies(), [(0, ACCEPTED)]);
|
||||
sp.downloaded(Batch {
|
||||
downloaded: vec![(0, PathBuf::from("/p/second-server"))],
|
||||
failed: vec![],
|
||||
});
|
||||
assert_eq!(
|
||||
sp.tick().reload,
|
||||
Some(vec![(0, PathBuf::from("/p/second-server"))])
|
||||
);
|
||||
sp.reloaded(true);
|
||||
sp.tick();
|
||||
assert_eq!(
|
||||
sp.take_replies(),
|
||||
[(0, DOWNLOADED), (0, SUCCESSFULLY_LOADED)]
|
||||
);
|
||||
sp.disconnect();
|
||||
settle(&mut sp, &mut |_| unreachable!(), &mut |paths| {
|
||||
assert!(paths.is_empty());
|
||||
true
|
||||
});
|
||||
assert!(sp.packs.is_empty());
|
||||
assert!(sp.downloads.is_empty());
|
||||
assert!(sp.take_replies().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_replacement_recovers_only_the_other_previously_active_pack() {
|
||||
let mut sp = ServerPacks::default();
|
||||
sp.connect(Prompt::Allowed);
|
||||
local_active(&mut sp, 1, "/p/old");
|
||||
local_active(&mut sp, 2, "/p/kept");
|
||||
sp.push_local(1, PathBuf::from("/p/broken"));
|
||||
assert_eq!(
|
||||
sp.tick().reload,
|
||||
Some(vec![
|
||||
(2, PathBuf::from("/p/kept")),
|
||||
(1, PathBuf::from("/p/broken"))
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
sp.reloaded(false),
|
||||
Some(vec![(2, PathBuf::from("/p/kept"))])
|
||||
);
|
||||
assert_eq!(sp.reloaded(true), None);
|
||||
sp.tick();
|
||||
assert_eq!(
|
||||
sp.take_replies(),
|
||||
[(1, ACCEPTED), (2, SUCCESSFULLY_LOADED), (1, FAILED_RELOAD)]
|
||||
);
|
||||
assert_eq!(sp.packs.len(), 1);
|
||||
assert_eq!(sp.packs[0].id, 2);
|
||||
assert_eq!(sp.packs[0].activation, Activation::Active);
|
||||
assert_eq!(sp.packs[0].path, Some(PathBuf::from("/p/kept")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_old_batch_keeps_vanillas_failure_rule_for_current_inactive_packs() {
|
||||
let mut sp = ServerPacks::default();
|
||||
sp.connect(Prompt::Allowed);
|
||||
sp.push(1, "https://example.com/old.zip", None);
|
||||
sp.tick();
|
||||
sp.push(1, "https://example.com/new.zip", None);
|
||||
sp.push_local(2, PathBuf::from("/p/not-active"));
|
||||
sp.tick();
|
||||
sp.take_replies();
|
||||
sp.downloaded(Batch {
|
||||
downloaded: vec![],
|
||||
failed: vec![1],
|
||||
});
|
||||
assert_eq!(sp.tick().reload, None);
|
||||
assert_eq!(sp.take_replies(), [(1, FAILED_DOWNLOAD), (2, DISCARDED)]);
|
||||
assert!(sp.packs.is_empty());
|
||||
// The second queued result still has its own identity, even after failure cleanup.
|
||||
sp.downloaded(ok(1));
|
||||
sp.tick();
|
||||
assert!(sp.packs.is_empty());
|
||||
assert!(sp.downloads.is_empty());
|
||||
assert!(sp.take_replies().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_old_failed_batch_after_reconnect_keeps_vanillas_uuid_failure_result() {
|
||||
// The actual 26.3 manager keeps its callbacks through cleanup/popAll and a new
|
||||
// server's configureForServerControl. Its global failure rule includes new packs.
|
||||
let mut sp = ServerPacks::default();
|
||||
sp.connect(Prompt::Allowed);
|
||||
sp.push(0, "https://example.com/old-server.zip", None);
|
||||
sp.tick();
|
||||
sp.disconnect();
|
||||
sp.tick();
|
||||
sp.connect(Prompt::Allowed);
|
||||
sp.push(0, "https://example.com/new-server.zip", None);
|
||||
sp.tick();
|
||||
sp.take_replies();
|
||||
sp.downloaded(Batch {
|
||||
downloaded: vec![],
|
||||
failed: vec![0],
|
||||
});
|
||||
assert_eq!(sp.tick().reload, None);
|
||||
assert_eq!(sp.take_replies(), [(0, FAILED_DOWNLOAD)]);
|
||||
assert!(sp.packs.is_empty());
|
||||
sp.downloaded(Batch {
|
||||
downloaded: vec![(0, PathBuf::from("/p/new-server"))],
|
||||
failed: vec![],
|
||||
});
|
||||
assert_eq!(sp.tick().reload, None);
|
||||
assert!(sp.take_replies().is_empty());
|
||||
assert!(sp.packs.is_empty());
|
||||
assert!(sp.downloads.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_an_active_pack_settles_after_one_reload() {
|
||||
let mut sp = ServerPacks::default();
|
||||
|
||||
@@ -101,6 +101,16 @@ impl Mth for f32 {
|
||||
}
|
||||
}
|
||||
|
||||
// Copyright (C) 1993 by Sun Microsystems, Inc. All rights reserved.
|
||||
//
|
||||
// Developed at SunSoft, a Sun Microsystems, Inc. business.
|
||||
// Permission to use, copy, modify, and distribute this
|
||||
// software is freely granted, provided that this notice
|
||||
// is preserved.
|
||||
//
|
||||
// The notice above covers `fdlibm_asin`, `fdlibm_atan` and `fdlibm_atan2` below, Rust ports
|
||||
// of fdlibm 5.3's e_asin.c, s_atan.c and e_atan2.c (https://www.netlib.org/fdlibm/).
|
||||
|
||||
/// fdlibm `__ieee754_asin`, as `StrictMath.asin` (and so `Math.asin`) computes it.
|
||||
#[allow(clippy::excessive_precision, clippy::approx_constant, clippy::eq_op)] // fdlibm's constants and NaN, verbatim.
|
||||
pub fn fdlibm_asin(x: f64) -> f64 {
|
||||
|
||||
@@ -13,28 +13,31 @@
|
||||
//! rounds `sin` and `cos` differently for about 3% of arguments, so there
|
||||
//! they are correctly rounded instead ([`exact`]), as Intel's stubs are for
|
||||
//! all but about 0.1%.
|
||||
//! - On AArch64 only `sin` and `cos` are intrinsics: fdlibm's kernels
|
||||
//! evaluated with fused multiply-adds (`macroAssembler_aarch64_trig.cpp`).
|
||||
//! Everything else is `StrictMath`.
|
||||
//! - On AArch64 only `sin` and `cos` are intrinsics, which compute fdlibm's
|
||||
//! `sin` and `cos` with fused multiply-adds ([`aarch64`]). Everything else
|
||||
//! is `StrictMath`, which the Java SE API defines as fdlibm 5.3's results
|
||||
//! ([`fdlibm`]).
|
||||
//!
|
||||
//! So these functions return what vanilla returns on the same machine, on
|
||||
//! Linux and on an Apple silicon Mac alike.
|
||||
|
||||
/// `Math.round(float)`, bit for bit: exact (no `x + 0.5` in `f32`), ties toward
|
||||
/// positive infinity, saturating to the `int` range, NaN to 0.
|
||||
pub mod fdlibm;
|
||||
|
||||
/// `Math.sin` and `Math.cos` as HotSpot computes them on AArch64: fdlibm's
|
||||
/// algorithm with fused multiply-adds, on every platform.
|
||||
pub mod aarch64 {
|
||||
pub use super::fdlibm::{cos_fma as cos, sin_fma as sin};
|
||||
}
|
||||
|
||||
/// `Math.round(float)`: the `int` closest to `a`, ties toward positive
|
||||
/// infinity (so `-2.5` is `-2`); `NaN` is 0, and values beyond the `int`
|
||||
/// range give `i32::MIN` or `i32::MAX`.
|
||||
pub fn round_f32(a: f32) -> i32 {
|
||||
let bits = a.to_bits() as i32;
|
||||
let biased_exp = (bits & 0x7F80_0000) >> 23;
|
||||
let shift = (22 + 127) - biased_exp;
|
||||
if shift & -32 == 0 {
|
||||
let mut r = bits & 0x007F_FFFF | 0x0080_0000;
|
||||
if bits < 0 {
|
||||
r = -r;
|
||||
}
|
||||
((r >> shift) + 1) >> 1
|
||||
} else {
|
||||
a as i32
|
||||
}
|
||||
// floor(a + 1/2). The sum is exact in an f64 for 2^-30 <= |a| < 2^31.
|
||||
// Below that it rounds to within 2^-30 of 1/2, whose floor is 0 as the
|
||||
// exact sum's is; above it the cast saturates either way. The cast also
|
||||
// maps NaN to 0.
|
||||
(f64::from(a) + 0.5).floor() as i32
|
||||
}
|
||||
|
||||
/// `Math.sin`.
|
||||
@@ -145,416 +148,6 @@ pub fn round(x: f64) -> i64 {
|
||||
(if x - f >= 0.5 { f + 1.0 } else { f }) as i64
|
||||
}
|
||||
|
||||
fn hi(x: f64) -> i32 {
|
||||
(x.to_bits() >> 32) as i32
|
||||
}
|
||||
|
||||
fn with_hi(x: f64, high: i32) -> f64 {
|
||||
f64::from_bits((x.to_bits() & 0xffff_ffff) | (u64::from(high as u32) << 32))
|
||||
}
|
||||
|
||||
/// `java.lang.FdLibm` ports (`StrictMath`) of what the `libm` crate computes
|
||||
/// differently.
|
||||
// fdlibm's constants and NaN, verbatim.
|
||||
#[allow(clippy::excessive_precision, clippy::approx_constant, clippy::eq_op)]
|
||||
pub mod fdlibm {
|
||||
use super::{hi, with_hi};
|
||||
|
||||
const TWO54: f64 = 1.80143985094819840000e+16;
|
||||
|
||||
/// `FdLibm.Log`.
|
||||
pub fn log(mut x: f64) -> f64 {
|
||||
const LN2_HI: f64 = 6.93147180369123816490e-01;
|
||||
const LN2_LO: f64 = 1.90821492927058770002e-10;
|
||||
const LG1: f64 = 6.666666666666735130e-01;
|
||||
const LG2: f64 = 3.999999999940941908e-01;
|
||||
const LG3: f64 = 2.857142874366239149e-01;
|
||||
const LG4: f64 = 2.222219843214978396e-01;
|
||||
const LG5: f64 = 1.818357216161805012e-01;
|
||||
const LG6: f64 = 1.531383769920937332e-01;
|
||||
const LG7: f64 = 1.479819860511658591e-01;
|
||||
let mut hx = hi(x);
|
||||
let lx = x.to_bits() as u32;
|
||||
let mut k = 0;
|
||||
if hx < 0x0010_0000 {
|
||||
if ((hx & 0x7fff_ffff) as u32 | lx) == 0 {
|
||||
return -TWO54 / 0.0;
|
||||
}
|
||||
if hx < 0 {
|
||||
return (x - x) / 0.0;
|
||||
}
|
||||
k -= 54;
|
||||
x *= TWO54;
|
||||
hx = hi(x);
|
||||
}
|
||||
if hx >= 0x7ff0_0000 {
|
||||
return x + x;
|
||||
}
|
||||
k += (hx >> 20) - 1023;
|
||||
hx &= 0x000f_ffff;
|
||||
let i = (hx + 0x9_5f64) & 0x10_0000;
|
||||
x = with_hi(x, hx | (i ^ 0x3ff0_0000));
|
||||
k += i >> 20;
|
||||
let f = x - 1.0;
|
||||
if (0x000f_ffff & (2 + hx)) < 3 {
|
||||
if f == 0.0 {
|
||||
if k == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
let dk = f64::from(k);
|
||||
return dk * LN2_HI + dk * LN2_LO;
|
||||
}
|
||||
let r = f * f * (0.5 - 0.33333333333333333 * f);
|
||||
if k == 0 {
|
||||
return f - r;
|
||||
}
|
||||
let dk = f64::from(k);
|
||||
return dk * LN2_HI - ((r - dk * LN2_LO) - f);
|
||||
}
|
||||
let s = f / (2.0 + f);
|
||||
let dk = f64::from(k);
|
||||
let z = s * s;
|
||||
let mut i = hx - 0x6_147a;
|
||||
let w = z * z;
|
||||
let j = 0x6b851 - hx;
|
||||
let t1 = w * (LG2 + w * (LG4 + w * LG6));
|
||||
let t2 = z * (LG1 + w * (LG3 + w * (LG5 + w * LG7)));
|
||||
i |= j;
|
||||
let r = t2 + t1;
|
||||
if i > 0 {
|
||||
let hfsq = 0.5 * f * f;
|
||||
if k == 0 {
|
||||
f - (hfsq - s * (hfsq + r))
|
||||
} else {
|
||||
dk * LN2_HI - ((hfsq - (s * (hfsq + r) + dk * LN2_LO)) - f)
|
||||
}
|
||||
} else if k == 0 {
|
||||
f - s * (f - r)
|
||||
} else {
|
||||
dk * LN2_HI - ((s * (f - r) - dk * LN2_LO) - f)
|
||||
}
|
||||
}
|
||||
|
||||
/// `FdLibm.Log10`.
|
||||
pub fn log10(mut x: f64) -> f64 {
|
||||
const IVLN10: f64 = 4.34294481903251816668e-01;
|
||||
const LOG10_2HI: f64 = 3.01029995663611771306e-01;
|
||||
const LOG10_2LO: f64 = 3.69423907715893078616e-13;
|
||||
let mut hx = hi(x);
|
||||
let lx = x.to_bits() as u32;
|
||||
let mut k = 0;
|
||||
if hx < 0x0010_0000 {
|
||||
if ((hx & 0x7fff_ffff) as u32 | lx) == 0 {
|
||||
return -TWO54 / 0.0;
|
||||
}
|
||||
if hx < 0 {
|
||||
return (x - x) / 0.0;
|
||||
}
|
||||
k -= 54;
|
||||
x *= TWO54;
|
||||
hx = hi(x);
|
||||
}
|
||||
if hx >= 0x7ff0_0000 {
|
||||
return x + x;
|
||||
}
|
||||
k += (hx >> 20) - 1023;
|
||||
let i = ((k as u32) >> 31) as i32;
|
||||
hx = (hx & 0x000f_ffff) | ((0x3ff - i) << 20);
|
||||
let y = f64::from(k + i);
|
||||
x = with_hi(x, hx);
|
||||
let z = y * LOG10_2LO + IVLN10 * log(x);
|
||||
z + y * LOG10_2HI
|
||||
}
|
||||
|
||||
/// `FdLibm.Cbrt`.
|
||||
pub fn cbrt(mut x: f64) -> f64 {
|
||||
const B1: i32 = 715094163;
|
||||
const B2: i32 = 696219795;
|
||||
const C: f64 = 5.42857142857142815906e-01;
|
||||
const D: f64 = -7.05306122448979611050e-01;
|
||||
const E: f64 = 1.41428571428571436819e+00;
|
||||
const F: f64 = 1.60714285714285720630e+00;
|
||||
const G: f64 = 3.57142857142857150787e-01;
|
||||
if x == 0.0 || !x.is_finite() {
|
||||
return x;
|
||||
}
|
||||
let sign = if x < 0.0 { -1.0 } else { 1.0 };
|
||||
x = x.abs();
|
||||
let mut t = 0.0f64;
|
||||
if x < f64::MIN_POSITIVE {
|
||||
t = 18014398509481984.0; // 2^54
|
||||
t *= x;
|
||||
t = with_hi(t, hi(t) / 3 + B2);
|
||||
} else {
|
||||
t = with_hi(t, hi(x) / 3 + B1);
|
||||
}
|
||||
let mut r = t * t / x;
|
||||
let mut s = C + r * t;
|
||||
t *= G + F / (s + E + D / s);
|
||||
t = f64::from_bits(t.to_bits() & 0xffff_ffff_0000_0000);
|
||||
t = with_hi(t, hi(t) + 1);
|
||||
s = t * t;
|
||||
r = x / s;
|
||||
let w = t + t;
|
||||
r = (r - t) / (w + r);
|
||||
t += t * r;
|
||||
sign * t
|
||||
}
|
||||
|
||||
/// `FdLibm.Hypot`.
|
||||
pub fn hypot(x: f64, y: f64) -> f64 {
|
||||
let mut a = x.abs();
|
||||
let mut b = y.abs();
|
||||
if !a.is_finite() || !b.is_finite() {
|
||||
if a == f64::INFINITY || b == f64::INFINITY {
|
||||
return f64::INFINITY;
|
||||
}
|
||||
return a + b;
|
||||
}
|
||||
if b > a {
|
||||
std::mem::swap(&mut a, &mut b);
|
||||
}
|
||||
let mut ha = hi(a);
|
||||
let mut hb = hi(b);
|
||||
if ha - hb > 0x3c0_0000 {
|
||||
return a + b;
|
||||
}
|
||||
let mut k = 0;
|
||||
if a > f64::from_bits(0x5f30_0000_ffff_ffff) {
|
||||
ha -= 0x2580_0000;
|
||||
hb -= 0x2580_0000;
|
||||
a *= f64::from_bits(0x1a70_0000_0000_0000); // 2^-600
|
||||
b *= f64::from_bits(0x1a70_0000_0000_0000);
|
||||
k += 600;
|
||||
}
|
||||
if b < f64::from_bits(0x20b0_0000_0000_0000) {
|
||||
// 2^-500
|
||||
if b < f64::MIN_POSITIVE {
|
||||
if b == 0.0 {
|
||||
return a;
|
||||
}
|
||||
let t1 = f64::from_bits(0x7fd0_0000_0000_0000); // 2^1022
|
||||
b *= t1;
|
||||
a *= t1;
|
||||
k -= 1022;
|
||||
} else {
|
||||
ha += 0x2580_0000;
|
||||
hb += 0x2580_0000;
|
||||
a *= f64::from_bits(0x6570_0000_0000_0000); // 2^600
|
||||
b *= f64::from_bits(0x6570_0000_0000_0000);
|
||||
k -= 600;
|
||||
}
|
||||
}
|
||||
let mut w = a - b;
|
||||
if w > b {
|
||||
let t1 = with_hi(0.0, ha);
|
||||
let t2 = a - t1;
|
||||
w = (t1 * t1 - (b * (-b) - t2 * (a + t1))).sqrt();
|
||||
} else {
|
||||
a += a;
|
||||
let y1 = with_hi(0.0, hb);
|
||||
let y2 = b - y1;
|
||||
let t1 = with_hi(0.0, ha + 0x0010_0000);
|
||||
let t2 = a - t1;
|
||||
w = (t1 * y1 - (w * (-w) - (t1 * y2 + t2 * b))).sqrt();
|
||||
}
|
||||
if k != 0 {
|
||||
f64::from_bits(((1023 + k) as u64) << 52) * w
|
||||
} else {
|
||||
w
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// HotSpot's AArch64 `Math.sin` / `Math.cos` stubs
|
||||
/// (`MacroAssembler::generate_dsin_dcos`): fdlibm with fused multiply-adds.
|
||||
/// Exposed on every architecture so they can be tested anywhere.
|
||||
// HotSpot's tables, verbatim.
|
||||
#[allow(
|
||||
clippy::excessive_precision,
|
||||
clippy::approx_constant,
|
||||
clippy::unreadable_literal
|
||||
)]
|
||||
pub mod aarch64 {
|
||||
use super::hi;
|
||||
|
||||
const S: [f64; 6] = [
|
||||
-1.66666666666666324348e-01,
|
||||
8.33333333332248946124e-03,
|
||||
-1.98412698298579493134e-04,
|
||||
2.75573137070700676789e-06,
|
||||
-2.50507602534068634195e-08,
|
||||
1.58969099521155010221e-10,
|
||||
];
|
||||
const C: [f64; 6] = [
|
||||
4.16666666666666019037e-02,
|
||||
-1.38888888888741095749e-03,
|
||||
2.48015872894767294178e-05,
|
||||
-2.75573143513906633035e-07,
|
||||
2.08757232129817482790e-09,
|
||||
-1.13596475577881948265e-11,
|
||||
];
|
||||
const INVPIO2: f64 = 6.36619772367581382433e-01;
|
||||
const PIO2_1: f64 = 1.57079632673412561417e+00;
|
||||
const PIO2_1T: f64 = 6.07710050650619224932e-11;
|
||||
const PIO2_2: f64 = 6.07710050630396597660e-11;
|
||||
const PIO2_2T: f64 = 2.02226624879595063154e-21;
|
||||
const PIO2_3: f64 = 2.02226624871116645580e-21;
|
||||
const PIO2_3T: f64 = 8.47842766036889956997e-32;
|
||||
const NPIO2_HW: [i32; 32] = [
|
||||
0x3FF921FB, 0x400921FB, 0x4012D97C, 0x401921FB, 0x401F6A7A, 0x4022D97C, 0x4025FDBB,
|
||||
0x402921FB, 0x402C463A, 0x402F6A7A, 0x4031475C, 0x4032D97C, 0x40346B9C, 0x4035FDBB,
|
||||
0x40378FDB, 0x403921FB, 0x403AB41B, 0x403C463A, 0x403DD85A, 0x403F6A7A, 0x40407E4C,
|
||||
0x4041475C, 0x4042106C, 0x4042D97C, 0x4043A28C, 0x40446B9C, 0x404534AC, 0x4045FDBB,
|
||||
0x4046C6CB, 0x40478FDB, 0x404858EB, 0x404921FB,
|
||||
];
|
||||
|
||||
/// `generate_kernel_sin`.
|
||||
fn kernel_sin(x: f64, y: f64, iy: bool) -> f64 {
|
||||
let z = x * x;
|
||||
let v = z * x;
|
||||
let mut r = z.mul_add(S[5], S[4]);
|
||||
r = z.mul_add(r, S[3]);
|
||||
r = z.mul_add(r, S[2]);
|
||||
r = z.mul_add(r, S[1]);
|
||||
if !iy {
|
||||
v.mul_add(z.mul_add(r, S[0]), x)
|
||||
} else {
|
||||
let t = (-v).mul_add(r, 0.5 * y);
|
||||
let t = (-z).mul_add(t, y);
|
||||
x + v.mul_add(S[0], t)
|
||||
}
|
||||
}
|
||||
|
||||
/// `generate_kernel_cos`. `ix` is the high word of the *original*
|
||||
/// argument: the stub reuses the register, so a reduced argument always
|
||||
/// takes the `qx = 0.28125` branch.
|
||||
fn kernel_cos(x: f64, y: f64, ix: i32) -> f64 {
|
||||
let z = x * x;
|
||||
let mut r = z.mul_add(C[5], C[4]);
|
||||
r = z.mul_add(r, C[3]);
|
||||
let xy = x * y;
|
||||
r = z.mul_add(r, C[2]);
|
||||
r = z.mul_add(r, C[1]);
|
||||
let zz = z * z;
|
||||
r = z.mul_add(r, C[0]);
|
||||
if ix <= 0x3FD33333 {
|
||||
let v = (-zz).mul_add(r, xy);
|
||||
1.0 - 0.5f64.mul_add(z, v)
|
||||
} else {
|
||||
let qx = if ix > 0x3fe90000 {
|
||||
0.28125
|
||||
} else {
|
||||
f64::from_bits(u64::from((ix - 0x0020_0000) as u32) << 32)
|
||||
};
|
||||
let zr_xy = zz.mul_add(r, -xy);
|
||||
let h = 0.5f64.mul_add(z, -qx);
|
||||
let a = 1.0 - qx;
|
||||
a - (h - zr_xy)
|
||||
}
|
||||
}
|
||||
|
||||
/// `generate__ieee754_rem_pio2` for |x| <= 2^19·π/2; `None` beyond.
|
||||
fn rem_pio2(x: f64, ix: i32) -> Option<(i32, f64, f64)> {
|
||||
if ix < 0x4002d97c {
|
||||
return Some(if x.to_bits() as i64 >= 0 {
|
||||
let z = x - PIO2_1;
|
||||
if ix != 0x3ff921fb {
|
||||
let y0 = z - PIO2_1T;
|
||||
(1, y0, (z - y0) - PIO2_1T)
|
||||
} else {
|
||||
let z = z - PIO2_2;
|
||||
let y0 = z - PIO2_2T;
|
||||
(1, y0, (z - y0) - PIO2_2T)
|
||||
}
|
||||
} else {
|
||||
let z = x + PIO2_1;
|
||||
if ix != 0x3ff921fb {
|
||||
let y0 = z + PIO2_1T;
|
||||
(-1, y0, (z - y0) + PIO2_1T)
|
||||
} else {
|
||||
let z = z + PIO2_2;
|
||||
let y0 = z + PIO2_2T;
|
||||
(-1, y0, (z - y0) + PIO2_2T)
|
||||
}
|
||||
});
|
||||
}
|
||||
if ix > 0x413921fb {
|
||||
return None;
|
||||
}
|
||||
let t = x.abs();
|
||||
let fnv = t.mul_add(INVPIO2, 0.5).trunc();
|
||||
let n = fnv as i32;
|
||||
let mut r = (-fnv).mul_add(PIO2_1, t);
|
||||
let mut w = fnv * PIO2_1T;
|
||||
let mut y0 = r - w;
|
||||
if n > 32 || ix == NPIO2_HW[(n - 1) as usize] {
|
||||
let j = ix >> 20;
|
||||
let exponent = |y: f64| ((y.to_bits() << 1) >> 53) as i32;
|
||||
if j - exponent(y0) > 16 {
|
||||
let t = r;
|
||||
let w2 = fnv * PIO2_2;
|
||||
r = t - w2;
|
||||
w = fnv.mul_add(PIO2_2T, w2 - (t - r));
|
||||
y0 = r - w;
|
||||
if j - exponent(y0) > 49 {
|
||||
let t = r;
|
||||
let w3 = fnv * PIO2_3;
|
||||
r = t - w3;
|
||||
w = fnv.mul_add(PIO2_3T, w3 - (t - r));
|
||||
y0 = r - w;
|
||||
}
|
||||
}
|
||||
}
|
||||
let y1 = (r - y0) - w;
|
||||
Some(if x > 0.0 { (n, y0, y1) } else { (-n, -y0, -y1) })
|
||||
}
|
||||
|
||||
fn sin_cos(x: f64, is_cos: bool) -> f64 {
|
||||
let ix = hi(x) & 0x7fff_ffff;
|
||||
if ix < 0x3e400000 {
|
||||
return if is_cos { 1.0 } else { x };
|
||||
}
|
||||
if ix <= 0x3fe921fb {
|
||||
return if is_cos {
|
||||
kernel_cos(x, 0.0, ix)
|
||||
} else {
|
||||
kernel_sin(x, 0.0, false)
|
||||
};
|
||||
}
|
||||
if ix >= 0x7ff00000 {
|
||||
return f64::from_bits(0x7ff0_0000_0000_0001);
|
||||
}
|
||||
// Beyond 2^19·π/2 the stub's Payne–Hanek reduction isn't ported:
|
||||
// StrictMath's is used instead.
|
||||
let Some((n, y0, y1)) = rem_pio2(x, ix) else {
|
||||
return if is_cos { libm::cos(x) } else { libm::sin(x) };
|
||||
};
|
||||
let (value, negate) = if (n & 1 == 1) != is_cos {
|
||||
(
|
||||
kernel_cos(y0, y1, ix),
|
||||
if is_cos { (n ^ (n >> 1)) & 1 } else { n & 2 },
|
||||
)
|
||||
} else {
|
||||
(
|
||||
kernel_sin(y0, y1, true),
|
||||
if is_cos { (n ^ (n >> 1)) & 1 } else { n & 2 },
|
||||
)
|
||||
};
|
||||
if negate != 0 { -value } else { value }
|
||||
}
|
||||
|
||||
/// `Math.sin` on AArch64 HotSpot.
|
||||
pub fn sin(x: f64) -> f64 {
|
||||
sin_cos(x, false)
|
||||
}
|
||||
|
||||
/// `Math.cos` on AArch64 HotSpot.
|
||||
pub fn cos(x: f64) -> f64 {
|
||||
sin_cos(x, true)
|
||||
}
|
||||
}
|
||||
|
||||
/// `sin` and `cos` correctly rounded, for Windows, whose libm rounds them
|
||||
/// unlike Java's x86-64 intrinsics. Double-double arithmetic (two `f64`s per
|
||||
/// value): `x` reduced by a four-part pi/2, a short series checked against an
|
||||
@@ -813,6 +406,23 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_f32_at_the_edges_of_the_int_range() {
|
||||
for (x, expected) in [
|
||||
(2_147_483_520.0, 2_147_483_520), // the largest float below 2^31
|
||||
(2_147_483_648.0, i32::MAX),
|
||||
(f32::INFINITY, i32::MAX),
|
||||
(-2_147_483_648.0, i32::MIN),
|
||||
(-2_147_483_904.0, i32::MIN),
|
||||
(f32::NEG_INFINITY, i32::MIN),
|
||||
(8_388_607.5, 8_388_608), // the largest floats with a fraction
|
||||
(-8_388_607.5, -8_388_607),
|
||||
(-f32::from_bits(1), 0),
|
||||
] {
|
||||
assert_eq!(round_f32(x), expected, "{x}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_ties_toward_positive_infinity() {
|
||||
for (x, want) in [
|
||||
|
||||
@@ -0,0 +1,587 @@
|
||||
// Copyright (C) 1993 by Sun Microsystems, Inc. All rights reserved.
|
||||
//
|
||||
// Developed at SunSoft, a Sun Microsystems, Inc. business.
|
||||
// Permission to use, copy, modify, and distribute this
|
||||
// software is freely granted, provided that this notice
|
||||
// is preserved.
|
||||
//
|
||||
// Rust port of fdlibm 5.3's e_log.c, e_log10.c, s_cbrt.c, e_hypot.c, s_sin.c, s_cos.c, k_sin.c, k_cos.c and e_rem_pio2.c.
|
||||
|
||||
//! fdlibm 5.3, Sun's freely distributable math library
|
||||
//! (<https://www.netlib.org/fdlibm/>), in Rust.
|
||||
//!
|
||||
//! - [`log`], [`log10`], [`cbrt`] and [`hypot`] are the C functions with the
|
||||
//! same double operations in the same order, so they round exactly as
|
||||
//! fdlibm does. The Java SE API defines `StrictMath`'s versions as fdlibm
|
||||
//! 5.3's results.
|
||||
//! - [`sin_fma`] and [`cos_fma`] are fdlibm's `sin` and `cos` as HotSpot's
|
||||
//! AArch64 intrinsics compute `Math.sin` and `Math.cos`. They differ from
|
||||
//! the C in these ways, checked against JDK 25 on Apple silicon:
|
||||
//! - `|x| < 2^-27` returns `x` (sin) or 1 (cos) first; the kernels have no
|
||||
//! shortcut of their own for a tiny argument.
|
||||
//! - The kernels' polynomials and corrections use fused multiply-adds, and
|
||||
//! the cosine kernel's `z * r` is `(z * z) * P`.
|
||||
//! - The cosine kernel picks its branch from the caller's argument, before
|
||||
//! reduction, and its first branch includes the bound `0x3fd33333`.
|
||||
//! - The reduction fuses its quotient and its second and third tails, and
|
||||
//! for n = 32 takes the extra steps only when the high word is 16 pi's,
|
||||
//! as for n < 32.
|
||||
//! - Beyond |x| = 2^19 * pi/2 the reduction is not modelled: the `libm`
|
||||
//! crate's `sin` and `cos` answer there, and may differ in the last bit.
|
||||
|
||||
/// High 32 bits of `x`, as a signed int (fdlibm's `__HI(x)`).
|
||||
fn high_word(x: f64) -> i32 {
|
||||
(x.to_bits() >> 32) as i32
|
||||
}
|
||||
|
||||
/// Low 32 bits of `x` (fdlibm's `__LO(x)`).
|
||||
fn low_word(x: f64) -> u32 {
|
||||
x.to_bits() as u32
|
||||
}
|
||||
|
||||
/// `x` with its high 32 bits replaced by `hw` (fdlibm's `__HI(x) = hw`).
|
||||
fn set_high_word(x: f64, hw: i32) -> f64 {
|
||||
f64::from_bits((u64::from(hw as u32) << 32) | u64::from(low_word(x)))
|
||||
}
|
||||
|
||||
/// The double whose high 32 bits are `hw` and whose low 32 bits are 0.
|
||||
fn from_high_word(hw: i32) -> f64 {
|
||||
f64::from_bits(u64::from(hw as u32) << 32)
|
||||
}
|
||||
|
||||
/// 2^54, to bring subnormal arguments into the normal range.
|
||||
const TWO54: f64 = f64::from_bits(0x4350_0000_0000_0000);
|
||||
|
||||
// e_log.c: ln 2 split so that k * LN2_HI is exact for |k| < 2000, and the
|
||||
// polynomial for log(1 + f) in s = f / (2 + f).
|
||||
const LN2_HI: f64 = f64::from_bits(0x3fe6_2e42_fee0_0000); // 6.93147180369123816490e-01
|
||||
const LN2_LO: f64 = f64::from_bits(0x3dea_39ef_3579_3c76); // 1.90821492927058770002e-10
|
||||
const LG1: f64 = f64::from_bits(0x3fe5_5555_5555_5593); // 6.666666666666735130e-01
|
||||
const LG2: f64 = f64::from_bits(0x3fd9_9999_9997_fa04); // 3.999999999940941908e-01
|
||||
const LG3: f64 = f64::from_bits(0x3fd2_4924_9422_9359); // 2.857142874366239149e-01
|
||||
const LG4: f64 = f64::from_bits(0x3fcc_71c5_1d8e_78af); // 2.222219843214978396e-01
|
||||
const LG5: f64 = f64::from_bits(0x3fc7_4664_96cb_03de); // 1.818357216161805012e-01
|
||||
const LG6: f64 = f64::from_bits(0x3fc3_9a09_d078_c69f); // 1.531383769920937332e-01
|
||||
const LG7: f64 = f64::from_bits(0x3fc2_f112_df3e_5244); // 1.479819860511658591e-01
|
||||
/// The C literal `0.33333333333333333`, rounded to a double.
|
||||
const ONE_THIRD: f64 = f64::from_bits(0x3fd5_5555_5555_5555);
|
||||
|
||||
/// Natural logarithm, fdlibm's `__ieee754_log`.
|
||||
///
|
||||
/// Writes `x = 2^k * (1 + f)` with `sqrt(2)/2 < 1 + f < sqrt(2)`, then
|
||||
/// returns `k * ln 2 + log(1 + f)`, with `log(1 + f)` from a degree-14
|
||||
/// polynomial in `s = f / (2 + f)` (or a short series when |f| < 2^-20).
|
||||
/// `log(±0)` is -inf, negative arguments give NaN, `log(+inf)` is +inf.
|
||||
pub fn log(x: f64) -> f64 {
|
||||
let mut x = x;
|
||||
let mut hx = high_word(x);
|
||||
let mut k = 0i32;
|
||||
if hx < 0x0010_0000 {
|
||||
// Zero, negative, or subnormal.
|
||||
if ((hx & 0x7fff_ffff) as u32 | low_word(x)) == 0 {
|
||||
return f64::NEG_INFINITY;
|
||||
}
|
||||
if hx < 0 {
|
||||
return f64::NAN;
|
||||
}
|
||||
k -= 54;
|
||||
x *= TWO54;
|
||||
hx = high_word(x);
|
||||
}
|
||||
if hx >= 0x7ff0_0000 {
|
||||
return x + x; // +inf or NaN
|
||||
}
|
||||
k += (hx >> 20) - 1023;
|
||||
hx &= 0x000f_ffff;
|
||||
// Give 1 + f the exponent of 1, or of 1/2 when the mantissa is above
|
||||
// sqrt(2), and move that factor of 2 into k.
|
||||
let i = (hx + 0x95f64) & 0x0010_0000;
|
||||
x = set_high_word(x, hx | (i ^ 0x3ff0_0000));
|
||||
k += i >> 20;
|
||||
let f = x - 1.0;
|
||||
let dk = f64::from(k);
|
||||
if (0x000f_ffff & (2 + hx)) < 3 {
|
||||
// |f| < 2^-20: log(1 + f) = f - f^2/2 + f^3/3.
|
||||
if f == 0.0 {
|
||||
return if k == 0 {
|
||||
0.0
|
||||
} else {
|
||||
dk * LN2_HI + dk * LN2_LO
|
||||
};
|
||||
}
|
||||
let r = f * f * (0.5 - ONE_THIRD * f);
|
||||
return if k == 0 {
|
||||
f - r
|
||||
} else {
|
||||
dk * LN2_HI - ((r - dk * LN2_LO) - f)
|
||||
};
|
||||
}
|
||||
let s = f / (2.0 + f);
|
||||
let z = s * s;
|
||||
let w = z * z;
|
||||
let t1 = w * (LG2 + w * (LG4 + w * LG6));
|
||||
let t2 = z * (LG1 + w * (LG3 + w * (LG5 + w * LG7)));
|
||||
let r = t2 + t1;
|
||||
// Mantissas between 0x6147a and 0x6b851 (in the top 20 bits) keep
|
||||
// f^2/2 apart for accuracy.
|
||||
if ((hx - 0x6147a) | (0x6b851 - hx)) > 0 {
|
||||
let hfsq = 0.5 * f * f;
|
||||
if k == 0 {
|
||||
f - (hfsq - s * (hfsq + r))
|
||||
} else {
|
||||
dk * LN2_HI - ((hfsq - (s * (hfsq + r) + dk * LN2_LO)) - f)
|
||||
}
|
||||
} else if k == 0 {
|
||||
f - s * (f - r)
|
||||
} else {
|
||||
dk * LN2_HI - ((s * (f - r) - dk * LN2_LO) - f)
|
||||
}
|
||||
}
|
||||
|
||||
// e_log10.c: 1/ln 10, and log10(2) split so that n * LOG10_2HI is exact.
|
||||
const IVLN10: f64 = f64::from_bits(0x3fdb_cb7b_1526_e50e); // 4.34294481903251816668e-01
|
||||
const LOG10_2HI: f64 = f64::from_bits(0x3fd3_4413_509f_6000); // 3.01029995663611771306e-01
|
||||
const LOG10_2LO: f64 = f64::from_bits(0x3d59_fef3_11f1_2b36); // 3.69423907715893078616e-13
|
||||
|
||||
/// Base-10 logarithm, fdlibm's `__ieee754_log10`.
|
||||
///
|
||||
/// Writes `x = 2^n * m`, with `m` in [1, 2), or in [1/2, 1) and `n` one
|
||||
/// higher when the exponent is negative, and returns
|
||||
/// `n * log10_2hi + (n * log10_2lo + log(m) / ln 10)`, with [`log`] for
|
||||
/// `log(m)`. Special cases as [`log`].
|
||||
pub fn log10(x: f64) -> f64 {
|
||||
let mut x = x;
|
||||
let mut hx = high_word(x);
|
||||
let mut k = 0i32;
|
||||
if hx < 0x0010_0000 {
|
||||
// Zero, negative, or subnormal.
|
||||
if ((hx & 0x7fff_ffff) as u32 | low_word(x)) == 0 {
|
||||
return f64::NEG_INFINITY;
|
||||
}
|
||||
if hx < 0 {
|
||||
return f64::NAN;
|
||||
}
|
||||
k -= 54;
|
||||
x *= TWO54;
|
||||
hx = high_word(x);
|
||||
}
|
||||
if hx >= 0x7ff0_0000 {
|
||||
return x + x; // +inf or NaN
|
||||
}
|
||||
k += (hx >> 20) - 1023;
|
||||
let i = i32::from(k < 0);
|
||||
hx = (hx & 0x000f_ffff) | ((0x3ff - i) << 20);
|
||||
let y = f64::from(k + i);
|
||||
x = set_high_word(x, hx);
|
||||
let z = y * LOG10_2LO + IVLN10 * log(x);
|
||||
z + y * LOG10_2HI
|
||||
}
|
||||
|
||||
// s_cbrt.c: exponent biases for the first estimate, (682 - 0.03306235651) *
|
||||
// 2^20 and (664 - 0.03306235651) * 2^20, and the rational correction's
|
||||
// coefficients.
|
||||
const B1: i32 = 715_094_163;
|
||||
const B2: i32 = 696_219_795;
|
||||
const CBRT_C: f64 = f64::from_bits(0x3fe1_5f15_f15f_15f1); // 19/35
|
||||
const CBRT_D: f64 = f64::from_bits(0xbfe6_91de_2532_c834); // -864/1225
|
||||
const CBRT_E: f64 = f64::from_bits(0x3ff6_a0ea_0ea0_ea0f); // 99/70
|
||||
const CBRT_F: f64 = f64::from_bits(0x3ff9_b6db_6db6_db6e); // 45/28
|
||||
const CBRT_G: f64 = f64::from_bits(0x3fd6_db6d_b6db_6db7); // 5/14
|
||||
|
||||
/// Cube root, fdlibm's `cbrt`.
|
||||
///
|
||||
/// A first estimate from the high word divided by 3 (5 bits), a rational
|
||||
/// correction (23 bits), the estimate chopped to 20 bits and nudged up, then
|
||||
/// one Newton step. `cbrt(±0)`, `cbrt(±inf)` and `cbrt(NaN)` return the
|
||||
/// argument.
|
||||
pub fn cbrt(x: f64) -> f64 {
|
||||
let hx = high_word(x);
|
||||
let sign = hx & i32::MIN;
|
||||
let hx = hx ^ sign;
|
||||
if hx >= 0x7ff0_0000 {
|
||||
return x + x; // NaN or ±inf
|
||||
}
|
||||
if (hx as u32 | low_word(x)) == 0 {
|
||||
return x; // ±0
|
||||
}
|
||||
let x = set_high_word(x, hx); // |x|
|
||||
let mut t = if hx < 0x0010_0000 {
|
||||
// Subnormal: estimate from 2^54 * x, keeping its low word.
|
||||
let t = TWO54 * x;
|
||||
set_high_word(t, high_word(t) / 3 + B2)
|
||||
} else {
|
||||
from_high_word(hx / 3 + B1)
|
||||
};
|
||||
let r = t * t / x;
|
||||
let s = CBRT_C + r * t;
|
||||
t *= CBRT_G + CBRT_F / (s + CBRT_E + CBRT_D / s);
|
||||
// Keep the top 20 bits of the mantissa and add one in the last of them,
|
||||
// so that t is above cbrt(x) and t * t is exact.
|
||||
t = from_high_word(high_word(t) + 1);
|
||||
let s = t * t;
|
||||
let r = x / s;
|
||||
let w = t + t;
|
||||
let r = (r - t) / (w + r);
|
||||
t += t * r;
|
||||
set_high_word(t, high_word(t) | sign)
|
||||
}
|
||||
|
||||
/// `sqrt(x^2 + y^2)` without undue overflow or underflow, fdlibm's
|
||||
/// `__ieee754_hypot`.
|
||||
///
|
||||
/// With `a >= b` the magnitudes, compared by high word: `a + b` when `a` is
|
||||
/// more than 2^60 times `b`; otherwise both scaled by a power of two when
|
||||
/// large or tiny, `a^2 + b^2` summed from parts with the low 32 bits split
|
||||
/// off, and the square root scaled back. An infinite argument gives +inf,
|
||||
/// even with a NaN; otherwise a NaN gives NaN.
|
||||
pub fn hypot(x: f64, y: f64) -> f64 {
|
||||
let mut ha = high_word(x) & 0x7fff_ffff;
|
||||
let mut hb = high_word(y) & 0x7fff_ffff;
|
||||
let (mut a, mut b) = if hb > ha {
|
||||
std::mem::swap(&mut ha, &mut hb);
|
||||
(y, x)
|
||||
} else {
|
||||
(x, y)
|
||||
};
|
||||
a = set_high_word(a, ha);
|
||||
b = set_high_word(b, hb);
|
||||
if ha - hb > 0x03c0_0000 {
|
||||
return a + b; // a / b > 2^60
|
||||
}
|
||||
let mut k = 0i32;
|
||||
if ha > 0x5f30_0000 {
|
||||
// a > 2^500
|
||||
if ha >= 0x7ff0_0000 {
|
||||
// inf or NaN: inf wins over NaN.
|
||||
let mut w = a + b;
|
||||
if ((ha & 0x000f_ffff) as u32 | low_word(a)) == 0 {
|
||||
w = a;
|
||||
}
|
||||
if ((hb ^ 0x7ff0_0000) as u32 | low_word(b)) == 0 {
|
||||
w = b;
|
||||
}
|
||||
return w;
|
||||
}
|
||||
// Scale both by 2^-600.
|
||||
ha -= 0x2580_0000;
|
||||
hb -= 0x2580_0000;
|
||||
k += 600;
|
||||
a = set_high_word(a, ha);
|
||||
b = set_high_word(b, hb);
|
||||
}
|
||||
if hb < 0x20b0_0000 {
|
||||
// b < 2^-500
|
||||
if hb <= 0x000f_ffff {
|
||||
// b subnormal or zero
|
||||
if (hb as u32 | low_word(b)) == 0 {
|
||||
return a;
|
||||
}
|
||||
// Scale both by 2^1022. As in the C, `ha` and `hb` keep the
|
||||
// high words from before this scaling.
|
||||
let t1 = from_high_word(0x7fd0_0000);
|
||||
b *= t1;
|
||||
a *= t1;
|
||||
k -= 1022;
|
||||
} else {
|
||||
// Scale both by 2^600.
|
||||
ha += 0x2580_0000;
|
||||
hb += 0x2580_0000;
|
||||
k -= 600;
|
||||
a = set_high_word(a, ha);
|
||||
b = set_high_word(b, hb);
|
||||
}
|
||||
}
|
||||
let mut w = a - b;
|
||||
if w > b {
|
||||
// a > 2b: a^2 + b^2 = t1^2 + (b^2 + t2 * (a + t1)), t1 = a with
|
||||
// its low word cleared.
|
||||
let t1 = from_high_word(ha);
|
||||
let t2 = a - t1;
|
||||
w = (t1 * t1 - (b * (-b) - t2 * (a + t1))).sqrt();
|
||||
} else {
|
||||
// a <= 2b: a^2 + b^2 = 2a * b + (a - b)^2, 2a and b split in two.
|
||||
a += a;
|
||||
let y1 = from_high_word(hb);
|
||||
let y2 = b - y1;
|
||||
let t1 = from_high_word(ha + 0x0010_0000);
|
||||
let t2 = a - t1;
|
||||
w = (t1 * y1 - (w * (-w) - (t1 * y2 + t2 * b))).sqrt();
|
||||
}
|
||||
if k == 0 {
|
||||
w
|
||||
} else {
|
||||
// 2^k * w
|
||||
from_high_word(0x3ff0_0000 + (k << 20)) * w
|
||||
}
|
||||
}
|
||||
|
||||
// k_sin.c's coefficients: sin x ~ x + S1 x^3 + ... + S6 x^13 on [-pi/4, pi/4].
|
||||
const S1: f64 = f64::from_bits(0xbfc5_5555_5555_5549); // -1.66666666666666324348e-01
|
||||
const S2: f64 = f64::from_bits(0x3f81_1111_1110_f8a6); // 8.33333333332248946124e-03
|
||||
const S3: f64 = f64::from_bits(0xbf2a_01a0_19c1_61d5); // -1.98412698298579493134e-04
|
||||
const S4: f64 = f64::from_bits(0x3ec7_1de3_57b1_fe7d); // 2.75573137070700676789e-06
|
||||
const S5: f64 = f64::from_bits(0xbe5a_e5e6_8a2b_9ceb); // -2.50507602534068634195e-08
|
||||
const S6: f64 = f64::from_bits(0x3de5_d93a_5acf_d57c); // 1.58969099521155010221e-10
|
||||
|
||||
// k_cos.c's coefficients: cos x ~ 1 - x^2/2 + C1 x^4 + ... + C6 x^14.
|
||||
const C1: f64 = f64::from_bits(0x3fa5_5555_5555_554c); // 4.16666666666666019037e-02
|
||||
const C2: f64 = f64::from_bits(0xbf56_c16c_16c1_5177); // -1.38888888888741095749e-03
|
||||
const C3: f64 = f64::from_bits(0x3efa_01a0_19cb_1590); // 2.48015872894767294178e-05
|
||||
const C4: f64 = f64::from_bits(0xbe92_7e4f_809c_52ad); // -2.75573143513906633035e-07
|
||||
const C5: f64 = f64::from_bits(0x3e21_ee9e_bdb4_b1c4); // 2.08757232129817482790e-09
|
||||
const C6: f64 = f64::from_bits(0xbda8_fae9_be88_38d4); // -1.13596475577881948265e-11
|
||||
|
||||
// e_rem_pio2.c: 2/pi, and pi/2 cut into 33-bit pieces (so that n times a piece
|
||||
// is exact for n < 2^20) and the remainders after each.
|
||||
const INVPIO2: f64 = f64::from_bits(0x3fe4_5f30_6dc9_c883); // 6.36619772367581382433e-01
|
||||
const PIO2_1: f64 = f64::from_bits(0x3ff9_21fb_5440_0000); // 1.57079632673412561417e+00
|
||||
const PIO2_1T: f64 = f64::from_bits(0x3dd0_b461_1a62_6331); // 6.07710050650619224932e-11
|
||||
const PIO2_2: f64 = f64::from_bits(0x3dd0_b461_1a60_0000); // 6.07710050630396597660e-11
|
||||
const PIO2_2T: f64 = f64::from_bits(0x3ba3_198a_2e03_7073); // 2.02226624879595063154e-21
|
||||
const PIO2_3: f64 = f64::from_bits(0x3ba3_198a_2e00_0000); // 2.02226624871116645580e-21
|
||||
const PIO2_3T: f64 = f64::from_bits(0x397b_839a_2520_49c1); // 8.47842766036889956997e-32
|
||||
/// High words of n * pi/2 for n = 1..=32.
|
||||
const NPIO2_HW: [i32; 32] = [
|
||||
0x3ff9_21fb,
|
||||
0x4009_21fb,
|
||||
0x4012_d97c,
|
||||
0x4019_21fb,
|
||||
0x401f_6a7a,
|
||||
0x4022_d97c,
|
||||
0x4025_fdbb,
|
||||
0x4029_21fb,
|
||||
0x402c_463a,
|
||||
0x402f_6a7a,
|
||||
0x4031_475c,
|
||||
0x4032_d97c,
|
||||
0x4034_6b9c,
|
||||
0x4035_fdbb,
|
||||
0x4037_8fdb,
|
||||
0x4039_21fb,
|
||||
0x403a_b41b,
|
||||
0x403c_463a,
|
||||
0x403d_d85a,
|
||||
0x403f_6a7a,
|
||||
0x4040_7e4c,
|
||||
0x4041_475c,
|
||||
0x4042_106c,
|
||||
0x4042_d97c,
|
||||
0x4043_a28c,
|
||||
0x4044_6b9c,
|
||||
0x4045_34ac,
|
||||
0x4045_fdbb,
|
||||
0x4046_c6cb,
|
||||
0x4047_8fdb,
|
||||
0x4048_58eb,
|
||||
0x4049_21fb,
|
||||
];
|
||||
|
||||
/// The biased exponent of `v`, 11 bits.
|
||||
fn biased_exponent(v: f64) -> i32 {
|
||||
(high_word(v) >> 20) & 0x7ff
|
||||
}
|
||||
|
||||
/// fdlibm's `__kernel_sin(x, y, iy)` with fused multiply-adds: sin(x + y)
|
||||
/// for |x| <= pi/4 + a little, `y` the tail of a reduced argument
|
||||
/// (`reduced == false`: y is 0). Unlike the C, a tiny `x` goes through the
|
||||
/// polynomial too.
|
||||
fn sin_kernel_fma(x: f64, y: f64, reduced: bool) -> f64 {
|
||||
let z = x * x;
|
||||
let v = z * x;
|
||||
// S2 + z (S3 + z (S4 + z (S5 + z S6)))
|
||||
let r = z.mul_add(z.mul_add(z.mul_add(z.mul_add(S6, S5), S4), S3), S2);
|
||||
if !reduced {
|
||||
// x + v (S1 + z r)
|
||||
return v.mul_add(z.mul_add(r, S1), x);
|
||||
}
|
||||
// x - ((z (y/2 - v r) - y) - v S1)
|
||||
let t = (-v).mul_add(r, 0.5 * y);
|
||||
let u = (-z).mul_add(t, y);
|
||||
x + v.mul_add(S1, u)
|
||||
}
|
||||
|
||||
/// fdlibm's `__kernel_cos(x, y)` with fused multiply-adds: cos(x + y) for
|
||||
/// |x| <= pi/4 + a little. `hw` is the high word (sign cleared) of the
|
||||
/// caller's argument before any reduction, and it alone picks the branch:
|
||||
/// so a reduced argument always takes the second one with `qx = 0.28125`.
|
||||
/// The first branch includes `hw == 0x3fd33333`, and a tiny `x` goes
|
||||
/// through the polynomial too.
|
||||
fn cos_kernel_fma(x: f64, y: f64, hw: i32) -> f64 {
|
||||
let z = x * x;
|
||||
// C1 + z (C2 + z (C3 + z (C4 + z (C5 + z C6)))); the C's z * r is zz * p.
|
||||
let p = z.mul_add(
|
||||
z.mul_add(z.mul_add(z.mul_add(z.mul_add(C6, C5), C4), C3), C2),
|
||||
C1,
|
||||
);
|
||||
let xy = x * y;
|
||||
let zz = z * z;
|
||||
if hw <= 0x3fd3_3333 {
|
||||
// 1 - (z/2 - (z r - x y))
|
||||
return 1.0 - 0.5f64.mul_add(z, (-zz).mul_add(p, xy));
|
||||
}
|
||||
// (1 - qx) - ((z/2 - qx) - (z r - x y)), with qx = |x|/4 cut to its
|
||||
// high word, or 0.28125 above 0.78125.
|
||||
let qx = if hw > 0x3fe9_0000 {
|
||||
0.28125
|
||||
} else {
|
||||
from_high_word(hw - 0x0020_0000)
|
||||
};
|
||||
let hz = 0.5f64.mul_add(z, -qx);
|
||||
let a = 1.0 - qx;
|
||||
a - (hz - zz.mul_add(p, -xy))
|
||||
}
|
||||
|
||||
/// fdlibm's `__ieee754_rem_pio2(x)` for 2^-27 <= |x| <= 2^19 * pi/2, with
|
||||
/// fused multiply-adds: `(n, y0, y1)` with `x - n * pi/2 = y0 + y1`.
|
||||
fn rem_pio2_fma(x: f64) -> (i32, f64, f64) {
|
||||
let hx = high_word(x);
|
||||
let ix = hx & 0x7fff_ffff;
|
||||
if ix < 0x4002_d97c {
|
||||
// |x| < 3pi/4: n = ±1, as in the C. Next to pi/2 itself (high word
|
||||
// 0x3ff921fb), subtract the second 33 bits of pi/2 too.
|
||||
let (z, tail) = match (hx > 0, ix == 0x3ff9_21fb) {
|
||||
(true, false) => (x - PIO2_1, -PIO2_1T),
|
||||
(true, true) => (x - PIO2_1 - PIO2_2, -PIO2_2T),
|
||||
(false, false) => (x + PIO2_1, PIO2_1T),
|
||||
(false, true) => (x + PIO2_1 + PIO2_2, PIO2_2T),
|
||||
};
|
||||
let y0 = z + tail;
|
||||
let n = if hx > 0 { 1 } else { -1 };
|
||||
return (n, y0, (z - y0) + tail);
|
||||
}
|
||||
let t = x.abs();
|
||||
let fnum = t.mul_add(INVPIO2, 0.5).trunc();
|
||||
let n = fnum as i32;
|
||||
// t - n * pi/2 with 33 + 53 bits of pi/2.
|
||||
let mut r = (-fnum).mul_add(PIO2_1, t);
|
||||
let mut w = fnum * PIO2_1T;
|
||||
let mut y0 = r - w;
|
||||
if n > 32 || ix == NPIO2_HW[(n - 1) as usize] {
|
||||
// Past n = 32, or with the high word of n * pi/2 itself, y0 may have
|
||||
// lost bits to cancellation: past 16 bits take the next 33 bits of
|
||||
// pi/2, and past 49 the next 33.
|
||||
let j = ix >> 20;
|
||||
if j - biased_exponent(y0) > 16 {
|
||||
let t = r;
|
||||
let w2 = fnum * PIO2_2;
|
||||
r = t - w2;
|
||||
w = fnum.mul_add(PIO2_2T, w2 - (t - r));
|
||||
y0 = r - w;
|
||||
if j - biased_exponent(y0) > 49 {
|
||||
let t = r;
|
||||
let w3 = fnum * PIO2_3;
|
||||
r = t - w3;
|
||||
w = fnum.mul_add(PIO2_3T, w3 - (t - r));
|
||||
y0 = r - w;
|
||||
}
|
||||
}
|
||||
}
|
||||
let y1 = (r - y0) - w;
|
||||
if hx < 0 { (-n, -y0, -y1) } else { (n, y0, y1) }
|
||||
}
|
||||
|
||||
/// `sin(x)`: fdlibm's `sin` (s_sin.c) with fused multiply-adds; see the
|
||||
/// module documentation for how it departs from the C.
|
||||
pub fn sin_fma(x: f64) -> f64 {
|
||||
let ix = high_word(x) & 0x7fff_ffff;
|
||||
if ix < 0x3e40_0000 {
|
||||
return x;
|
||||
}
|
||||
if ix <= 0x3fe9_21fb {
|
||||
return sin_kernel_fma(x, 0.0, false);
|
||||
}
|
||||
if ix >= 0x7ff0_0000 {
|
||||
return f64::NAN;
|
||||
}
|
||||
if ix > 0x4139_21fb {
|
||||
return libm::sin(x);
|
||||
}
|
||||
let (n, y0, y1) = rem_pio2_fma(x);
|
||||
match n & 3 {
|
||||
0 => sin_kernel_fma(y0, y1, true),
|
||||
1 => cos_kernel_fma(y0, y1, ix),
|
||||
2 => -sin_kernel_fma(y0, y1, true),
|
||||
_ => -cos_kernel_fma(y0, y1, ix),
|
||||
}
|
||||
}
|
||||
|
||||
/// `cos(x)`: fdlibm's `cos` (s_cos.c) with fused multiply-adds; see the
|
||||
/// module documentation for how it departs from the C.
|
||||
pub fn cos_fma(x: f64) -> f64 {
|
||||
let ix = high_word(x) & 0x7fff_ffff;
|
||||
if ix < 0x3e40_0000 {
|
||||
return 1.0;
|
||||
}
|
||||
if ix <= 0x3fe9_21fb {
|
||||
return cos_kernel_fma(x, 0.0, ix);
|
||||
}
|
||||
if ix >= 0x7ff0_0000 {
|
||||
return f64::NAN;
|
||||
}
|
||||
if ix > 0x4139_21fb {
|
||||
return libm::cos(x);
|
||||
}
|
||||
let (n, y0, y1) = rem_pio2_fma(x);
|
||||
match n & 3 {
|
||||
0 => cos_kernel_fma(y0, y1, ix),
|
||||
1 => -sin_kernel_fma(y0, y1, true),
|
||||
2 => -cos_kernel_fma(y0, y1, ix),
|
||||
_ => sin_kernel_fma(y0, y1, true),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{cbrt, cos_fma, hypot, log, log10, sin_fma};
|
||||
|
||||
fn check1(f: fn(f64) -> f64, cases: &[(u64, u64)]) {
|
||||
for &(x, want) in cases {
|
||||
assert_eq!(f(f64::from_bits(x)).to_bits(), want, "{x:016x}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `StrictMath` on JDK 25 (x86-64), on the paths the plain vectors in
|
||||
/// `jmath::tests` don't reach: subnormal arguments, |f| < 2^-20 in `log`,
|
||||
/// and each of `hypot`'s scalings.
|
||||
#[test]
|
||||
fn matches_strictmath_on_every_path() {
|
||||
check1(log, &[(0x0006204cc2b51c13, 0xc0862ad9fbcaf99b)]);
|
||||
check1(log, &[(0x3feffffffffffd15, 0xbd37580000000110)]);
|
||||
check1(log10, &[(0x0003397f14b3dd31, 0xc0734592a35d544b)]);
|
||||
check1(cbrt, &[(0x8007a2937cf342e8, 0xaa9f817dc09448aa)]);
|
||||
for (x, y, want) in [
|
||||
// a > 2^500: scaled by 2^-600.
|
||||
(0xe08d3dde0d47e8aa, 0xdf642854234b0418, 0x608d3dde0d4857d4),
|
||||
// b < 2^-500: scaled by 2^600.
|
||||
(0x9656cdb86ae83b21, 0x96303a08c1241dab, 0x1657295fd0495647),
|
||||
// b subnormal: scaled by 2^1022, the split still using the high
|
||||
// words from before the scaling (updating them changes these).
|
||||
(0x00d66c302c6465c5, 0x000ffffff731f247, 0x00d66c303219c78c),
|
||||
(0x007cf84ccb90d050, 0x000fffffd32f892e, 0x007cf8937ce3c936),
|
||||
// inf beats NaN.
|
||||
(0x7ff0000000000000, 0x7ff8000000000000, 0x7ff0000000000000),
|
||||
] {
|
||||
let got = hypot(f64::from_bits(x), f64::from_bits(y)).to_bits();
|
||||
assert_eq!(got, want, "{x:016x} {y:016x}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `Math.sin` / `Math.cos` on JDK 25, Apple M4: one argument for each way
|
||||
/// the fused versions differ from fdlibm's C, where the C's way gives
|
||||
/// another result.
|
||||
#[test]
|
||||
fn matches_hotspot_aarch64_where_it_departs_from_fdlibm() {
|
||||
// n = 32 away from 16 pi's high word: one reduction step only.
|
||||
check1(sin_fma, &[(0x404921fcfcc2b2f1, 0x3f0a87e85d5af49d)]);
|
||||
// High word 0x3fd33333: the cosine kernel's first branch.
|
||||
check1(cos_fma, &[(0x3fd33333d22b3d20, 0x3fee921dbcb1c922)]);
|
||||
// The cosine kernel's branch follows the caller's argument.
|
||||
check1(sin_fma, &[(0xc1262ac2ad35bfa3, 0xbfefbacd8d55fb04)]);
|
||||
// A tiny reduced argument still goes through the sine polynomial.
|
||||
check1(cos_fma, &[(0x412136d51041526e, 0x3e3dc3d8bd377c4d)]);
|
||||
// Fused polynomial, tail correction and z * r.
|
||||
check1(sin_fma, &[(0xbfe1b3f7fec43873, 0xbfe0d04fc1a57b1d)]);
|
||||
check1(cos_fma, &[(0x401e6ba3a2f24963, 0x3fcf86e6398c69d5)]);
|
||||
check1(cos_fma, &[(0x400f271ce0981ec5, 0xbfe75bfc80508df7)]);
|
||||
}
|
||||
}
|
||||
@@ -320,11 +320,65 @@ pub fn trail_part(item: &Item) -> Option<Part> {
|
||||
/// Draws `draws` into a `width`×`height` picture seen from `camera`. At `Camera::HOME`'s zoom
|
||||
/// the head's top is a fifth of the way down (room for a hat) and the feet 13% up.
|
||||
pub fn render(draws: &[Draw], width: u32, height: u32, camera: &Camera) -> Image {
|
||||
render_view(draws, width, height, camera.view(width, height))
|
||||
}
|
||||
|
||||
/// Each corner of every face `draws` holds, in model space (blocks).
|
||||
fn corners(draws: &[Draw], mut each: impl FnMut(Vec3)) {
|
||||
for d in draws {
|
||||
let mut stack = vec![(&*d.root, d.base)];
|
||||
while let Some((part, parent)) = stack.pop() {
|
||||
let m = parent * pose_matrix(pose_of(part, &d.poses));
|
||||
for face in &part.faces {
|
||||
for v in &face.vertices {
|
||||
each(m.transform_point3(Vec3::new(v[0], v[1], v[2]) / 16.0));
|
||||
}
|
||||
}
|
||||
for child in &part.children {
|
||||
stack.push((child, m));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A view of `draws` alone, turned `yaw` degrees and seen from `pitch` degrees above as
|
||||
/// `Camera` turns, as large as fits a `width`×`height` picture with `margin` of its smaller side
|
||||
/// left free on each side: a shop's picture of one item, framed on it. None when there is
|
||||
/// nothing to see.
|
||||
pub fn fitted_view(
|
||||
draws: &[Draw],
|
||||
(width, height): (u32, u32),
|
||||
(yaw, pitch): (f32, f32),
|
||||
margin: f32,
|
||||
) -> Option<Mat4> {
|
||||
let turn = Mat4::from_rotation_x(pitch.to_radians()) * Mat4::from_rotation_y(yaw.to_radians());
|
||||
let (mut lo, mut hi) = (Vec3::splat(f32::INFINITY), Vec3::splat(f32::NEG_INFINITY));
|
||||
corners(draws, |p| {
|
||||
let p = turn.transform_point3(p);
|
||||
(lo, hi) = (lo.min(p), hi.max(p));
|
||||
});
|
||||
let size = hi - lo;
|
||||
if !size.is_finite() || size.x <= 0.0 && size.y <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let free = margin * width.min(height) as f32 * 2.0;
|
||||
let (w, h) = (width as f32 - free, height as f32 - free);
|
||||
let scale = (w / size.x.max(1e-3)).min(h / size.y.max(1e-3));
|
||||
let centre = (lo + hi) / 2.0;
|
||||
Some(
|
||||
Mat4::from_translation(Vec3::new(width as f32 / 2.0, height as f32 / 2.0, 0.0))
|
||||
* Mat4::from_scale(Vec3::splat(scale))
|
||||
* Mat4::from_translation(-centre)
|
||||
* turn,
|
||||
)
|
||||
}
|
||||
|
||||
/// Draws `draws` into a `width`×`height` picture through `view` (model space to the picture's
|
||||
/// pixels, depth growing away from the viewer: `Camera::view`, `fitted_view`).
|
||||
pub fn render_view(draws: &[Draw], width: u32, height: u32, view: Mat4) -> Image {
|
||||
let (w, h) = (width as usize, height as usize);
|
||||
let mut rgba = vec![0u8; w * h * 4];
|
||||
let mut depth = vec![f32::INFINITY; w * h];
|
||||
// Model space has y down and the front at -z: the camera looks along +z from the front.
|
||||
let view = camera.view(width, height);
|
||||
// Solid first; see-through over them, unsorted (an aura is one thin layer).
|
||||
let solid = draws.iter().filter(|d| d.alpha >= 1.0);
|
||||
for d in solid.chain(draws.iter().filter(|d| d.alpha < 1.0)) {
|
||||
@@ -440,6 +494,57 @@ fn raster(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The drawn pixels' bounds: (left, top, right, bottom), inclusive.
|
||||
fn drawn(image: &Image) -> Option<(u32, u32, u32, u32)> {
|
||||
let mut out: Option<(u32, u32, u32, u32)> = None;
|
||||
for y in 0..image.height {
|
||||
for x in 0..image.width {
|
||||
if image.rgba[((y * image.width + x) * 4 + 3) as usize] > 0 {
|
||||
let (l, t, r, b) = out.unwrap_or((x, y, x, y));
|
||||
out = Some((l.min(x), t.min(y), r.max(x), b.max(y)));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fitted_view_fills_the_picture_with_the_head_alone() {
|
||||
let (body, skin) = (player_model(false), skin());
|
||||
let head = body.children.iter().find(|p| p.name == "head").unwrap();
|
||||
let draws = [Draw::model(head, &skin)];
|
||||
// Straight at the front: the head is a square, centred, a tenth of the height free
|
||||
// around its (see-through, half a pixel larger) hat layer: 48 of 60 pixels for 9 of the
|
||||
// skin's, so 42.7 for the head's 8.
|
||||
let view = fitted_view(&draws, (100, 60), (0.0, 0.0), 0.1).unwrap();
|
||||
let image = render_view(&draws, 100, 60, view);
|
||||
assert_eq!(drawn(&image), Some((29, 9, 70, 50)));
|
||||
// Its front is the red face, lit as a face toward the viewer is (three quarters).
|
||||
assert_eq!(
|
||||
&image.rgba[((30 * 100 + 50) * 4) as usize..][..3],
|
||||
&[150, 0, 0]
|
||||
);
|
||||
// Turned, it is wider than tall, and still within the margins.
|
||||
let view = fitted_view(&draws, (100, 100), (45.0, 20.0), 0.1).unwrap();
|
||||
let (l, t, r, b) = drawn(&render_view(&draws, 100, 100, view)).unwrap();
|
||||
assert!(l >= 9 && t >= 9 && r <= 90 && b <= 90, "{l} {t} {r} {b}");
|
||||
assert!(r - l >= 68 || b - t >= 68, "fills one way: {l} {t} {r} {b}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_to_see_has_no_fitted_view() {
|
||||
let (skin, empty) = (
|
||||
skin(),
|
||||
Part {
|
||||
name: "empty".into(),
|
||||
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
|
||||
faces: Vec::new(),
|
||||
children: Vec::new(),
|
||||
},
|
||||
);
|
||||
assert!(fitted_view(&[Draw::model(&empty, &skin)], (64, 64), (30.0, 15.0), 0.1).is_none());
|
||||
}
|
||||
|
||||
/// A 64×64 skin in one colour per region: what lands where shows which face was drawn.
|
||||
fn skin() -> Texture {
|
||||
let mut rgba = vec![0u8; 64 * 64 * 4];
|
||||
|
||||
@@ -115,13 +115,11 @@ pub fn resolve_relay(url: &str) -> io::Result<Vec<SocketAddr>> {
|
||||
/// teardown) are detected by the OS instead of holding threads indefinitely.
|
||||
pub fn set_tcp_keepalive(stream: &TcpStream) -> io::Result<()> {
|
||||
let sock = socket2::SockRef::from(stream);
|
||||
let mut keepalive = socket2::TcpKeepalive::new()
|
||||
let keepalive = socket2::TcpKeepalive::new()
|
||||
.with_time(Duration::from_secs(10))
|
||||
.with_interval(Duration::from_secs(3));
|
||||
#[cfg(not(any(target_os = "windows", target_os = "openbsd")))]
|
||||
{
|
||||
keepalive = keepalive.with_retries(3);
|
||||
}
|
||||
let keepalive = keepalive.with_retries(3);
|
||||
sock.set_tcp_keepalive(&keepalive)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
//! check of the codecs against a real vanilla server:
|
||||
//!
|
||||
//! cargo run -p horizon-protocol --example protocol_probe -- 127.0.0.1:25565 [seconds] [registries.json]
|
||||
//!
|
||||
//! Append `--server-protocol 772` to exercise the experimental adapter against a vanilla 1.21.8 server.
|
||||
use horizon_protocol::{
|
||||
PROTOCOL_VERSION, State,
|
||||
buf::{Reader, Writer},
|
||||
connection::Connection,
|
||||
ids,
|
||||
packets::{Empty, configuration as cfg, handshake, login, play},
|
||||
@@ -17,7 +20,28 @@ use std::{
|
||||
};
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let mut args: Vec<String> = std::env::args().collect();
|
||||
let suggestions = args
|
||||
.iter()
|
||||
.position(|s| s == "--suggestions")
|
||||
.is_some_and(|at| {
|
||||
args.remove(at);
|
||||
true
|
||||
});
|
||||
let version = if let Some(at) = args.iter().position(|s| s == "--server-protocol") {
|
||||
args.remove(at);
|
||||
let protocol: i32 = args
|
||||
.get(at)
|
||||
.ok_or("--server-protocol needs a number")?
|
||||
.parse()?;
|
||||
args.remove(at);
|
||||
Some(
|
||||
horizon_protocol::adapter::ServerVersion::from_protocol(protocol)
|
||||
.ok_or("unsupported server protocol")?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let (host, port) =
|
||||
parse_address(args.get(1).map_or("127.0.0.1", String::as_str)).ok_or("bad address")?;
|
||||
let seconds: u64 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(10);
|
||||
@@ -26,6 +50,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.map(|p| Builtin::load(std::path::Path::new(p)))
|
||||
.transpose()?;
|
||||
let mut c = Connection::connect(&host, port, Duration::from_secs(5))?;
|
||||
c.reader.set_read_timeout(Some(Duration::from_secs(10)))?;
|
||||
if let Some(version) = version {
|
||||
c.adapt(version);
|
||||
}
|
||||
c.send(
|
||||
&handshake::Serverbound::Intention(handshake::Intention {
|
||||
protocol: PROTOCOL_VERSION,
|
||||
@@ -62,6 +90,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
c.send(&cfg::Serverbound::ClientInformation(cfg::ClientInformation::new(8)).encode())?;
|
||||
let mut registries = Dynamic::default();
|
||||
let mut decode_errors = 0;
|
||||
loop {
|
||||
let (id, payload) = c.recv()?;
|
||||
match cfg::Clientbound::decode(id, &payload) {
|
||||
@@ -100,13 +129,19 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
)
|
||||
.unwrap_or("?")
|
||||
),
|
||||
Err(e) => println!("configuration decode error in {id}: {e}"),
|
||||
Err(e) => {
|
||||
decode_errors += 1;
|
||||
println!("configuration decode error in {id}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
let biome_bits = ceil_log2(registries.len("minecraft:worldgen/biome"));
|
||||
let mut counts: BTreeMap<&str, (u32, u32)> = BTreeMap::new();
|
||||
let mut entity_types: BTreeMap<String, u32> = BTreeMap::new();
|
||||
let mut dimension = registries.dimension(0);
|
||||
let start = Instant::now();
|
||||
let mut suggestion_sent = false;
|
||||
let mut suggestion_received = false;
|
||||
c.reader
|
||||
.set_read_timeout(Some(Duration::from_millis(500)))?;
|
||||
let mut ticks = 0;
|
||||
@@ -118,6 +153,36 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
if suggestions && !suggestion_sent && start.elapsed() >= Duration::from_secs(6) {
|
||||
let mut w = Writer::new();
|
||||
w.varint(ids::play::serverbound::COMMAND_SUGGESTION);
|
||||
w.varint(764);
|
||||
w.string("/execute as @e[");
|
||||
c.send(&w.data)?;
|
||||
suggestion_sent = true;
|
||||
}
|
||||
if suggestions && id == ids::play::clientbound::COMMAND_SUGGESTIONS {
|
||||
let mut r = Reader::new(&payload);
|
||||
let request = r.varint()?;
|
||||
r.varint()?; // replacement start
|
||||
r.varint()?; // replacement length
|
||||
let count = r.count(65536)?;
|
||||
let mut tooltips = 0;
|
||||
for _ in 0..count {
|
||||
r.string(32767)?;
|
||||
if r.bool()? {
|
||||
r.nbt()?;
|
||||
tooltips += 1;
|
||||
}
|
||||
}
|
||||
if r.remaining() != 0 {
|
||||
return Err("trailing command suggestion bytes".into());
|
||||
}
|
||||
if request == 764 && count > 0 && tooltips > 0 {
|
||||
suggestion_received = true;
|
||||
println!("command_suggestions_validated {count} {tooltips}");
|
||||
}
|
||||
}
|
||||
let name =
|
||||
ids::name(State::Play, horizon_protocol::Direction::Clientbound, id).unwrap_or("?");
|
||||
let entry = counts.entry(name).or_default();
|
||||
@@ -132,6 +197,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
);
|
||||
c.send(&play::Serverbound::PlayerLoaded(Empty).encode())?;
|
||||
}
|
||||
play::Clientbound::Respawn(p) => {
|
||||
dimension = registries.dimension(p.spawn.dimension_type);
|
||||
c.send(&play::Serverbound::PlayerLoaded(Empty).encode())?;
|
||||
}
|
||||
play::Clientbound::KeepAlive(p) => {
|
||||
c.send(&play::Serverbound::KeepAlive(p).encode())?
|
||||
}
|
||||
@@ -166,6 +235,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Err(e) = p.chunk.sections(dimension.sections(), 16, biome_bits) {
|
||||
println!("chunk {} {} sections: {e}", p.x, p.z);
|
||||
entry.1 += 1;
|
||||
decode_errors += 1;
|
||||
}
|
||||
}
|
||||
play::Clientbound::AddEntity(p) => {
|
||||
@@ -173,6 +243,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.as_ref()
|
||||
.and_then(|b| b.get("minecraft:entity_type")?.name(p.kind))
|
||||
.unwrap_or("?");
|
||||
*entity_types.entry(kind.to_owned()).or_default() += 1;
|
||||
if counts.get("add_entity").is_some_and(|c| c.0 < 4) {
|
||||
println!("entity {} {kind} at {:?}", p.id, p.position);
|
||||
}
|
||||
@@ -188,6 +259,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
},
|
||||
Err(e) => {
|
||||
entry.1 += 1;
|
||||
decode_errors += 1;
|
||||
if entry.1 <= 2 {
|
||||
println!("decode error in {name} ({} bytes): {e}", payload.len());
|
||||
}
|
||||
@@ -195,6 +267,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
}
|
||||
println!("{ticks} chunk batches");
|
||||
for (kind, count) in entity_types {
|
||||
println!("entity_type {kind} {count}");
|
||||
}
|
||||
for (name, (n, errors)) in counts {
|
||||
println!(
|
||||
"{name:32} {n:6} {}",
|
||||
@@ -205,5 +280,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
);
|
||||
}
|
||||
if decode_errors > 0 {
|
||||
return Err(format!("{decode_errors} packet/chunk decode failures").into());
|
||||
}
|
||||
if suggestions && !suggestion_received {
|
||||
return Err("command suggestion request had no validated tooltip response".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -9,8 +9,14 @@
|
||||
pub mod client_session;
|
||||
pub mod ids;
|
||||
pub mod layout_26_2;
|
||||
mod legacy;
|
||||
pub mod session;
|
||||
mod translate;
|
||||
pub(crate) use translate::v1_20_2::read_component as read_legacy_component;
|
||||
pub mod v1_20_2;
|
||||
mod v1_20_2_ids;
|
||||
pub mod v1_20_3;
|
||||
mod v1_20_3_ids;
|
||||
pub mod v1_20_6;
|
||||
mod v1_20_6_ids;
|
||||
pub mod v1_21_1;
|
||||
@@ -65,11 +71,14 @@ pub enum ServerVersion {
|
||||
V1_21_1,
|
||||
/// 1.20.5 and 1.20.6: one protocol.
|
||||
V1_20_6,
|
||||
/// 1.20.3 and 1.20.4: one protocol.
|
||||
V1_20_3,
|
||||
V1_20_2,
|
||||
}
|
||||
|
||||
impl ServerVersion {
|
||||
/// Newest first.
|
||||
pub const ALL: [Self; 12] = [
|
||||
pub const ALL: [Self; 14] = [
|
||||
Self::Current,
|
||||
Self::V26_2,
|
||||
Self::V26_1,
|
||||
@@ -82,6 +91,8 @@ impl ServerVersion {
|
||||
Self::V1_21_3,
|
||||
Self::V1_21_1,
|
||||
Self::V1_20_6,
|
||||
Self::V1_20_3,
|
||||
Self::V1_20_2,
|
||||
];
|
||||
|
||||
/// Whether this is `other` or an older release: the translation steps from `other` up apply.
|
||||
@@ -99,12 +110,14 @@ impl ServerVersion {
|
||||
Self::V1_21_11 => v1_21_11::PROTOCOL,
|
||||
Self::V1_21_10 => v1_21_10::PROTOCOL,
|
||||
Self::V1_21_8 => v1_21_8::PROTOCOL,
|
||||
Self::V1_21_5 => v1_21_5::PROTOCOL,
|
||||
Self::V1_20_6 => v1_20_6::PROTOCOL,
|
||||
Self::V1_21_1 => v1_21_1::PROTOCOL,
|
||||
Self::V1_21_3 => v1_21_3::PROTOCOL,
|
||||
Self::V1_21_4 => v1_21_4::PROTOCOL,
|
||||
Self::V1_21_6 => v1_21_6::PROTOCOL,
|
||||
Self::V1_21_5 => v1_21_5::PROTOCOL,
|
||||
Self::V1_21_4 => v1_21_4::PROTOCOL,
|
||||
Self::V1_21_3 => v1_21_3::PROTOCOL,
|
||||
Self::V1_21_1 => v1_21_1::PROTOCOL,
|
||||
Self::V1_20_6 => v1_20_6::PROTOCOL,
|
||||
Self::V1_20_3 => v1_20_3::PROTOCOL,
|
||||
Self::V1_20_2 => v1_20_2::PROTOCOL,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,12 +130,14 @@ impl ServerVersion {
|
||||
Self::V1_21_11 => v1_21_11::NAME,
|
||||
Self::V1_21_10 => "1.21.10",
|
||||
Self::V1_21_8 => v1_21_8::NAME,
|
||||
Self::V1_21_5 => v1_21_5::NAME,
|
||||
Self::V1_20_6 => v1_20_6::NAME,
|
||||
Self::V1_21_1 => v1_21_1::NAME,
|
||||
Self::V1_21_3 => v1_21_3::NAME,
|
||||
Self::V1_21_4 => v1_21_4::NAME,
|
||||
Self::V1_21_6 => v1_21_6::NAME,
|
||||
Self::V1_21_5 => v1_21_5::NAME,
|
||||
Self::V1_21_4 => v1_21_4::NAME,
|
||||
Self::V1_21_3 => v1_21_3::NAME,
|
||||
Self::V1_21_1 => v1_21_1::NAME,
|
||||
Self::V1_20_6 => v1_20_6::NAME,
|
||||
Self::V1_20_3 => v1_20_3::NAME,
|
||||
Self::V1_20_2 => v1_20_2::NAME,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,22 +165,15 @@ impl ServerVersion {
|
||||
const BEFORE_26_3: ids::Quirks = ids::Quirks {
|
||||
tag_slot_display_is_id: true,
|
||||
pot_decorations_are_items: true,
|
||||
either_holders_and_tag_resistance: false,
|
||||
stacks_in_components: false,
|
||||
components_before_1_21_6: false,
|
||||
..ids::Quirks::NONE
|
||||
};
|
||||
// Before 26.1 registry entries in components (a jukebox song, an instrument, a trim material...)
|
||||
// were EitherHolders, damage resistance a tag, and the items components hold ItemStacks.
|
||||
// Before 26.1 registry entries in components were EitherHolders, damage resistance a tag,
|
||||
// and components held ItemStacks rather than templates.
|
||||
const BEFORE_26_1: ids::Quirks = ids::Quirks {
|
||||
either_holders_and_tag_resistance: true,
|
||||
stacks_in_components: true,
|
||||
..BEFORE_26_3
|
||||
};
|
||||
// Before 1.21.6 attribute modifiers have no display, and equippables no shearing fields.
|
||||
const BEFORE_1_21_6: ids::Quirks = ids::Quirks {
|
||||
components_before_1_21_6: true,
|
||||
..BEFORE_26_1
|
||||
};
|
||||
static V26_2: ids::Ids = ids::Ids {
|
||||
quirks: BEFORE_26_3,
|
||||
..v26_2_ids::IDS
|
||||
@@ -217,14 +225,19 @@ impl ServerVersion {
|
||||
..v1_21_10_ids::IDS
|
||||
};
|
||||
static V1_21_8: ids::Ids = ids::Ids {
|
||||
quirks: BEFORE_26_1,
|
||||
// 1.21.9 dropped the NBT serializer (16) and added the copper golem, weathering copper and
|
||||
// resolvable profile ones (from the wiki's entity metadata lists).
|
||||
quirks: ids::Quirks {
|
||||
shoulder_parrots_as_nbt: true,
|
||||
count_first_templates: true,
|
||||
optional_profile_fields: true,
|
||||
simple_spell_particles: true,
|
||||
..BEFORE_26_1
|
||||
},
|
||||
// EntityDataSerializers in 1.21.8's registration order: compound tags (16) went away;
|
||||
// the later sound variants, copper golem states and profile serializers were added.
|
||||
entity_data_serializer: ids::Remap::Runs {
|
||||
up: &[
|
||||
(0, 0, 16),
|
||||
(17, 16, 4),
|
||||
(21, 20, 2),
|
||||
(17, 16, 6),
|
||||
(23, 23, 1),
|
||||
(24, 25, 4),
|
||||
(28, 30, 1),
|
||||
@@ -233,8 +246,7 @@ impl ServerVersion {
|
||||
],
|
||||
down: &[
|
||||
(0, 0, 16),
|
||||
(16, 17, 4),
|
||||
(20, 21, 2),
|
||||
(16, 17, 6),
|
||||
(23, 23, 1),
|
||||
(25, 24, 4),
|
||||
(30, 28, 1),
|
||||
@@ -245,64 +257,27 @@ impl ServerVersion {
|
||||
..v1_21_8_ids::IDS
|
||||
};
|
||||
static V1_21_6: ids::Ids = ids::Ids {
|
||||
quirks: BEFORE_26_1,
|
||||
// 1.21.9 dropped the NBT serializer (16) and added the copper golem, weathering copper and
|
||||
// resolvable profile ones (from the wiki's entity metadata lists).
|
||||
entity_data_serializer: ids::Remap::Runs {
|
||||
up: &[
|
||||
(0, 0, 16),
|
||||
(17, 16, 4),
|
||||
(21, 20, 2),
|
||||
(23, 23, 1),
|
||||
(24, 25, 4),
|
||||
(28, 30, 1),
|
||||
(29, 33, 4),
|
||||
(33, 39, 2),
|
||||
],
|
||||
down: &[
|
||||
(0, 0, 16),
|
||||
(16, 17, 4),
|
||||
(20, 21, 2),
|
||||
(23, 23, 1),
|
||||
(25, 24, 4),
|
||||
(30, 28, 1),
|
||||
(33, 29, 4),
|
||||
(39, 33, 2),
|
||||
],
|
||||
},
|
||||
// The same layouts and metadata serializers, with the item/sound numbering before
|
||||
// 1.21.7 added the lava chicken disc. Other registries are shared by generated tables.
|
||||
quirks: V1_21_8.quirks,
|
||||
entity_data_serializer: V1_21_8.entity_data_serializer,
|
||||
..v1_21_6_ids::IDS
|
||||
};
|
||||
static V1_21_5: ids::Ids = ids::Ids {
|
||||
quirks: BEFORE_1_21_6,
|
||||
// 1.21.9 dropped the NBT serializer (16) and added the copper golem, weathering copper and
|
||||
// resolvable profile ones (from the wiki's entity metadata lists).
|
||||
entity_data_serializer: ids::Remap::Runs {
|
||||
up: &[
|
||||
(0, 0, 16),
|
||||
(17, 16, 4),
|
||||
(21, 20, 2),
|
||||
(23, 23, 1),
|
||||
(24, 25, 4),
|
||||
(28, 30, 1),
|
||||
(29, 33, 4),
|
||||
(33, 39, 2),
|
||||
],
|
||||
down: &[
|
||||
(0, 0, 16),
|
||||
(16, 17, 4),
|
||||
(20, 21, 2),
|
||||
(23, 23, 1),
|
||||
(25, 24, 4),
|
||||
(30, 28, 1),
|
||||
(33, 29, 4),
|
||||
(39, 33, 2),
|
||||
],
|
||||
quirks: ids::Quirks {
|
||||
components_before_1_21_6: true,
|
||||
..V1_21_8.quirks
|
||||
},
|
||||
entity_data_serializer: V1_21_8.entity_data_serializer,
|
||||
..v1_21_5_ids::IDS
|
||||
};
|
||||
static V1_21_4: ids::Ids = ids::Ids {
|
||||
quirks: V1_21_5.quirks,
|
||||
// As 1.21.5's, without the cow, wolf sound, pig and chicken variants 1.21.5 added.
|
||||
quirks: ids::Quirks {
|
||||
wolf_variant_holder: true,
|
||||
components_before_1_21_5: true,
|
||||
..V1_21_5.quirks
|
||||
},
|
||||
// Vanilla EntityDataSerializers' registration order (not their declaration order).
|
||||
entity_data_serializer: ids::Remap::Runs {
|
||||
up: &[
|
||||
(0, 0, 16),
|
||||
@@ -324,73 +299,67 @@ impl ServerVersion {
|
||||
..v1_21_4_ids::IDS
|
||||
};
|
||||
static V1_21_3: ids::Ids = ids::Ids {
|
||||
quirks: V1_21_5.quirks,
|
||||
// As 1.21.5's, without the cow, wolf sound, pig and chicken variants 1.21.5 added.
|
||||
entity_data_serializer: ids::Remap::Runs {
|
||||
up: &[
|
||||
(0, 0, 16),
|
||||
(17, 16, 6),
|
||||
(23, 25, 1),
|
||||
(24, 27, 1),
|
||||
(25, 33, 4),
|
||||
(29, 39, 2),
|
||||
],
|
||||
down: &[
|
||||
(0, 0, 16),
|
||||
(16, 17, 6),
|
||||
(25, 23, 1),
|
||||
(27, 24, 1),
|
||||
(33, 25, 4),
|
||||
(39, 29, 2),
|
||||
],
|
||||
quirks: ids::Quirks {
|
||||
integer_custom_model_data: true,
|
||||
trail_without_duration: true,
|
||||
..V1_21_4.quirks
|
||||
},
|
||||
entity_data_serializer: V1_21_4.entity_data_serializer,
|
||||
..v1_21_3_ids::IDS
|
||||
};
|
||||
static V1_21_1: ids::Ids = ids::Ids {
|
||||
quirks: V1_21_5.quirks,
|
||||
// As 1.21.5's, without the cow, wolf sound, pig and chicken variants 1.21.5 added.
|
||||
entity_data_serializer: ids::Remap::Runs {
|
||||
up: &[
|
||||
(0, 0, 16),
|
||||
(17, 16, 6),
|
||||
(23, 25, 1),
|
||||
(24, 27, 1),
|
||||
(25, 33, 4),
|
||||
(29, 39, 2),
|
||||
],
|
||||
down: &[
|
||||
(0, 0, 16),
|
||||
(16, 17, 6),
|
||||
(25, 23, 1),
|
||||
(27, 24, 1),
|
||||
(33, 25, 4),
|
||||
(39, 29, 2),
|
||||
],
|
||||
quirks: ids::Quirks {
|
||||
components_before_1_21_2: true,
|
||||
..V1_21_3.quirks
|
||||
},
|
||||
entity_data_serializer: V1_21_3.entity_data_serializer,
|
||||
..v1_21_1_ids::IDS
|
||||
};
|
||||
static V1_20_6: ids::Ids = ids::Ids {
|
||||
quirks: V1_21_5.quirks,
|
||||
// As 1.21.5's, without the cow, wolf sound, pig and chicken variants 1.21.5 added.
|
||||
quirks: ids::Quirks {
|
||||
components_before_1_21: true,
|
||||
wolf_variant_holder: false,
|
||||
..V1_21_1.quirks
|
||||
},
|
||||
entity_data_serializer: V1_21_1.entity_data_serializer,
|
||||
..v1_20_6_ids::IDS
|
||||
};
|
||||
static V1_20_3: ids::Ids = ids::Ids {
|
||||
quirks: ids::Quirks {
|
||||
legacy_item_nbt: true,
|
||||
legacy_item_data_version: 3698,
|
||||
..V1_20_6.quirks
|
||||
},
|
||||
// EntityDataSerializers registration order before PARTICLES, WOLF_VARIANT and ARMADILLO_STATE.
|
||||
entity_data_serializer: ids::Remap::Runs {
|
||||
up: &[
|
||||
(0, 0, 16),
|
||||
(17, 16, 6),
|
||||
(23, 25, 1),
|
||||
(24, 27, 1),
|
||||
(25, 33, 4),
|
||||
(29, 39, 2),
|
||||
(17, 16, 1),
|
||||
(18, 18, 4),
|
||||
(22, 27, 1),
|
||||
(23, 33, 3),
|
||||
(26, 39, 2),
|
||||
],
|
||||
down: &[
|
||||
(0, 0, 16),
|
||||
(16, 17, 6),
|
||||
(25, 23, 1),
|
||||
(27, 24, 1),
|
||||
(33, 25, 4),
|
||||
(39, 29, 2),
|
||||
(16, 17, 1),
|
||||
(18, 18, 4),
|
||||
(27, 22, 1),
|
||||
(33, 23, 3),
|
||||
(39, 26, 2),
|
||||
],
|
||||
},
|
||||
..v1_20_6_ids::IDS
|
||||
..v1_20_3_ids::IDS
|
||||
};
|
||||
static V1_20_2: ids::Ids = ids::Ids {
|
||||
quirks: ids::Quirks {
|
||||
legacy_item_data_version: 3578,
|
||||
components_as_json: true,
|
||||
vibration_source_identifier: true,
|
||||
..V1_20_3.quirks
|
||||
},
|
||||
entity_data_serializer: V1_20_3.entity_data_serializer,
|
||||
..v1_20_2_ids::IDS
|
||||
};
|
||||
match self {
|
||||
Self::Current => None,
|
||||
@@ -399,12 +368,14 @@ impl ServerVersion {
|
||||
Self::V1_21_11 => Some(&V1_21_11),
|
||||
Self::V1_21_10 => Some(&V1_21_10),
|
||||
Self::V1_21_8 => Some(&V1_21_8),
|
||||
Self::V1_21_5 => Some(&V1_21_5),
|
||||
Self::V1_20_6 => Some(&V1_20_6),
|
||||
Self::V1_21_1 => Some(&V1_21_1),
|
||||
Self::V1_21_3 => Some(&V1_21_3),
|
||||
Self::V1_21_4 => Some(&V1_21_4),
|
||||
Self::V1_21_6 => Some(&V1_21_6),
|
||||
Self::V1_21_5 => Some(&V1_21_5),
|
||||
Self::V1_21_4 => Some(&V1_21_4),
|
||||
Self::V1_21_3 => Some(&V1_21_3),
|
||||
Self::V1_21_1 => Some(&V1_21_1),
|
||||
Self::V1_20_6 => Some(&V1_20_6),
|
||||
Self::V1_20_3 => Some(&V1_20_3),
|
||||
Self::V1_20_2 => Some(&V1_20_2),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,18 +425,22 @@ impl ServerVersion {
|
||||
(Self::V1_21_10, Direction::Serverbound) => v1_21_10::serverbound(state),
|
||||
(Self::V1_21_8, Direction::Clientbound) => v1_21_8::clientbound(state),
|
||||
(Self::V1_21_8, Direction::Serverbound) => v1_21_8::serverbound(state),
|
||||
(Self::V1_21_5, Direction::Clientbound) => v1_21_5::clientbound(state),
|
||||
(Self::V1_21_5, Direction::Serverbound) => v1_21_5::serverbound(state),
|
||||
(Self::V1_20_6, Direction::Clientbound) => v1_20_6::clientbound(state),
|
||||
(Self::V1_20_6, Direction::Serverbound) => v1_20_6::serverbound(state),
|
||||
(Self::V1_21_1, Direction::Clientbound) => v1_21_1::clientbound(state),
|
||||
(Self::V1_21_1, Direction::Serverbound) => v1_21_1::serverbound(state),
|
||||
(Self::V1_21_3, Direction::Clientbound) => v1_21_3::clientbound(state),
|
||||
(Self::V1_21_3, Direction::Serverbound) => v1_21_3::serverbound(state),
|
||||
(Self::V1_21_4, Direction::Clientbound) => v1_21_4::clientbound(state),
|
||||
(Self::V1_21_4, Direction::Serverbound) => v1_21_4::serverbound(state),
|
||||
(Self::V1_21_6, Direction::Clientbound) => v1_21_6::clientbound(state),
|
||||
(Self::V1_21_6, Direction::Serverbound) => v1_21_6::serverbound(state),
|
||||
(Self::V1_21_5, Direction::Clientbound) => v1_21_5::clientbound(state),
|
||||
(Self::V1_21_5, Direction::Serverbound) => v1_21_5::serverbound(state),
|
||||
(Self::V1_21_4, Direction::Clientbound) => v1_21_4::clientbound(state),
|
||||
(Self::V1_21_4, Direction::Serverbound) => v1_21_4::serverbound(state),
|
||||
(Self::V1_21_3, Direction::Clientbound) => v1_21_3::clientbound(state),
|
||||
(Self::V1_21_3, Direction::Serverbound) => v1_21_3::serverbound(state),
|
||||
(Self::V1_21_1, Direction::Clientbound) => v1_21_1::clientbound(state),
|
||||
(Self::V1_21_1, Direction::Serverbound) => v1_21_1::serverbound(state),
|
||||
(Self::V1_20_6, Direction::Clientbound) => v1_20_6::clientbound(state),
|
||||
(Self::V1_20_6, Direction::Serverbound) => v1_20_6::serverbound(state),
|
||||
(Self::V1_20_3, Direction::Clientbound) => v1_20_3::clientbound(state),
|
||||
(Self::V1_20_3, Direction::Serverbound) => v1_20_3::serverbound(state),
|
||||
(Self::V1_20_2, Direction::Clientbound) => v1_20_2::clientbound(state),
|
||||
(Self::V1_20_2, Direction::Serverbound) => v1_20_2::serverbound(state),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -546,7 +521,15 @@ mod tests {
|
||||
ServerVersion::from_protocol(766),
|
||||
Some(ServerVersion::V1_20_6)
|
||||
);
|
||||
assert_eq!(ServerVersion::from_protocol(765), None);
|
||||
assert_eq!(
|
||||
ServerVersion::from_protocol(765),
|
||||
Some(ServerVersion::V1_20_3)
|
||||
);
|
||||
assert_eq!(
|
||||
ServerVersion::from_protocol(764),
|
||||
Some(ServerVersion::V1_20_2)
|
||||
);
|
||||
assert_eq!(ServerVersion::from_protocol(763), None);
|
||||
assert!(ServerVersion::V26_1.at_most(ServerVersion::V26_2));
|
||||
assert!(!ServerVersion::V26_2.at_most(ServerVersion::V26_1));
|
||||
}
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
//! client's use_item gets the head's yaw and pitch, which 1.21 reads and 1.20.6 does not send.
|
||||
//!
|
||||
//! The pinned ids are renumbered down before this step, so the stacks are in the client's numbering; their components
|
||||
//! are measured with the stack codec under `ids` renumbered up, as [`translate::v1_20_6::slot`] does.
|
||||
//! are measured with the stack codec under `ids` renumbered up, as [`translate::client_layout_v1_20_6::slot`] does.
|
||||
use crate::{
|
||||
Result, State,
|
||||
adapter::{
|
||||
ids::{self, Ids, Way},
|
||||
translate::v1_20_6::{LEVEL_PARTICLES_HEAD, slot},
|
||||
translate::client_layout_v1_20_6::{LEVEL_PARTICLES_HEAD, slot},
|
||||
},
|
||||
buf::{Codec, Reader, Writer},
|
||||
ids as packet_ids,
|
||||
@@ -203,7 +203,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(Reader::new(&down).varint().unwrap(), 7);
|
||||
let up = translate::v1_20_6::clientbound(&session, State::Play, id, &down)
|
||||
let up = translate::client_layout_v1_20_6::clientbound(&session, State::Play, id, &down)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(up, original);
|
||||
@@ -244,7 +244,7 @@ mod tests {
|
||||
assert_eq!(r.varint().unwrap(), 2);
|
||||
assert_eq!(r.varint().unwrap(), 3);
|
||||
assert_eq!(r.string(32767).unwrap(), "minecraft:entity.generic.explode");
|
||||
let up = translate::v1_20_6::clientbound(&session, State::Play, id, &down)
|
||||
let up = translate::client_layout_v1_20_6::clientbound(&session, State::Play, id, &down)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(up, original);
|
||||
@@ -276,7 +276,7 @@ mod tests {
|
||||
v.f64(0.0);
|
||||
v.f64(3.0);
|
||||
v.f64(4.0);
|
||||
let up = translate::v1_20_6::clientbound(&session, State::Play, id, &v.data)
|
||||
let up = translate::client_layout_v1_20_6::clientbound(&session, State::Play, id, &v.data)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let mut r = Reader::new(&up);
|
||||
@@ -321,9 +321,10 @@ mod tests {
|
||||
let down = clientbound(State::Play, id, &original, ids)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let up = translate::v1_20_6::clientbound(&session, State::Play, id, &down)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let up =
|
||||
translate::client_layout_v1_20_6::clientbound(&session, State::Play, id, &down)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(up, original);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,7 +367,7 @@ mod tests {
|
||||
/// Through the reader and back: `payload` (1.21.1's) must come out as it went in.
|
||||
fn round_trip(id: i32, payload: &[u8]) {
|
||||
let session = Session::new(ServerVersion::V1_21_1);
|
||||
let up = translate::v1_21_1::clientbound(&session, State::Play, id, payload)
|
||||
let up = translate::client_layout_v1_21_1::clientbound(&session, State::Play, id, payload)
|
||||
.unwrap()
|
||||
.expect("the reader changes this packet");
|
||||
let down = clientbound(State::Play, id, &up)
|
||||
@@ -384,7 +384,7 @@ mod tests {
|
||||
assert_eq!(down.len(), 21);
|
||||
assert_eq!(down[20], 0);
|
||||
let session = Session::new(ServerVersion::V1_21_1);
|
||||
let up = translate::v1_21_1::clientbound(&session, State::Login, id, &down)
|
||||
let up = translate::client_layout_v1_21_1::clientbound(&session, State::Login, id, &down)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(up, profile);
|
||||
|
||||
@@ -393,7 +393,12 @@ mod tests {
|
||||
let mut w = Writer::new();
|
||||
play::Attack::write(&play::Attack { id: 9 }, &mut w);
|
||||
// translate writes the 1.21.11 interact of action 1 for an attack.
|
||||
let (_, sent) = translate::v1_21_11::attack(&w.data).unwrap().unwrap();
|
||||
let (_, sent) = translate::v1_21_11::attack(
|
||||
&crate::adapter::session::Session::new(crate::adapter::ServerVersion::V1_21_11),
|
||||
&w.data,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let body = attack(&sent).unwrap();
|
||||
assert_eq!(play::Attack::read(&mut Reader::new(&body)).unwrap().id, 9);
|
||||
}
|
||||
|
||||
@@ -82,11 +82,11 @@ pub(super) fn serverbound(
|
||||
})
|
||||
}
|
||||
|
||||
/// Entity data in 1.21.4's indexes, from 1.21.5's (`translate::v1_21_4::remap_entity_data`, back). `kind` is the
|
||||
/// Entity data in 1.21.4's indexes, from 1.21.5's (`translate::client_layout_v1_21_4::remap_entity_data`, back). `kind` is the
|
||||
/// entity's type in the pinned ids, `None` when unknown, which leaves the data as it is.
|
||||
pub(super) fn entity_data(kind: Option<i32>, data: &mut play::SetEntityData) {
|
||||
if let Some(kind) = kind {
|
||||
translate::v1_21_4::remap_entity_data(kind, data, false);
|
||||
translate::client_layout_v1_21_4::remap_entity_data(kind, data, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ fn paletted_down(r: &mut Reader, w: &mut Writer, blocks: bool) -> Result<()> {
|
||||
let mut storage = bits;
|
||||
if bits == 0 {
|
||||
w.varint(r.varint()?);
|
||||
} else if translate::v1_21_4::indirect(bits, blocks) {
|
||||
} else if translate::client_layout_v1_21_4::indirect(bits, blocks) {
|
||||
let n = r.varint()?;
|
||||
w.varint(n);
|
||||
for _ in 0..n {
|
||||
@@ -137,7 +137,7 @@ fn heightmaps_down(r: &mut Reader, w: &mut Writer) -> Result<()> {
|
||||
for _ in 0..n {
|
||||
let kind = r.varint()?;
|
||||
let longs = r.long_array()?;
|
||||
if let Some(&(_, name)) = translate::v1_21_4::HEIGHTMAPS
|
||||
if let Some(&(_, name)) = translate::client_layout_v1_21_4::HEIGHTMAPS
|
||||
.iter()
|
||||
.find(|(k, _)| *k == kind)
|
||||
{
|
||||
@@ -151,7 +151,7 @@ fn heightmaps_down(r: &mut Reader, w: &mut Writer) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Level chunk with light, 1.21.5's layout to 1.21.4's (the mirror of `translate::v1_21_4::chunk_up`).
|
||||
/// Level chunk with light, 1.21.5's layout to 1.21.4's (the mirror of `translate::client_layout_v1_21_4::chunk_up`).
|
||||
fn chunk_down(payload: &[u8]) -> Result<Vec<u8>> {
|
||||
let mut r = Reader::new(payload);
|
||||
let mut w = Writer::new();
|
||||
@@ -164,7 +164,7 @@ fn chunk_down(payload: &[u8]) -> Result<Vec<u8>> {
|
||||
Ok(w.data)
|
||||
}
|
||||
|
||||
/// Chunk biomes, 1.21.5's layout to 1.21.4's (the mirror of `translate::v1_21_4::chunks_biomes_up`).
|
||||
/// Chunk biomes, 1.21.5's layout to 1.21.4's (the mirror of `translate::client_layout_v1_21_4::chunks_biomes_up`).
|
||||
fn chunks_biomes_down(payload: &[u8]) -> Result<Vec<u8>> {
|
||||
let mut r = Reader::new(payload);
|
||||
let mut w = Writer::new();
|
||||
@@ -204,12 +204,12 @@ fn team_down(payload: &[u8]) -> Result<Vec<u8>> {
|
||||
if method == 0 || method == 2 {
|
||||
w.nbt(&r.nbt()?);
|
||||
w.u8(r.u8()?);
|
||||
w.string(translate::v1_21_4::name_of(
|
||||
&translate::v1_21_4::VISIBILITY,
|
||||
w.string(translate::client_layout_v1_21_4::name_of(
|
||||
&translate::client_layout_v1_21_4::VISIBILITY,
|
||||
r.varint()?,
|
||||
)?);
|
||||
w.string(translate::v1_21_4::name_of(
|
||||
&translate::v1_21_4::COLLISION,
|
||||
w.string(translate::client_layout_v1_21_4::name_of(
|
||||
&translate::client_layout_v1_21_4::COLLISION,
|
||||
r.varint()?,
|
||||
)?);
|
||||
w.varint(r.varint()?);
|
||||
@@ -271,7 +271,7 @@ mod tests {
|
||||
|
||||
fn read_back_clientbound(id: i32, down: &[u8]) -> Vec<u8> {
|
||||
let session = Session::new(ServerVersion::V1_21_4);
|
||||
translate::v1_21_4::clientbound(&session, State::Play, id, down)
|
||||
translate::client_layout_v1_21_4::clientbound(&session, State::Play, id, down)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
}
|
||||
@@ -387,7 +387,10 @@ mod tests {
|
||||
panic!("a chat is rewritten");
|
||||
};
|
||||
assert_eq!(pinned.last(), Some(&0));
|
||||
assert_eq!(translate::v1_21_4::without_checksum(&pinned), older);
|
||||
assert_eq!(
|
||||
translate::client_layout_v1_21_4::without_checksum(&pinned),
|
||||
older
|
||||
);
|
||||
}
|
||||
|
||||
/// A level chunk in 1.21.5's layout: two heightmaps, then a single-valued section, an indirect one (4 bits
|
||||
@@ -430,7 +433,10 @@ mod tests {
|
||||
let newer = chunk_1_21_5();
|
||||
let older = clientbound(State::Play, id, &newer).unwrap().unwrap();
|
||||
assert_ne!(older, newer);
|
||||
assert_eq!(translate::v1_21_4::chunk_up(&older).unwrap(), newer);
|
||||
assert_eq!(
|
||||
translate::client_layout_v1_21_4::chunk_up(&older).unwrap(),
|
||||
newer
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -449,7 +455,7 @@ mod tests {
|
||||
);
|
||||
w.nbt(&horizon_world::nbt::Tag::Compound(map));
|
||||
w.byte_array(&[]);
|
||||
let up = translate::v1_21_4::chunk_up(&w.data).unwrap();
|
||||
let up = translate::client_layout_v1_21_4::chunk_up(&w.data).unwrap();
|
||||
let mut r = Reader::new(&up);
|
||||
assert_eq!((r.i32().unwrap(), r.i32().unwrap()), (0, 0));
|
||||
assert_eq!(r.varint().unwrap(), 1);
|
||||
@@ -474,7 +480,10 @@ mod tests {
|
||||
let newer = w.data;
|
||||
let older = clientbound(State::Play, id, &newer).unwrap().unwrap();
|
||||
assert_ne!(older, newer);
|
||||
assert_eq!(translate::v1_21_4::chunks_biomes_up(&older).unwrap(), newer);
|
||||
assert_eq!(
|
||||
translate::client_layout_v1_21_4::chunks_biomes_up(&older).unwrap(),
|
||||
newer
|
||||
);
|
||||
}
|
||||
|
||||
/// Entity data of `kind` taken through translate's way up, then this step's way back.
|
||||
@@ -486,7 +495,7 @@ mod tests {
|
||||
id: 1,
|
||||
values: older,
|
||||
};
|
||||
translate::v1_21_4::remap_entity_data(kind, &mut d, true);
|
||||
translate::client_layout_v1_21_4::remap_entity_data(kind, &mut d, true);
|
||||
let newer = d.values.clone();
|
||||
entity_data(Some(kind), &mut d);
|
||||
(newer, d.values)
|
||||
@@ -499,7 +508,7 @@ mod tests {
|
||||
(18, serializer::BOOLEAN, Value::Bool(true)),
|
||||
(19, serializer::INT, Value::Int(5)),
|
||||
];
|
||||
let (newer, back) = up_then_down(translate::v1_21_4::DOLPHIN, older);
|
||||
let (newer, back) = up_then_down(translate::client_layout_v1_21_4::DOLPHIN, older);
|
||||
assert_eq!(
|
||||
newer,
|
||||
vec![
|
||||
@@ -526,7 +535,7 @@ mod tests {
|
||||
(21, serializer::BOOLEAN, Value::Bool(true)),
|
||||
(22, serializer::BOOLEAN, Value::Bool(true)),
|
||||
];
|
||||
let (newer, back) = up_then_down(translate::v1_21_4::TURTLE, older);
|
||||
let (newer, back) = up_then_down(translate::client_layout_v1_21_4::TURTLE, older);
|
||||
assert_eq!(
|
||||
newer,
|
||||
vec![
|
||||
@@ -550,7 +559,7 @@ mod tests {
|
||||
(18, serializer::BOOLEAN, Value::Bool(true)),
|
||||
(19, serializer::BOOLEAN, Value::Bool(true)),
|
||||
];
|
||||
let (newer, back) = up_then_down(translate::v1_21_4::STRIDER, older);
|
||||
let (newer, back) = up_then_down(translate::client_layout_v1_21_4::STRIDER, older);
|
||||
let kept = vec![
|
||||
(17, serializer::INT, Value::Int(3)),
|
||||
(18, serializer::BOOLEAN, Value::Bool(true)),
|
||||
@@ -565,7 +574,7 @@ mod tests {
|
||||
(17, serializer::BOOLEAN, Value::Bool(true)),
|
||||
(18, serializer::INT, Value::Int(7)),
|
||||
];
|
||||
let (newer, back) = up_then_down(translate::v1_21_4::PIG, older);
|
||||
let (newer, back) = up_then_down(translate::client_layout_v1_21_4::PIG, older);
|
||||
assert_eq!(newer, vec![(17, serializer::INT, Value::Int(7))]);
|
||||
assert_eq!(back, vec![(18, serializer::INT, Value::Int(7))]);
|
||||
}
|
||||
@@ -573,7 +582,8 @@ mod tests {
|
||||
#[test]
|
||||
fn a_mooshroom_s_type_is_a_string_then_a_varint() {
|
||||
let older = vec![(17, serializer::STRING, Value::String("brown".into()))];
|
||||
let (newer, back) = up_then_down(translate::v1_21_4::MOOSHROOM, older.clone());
|
||||
let (newer, back) =
|
||||
up_then_down(translate::client_layout_v1_21_4::MOOSHROOM, older.clone());
|
||||
assert_eq!(newer, vec![(17, serializer::INT, Value::Int(1))]);
|
||||
assert_eq!(back, older);
|
||||
}
|
||||
@@ -585,7 +595,7 @@ mod tests {
|
||||
(9, serializer::INT, Value::Int(2)),
|
||||
(10, serializer::BOOLEAN, Value::Bool(true)),
|
||||
];
|
||||
let (newer, back) = up_then_down(translate::v1_21_4::MINECARTS[0], older);
|
||||
let (newer, back) = up_then_down(translate::client_layout_v1_21_4::MINECARTS[0], older);
|
||||
assert_eq!(
|
||||
newer,
|
||||
vec![
|
||||
@@ -610,7 +620,7 @@ mod tests {
|
||||
(9, serializer::INT, Value::Int(2)),
|
||||
(10, serializer::BOOLEAN, Value::Bool(false)),
|
||||
];
|
||||
let (newer, back) = up_then_down(translate::v1_21_4::MINECARTS[1], older);
|
||||
let (newer, back) = up_then_down(translate::client_layout_v1_21_4::MINECARTS[1], older);
|
||||
assert_eq!(newer, vec![(12, serializer::INT, Value::Int(2))]);
|
||||
assert_eq!(back, vec![(9, serializer::INT, Value::Int(2))]);
|
||||
}
|
||||
@@ -621,7 +631,10 @@ mod tests {
|
||||
id: 1,
|
||||
values: vec![(8, serializer::INT, Value::Int(4))],
|
||||
};
|
||||
entity_data(Some(translate::v1_21_4::EXPERIENCE_ORB), &mut d);
|
||||
entity_data(
|
||||
Some(translate::client_layout_v1_21_4::EXPERIENCE_ORB),
|
||||
&mut d,
|
||||
);
|
||||
assert!(d.values.is_empty());
|
||||
}
|
||||
|
||||
@@ -632,7 +645,7 @@ mod tests {
|
||||
(17, serializer::BOOLEAN, Value::Bool(false)),
|
||||
(18, serializer::BOOLEAN, Value::Bool(true)),
|
||||
];
|
||||
let (newer, back) = up_then_down(translate::v1_21_4::PIGLIN, older.clone());
|
||||
let (newer, back) = up_then_down(translate::client_layout_v1_21_4::PIGLIN, older.clone());
|
||||
assert_eq!(
|
||||
newer,
|
||||
vec![
|
||||
@@ -650,7 +663,10 @@ mod tests {
|
||||
id: 1,
|
||||
values: vec![(17, serializer::CHICKEN_VARIANT, Value::Id(3))],
|
||||
};
|
||||
entity_data(Some(translate::v1_21_4::CHICKEN), &mut chicken);
|
||||
entity_data(
|
||||
Some(translate::client_layout_v1_21_4::CHICKEN),
|
||||
&mut chicken,
|
||||
);
|
||||
assert!(chicken.values.is_empty());
|
||||
let mut wolf = play::SetEntityData {
|
||||
id: 1,
|
||||
@@ -659,7 +675,7 @@ mod tests {
|
||||
(23, serializer::WOLF_SOUND_VARIANT, Value::Id(2)),
|
||||
],
|
||||
};
|
||||
entity_data(Some(translate::v1_21_4::WOLF), &mut wolf);
|
||||
entity_data(Some(translate::client_layout_v1_21_4::WOLF), &mut wolf);
|
||||
assert_eq!(
|
||||
wolf.values,
|
||||
vec![(22, serializer::WOLF_VARIANT, Value::Id(1))]
|
||||
@@ -705,7 +721,7 @@ mod tests {
|
||||
else {
|
||||
panic!("a container click is rewritten");
|
||||
};
|
||||
let back = translate::v1_21_4::container_click(&pinned).unwrap();
|
||||
let back = translate::client_layout_v1_21_4::container_click(&pinned).unwrap();
|
||||
assert_eq!(back, older);
|
||||
}
|
||||
}
|
||||
@@ -107,9 +107,10 @@ mod tests {
|
||||
let id = packet_ids::play::serverbound::PLAYER_COMMAND;
|
||||
for action in 0..=6 {
|
||||
let pinned = command(9, action, 0);
|
||||
let (_, older) = translate::v1_21_5::player_command(&session, id, &pinned)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let (_, older) =
|
||||
translate::client_layout_v1_21_5::player_command(&session, id, &pinned)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serverbound(State::Play, id, &older).unwrap(),
|
||||
Out::Rewritten(pinned)
|
||||
|
||||
@@ -152,7 +152,7 @@ mod tests {
|
||||
|
||||
/// 1.21.8's packet, read by translate's 1.21.8 reader, then written back by this step.
|
||||
fn round_trip(session: &Session, id: i32, body: &[u8]) -> Vec<u8> {
|
||||
let up = translate::v1_21_8::clientbound(session, State::Play, id, body)
|
||||
let up = translate::client_layout_v1_21_8::clientbound(session, State::Play, id, body)
|
||||
.unwrap()
|
||||
.expect("1.21.8 packet has a 1.21.10 layout");
|
||||
clientbound(State::Play, id, &up)
|
||||
@@ -237,7 +237,7 @@ mod tests {
|
||||
assert_eq!(round_trip(&session, id, &body), body);
|
||||
|
||||
// And the 1.21.10 side the pinned reader sees: the radius and count it was given, no block particles.
|
||||
let up = translate::v1_21_8::clientbound(&session, State::Play, id, &body)
|
||||
let up = translate::client_layout_v1_21_8::clientbound(&session, State::Play, id, &body)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let mut up = up;
|
||||
@@ -277,7 +277,7 @@ mod tests {
|
||||
);
|
||||
// The way back, by translate's reader, gives 1.21.10's order again, minus the shoulders.
|
||||
let mut back = d.clone();
|
||||
translate::v1_21_8::entity_data(&session, &mut back);
|
||||
translate::client_layout_v1_21_8::entity_data(&session, &mut back);
|
||||
assert_eq!(
|
||||
back.values,
|
||||
vec![
|
||||
|
||||
@@ -82,7 +82,7 @@ fn login(payload: &[u8]) -> Result<Vec<u8>> {
|
||||
for _ in 0..3 {
|
||||
r.bool()?;
|
||||
}
|
||||
translate::spawn_info(&mut r)?;
|
||||
translate::spawn_info(&mut r, crate::adapter::ServerVersion::V26_2)?;
|
||||
let at = r.position();
|
||||
r.bool()?;
|
||||
let mut body = payload[..at].to_vec();
|
||||
|
||||
@@ -389,7 +389,12 @@ mod tests {
|
||||
assert_eq!(s[1].states.get(330), 13330);
|
||||
assert_eq!(c.block_entities[0].kind, 0);
|
||||
}
|
||||
let back = translate::chunk(ids, &old).unwrap();
|
||||
let back = translate::chunk(
|
||||
&crate::adapter::session::Session::new(crate::adapter::ServerVersion::V26_2),
|
||||
ids,
|
||||
&old,
|
||||
)
|
||||
.unwrap();
|
||||
let q = play::LevelChunkWithLight::read(&mut Reader::new(&back)).unwrap();
|
||||
assert_eq!((q.x, q.z), (3, -7));
|
||||
assert_eq!(q.chunk.heightmaps, p.chunk.heightmaps);
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
//! writes, and the per-thread context the item and particle readers consult while a packet from such a
|
||||
//! server is decoded (an id there decides how the bytes after it read, so it must be renumbered as it is
|
||||
//! read, not afterwards).
|
||||
use std::cell::Cell;
|
||||
use std::{
|
||||
cell::{Cell, RefCell},
|
||||
collections::HashMap,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
/// How one registry's ids differ between an older version and the pinned one.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -129,10 +133,36 @@ pub struct Quirks {
|
||||
/// `use_remainder` holds an ItemStack and `container` a list of optional ones (a count first, 0 for
|
||||
/// none) instead of item stack templates (1.21.11).
|
||||
pub stacks_in_components: bool,
|
||||
/// Before 1.21.6 an `attribute_modifiers` entry has no display, and `equippable` has neither
|
||||
/// `can_be_sheared` nor `shearing_sound` (1.21.6 added them). Read: the pinned layout with no display,
|
||||
/// false and the shears' sound. Written: left out.
|
||||
/// Before 1.21.9, player shoulder parrots were compound tags (serializer 16), not optional variants.
|
||||
pub shoulder_parrots_as_nbt: bool,
|
||||
/// Before 1.21.6, attribute modifiers had no display and equippables no shearing fields.
|
||||
pub components_before_1_21_6: bool,
|
||||
/// The 1.21.4 component patch has per-component tooltip flags and older nested codecs.
|
||||
pub components_before_1_21_5: bool,
|
||||
/// These releases use count-first ItemStack.STREAM_CODEC where the pinned version uses templates.
|
||||
pub count_first_templates: bool,
|
||||
/// Optional name/UUID and properties, without the pinned profile tag or skin patch.
|
||||
pub optional_profile_fields: bool,
|
||||
/// Before 1.21.4, custom model data was one VarInt instead of four lists.
|
||||
pub integer_custom_model_data: bool,
|
||||
/// Before 1.21.4, trail options left the lifetime to the client particle provider.
|
||||
pub trail_without_duration: bool,
|
||||
/// Before 1.21.2, food combined consumption effects and several packet/component layouts differed.
|
||||
pub components_before_1_21_2: bool,
|
||||
/// Before 1.21, food had no remainder and attribute modifiers used UUIDs and names.
|
||||
pub components_before_1_21: bool,
|
||||
/// 1.21 through 1.21.4 encoded wolf variants as registry-or-inline holders.
|
||||
pub wolf_variant_holder: bool,
|
||||
/// Before 1.20.5 an item stack carried a single legacy NBT tag instead of a component patch.
|
||||
pub legacy_item_nbt: bool,
|
||||
/// Saved-item data version at the legacy wire boundary (zero when components are native).
|
||||
pub legacy_item_data_version: i32,
|
||||
/// Before 1.20.3, component fields inside entity data use UTF JSON rather than NBT.
|
||||
pub components_as_json: bool,
|
||||
/// Before 1.20.3, vibration position sources start with a resource identifier.
|
||||
pub vibration_source_identifier: bool,
|
||||
/// Through 1.21.8 these particle types carried no options at all.
|
||||
pub simple_spell_particles: bool,
|
||||
}
|
||||
impl Quirks {
|
||||
pub const NONE: Self = Self {
|
||||
@@ -140,7 +170,21 @@ impl Quirks {
|
||||
pot_decorations_are_items: false,
|
||||
either_holders_and_tag_resistance: false,
|
||||
stacks_in_components: false,
|
||||
shoulder_parrots_as_nbt: false,
|
||||
components_before_1_21_6: false,
|
||||
components_before_1_21_5: false,
|
||||
count_first_templates: false,
|
||||
optional_profile_fields: false,
|
||||
integer_custom_model_data: false,
|
||||
trail_without_duration: false,
|
||||
components_before_1_21_2: false,
|
||||
components_before_1_21: false,
|
||||
wolf_variant_holder: false,
|
||||
legacy_item_nbt: false,
|
||||
legacy_item_data_version: 0,
|
||||
components_as_json: false,
|
||||
vibration_source_identifier: false,
|
||||
simple_spell_particles: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -192,6 +236,65 @@ pub enum Way {
|
||||
|
||||
thread_local! {
|
||||
static ACTIVE: Cell<Option<(&'static Ids, Way)>> = const { Cell::new(None) };
|
||||
static TRIM_PATTERNS: RefCell<HashMap<i32,i32>> = RefCell::new(HashMap::new());
|
||||
static LEGACY_REGISTRIES: RefCell<Option<Arc<LegacyRegistries>>> = const { RefCell::new(None) };
|
||||
static LEGACY_ITEMS: RefCell<Option<Arc<Mutex<crate::item::v1_20_3::Cache>>>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
pub(crate) fn legacy_items(cache: Arc<Mutex<crate::item::v1_20_3::Cache>>) -> LegacyItemGuard {
|
||||
LegacyItemGuard(LEGACY_ITEMS.replace(Some(cache)))
|
||||
}
|
||||
pub(crate) fn legacy_item_cache() -> Option<Arc<Mutex<crate::item::v1_20_3::Cache>>> {
|
||||
LEGACY_ITEMS.with_borrow(Clone::clone)
|
||||
}
|
||||
pub(crate) struct LegacyItemGuard(Option<Arc<Mutex<crate::item::v1_20_3::Cache>>>);
|
||||
impl Drop for LegacyItemGuard {
|
||||
fn drop(&mut self) {
|
||||
LEGACY_ITEMS.replace(self.0.take());
|
||||
}
|
||||
}
|
||||
|
||||
/// Name lookups for legacy NBT. Fixed registries use pinned ids; synchronized registries keep the
|
||||
/// connection's ids. Reverse names retain old spellings, with current names accepted as aliases.
|
||||
pub(crate) type LegacyRegistries = HashMap<String, crate::registry::Ids>;
|
||||
|
||||
pub(crate) fn legacy_registries(registries: Arc<LegacyRegistries>) -> LegacyRegistryGuard {
|
||||
LegacyRegistryGuard(LEGACY_REGISTRIES.replace(Some(registries)))
|
||||
}
|
||||
|
||||
pub(crate) struct LegacyRegistryGuard(Option<Arc<LegacyRegistries>>);
|
||||
impl Drop for LegacyRegistryGuard {
|
||||
fn drop(&mut self) {
|
||||
LEGACY_REGISTRIES.replace(self.0.take());
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn legacy_registry_id(registry: &str, name: &str) -> Option<i32> {
|
||||
LEGACY_REGISTRIES.with_borrow(|registries| registries.as_ref()?.get(registry)?.id(name))
|
||||
}
|
||||
|
||||
pub(crate) fn legacy_registry_name(registry: &str, id: i32) -> Option<String> {
|
||||
LEGACY_REGISTRIES.with_borrow(|registries| {
|
||||
registries
|
||||
.as_ref()?
|
||||
.get(registry)?
|
||||
.name(id)
|
||||
.map(str::to_owned)
|
||||
})
|
||||
}
|
||||
|
||||
/// Connection-local trim ids while decoding the old smithing preview's template displays.
|
||||
pub(crate) fn trim_patterns(patterns: HashMap<i32, i32>) -> TrimGuard {
|
||||
TrimGuard(TRIM_PATTERNS.replace(patterns))
|
||||
}
|
||||
pub(crate) struct TrimGuard(HashMap<i32, i32>);
|
||||
impl Drop for TrimGuard {
|
||||
fn drop(&mut self) {
|
||||
TRIM_PATTERNS.replace(std::mem::take(&mut self.0));
|
||||
}
|
||||
}
|
||||
pub(crate) fn trim_pattern(item: i32) -> Option<i32> {
|
||||
TRIM_PATTERNS.with_borrow(|patterns| patterns.get(&item).copied())
|
||||
}
|
||||
|
||||
/// Renumber ids `way` on this thread until the guard drops. Readers and writers that pick a layout by an id
|
||||
@@ -199,6 +302,10 @@ thread_local! {
|
||||
pub fn renumbering(ids: &'static Ids, way: Way) -> Guard {
|
||||
Guard(ACTIVE.replace(Some((ids, way))))
|
||||
}
|
||||
/// Decode a stored pinned value before encoding its nested values for an older server.
|
||||
pub(crate) fn unrenumbered() -> Guard {
|
||||
Guard(ACTIVE.replace(None))
|
||||
}
|
||||
|
||||
/// Restores the previous context when dropped.
|
||||
pub struct Guard(Option<(&'static Ids, Way)>);
|
||||
@@ -231,6 +338,10 @@ pub fn item(id: i32) -> i32 {
|
||||
pub fn component(id: i32) -> i32 {
|
||||
apply(id, |ids| ids.data_component_type)
|
||||
}
|
||||
/// An argument parser id; its properties must be read using the pinned parser's layout.
|
||||
pub fn command_argument_type(id: i32) -> i32 {
|
||||
apply(id, |ids| ids.command_argument_type)
|
||||
}
|
||||
/// The name of the data component an older server sent as `id` (its own number) that the pinned version
|
||||
/// no longer has, while such a server's packet is read on this thread. The readers read its value past and
|
||||
/// drop it.
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
//! Connection-local registry names used by the pre-component NBT item format.
|
||||
use super::{ServerVersion, ids, v1_20_2_ids as before, v1_20_3_ids as old};
|
||||
use crate::{Error, Result, packets::configuration, registry};
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(super) fn registries(version: ServerVersion) -> ids::LegacyRegistries {
|
||||
let Some(ids) = version.ids() else {
|
||||
return Default::default();
|
||||
};
|
||||
if !version.at_most(ServerVersion::V1_20_3) {
|
||||
return Default::default();
|
||||
}
|
||||
let mut out = ids::LegacyRegistries::new();
|
||||
let tables = if version.at_most(ServerVersion::V1_20_2) {
|
||||
[
|
||||
("minecraft:item", before::OLD_ITEM_NAMES),
|
||||
("minecraft:block", before::BLOCK_NAMES),
|
||||
("minecraft:entity_type", before::ENTITY_TYPE_NAMES),
|
||||
(
|
||||
"minecraft:block_entity_type",
|
||||
before::BLOCK_ENTITY_TYPE_NAMES,
|
||||
),
|
||||
("minecraft:attribute", before::ATTRIBUTE_NAMES),
|
||||
("minecraft:sound_event", before::SOUND_EVENT_NAMES),
|
||||
("minecraft:mob_effect", before::MOB_EFFECT_NAMES),
|
||||
("minecraft:potion", before::POTION_NAMES),
|
||||
("minecraft:enchantment", before::ENCHANTMENT_NAMES),
|
||||
("minecraft:painting_variant", before::PAINTING_VARIANT_NAMES),
|
||||
("minecraft:banner_pattern", before::BANNER_PATTERN_NAMES),
|
||||
("minecraft:instrument", before::INSTRUMENT_NAMES),
|
||||
]
|
||||
} else {
|
||||
[
|
||||
("minecraft:item", old::OLD_ITEM_NAMES),
|
||||
("minecraft:block", old::BLOCK_NAMES),
|
||||
("minecraft:entity_type", old::ENTITY_TYPE_NAMES),
|
||||
("minecraft:block_entity_type", old::BLOCK_ENTITY_TYPE_NAMES),
|
||||
("minecraft:attribute", old::ATTRIBUTE_NAMES),
|
||||
("minecraft:sound_event", old::SOUND_EVENT_NAMES),
|
||||
("minecraft:mob_effect", old::MOB_EFFECT_NAMES),
|
||||
("minecraft:potion", old::POTION_NAMES),
|
||||
("minecraft:enchantment", old::ENCHANTMENT_NAMES),
|
||||
("minecraft:painting_variant", old::PAINTING_VARIANT_NAMES),
|
||||
("minecraft:banner_pattern", old::BANNER_PATTERN_NAMES),
|
||||
("minecraft:instrument", old::INSTRUMENT_NAMES),
|
||||
]
|
||||
};
|
||||
let aliases = if version.at_most(ServerVersion::V1_20_2) {
|
||||
before::NAME_ALIASES
|
||||
} else {
|
||||
old::NAME_ALIASES
|
||||
};
|
||||
for (registry, names) in tables {
|
||||
let remap = ids.by_registry(registry);
|
||||
let mut table = registry::Ids::default();
|
||||
for (raw, name) in names.iter().enumerate() {
|
||||
let pinned = if registry == "minecraft:potion" {
|
||||
// 1.20.5 removed EMPTY: all 42 surviving potions moved down one slot.
|
||||
(raw > 0).then_some(raw as i32 - 1)
|
||||
} else {
|
||||
remap.up(raw as i32)
|
||||
};
|
||||
let Some(pinned) = pinned.and_then(|id| usize::try_from(id).ok()) else {
|
||||
continue;
|
||||
};
|
||||
table
|
||||
.names
|
||||
.resize(table.names.len().max(pinned + 1), String::new());
|
||||
table.names[pinned] = (*name).into();
|
||||
table.ids.insert((*name).into(), pinned as i32);
|
||||
}
|
||||
for &(kind, before, current) in aliases {
|
||||
if kind == registry
|
||||
&& let Some(id) = table.id(before)
|
||||
{
|
||||
table.ids.insert(current.into(), id);
|
||||
}
|
||||
}
|
||||
out.insert(registry.into(), table);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A 765 RegistryAccess codec writes all synchronized registries in one compound, with explicit ids.
|
||||
/// Split that compound into current per-registry packets while retaining each registry's order.
|
||||
pub(super) fn split_registry_data(tag: &Tag) -> Result<Vec<configuration::RegistryData>> {
|
||||
let Tag::Compound(registries) = tag else {
|
||||
return Err(Error::new("invalid legacy registry compound"));
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for (key, value) in registries {
|
||||
let registry = key.to_string_lossy();
|
||||
let Some(Tag::List(entries)) = value.get("value") else {
|
||||
return Err(Error::new("missing legacy registry entries"));
|
||||
};
|
||||
if entries.len() > 65536 {
|
||||
return Err(Error::new("legacy registry is too large"));
|
||||
}
|
||||
let mut ordered = vec![None; entries.len()];
|
||||
for entry in entries {
|
||||
let Some(Tag::String(name)) = entry.get("name") else {
|
||||
return Err(Error::new("missing legacy registry name"));
|
||||
};
|
||||
let Some(Tag::Int(id)) = entry.get("id") else {
|
||||
return Err(Error::new("missing legacy registry id"));
|
||||
};
|
||||
let id = usize::try_from(*id).map_err(|_| Error::new("negative legacy registry id"))?;
|
||||
let slot = ordered
|
||||
.get_mut(id)
|
||||
.ok_or_else(|| Error::new("legacy registry id out of range"))?;
|
||||
if slot.is_some() {
|
||||
return Err(Error::new("duplicate legacy registry id"));
|
||||
}
|
||||
*slot = Some(configuration::RegistryEntry {
|
||||
id: name.to_string_lossy(),
|
||||
data: entry.get("element").cloned(),
|
||||
});
|
||||
}
|
||||
let entries = ordered
|
||||
.into_iter()
|
||||
.collect::<Option<Vec<_>>>()
|
||||
.ok_or_else(|| Error::new("non-contiguous legacy registry ids"))?;
|
||||
out.push(configuration::RegistryData { registry, entries });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(super) fn remember(session: &super::Session, packet: &configuration::RegistryData) {
|
||||
let mut tables = session.legacy_registries.lock().unwrap();
|
||||
Arc::make_mut(&mut tables).insert(
|
||||
packet.registry.clone(),
|
||||
registry::Ids::from_names(packet.entries.iter().map(|e| e.id.clone()).collect()),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry(name: &str, id: i32) -> Tag {
|
||||
Tag::compound([
|
||||
("name", Tag::String(name.into())),
|
||||
("id", Tag::Int(id)),
|
||||
(
|
||||
"element",
|
||||
Tag::compound([("synthetic", Tag::Int(id.saturating_add(10)))]),
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_registry_sync_uses_explicit_ids_and_connection_local_names() {
|
||||
let tag = Tag::compound([(
|
||||
"minecraft:trim_material",
|
||||
Tag::compound([
|
||||
("type", Tag::String("minecraft:trim_material".into())),
|
||||
(
|
||||
"value",
|
||||
Tag::List(vec![entry("minecraft:gold", 1), entry("minecraft:iron", 0)]),
|
||||
),
|
||||
]),
|
||||
)]);
|
||||
let packets = split_registry_data(&tag).unwrap();
|
||||
assert_eq!(packets.len(), 1);
|
||||
assert_eq!(packets[0].entries[0].id, "minecraft:iron");
|
||||
assert_eq!(
|
||||
packets[0].entries[1]
|
||||
.data
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.get("synthetic"),
|
||||
Some(&Tag::Int(11))
|
||||
);
|
||||
let session = super::super::Session::new(ServerVersion::V1_20_3);
|
||||
remember(&session, &packets[0]);
|
||||
let _names = ids::legacy_registries(session.legacy_registries.lock().unwrap().clone());
|
||||
assert_eq!(
|
||||
ids::legacy_registry_id("minecraft:trim_material", "minecraft:gold"),
|
||||
Some(1)
|
||||
);
|
||||
assert_eq!(
|
||||
ids::legacy_registry_name("minecraft:trim_material", 0).as_deref(),
|
||||
Some("minecraft:iron")
|
||||
);
|
||||
assert_eq!(
|
||||
ids::legacy_registry_id("minecraft:item", "minecraft:turtle_scute"),
|
||||
ids::legacy_registry_id("minecraft:item", "minecraft:scute")
|
||||
);
|
||||
assert_eq!(
|
||||
ids::legacy_registry_id("minecraft:potion", "minecraft:water"),
|
||||
Some(0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_registry_ids_fail_instead_of_reordering_or_allocating_by_id() {
|
||||
for entries in [
|
||||
vec![entry("a", -1)],
|
||||
vec![entry("a", i32::MAX)],
|
||||
vec![entry("a", 0), entry("b", 0)],
|
||||
] {
|
||||
let tag = Tag::compound([(
|
||||
"minecraft:trim_material",
|
||||
Tag::compound([("value", Tag::List(entries))]),
|
||||
)]);
|
||||
assert!(split_registry_data(&tag).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,8 @@
|
||||
use std::{
|
||||
collections::{HashMap, VecDeque},
|
||||
sync::{
|
||||
Mutex,
|
||||
atomic::{AtomicU8, Ordering},
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, AtomicU8, Ordering},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -43,6 +43,21 @@ fn from_u8(v: u8) -> State {
|
||||
/// Shared by a connection's reader and writer.
|
||||
pub struct Session {
|
||||
pub version: ServerVersion,
|
||||
/// Dimension of the last play login or respawn, for older packets with only a local block position.
|
||||
pub(super) dimension: Mutex<String>,
|
||||
/// Local entity id and last shift input, for pre-1.21.6 crouch commands.
|
||||
pub(super) local_player: Mutex<Option<(i32, bool)>>,
|
||||
pub(super) chat_index: std::sync::atomic::AtomicI32,
|
||||
pub(super) minecart_display: Mutex<HashMap<i32, (i32, bool)>>,
|
||||
pub(super) trim_patterns: Mutex<HashMap<i32, i32>>,
|
||||
pub(super) legacy_recipes: Mutex<translate::v1_21_1::recipes::Recipes>,
|
||||
pub(super) legacy_boats: Mutex<HashMap<i32, translate::v1_21_1::boats::Boat>>,
|
||||
pub(super) legacy_registries: Mutex<Arc<super::ids::LegacyRegistries>>,
|
||||
pub(super) legacy_metadata: Mutex<translate::v1_20_3::Metadata>,
|
||||
pub(super) legacy_items: Arc<Mutex<crate::item::v1_20_3::Cache>>,
|
||||
pub(super) legacy_resource_packs: Mutex<translate::v1_20_2::ResourcePacks>,
|
||||
login_requires_authentication: AtomicBool,
|
||||
legacy_secure_chat_enforcement: Mutex<Option<bool>>,
|
||||
reading: AtomicU8,
|
||||
writing: AtomicU8,
|
||||
/// Entity UUIDs by network id, for a server that is told which entity a spectator picked by its UUID.
|
||||
@@ -60,6 +75,19 @@ impl Session {
|
||||
pub fn new(version: ServerVersion) -> Self {
|
||||
Self {
|
||||
version,
|
||||
dimension: Mutex::new("minecraft:overworld".into()),
|
||||
local_player: Mutex::new(None),
|
||||
chat_index: std::sync::atomic::AtomicI32::new(0),
|
||||
minecart_display: Mutex::new(HashMap::new()),
|
||||
trim_patterns: Mutex::new(HashMap::new()),
|
||||
legacy_recipes: Mutex::new(translate::v1_21_1::recipes::Recipes::default()),
|
||||
legacy_boats: Mutex::new(HashMap::new()),
|
||||
legacy_registries: Mutex::new(Arc::new(super::legacy::registries(version))),
|
||||
legacy_metadata: Mutex::new(Default::default()),
|
||||
legacy_items: Arc::new(Mutex::new(Default::default())),
|
||||
legacy_resource_packs: Mutex::new(Default::default()),
|
||||
login_requires_authentication: AtomicBool::new(false),
|
||||
legacy_secure_chat_enforcement: Mutex::new(None),
|
||||
reading: AtomicU8::new(0),
|
||||
writing: AtomicU8::new(0),
|
||||
uuids: Mutex::new(HashMap::new()),
|
||||
@@ -81,10 +109,47 @@ impl Session {
|
||||
self.writing.store(to_u8(state), Ordering::Release);
|
||||
}
|
||||
|
||||
pub(super) fn login_requires_authentication(&self) -> bool {
|
||||
self.login_requires_authentication.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
/// Before 1.20.5 this flag arrives in ServerData instead of the play login.
|
||||
pub fn legacy_secure_chat_enforcement(&self) -> Option<bool> {
|
||||
if !self.version.at_most(ServerVersion::V1_20_3) {
|
||||
return None;
|
||||
}
|
||||
*self.legacy_secure_chat_enforcement.lock().unwrap()
|
||||
}
|
||||
|
||||
pub(super) fn set_legacy_secure_chat_enforcement(&self, value: Option<bool>) {
|
||||
*self.legacy_secure_chat_enforcement.lock().unwrap() = value;
|
||||
}
|
||||
|
||||
/// A packet the server sent, as the pinned version numbers and lays it out. `None` drops it: Horizon
|
||||
/// knows no such packet.
|
||||
pub fn clientbound(&self, id: i32, payload: Vec<u8>) -> Result<Option<(i32, Vec<u8>)>> {
|
||||
let state = self.reading();
|
||||
let _legacy = self
|
||||
.version
|
||||
.at_most(ServerVersion::V1_20_3)
|
||||
.then(|| super::ids::legacy_registries(self.legacy_registries.lock().unwrap().clone()));
|
||||
let _items = self
|
||||
.version
|
||||
.at_most(ServerVersion::V1_20_3)
|
||||
.then(|| super::ids::legacy_items(self.legacy_items.clone()));
|
||||
let recipe_book = if self.version.at_most(ServerVersion::V1_20_3) {
|
||||
63
|
||||
} else {
|
||||
65
|
||||
};
|
||||
if self.version.at_most(ServerVersion::V1_21_1) && state == State::Play && id == recipe_book
|
||||
{
|
||||
return translate::v1_21_1::recipes::recipe_book(self, &payload).map(Some);
|
||||
}
|
||||
// Vanilla 1.21.4's separate add_experience_orb (id 2) became AddEntity plus metadata.
|
||||
if self.version.at_most(ServerVersion::V1_21_4) && state == State::Play && id == 2 {
|
||||
return translate::old_experience_orb(self, &payload).map(Some);
|
||||
}
|
||||
let Some(pinned) = self.version.clientbound_id(state, id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
@@ -96,30 +161,57 @@ impl Session {
|
||||
self.set_reading(State::Play)
|
||||
}
|
||||
(State::Play, ids::play::clientbound::START_CONFIGURATION) => {
|
||||
self.set_legacy_secure_chat_enforcement(None);
|
||||
self.set_reading(State::Configuration)
|
||||
}
|
||||
(State::Play, ids::play::clientbound::LOGIN) => {
|
||||
self.set_legacy_secure_chat_enforcement(None);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// A packet the translation can't read goes on as it came, as an unknown packet would: Horizon's
|
||||
// own reader then skips it or reports it, rather than the connection dropping here.
|
||||
match translate::clientbound(self, state, pinned, &payload) {
|
||||
Ok(Some((id, Some(translated)))) => Ok(Some((id, translated))),
|
||||
Ok(Some((id, None))) => Ok(Some((id, payload))),
|
||||
Ok(None) => Ok(None),
|
||||
let translated = match translate::clientbound(self, state, pinned, &payload) {
|
||||
Ok(Some((id, Some(translated)))) => Some((id, translated)),
|
||||
Ok(Some((id, None))) => Some((id, payload)),
|
||||
Ok(None) => None,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Horizon: could not translate {} packet {pinned} ({state:?}): {e}",
|
||||
self.version.name()
|
||||
);
|
||||
Ok(Some((pinned, payload)))
|
||||
Some((pinned, payload))
|
||||
}
|
||||
};
|
||||
if state == State::Login
|
||||
&& let Some((id, payload)) = &translated
|
||||
&& let Ok(crate::packets::login::Clientbound::Hello(hello)) =
|
||||
crate::packets::login::Clientbound::decode(*id, payload)
|
||||
{
|
||||
self.login_requires_authentication
|
||||
.store(hello.should_authenticate, Ordering::Release);
|
||||
}
|
||||
if self.version.at_most(ServerVersion::V1_21_1)
|
||||
&& state == State::Play
|
||||
&& let Some((id, body)) = translated
|
||||
{
|
||||
return translate::v1_21_1::boats::finish(self, id, body).map(Some);
|
||||
}
|
||||
Ok(translated)
|
||||
}
|
||||
|
||||
/// A packet body (VarInt id + payload) Horizon sends, as this server numbers and lays it out. `None`:
|
||||
/// the server has no such packet, so nothing is sent.
|
||||
pub fn serverbound(&self, body: &[u8]) -> Result<Option<Vec<u8>>> {
|
||||
let state = self.writing();
|
||||
let _legacy = self
|
||||
.version
|
||||
.at_most(ServerVersion::V1_20_3)
|
||||
.then(|| super::ids::legacy_registries(self.legacy_registries.lock().unwrap().clone()));
|
||||
let _items = self
|
||||
.version
|
||||
.at_most(ServerVersion::V1_20_3)
|
||||
.then(|| super::ids::legacy_items(self.legacy_items.clone()));
|
||||
let mut r = Reader::new(body);
|
||||
let id = r.varint()?;
|
||||
let payload = r.rest();
|
||||
@@ -191,20 +283,172 @@ mod tests {
|
||||
w.data
|
||||
}
|
||||
|
||||
fn old_play_id(version: ServerVersion, pinned: i32) -> i32 {
|
||||
(0..256)
|
||||
.find(|&id| version.clientbound_id(State::Play, id) == Some(pinned))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn old_login(version: ServerVersion) -> Vec<u8> {
|
||||
let mut w = Writer::new();
|
||||
w.i32(42);
|
||||
w.bool(false);
|
||||
w.varint(1);
|
||||
w.string("minecraft:overworld");
|
||||
for n in [20, 8, 6] {
|
||||
w.varint(n);
|
||||
}
|
||||
for flag in [false, true, false] {
|
||||
w.bool(flag);
|
||||
}
|
||||
if version.at_most(ServerVersion::V1_20_3) {
|
||||
w.string("minecraft:overworld");
|
||||
} else {
|
||||
w.varint(0);
|
||||
}
|
||||
w.string("minecraft:overworld");
|
||||
w.i64(0);
|
||||
w.i8(0);
|
||||
w.i8(-1);
|
||||
for _ in 0..3 {
|
||||
w.bool(false);
|
||||
}
|
||||
w.varint(0);
|
||||
if !version.at_most(ServerVersion::V1_20_3) {
|
||||
w.bool(true); // The newer login has its own enforcement flag.
|
||||
}
|
||||
w.data
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_logins_preserve_the_encryption_requests_authentication_signal() {
|
||||
use crate::packets::{login, play};
|
||||
for version in [ServerVersion::V1_20_3, ServerVersion::V1_20_6] {
|
||||
for authenticated in [false, true] {
|
||||
let s = Session::new(version);
|
||||
s.set_reading(State::Login);
|
||||
// 765 had no opt-out bit: offline login omitted the encryption request.
|
||||
if authenticated || version == ServerVersion::V1_20_6 {
|
||||
let mut hello = Writer::new();
|
||||
hello.string("");
|
||||
hello.byte_array(&[1, 2]);
|
||||
hello.byte_array(&[3, 4]);
|
||||
if version == ServerVersion::V1_20_6 {
|
||||
hello.bool(authenticated);
|
||||
}
|
||||
let (id, payload) = s.clientbound(1, hello.data).unwrap().unwrap();
|
||||
let login::Clientbound::Hello(hello) =
|
||||
login::Clientbound::decode(id, &payload).unwrap()
|
||||
else {
|
||||
panic!("hello")
|
||||
};
|
||||
assert_eq!(hello.should_authenticate, authenticated);
|
||||
}
|
||||
assert_eq!(s.login_requires_authentication(), authenticated);
|
||||
if version == ServerVersion::V1_20_3 {
|
||||
Arc::make_mut(&mut s.legacy_registries.lock().unwrap()).insert(
|
||||
"minecraft:dimension_type".into(),
|
||||
crate::registry::Ids::from_names(vec!["minecraft:overworld".into()]),
|
||||
);
|
||||
}
|
||||
s.set_reading(State::Play);
|
||||
let (id, payload) = s
|
||||
.clientbound(
|
||||
old_play_id(version, ids::play::clientbound::LOGIN),
|
||||
old_login(version),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let play::Clientbound::Login(login) =
|
||||
play::Clientbound::decode(id, &payload).unwrap()
|
||||
else {
|
||||
panic!("login")
|
||||
};
|
||||
assert_eq!(login.online_mode, authenticated, "{}", version.name());
|
||||
assert_eq!(
|
||||
login.enforces_secure_chat,
|
||||
version == ServerVersion::V1_20_6
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_server_data_updates_and_resets_secure_chat_enforcement() {
|
||||
use crate::buf::Codec;
|
||||
use crate::packets::play;
|
||||
use horizon_world::nbt::Tag;
|
||||
let s = Session::new(ServerVersion::V1_20_3);
|
||||
s.set_reading(State::Play);
|
||||
let data = play::ServerData {
|
||||
motd: Tag::String("Wire server".into()),
|
||||
icon: None,
|
||||
};
|
||||
for enforced in [true, false] {
|
||||
let mut w = Writer::new();
|
||||
play::ServerData::write(&data, &mut w);
|
||||
let expected = w.data.clone();
|
||||
w.bool(enforced);
|
||||
let (id, payload) = s
|
||||
.clientbound(
|
||||
old_play_id(s.version, ids::play::clientbound::SERVER_DATA),
|
||||
w.data,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(payload, expected);
|
||||
assert!(matches!(
|
||||
play::Clientbound::decode(id, &payload).unwrap(),
|
||||
play::Clientbound::ServerData(_)
|
||||
));
|
||||
assert_eq!(s.legacy_secure_chat_enforcement(), Some(enforced));
|
||||
}
|
||||
s.set_legacy_secure_chat_enforcement(Some(true));
|
||||
s.clientbound(
|
||||
old_play_id(s.version, ids::play::clientbound::START_CONFIGURATION),
|
||||
vec![],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.legacy_secure_chat_enforcement(), None);
|
||||
s.set_reading(State::Play);
|
||||
s.set_legacy_secure_chat_enforcement(Some(true));
|
||||
// Reset belongs to the login boundary even if a malformed body cannot be translated.
|
||||
s.clientbound(
|
||||
old_play_id(s.version, ids::play::clientbound::LOGIN),
|
||||
vec![],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.legacy_secure_chat_enforcement(), None);
|
||||
assert_eq!(
|
||||
Session::new(ServerVersion::Current).legacy_secure_chat_enforcement(),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_handshake_states_the_older_protocol_and_moves_both_directions() {
|
||||
let s = Session::new(ServerVersion::V26_2);
|
||||
let mut payload = Writer::new();
|
||||
payload.varint(crate::PROTOCOL_VERSION);
|
||||
payload.string("example.org");
|
||||
payload.u16(25565);
|
||||
payload.varint(handshake::LOGIN);
|
||||
let sent = s.serverbound(&body(0, &payload.data)).unwrap().unwrap();
|
||||
let mut r = Reader::new(&sent);
|
||||
assert_eq!(r.varint().unwrap(), 0);
|
||||
assert_eq!(r.varint().unwrap(), 776);
|
||||
assert_eq!(r.string(255).unwrap(), "example.org");
|
||||
assert_eq!((s.reading(), s.writing()), (State::Login, State::Login));
|
||||
for (version, protocol) in [
|
||||
(ServerVersion::V26_2, 776),
|
||||
(ServerVersion::V1_21_8, 772),
|
||||
(ServerVersion::V1_21_6, 771),
|
||||
(ServerVersion::V1_21_5, 770),
|
||||
(ServerVersion::V1_21_4, 769),
|
||||
(ServerVersion::V1_21_3, 768),
|
||||
(ServerVersion::V1_21_1, 767),
|
||||
] {
|
||||
let s = Session::new(version);
|
||||
let mut payload = Writer::new();
|
||||
payload.varint(crate::PROTOCOL_VERSION);
|
||||
payload.string("example.org");
|
||||
payload.u16(25565);
|
||||
payload.varint(handshake::LOGIN);
|
||||
let sent = s.serverbound(&body(0, &payload.data)).unwrap().unwrap();
|
||||
let mut r = Reader::new(&sent);
|
||||
assert_eq!(r.varint().unwrap(), 0);
|
||||
assert_eq!(r.varint().unwrap(), protocol);
|
||||
assert_eq!(r.string(255).unwrap(), "example.org");
|
||||
assert_eq!((s.reading(), s.writing()), (State::Login, State::Login));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,658 @@
|
||||
//! Packet fields before 1.20.3, after the separate JSON text conversion.
|
||||
use super::*;
|
||||
|
||||
pub(crate) mod text;
|
||||
pub(crate) use text::read_component;
|
||||
|
||||
// The legacy protocol has one server-pack slot and no UUID on either direction. Reusing its
|
||||
// opaque id gives the current pack manager's same-id replacement, rather than stacking packs.
|
||||
const PACK_ID: u128 = 0;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct ResourcePacks {
|
||||
requested: bool,
|
||||
}
|
||||
|
||||
pub(super) fn clientbound(
|
||||
session: &Session,
|
||||
state: State,
|
||||
id: i32,
|
||||
payload: &[u8],
|
||||
) -> Result<Option<Vec<u8>>> {
|
||||
let resource = match state {
|
||||
State::Configuration => id == packet_ids::configuration::clientbound::RESOURCE_PACK_PUSH,
|
||||
State::Play => id == packet_ids::play::clientbound::RESOURCE_PACK_PUSH,
|
||||
_ => false,
|
||||
};
|
||||
let mut r = Reader::new(payload);
|
||||
let mut w = Writer::new();
|
||||
if resource {
|
||||
r.string(32767)?;
|
||||
r.string(40)?;
|
||||
r.bool()?;
|
||||
<crate::buf::Opt<crate::buf::Nbt>>::read(&mut r)?;
|
||||
finish(&r)?;
|
||||
w.uuid(PACK_ID);
|
||||
w.bytes(payload);
|
||||
session.legacy_resource_packs.lock().unwrap().requested = true;
|
||||
return Ok(Some(w.data));
|
||||
}
|
||||
if state == State::Play && id == packet_ids::play::clientbound::OPEN_SCREEN {
|
||||
let mut screen = play::OpenScreen::read(&mut r)?;
|
||||
finish(&r)?;
|
||||
screen.menu = session
|
||||
.version
|
||||
.ids()
|
||||
.and_then(|ids| ids.menu.up(screen.menu))
|
||||
.ok_or_else(|| crate::Error::new("unknown legacy menu type"))?;
|
||||
play::OpenScreen::write(&screen, &mut w);
|
||||
return Ok(Some(w.data));
|
||||
}
|
||||
if state != State::Play || id != packet_ids::play::clientbound::SET_OBJECTIVE {
|
||||
return Ok(None);
|
||||
}
|
||||
r.string(32767)?;
|
||||
let method = r.i8()?;
|
||||
match method {
|
||||
0 | 2 => {
|
||||
r.nbt()?;
|
||||
if !(0..=1).contains(&r.varint()?) {
|
||||
return Err(crate::Error::new("invalid legacy objective render type"));
|
||||
}
|
||||
}
|
||||
1 => {}
|
||||
_ => return Err(crate::Error::new("invalid legacy objective method")),
|
||||
}
|
||||
finish(&r)?;
|
||||
w.bytes(payload);
|
||||
if method != 1 {
|
||||
w.bool(false); // Number-format overrides did not exist.
|
||||
}
|
||||
Ok(Some(w.data))
|
||||
}
|
||||
|
||||
fn finish(r: &Reader) -> Result<()> {
|
||||
if r.remaining() == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(crate::Error::new("trailing legacy packet bytes"))
|
||||
}
|
||||
}
|
||||
|
||||
/// The old combined score update/removal becomes one of two current packet types.
|
||||
/// Both results are canonical and bypass the later packet conversion steps.
|
||||
pub(super) fn set_score(_session: &Session, payload: &[u8]) -> Result<(i32, Vec<u8>)> {
|
||||
let mut r = Reader::new(payload);
|
||||
let owner = r.string(32767)?;
|
||||
let method = r.varint()?;
|
||||
let objective = r.string(32767)?;
|
||||
let packet = match method {
|
||||
0 => {
|
||||
if objective.is_empty() {
|
||||
return Err(crate::Error::new("legacy score has no objective"));
|
||||
}
|
||||
play::Clientbound::SetScore(play::SetScore {
|
||||
owner,
|
||||
objective,
|
||||
score: r.varint()?,
|
||||
display: None,
|
||||
number_format: None,
|
||||
})
|
||||
}
|
||||
1 => play::Clientbound::ResetScore(play::ResetScore {
|
||||
owner,
|
||||
objective: (!objective.is_empty()).then_some(objective),
|
||||
}),
|
||||
_ => return Err(crate::Error::new("invalid legacy score method")),
|
||||
};
|
||||
finish(&r)?;
|
||||
let encoded = packet.encode();
|
||||
let mut r = Reader::new(&encoded);
|
||||
Ok((r.varint()?, r.rest().to_vec()))
|
||||
}
|
||||
|
||||
/// Supply exactly the old client-side explosion constructor's defaults, then retain geometry,
|
||||
/// knockback and immediate block destruction through the existing pre-component conversion.
|
||||
pub(super) fn explosion(session: &Session, payload: &[u8]) -> Result<Vec<u8>> {
|
||||
let mut r = Reader::new(payload);
|
||||
crate::buf::Vec3d::read(&mut r)?;
|
||||
r.f32()?;
|
||||
let count = r.count(1_048_576)?;
|
||||
r.bytes(count * 3)?;
|
||||
for _ in 0..3 {
|
||||
r.f32()?;
|
||||
}
|
||||
finish(&r)?;
|
||||
let ids = session
|
||||
.version
|
||||
.ids()
|
||||
.ok_or_else(|| crate::Error::new("legacy explosion requires an adapter"))?;
|
||||
let mut w = Writer::new();
|
||||
w.bytes(payload);
|
||||
w.varint(2); // Explosion's old convenience constructor uses DESTROY_WITH_DECAY.
|
||||
for pinned in [30, 29] {
|
||||
// Pinned registry: explosion, explosion_emitter. Read later with this session's map.
|
||||
w.varint(
|
||||
ids.particle_type
|
||||
.down(pinned)
|
||||
.ok_or_else(|| crate::Error::new("missing legacy explosion particle"))?,
|
||||
);
|
||||
}
|
||||
w.string("minecraft:entity.generic.explode");
|
||||
w.bool(false); // Direct SoundEvent, without a fixed range.
|
||||
v1_20_3::explosion(session, &w.data)
|
||||
}
|
||||
|
||||
/// A single old pack's response has no UUID and only four status values.
|
||||
pub(super) fn resource_status(session: &Session, payload: &[u8]) -> Result<Option<Vec<u8>>> {
|
||||
let mut r = Reader::new(payload);
|
||||
let id = r.uuid()?;
|
||||
let action = r.varint()?;
|
||||
finish(&r)?;
|
||||
if !(0..=7).contains(&action) {
|
||||
return Err(crate::Error::new("invalid resource-pack response"));
|
||||
}
|
||||
if id != PACK_ID || !session.legacy_resource_packs.lock().unwrap().requested {
|
||||
return Ok(None);
|
||||
}
|
||||
let action = match action {
|
||||
0..=3 => action,
|
||||
// 764 reports FAILED_DOWNLOAD for an invalid URL or a failed download/apply future.
|
||||
5 | 6 => 2,
|
||||
// Download progress and multi-pack discards have no old response.
|
||||
4 | 7 => return Ok(None),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let mut w = Writer::new();
|
||||
w.varint(action);
|
||||
Ok(Some(w.data))
|
||||
}
|
||||
|
||||
/// The priority fields were added after the old jigsaw edit screen's last string.
|
||||
pub(super) fn jigsaw(payload: &[u8]) -> Result<Vec<u8>> {
|
||||
let mut r = Reader::new(payload);
|
||||
r.i64()?;
|
||||
for _ in 0..5 {
|
||||
r.string(32767)?;
|
||||
}
|
||||
let end = r.position();
|
||||
r.varint()?;
|
||||
r.varint()?;
|
||||
finish(&r)?;
|
||||
Ok(payload[..end].to_vec())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Actual packet.write outputs from the checksummed vanilla 1.20.2 and 1.20.3 jars.
|
||||
fn fixture(label: &str, newer: bool) -> Vec<u8> {
|
||||
let rows = [
|
||||
(
|
||||
"score_change",
|
||||
"0b486f72697a6f6e5769726500047769726511",
|
||||
"0b486f72697a6f6e576972650477697265110000",
|
||||
),
|
||||
(
|
||||
"reset_named",
|
||||
"0b486f72697a6f6e57697265010477697265",
|
||||
"0b486f72697a6f6e57697265010477697265",
|
||||
),
|
||||
(
|
||||
"reset_all",
|
||||
"0b486f72697a6f6e576972650100",
|
||||
"0b486f72697a6f6e5769726500",
|
||||
),
|
||||
(
|
||||
"objective0",
|
||||
"047769726500197b2274657874223a2257697265206f626a656374697665227d01",
|
||||
"04776972650008000e57697265206f626a6563746976650100",
|
||||
),
|
||||
("objective1", "047769726501", "047769726501"),
|
||||
(
|
||||
"objective2",
|
||||
"047769726502197b2274657874223a2257697265206f626a656374697665227d01",
|
||||
"04776972650208000e57697265206f626a6563746976650100",
|
||||
),
|
||||
(
|
||||
"resource",
|
||||
"2368747470733a2f2f6578616d706c652e696e76616c69642f686f72697a6f6e2e7a697028303030303030303030303030303030303030303030303030303030303030303030303030303030300101147b2274657874223a2257697265207061636b227d",
|
||||
"000000000000000000000000000000002368747470733a2f2f6578616d706c652e696e76616c69642f686f72697a6f6e2e7a69702830303030303030303030303030303030303030303030303030303030303030303030303030303030010108000957697265207061636b",
|
||||
),
|
||||
(
|
||||
"resource_status0",
|
||||
"00",
|
||||
"0000000000000000000000000000000000",
|
||||
),
|
||||
(
|
||||
"resource_status1",
|
||||
"01",
|
||||
"0000000000000000000000000000000001",
|
||||
),
|
||||
(
|
||||
"resource_status2",
|
||||
"02",
|
||||
"0000000000000000000000000000000002",
|
||||
),
|
||||
(
|
||||
"resource_status3",
|
||||
"03",
|
||||
"0000000000000000000000000000000003",
|
||||
),
|
||||
(
|
||||
"jigsaw",
|
||||
"0000007fffffd040136d696e6563726166743a776972655f6e616d65156d696e6563726166743a776972655f7461726765740f6d696e6563726166743a656d7074790f6d696e6563726166743a73746f6e6507616c69676e6564",
|
||||
"0000007fffffd040136d696e6563726166743a776972655f6e616d65156d696e6563726166743a776972655f7461726765740f6d696e6563726166743a656d7074790f6d696e6563726166743a73746f6e6507616c69676e6564fbffffff0f07",
|
||||
),
|
||||
(
|
||||
"explosion",
|
||||
"3ff40000000000004050000000000000c00600000000000040800000010100ff3e8000003f000000bf400000",
|
||||
"3ff40000000000004050000000000000c00600000000000040800000010100ff3e8000003f000000bf400000021716206d696e6563726166743a656e746974792e67656e657269632e6578706c6f646500",
|
||||
),
|
||||
("resource_status6", "", "0000000000000000000000000000000006"),
|
||||
("resource_status7", "", "0000000000000000000000000000000007"),
|
||||
("resource_status5", "", "0000000000000000000000000000000005"),
|
||||
("resource_status4", "", "0000000000000000000000000000000004"),
|
||||
];
|
||||
let (_, old, new) = rows.iter().find(|(name, _, _)| *name == label).unwrap();
|
||||
let hex = if newer { new } else { old };
|
||||
(0..hex.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
|
||||
.collect()
|
||||
}
|
||||
fn full(session: &Session, state: State, id: i32, old: &[u8]) -> (i32, Vec<u8>) {
|
||||
let (id, body) = super::super::clientbound(session, state, id, old)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
(id, body.unwrap_or_else(|| old.to_vec()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vanilla_score_updates_and_removals_choose_the_current_packet() {
|
||||
let session = Session::new(ServerVersion::V1_20_2);
|
||||
use packet_ids::play::clientbound as cb;
|
||||
for (label, expected_id) in [
|
||||
("score_change", cb::SET_SCORE),
|
||||
("reset_named", cb::RESET_SCORE),
|
||||
("reset_all", cb::RESET_SCORE),
|
||||
] {
|
||||
let old = fixture(label, false);
|
||||
let (id, body) = set_score(&session, &old).unwrap();
|
||||
assert_eq!(id, expected_id, "{label}");
|
||||
assert_eq!(body, fixture(label, true), "{label}");
|
||||
assert_eq!(
|
||||
full(&session, State::Play, cb::SET_SCORE, &old),
|
||||
(id, body.clone())
|
||||
);
|
||||
let packet = play::Clientbound::decode(id, &body).unwrap();
|
||||
match packet {
|
||||
play::Clientbound::SetScore(p) => {
|
||||
assert_eq!(
|
||||
(p.owner.as_str(), p.objective.as_str(), p.score),
|
||||
("HorizonWire", "wire", 17)
|
||||
);
|
||||
assert!(p.display.is_none() && p.number_format.is_none());
|
||||
}
|
||||
play::Clientbound::ResetScore(p) => {
|
||||
assert_eq!(p.owner, "HorizonWire");
|
||||
assert_eq!(
|
||||
p.objective.as_deref(),
|
||||
(label == "reset_named").then_some("wire")
|
||||
);
|
||||
}
|
||||
_ => panic!("score packet"),
|
||||
}
|
||||
let mut trailing = old.clone();
|
||||
trailing.push(0);
|
||||
assert!(set_score(&session, &trailing).is_err());
|
||||
assert!(set_score(&session, &old[..old.len() - 1]).is_err());
|
||||
}
|
||||
assert!(set_score(&session, &[0, 2, 0]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vanilla_objectives_and_pack_prompts_pass_text_before_header_conversion() {
|
||||
let session = Session::new(ServerVersion::V1_20_2);
|
||||
use packet_ids::play::clientbound as cb;
|
||||
for label in ["objective0", "objective1", "objective2"] {
|
||||
let old = fixture(label, false);
|
||||
let (id, body) = full(&session, State::Play, cb::SET_OBJECTIVE, &old);
|
||||
assert_eq!(id, cb::SET_OBJECTIVE);
|
||||
assert_eq!(body, fixture(label, true), "{label}");
|
||||
let p = play::SetObjective::read(&mut Reader::new(&body)).unwrap();
|
||||
assert_eq!(p.name, "wire");
|
||||
if p.method == 1 {
|
||||
assert!(p.display.is_none());
|
||||
} else {
|
||||
let (name, render, format) = p.display.unwrap();
|
||||
assert_eq!(
|
||||
name,
|
||||
horizon_world::nbt::Tag::String("Wire objective".into())
|
||||
);
|
||||
assert_eq!(render, 1);
|
||||
assert!(format.is_none());
|
||||
}
|
||||
}
|
||||
let old = fixture("resource", false);
|
||||
for (state, id) in [
|
||||
(
|
||||
State::Configuration,
|
||||
packet_ids::configuration::clientbound::RESOURCE_PACK_PUSH,
|
||||
),
|
||||
(State::Play, cb::RESOURCE_PACK_PUSH),
|
||||
] {
|
||||
let (mapped, body) = full(&session, state, id, &old);
|
||||
assert_eq!(mapped, id);
|
||||
assert_eq!(body, fixture("resource", true));
|
||||
let p = configuration::ResourcePackPush::read(&mut Reader::new(&body)).unwrap();
|
||||
assert_eq!(
|
||||
(p.id, p.url.as_str(), p.hash.len(), p.required),
|
||||
(PACK_ID, "https://example.invalid/horizon.zip", 40, true)
|
||||
);
|
||||
assert_eq!(
|
||||
p.prompt,
|
||||
Some(horizon_world::nbt::Tag::String("Wire pack".into()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_responses_keep_the_single_slot_across_configuration_and_play() {
|
||||
let session = Session::new(ServerVersion::V1_20_2);
|
||||
assert_eq!(
|
||||
resource_status(&session, &fixture("resource_status3", true)).unwrap(),
|
||||
None
|
||||
);
|
||||
let id = packet_ids::configuration::clientbound::RESOURCE_PACK_PUSH;
|
||||
full(
|
||||
&session,
|
||||
State::Configuration,
|
||||
id,
|
||||
&fixture("resource", false),
|
||||
);
|
||||
for action in 0..=7 {
|
||||
let incoming = fixture(&format!("resource_status{action}"), true);
|
||||
let expected = match action {
|
||||
0..=3 => Some(fixture(&format!("resource_status{action}"), false)),
|
||||
5 | 6 => Some(fixture("resource_status2", false)),
|
||||
_ => None,
|
||||
};
|
||||
assert_eq!(resource_status(&session, &incoming).unwrap(), expected);
|
||||
for (state, id, old_id) in [
|
||||
(
|
||||
State::Configuration,
|
||||
packet_ids::configuration::serverbound::RESOURCE_PACK,
|
||||
5,
|
||||
),
|
||||
(
|
||||
State::Play,
|
||||
packet_ids::play::serverbound::RESOURCE_PACK,
|
||||
39,
|
||||
),
|
||||
] {
|
||||
let actual = super::super::serverbound(&session, state, id, &incoming).unwrap();
|
||||
assert_eq!(actual, expected.clone().map(|body| (old_id, body)));
|
||||
}
|
||||
}
|
||||
// A replacement uses the same slot in both directions; a reply cannot refer to another id.
|
||||
let id = packet_ids::play::clientbound::RESOURCE_PACK_PUSH;
|
||||
full(&session, State::Play, id, &fixture("resource", false));
|
||||
assert_eq!(
|
||||
resource_status(&session, &fixture("resource_status0", true)).unwrap(),
|
||||
Some(vec![0])
|
||||
);
|
||||
let mut other = fixture("resource_status3", true);
|
||||
other[15] = 1;
|
||||
assert_eq!(resource_status(&session, &other).unwrap(), None);
|
||||
let mut invalid = fixture("resource_status3", true);
|
||||
*invalid.last_mut().unwrap() = 8;
|
||||
assert!(resource_status(&session, &invalid).is_err());
|
||||
invalid = fixture("resource_status3", true);
|
||||
invalid.push(0);
|
||||
assert!(resource_status(&session, &invalid).is_err());
|
||||
assert!(resource_status(&session, &invalid[..15]).is_err());
|
||||
let fresh = Session::new(ServerVersion::V1_20_2);
|
||||
assert_eq!(
|
||||
resource_status(&fresh, &fixture("resource_status0", true)).unwrap(),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vanilla_jigsaw_priorities_are_removed_after_the_last_old_field() {
|
||||
let session = Session::new(ServerVersion::V1_20_2);
|
||||
let new = fixture("jigsaw", true);
|
||||
let old = fixture("jigsaw", false);
|
||||
assert_eq!(jigsaw(&new).unwrap(), old);
|
||||
let actual = super::super::serverbound(
|
||||
&session,
|
||||
State::Play,
|
||||
packet_ids::play::serverbound::SET_JIGSAW_BLOCK,
|
||||
&new,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(actual, Some((47, old)));
|
||||
assert!(jigsaw(&new[..new.len() - 1]).is_err());
|
||||
let mut trailing = new;
|
||||
trailing.push(0);
|
||||
assert!(jigsaw(&trailing).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vanilla_explosion_uses_old_defaults_and_preserves_block_destruction() {
|
||||
let session = Session::new(ServerVersion::V1_20_2);
|
||||
let next = Session::new(ServerVersion::V1_20_3);
|
||||
let old = fixture("explosion", false);
|
||||
let body = explosion(&session, &old).unwrap();
|
||||
assert_eq!(
|
||||
body,
|
||||
v1_20_3::explosion(&next, &fixture("explosion", true)).unwrap()
|
||||
);
|
||||
let p = play::Explode::read(&mut Reader::new(&body)).unwrap();
|
||||
assert_eq!(p.center, [1.25, 64.0, -2.75]);
|
||||
assert_eq!((p.radius, p.block_count), (4.0, 1));
|
||||
assert_eq!(p.knockback, Some([0.25, 0.5, -0.75]));
|
||||
assert_eq!(p.particle.id, 29);
|
||||
assert_eq!(
|
||||
p.sound.1,
|
||||
Some(("minecraft:entity.generic.explode".into(), None))
|
||||
);
|
||||
assert!(p.play_sound);
|
||||
let (id, bytes) = session.next_incoming().unwrap();
|
||||
assert_eq!(id, packet_ids::play::clientbound::BLOCK_UPDATE);
|
||||
let p = play::BlockUpdate::read(&mut Reader::new(&bytes)).unwrap();
|
||||
assert_eq!((p.pos, p.state), ([2, 64, -4], 0));
|
||||
assert!(session.next_incoming().is_none());
|
||||
let mut trailing = old.clone();
|
||||
trailing.push(0);
|
||||
assert!(explosion(&session, &trailing).is_err());
|
||||
assert!(explosion(&session, &old[..old.len() - 1]).is_err());
|
||||
assert!(session.next_incoming().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vanilla_open_screen_writers_map_all_old_menu_ids_after_text_normalization() {
|
||||
use packet_ids::play::clientbound as cb;
|
||||
let old_session = Session::new(ServerVersion::V1_20_2);
|
||||
let next_session = Session::new(ServerVersion::V1_20_3);
|
||||
let bytes = |hex: &str| {
|
||||
(0..hex.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
// Actual full ClientboundOpenScreenPacket writers and readers from both vanilla jars.
|
||||
// 765/current insert crafter at7; 764 merchant18 must become19, rather than Loom18.
|
||||
for (name, old, current) in [
|
||||
(
|
||||
"minecraft:generic_9x1",
|
||||
"0700177b2274657874223a22486f72697a6f6e206d656e75227d",
|
||||
"070008000c486f72697a6f6e206d656e75",
|
||||
),
|
||||
(
|
||||
"minecraft:generic_9x2",
|
||||
"0701177b2274657874223a22486f72697a6f6e206d656e75227d",
|
||||
"070108000c486f72697a6f6e206d656e75",
|
||||
),
|
||||
(
|
||||
"minecraft:generic_9x3",
|
||||
"0702177b2274657874223a22486f72697a6f6e206d656e75227d",
|
||||
"070208000c486f72697a6f6e206d656e75",
|
||||
),
|
||||
(
|
||||
"minecraft:generic_9x4",
|
||||
"0703177b2274657874223a22486f72697a6f6e206d656e75227d",
|
||||
"070308000c486f72697a6f6e206d656e75",
|
||||
),
|
||||
(
|
||||
"minecraft:generic_9x5",
|
||||
"0704177b2274657874223a22486f72697a6f6e206d656e75227d",
|
||||
"070408000c486f72697a6f6e206d656e75",
|
||||
),
|
||||
(
|
||||
"minecraft:generic_9x6",
|
||||
"0705177b2274657874223a22486f72697a6f6e206d656e75227d",
|
||||
"070508000c486f72697a6f6e206d656e75",
|
||||
),
|
||||
(
|
||||
"minecraft:generic_3x3",
|
||||
"0706177b2274657874223a22486f72697a6f6e206d656e75227d",
|
||||
"070608000c486f72697a6f6e206d656e75",
|
||||
),
|
||||
(
|
||||
"minecraft:anvil",
|
||||
"0707177b2274657874223a22486f72697a6f6e206d656e75227d",
|
||||
"070808000c486f72697a6f6e206d656e75",
|
||||
),
|
||||
(
|
||||
"minecraft:beacon",
|
||||
"0708177b2274657874223a22486f72697a6f6e206d656e75227d",
|
||||
"070908000c486f72697a6f6e206d656e75",
|
||||
),
|
||||
(
|
||||
"minecraft:blast_furnace",
|
||||
"0709177b2274657874223a22486f72697a6f6e206d656e75227d",
|
||||
"070a08000c486f72697a6f6e206d656e75",
|
||||
),
|
||||
(
|
||||
"minecraft:brewing_stand",
|
||||
"070a177b2274657874223a22486f72697a6f6e206d656e75227d",
|
||||
"070b08000c486f72697a6f6e206d656e75",
|
||||
),
|
||||
(
|
||||
"minecraft:crafting",
|
||||
"070b177b2274657874223a22486f72697a6f6e206d656e75227d",
|
||||
"070c08000c486f72697a6f6e206d656e75",
|
||||
),
|
||||
(
|
||||
"minecraft:enchantment",
|
||||
"070c177b2274657874223a22486f72697a6f6e206d656e75227d",
|
||||
"070d08000c486f72697a6f6e206d656e75",
|
||||
),
|
||||
(
|
||||
"minecraft:furnace",
|
||||
"070d177b2274657874223a22486f72697a6f6e206d656e75227d",
|
||||
"070e08000c486f72697a6f6e206d656e75",
|
||||
),
|
||||
(
|
||||
"minecraft:grindstone",
|
||||
"070e177b2274657874223a22486f72697a6f6e206d656e75227d",
|
||||
"070f08000c486f72697a6f6e206d656e75",
|
||||
),
|
||||
(
|
||||
"minecraft:hopper",
|
||||
"070f177b2274657874223a22486f72697a6f6e206d656e75227d",
|
||||
"071008000c486f72697a6f6e206d656e75",
|
||||
),
|
||||
(
|
||||
"minecraft:lectern",
|
||||
"0710177b2274657874223a22486f72697a6f6e206d656e75227d",
|
||||
"071108000c486f72697a6f6e206d656e75",
|
||||
),
|
||||
(
|
||||
"minecraft:loom",
|
||||
"0711177b2274657874223a22486f72697a6f6e206d656e75227d",
|
||||
"071208000c486f72697a6f6e206d656e75",
|
||||
),
|
||||
(
|
||||
"minecraft:merchant",
|
||||
"0712177b2274657874223a22486f72697a6f6e206d656e75227d",
|
||||
"071308000c486f72697a6f6e206d656e75",
|
||||
),
|
||||
(
|
||||
"minecraft:shulker_box",
|
||||
"0713177b2274657874223a22486f72697a6f6e206d656e75227d",
|
||||
"071408000c486f72697a6f6e206d656e75",
|
||||
),
|
||||
(
|
||||
"minecraft:smithing",
|
||||
"0714177b2274657874223a22486f72697a6f6e206d656e75227d",
|
||||
"071508000c486f72697a6f6e206d656e75",
|
||||
),
|
||||
(
|
||||
"minecraft:smoker",
|
||||
"0715177b2274657874223a22486f72697a6f6e206d656e75227d",
|
||||
"071608000c486f72697a6f6e206d656e75",
|
||||
),
|
||||
(
|
||||
"minecraft:cartography_table",
|
||||
"0716177b2274657874223a22486f72697a6f6e206d656e75227d",
|
||||
"071708000c486f72697a6f6e206d656e75",
|
||||
),
|
||||
(
|
||||
"minecraft:stonecutter",
|
||||
"0717177b2274657874223a22486f72697a6f6e206d656e75227d",
|
||||
"071808000c486f72697a6f6e206d656e75",
|
||||
),
|
||||
] {
|
||||
let old = bytes(old);
|
||||
let current = bytes(current);
|
||||
assert_eq!(
|
||||
full(&old_session, State::Play, cb::OPEN_SCREEN, &old),
|
||||
(cb::OPEN_SCREEN, current.clone()),
|
||||
"{name}"
|
||||
);
|
||||
assert_eq!(
|
||||
full(&next_session, State::Play, cb::OPEN_SCREEN, ¤t),
|
||||
(cb::OPEN_SCREEN, current.clone()),
|
||||
"{name} 765 regression"
|
||||
);
|
||||
let p = play::OpenScreen::read(&mut Reader::new(¤t)).unwrap();
|
||||
assert_eq!(p.container, 7);
|
||||
assert_eq!(
|
||||
p.title,
|
||||
horizon_world::nbt::Tag::String("Horizon menu".into())
|
||||
);
|
||||
if name == "minecraft:merchant" {
|
||||
assert_eq!(p.menu, 19);
|
||||
}
|
||||
let mut trailing = old.clone();
|
||||
trailing.push(0);
|
||||
assert!(
|
||||
super::super::clientbound(&old_session, State::Play, cb::OPEN_SCREEN, &trailing)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
super::super::clientbound(
|
||||
&old_session,
|
||||
State::Play,
|
||||
cb::OPEN_SCREEN,
|
||||
&old[..old.len() - 1]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
let mut invalid = Writer::new();
|
||||
play::OpenScreen::write(
|
||||
&play::OpenScreen {
|
||||
container: 7,
|
||||
menu: 24,
|
||||
title: horizon_world::nbt::Tag::String("Horizon menu".into()),
|
||||
},
|
||||
&mut invalid,
|
||||
);
|
||||
assert!(clientbound(&old_session, State::Play, cb::OPEN_SCREEN, &invalid.data).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,863 @@
|
||||
//! The component fields of protocol 764, before 765 changed their wire codec to NBT.
|
||||
//! Other NBT and item bytes retain their old layout for the following translation steps.
|
||||
use crate::{
|
||||
Error, Result, State,
|
||||
adapter::Session,
|
||||
buf::{Codec, Reader, Writer},
|
||||
ids,
|
||||
packets::play,
|
||||
};
|
||||
use horizon_world::{datafix::json, nbt::Tag};
|
||||
|
||||
pub(crate) fn read_component(r: &mut Reader) -> Result<Tag> {
|
||||
let value = r.string(262144)?;
|
||||
let mut value = json::parse_prefix_strict(&value.encode_utf16().collect::<Vec<_>>())
|
||||
.ok_or_else(|| Error::new("invalid legacy component JSON"))?;
|
||||
if matches!(&value, json::Json::Arr(values) if values.is_empty()) {
|
||||
return Err(Error::new("empty legacy component list"));
|
||||
}
|
||||
component_json(&mut value)?;
|
||||
json::to_nbt(&value).ok_or_else(|| Error::new("null legacy component"))
|
||||
}
|
||||
|
||||
fn key(key: &[u16], name: &str) -> bool {
|
||||
key.iter().copied().eq(name.encode_utf16())
|
||||
}
|
||||
|
||||
/// The old Gson codec writes an unstyled literal as {"text":"..."}; the 765 codec
|
||||
/// uses a string. Hover-item counts keep their value types; primitive translation
|
||||
/// arguments become strings like the old Gson serializer, retaining their spelling.
|
||||
fn component_json(value: &mut json::Json) -> Result<()> {
|
||||
use json::Json;
|
||||
match value {
|
||||
Json::Num(v) => *value = Json::string(v),
|
||||
Json::Bool(v) => *value = Json::string(if *v { "true" } else { "false" }),
|
||||
Json::Arr(values) => {
|
||||
for value in values {
|
||||
component_json(value)?;
|
||||
}
|
||||
}
|
||||
Json::Obj(values) => {
|
||||
if !values.iter().any(|(name, _)| {
|
||||
["text", "translate", "score", "selector", "keybind", "nbt"]
|
||||
.iter()
|
||||
.any(|content| key(name, content))
|
||||
}) {
|
||||
return Err(Error::new("missing legacy component content"));
|
||||
}
|
||||
for (name, child) in values.iter_mut() {
|
||||
if key(name, "extra") || key(name, "separator") {
|
||||
component_json(child)?;
|
||||
} else if key(name, "with") {
|
||||
if let Json::Arr(args) = child {
|
||||
for arg in args {
|
||||
if matches!(arg, Json::Null) {
|
||||
return Err(Error::new("null legacy translation argument"));
|
||||
}
|
||||
component_json(arg)?;
|
||||
}
|
||||
}
|
||||
} else if key(name, "text") {
|
||||
if !matches!(child, Json::Str(_) | Json::Num(_) | Json::Bool(_)) {
|
||||
return Err(Error::new("invalid legacy literal text"));
|
||||
}
|
||||
component_json(child)?;
|
||||
} else if [
|
||||
"bold",
|
||||
"italic",
|
||||
"underlined",
|
||||
"strikethrough",
|
||||
"obfuscated",
|
||||
]
|
||||
.iter()
|
||||
.any(|flag| key(name, flag))
|
||||
{
|
||||
let flag = match child {
|
||||
Json::Bool(value) => *value,
|
||||
Json::Str(value) => {
|
||||
String::from_utf16_lossy(value).eq_ignore_ascii_case("true")
|
||||
}
|
||||
Json::Num(_) => false,
|
||||
_ => return Err(Error::new("invalid legacy style flag")),
|
||||
};
|
||||
*child = Json::Bool(flag);
|
||||
} else if key(name, "hoverEvent") {
|
||||
let action = child.get("action").and_then(|v| match v {
|
||||
Json::Str(s) => Some(String::from_utf16_lossy(s)),
|
||||
_ => None,
|
||||
});
|
||||
if let Json::Obj(fields) = child {
|
||||
for (name, content) in fields {
|
||||
if !key(name, "contents") && !key(name, "value") {
|
||||
continue;
|
||||
}
|
||||
match action.as_deref() {
|
||||
Some("show_text") => component_json(content)?,
|
||||
Some("show_entity") => {
|
||||
if let Json::Obj(entity) = content {
|
||||
for (name, component) in entity {
|
||||
if key(name, "name") {
|
||||
component_json(component)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if values.len() == 1
|
||||
&& key(&values[0].0, "text")
|
||||
&& matches!(&values[0].1, Json::Str(_))
|
||||
{
|
||||
*value = std::mem::replace(&mut values[0].1, Json::Null);
|
||||
}
|
||||
}
|
||||
Json::Null => return Err(Error::new("null legacy component")),
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copy primitive spans exactly, replacing only explicitly identified component fields.
|
||||
struct Fields<'a> {
|
||||
r: Reader<'a>,
|
||||
w: Writer,
|
||||
}
|
||||
impl<'a> Fields<'a> {
|
||||
fn new(payload: &'a [u8]) -> Self {
|
||||
Self {
|
||||
r: Reader::new(payload),
|
||||
w: Writer::new(),
|
||||
}
|
||||
}
|
||||
fn copy<T>(&mut self, read: impl FnOnce(&mut Reader<'a>) -> Result<T>) -> Result<T> {
|
||||
let start = self.r.position();
|
||||
let value = read(&mut self.r)?;
|
||||
self.w.bytes(self.r.since(start));
|
||||
Ok(value)
|
||||
}
|
||||
fn int(&mut self) -> Result<i32> {
|
||||
self.copy(Reader::varint)
|
||||
}
|
||||
fn byte(&mut self) -> Result<u8> {
|
||||
self.copy(Reader::u8)
|
||||
}
|
||||
fn flag(&mut self) -> Result<bool> {
|
||||
self.copy(Reader::bool)
|
||||
}
|
||||
fn string(&mut self) -> Result<String> {
|
||||
self.copy(|r| r.string(32767))
|
||||
}
|
||||
fn count(&mut self) -> Result<usize> {
|
||||
self.copy(|r| r.count(65536))
|
||||
}
|
||||
fn bytes(&mut self, n: usize) -> Result<()> {
|
||||
self.copy(|r| r.bytes(n).map(|_| ()))
|
||||
}
|
||||
fn byte_array(&mut self) -> Result<()> {
|
||||
self.copy(|r| {
|
||||
let n = r.count(r.remaining())?;
|
||||
r.bytes(n)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
fn component(&mut self) -> Result<()> {
|
||||
self.w.nbt(&read_component(&mut self.r)?);
|
||||
Ok(())
|
||||
}
|
||||
fn optional_component(&mut self) -> Result<()> {
|
||||
if self.flag()? {
|
||||
self.component()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn optional_string(&mut self) -> Result<()> {
|
||||
if self.flag()? {
|
||||
self.string()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn strings(&mut self) -> Result<()> {
|
||||
for _ in 0..self.count()? {
|
||||
self.string()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn bound_chat_type(&mut self) -> Result<()> {
|
||||
self.int()?;
|
||||
self.component()?;
|
||||
self.optional_component()
|
||||
}
|
||||
fn resource_pack(&mut self) -> Result<()> {
|
||||
self.string()?;
|
||||
self.copy(|r| r.string(40))?;
|
||||
self.flag()?;
|
||||
self.optional_component()
|
||||
}
|
||||
fn player_chat(&mut self) -> Result<()> {
|
||||
self.bytes(16)?;
|
||||
self.int()?;
|
||||
if self.flag()? {
|
||||
self.bytes(256)?;
|
||||
}
|
||||
self.copy(|r| r.string(256))?;
|
||||
self.bytes(16)?; // timestamp and salt
|
||||
for _ in 0..self.count()? {
|
||||
match self.int()? {
|
||||
0 => self.bytes(256)?,
|
||||
id if id > 0 => {}
|
||||
_ => return Err(Error::new("negative legacy signature reference")),
|
||||
}
|
||||
}
|
||||
self.optional_component()?;
|
||||
self.copy(play::FilterMask::read)?;
|
||||
self.bound_chat_type()
|
||||
}
|
||||
fn player_info(&mut self) -> Result<()> {
|
||||
let actions = self.byte()?;
|
||||
if actions & !63 != 0 {
|
||||
return Err(Error::new("invalid legacy player-info actions"));
|
||||
}
|
||||
for _ in 0..self.count()? {
|
||||
self.bytes(16)?;
|
||||
if actions & 1 != 0 {
|
||||
self.copy(|r| r.string(16))?;
|
||||
for _ in 0..self.count()? {
|
||||
self.string()?;
|
||||
self.string()?;
|
||||
self.optional_string()?;
|
||||
}
|
||||
}
|
||||
if actions & 2 != 0 && self.flag()? {
|
||||
self.bytes(24)?; // chat-session UUID and key expiry
|
||||
self.byte_array()?;
|
||||
self.byte_array()?;
|
||||
}
|
||||
if actions & 4 != 0 {
|
||||
self.int()?;
|
||||
}
|
||||
if actions & 8 != 0 {
|
||||
self.flag()?;
|
||||
}
|
||||
if actions & 16 != 0 {
|
||||
self.int()?;
|
||||
}
|
||||
if actions & 32 != 0 {
|
||||
self.optional_component()?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn map(&mut self) -> Result<()> {
|
||||
self.int()?;
|
||||
self.byte()?;
|
||||
self.flag()?;
|
||||
if self.flag()? {
|
||||
for _ in 0..self.count()? {
|
||||
self.int()?;
|
||||
self.bytes(3)?;
|
||||
self.optional_component()?;
|
||||
}
|
||||
}
|
||||
if self.byte()? != 0 {
|
||||
self.bytes(3)?;
|
||||
self.byte_array()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn team(&mut self) -> Result<()> {
|
||||
self.string()?;
|
||||
let method = self.byte()?;
|
||||
if method > 4 {
|
||||
return Err(Error::new("invalid legacy team method"));
|
||||
}
|
||||
if matches!(method, 0 | 2) {
|
||||
self.component()?;
|
||||
self.byte()?;
|
||||
self.string()?;
|
||||
self.string()?;
|
||||
self.int()?;
|
||||
self.component()?;
|
||||
self.component()?;
|
||||
}
|
||||
if matches!(method, 0 | 3 | 4) {
|
||||
self.strings()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn advancements(&mut self) -> Result<()> {
|
||||
self.flag()?;
|
||||
for _ in 0..self.count()? {
|
||||
self.string()?;
|
||||
self.optional_string()?;
|
||||
if self.flag()? {
|
||||
self.component()?;
|
||||
self.component()?;
|
||||
// An old item is present + id + signed-byte count + unnamed NBT. Copy it
|
||||
// raw: the later shared reader upgrades and renumbers it exactly once.
|
||||
self.copy(|r| {
|
||||
if r.bool()? {
|
||||
r.varint()?;
|
||||
r.i8()?;
|
||||
r.nbt()?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
self.int()?;
|
||||
let flags = self.copy(Reader::i32)?;
|
||||
if flags & 1 != 0 {
|
||||
self.string()?;
|
||||
}
|
||||
self.bytes(8)?; // the old display's x/y position
|
||||
}
|
||||
for _ in 0..self.count()? {
|
||||
self.strings()?;
|
||||
}
|
||||
self.flag()?; // sends telemetry
|
||||
}
|
||||
self.strings()?;
|
||||
for _ in 0..self.count()? {
|
||||
self.string()?;
|
||||
for _ in 0..self.count()? {
|
||||
self.string()?;
|
||||
if self.flag()? {
|
||||
self.bytes(8)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn finish(self) -> Result<Vec<u8>> {
|
||||
if self.r.remaining() != 0 {
|
||||
return Err(Error::new("trailing legacy text packet bytes"));
|
||||
}
|
||||
Ok(self.w.data)
|
||||
}
|
||||
}
|
||||
|
||||
/// `id` is already the pinned packet id; the body is still protocol 764.
|
||||
pub(crate) fn clientbound(
|
||||
_session: &Session,
|
||||
state: State,
|
||||
id: i32,
|
||||
payload: &[u8],
|
||||
) -> Result<Option<Vec<u8>>> {
|
||||
let mut f = Fields::new(payload);
|
||||
if state == State::Configuration {
|
||||
match id {
|
||||
ids::configuration::clientbound::DISCONNECT => f.component()?,
|
||||
ids::configuration::clientbound::RESOURCE_PACK_PUSH => f.resource_pack()?,
|
||||
_ => return Ok(None),
|
||||
}
|
||||
} else if state == State::Play {
|
||||
use ids::play::clientbound as cb;
|
||||
match id {
|
||||
cb::DISCONNECT
|
||||
| cb::SET_ACTION_BAR_TEXT
|
||||
| cb::SET_SUBTITLE_TEXT
|
||||
| cb::SET_TITLE_TEXT => f.component()?,
|
||||
cb::SYSTEM_CHAT => {
|
||||
f.component()?;
|
||||
f.flag()?;
|
||||
}
|
||||
cb::TAB_LIST => {
|
||||
f.component()?;
|
||||
f.component()?;
|
||||
}
|
||||
cb::RESOURCE_PACK_PUSH => f.resource_pack()?,
|
||||
cb::SERVER_DATA => {
|
||||
f.component()?;
|
||||
if f.flag()? {
|
||||
f.byte_array()?;
|
||||
}
|
||||
f.flag()?;
|
||||
}
|
||||
cb::OPEN_SCREEN => {
|
||||
f.int()?;
|
||||
f.int()?;
|
||||
f.component()?;
|
||||
}
|
||||
cb::PLAYER_COMBAT_KILL => {
|
||||
f.int()?;
|
||||
f.component()?;
|
||||
}
|
||||
cb::BOSS_EVENT => {
|
||||
f.bytes(16)?;
|
||||
match f.int()? {
|
||||
0 => {
|
||||
f.component()?;
|
||||
f.bytes(4)?;
|
||||
f.int()?;
|
||||
f.int()?;
|
||||
f.byte()?;
|
||||
}
|
||||
1 => {}
|
||||
2 => f.bytes(4)?,
|
||||
3 => f.component()?,
|
||||
4 => {
|
||||
f.int()?;
|
||||
f.int()?;
|
||||
}
|
||||
5 => {
|
||||
f.byte()?;
|
||||
}
|
||||
_ => return Err(Error::new("invalid legacy boss operation")),
|
||||
}
|
||||
}
|
||||
cb::COMMAND_SUGGESTIONS => {
|
||||
f.int()?;
|
||||
f.int()?;
|
||||
f.int()?;
|
||||
for _ in 0..f.count()? {
|
||||
f.string()?;
|
||||
f.optional_component()?;
|
||||
}
|
||||
}
|
||||
cb::DISGUISED_CHAT => {
|
||||
f.component()?;
|
||||
f.bound_chat_type()?;
|
||||
}
|
||||
cb::PLAYER_CHAT => f.player_chat()?,
|
||||
cb::PLAYER_INFO_UPDATE => f.player_info()?,
|
||||
cb::MAP_ITEM_DATA => f.map()?,
|
||||
cb::SET_OBJECTIVE => {
|
||||
f.string()?;
|
||||
match f.byte()? {
|
||||
0 | 2 => {
|
||||
f.component()?;
|
||||
f.int()?;
|
||||
}
|
||||
1 => {}
|
||||
_ => return Err(Error::new("invalid legacy objective method")),
|
||||
}
|
||||
}
|
||||
cb::SET_PLAYER_TEAM => f.team()?,
|
||||
cb::UPDATE_ADVANCEMENTS => f.advancements()?,
|
||||
_ => return Ok(None),
|
||||
}
|
||||
} else {
|
||||
// Login-disconnect deliberately retains its JSON string in 765, too.
|
||||
return Ok(None);
|
||||
}
|
||||
f.finish().map(Some)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::adapter::{ServerVersion, ids as context};
|
||||
|
||||
fn bytes(hex: &str) -> Vec<u8> {
|
||||
(0..hex.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rich_components_match_nbt_independently_accepted_by_the_vanilla_765_reader() {
|
||||
// Real JsonOps/NbtOps bytes decoded by vanilla readComponentTrusted; Component.equals
|
||||
// independently confirms mixed lists, style booleans and both structured hover forms.
|
||||
for (name, json, nbt) in [
|
||||
(
|
||||
"literal",
|
||||
"\"Horizon wire\"",
|
||||
"08000c486f72697a6f6e2077697265",
|
||||
),
|
||||
(
|
||||
"list",
|
||||
"[\"First\",{\"text\":\"Second\",\"bold\":true},{\"text\":\"Third\",\"italic\":false}]",
|
||||
"090a0000000308000000054669727374000800047465787400065365636f6e64010004626f6c64010008000474657874000554686972640100066974616c69630000",
|
||||
),
|
||||
(
|
||||
"style",
|
||||
"{\"text\":\"Root\",\"bold\":true,\"italic\":false,\"color\":\"gold\",\"extra\":[\" plain\",{\"translate\":\"chat.type.text\",\"with\":[{\"text\":\"Wire\",\"bold\":false},\"hello\"]}]}",
|
||||
"0a080005636f6c6f720004676f6c6409000565787472610a00000002080000000620706c61696e00090004776974680a0000000208000474657874000457697265010004626f6c640000080000000568656c6c6f000800097472616e736c617465000e636861742e747970652e7465787400080004746578740004526f6f74010004626f6c64010100066974616c69630000",
|
||||
),
|
||||
(
|
||||
"hover_item",
|
||||
"{\"text\":\"Item\",\"hoverEvent\":{\"action\":\"show_item\",\"contents\":{\"id\":\"minecraft:stone\",\"count\":2,\"tag\":\"{HorizonWire:1b}\"}}}",
|
||||
"0a0a000a686f7665724576656e740a0008636f6e74656e7473010005636f756e74020800026964000f6d696e6563726166743a73746f6e6508000374616700107b486f72697a6f6e576972653a31627d00080006616374696f6e000973686f775f6974656d000800047465787400044974656d00",
|
||||
),
|
||||
(
|
||||
"hover_entity",
|
||||
"{\"text\":\"Entity\",\"hoverEvent\":{\"action\":\"show_entity\",\"contents\":{\"type\":\"minecraft:pig\",\"id\":\"00000000-0000-0000-0000-000000000001\",\"name\":{\"text\":\"Wire entity\",\"bold\":true}}}}",
|
||||
"0a0a000a686f7665724576656e740a0008636f6e74656e74730a00046e616d6508000474657874000b5769726520656e74697479010004626f6c6401000800026964002430303030303030302d303030302d303030302d303030302d30303030303030303030303108000474797065000d6d696e6563726166743a70696700080006616374696f6e000b73686f775f656e7469747900080004746578740006456e7469747900",
|
||||
),
|
||||
] {
|
||||
let mut old = Writer::new();
|
||||
old.string(json);
|
||||
old.u8(99);
|
||||
let mut r = Reader::new(&old.data);
|
||||
let actual = read_component(&mut r).unwrap();
|
||||
let expected = Reader::new(&bytes(nbt)).nbt().unwrap();
|
||||
assert_eq!(actual, expected, "{name}");
|
||||
assert_eq!(r.u8().unwrap(), 99, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_text_packet_family_matches_actual_paired_packet_codecs() {
|
||||
use ids::play::clientbound as cb;
|
||||
let session = Session::new(ServerVersion::V1_20_2);
|
||||
// Actual vanilla packet constructors read these synthetic payloads, consume every byte,
|
||||
// then the real packet writers encode them independently on 764 and 765.
|
||||
for (name, id, old, newer) in [
|
||||
(
|
||||
"disconnect",
|
||||
cb::DISCONNECT,
|
||||
"157b2274657874223a22646973636f6e6e656374227d",
|
||||
"08000a646973636f6e6e656374",
|
||||
),
|
||||
(
|
||||
"actionbar",
|
||||
cb::SET_ACTION_BAR_TEXT,
|
||||
"147b2274657874223a22616374696f6e626172227d",
|
||||
"080009616374696f6e626172",
|
||||
),
|
||||
(
|
||||
"title",
|
||||
cb::SET_TITLE_TEXT,
|
||||
"107b2274657874223a227469746c65227d",
|
||||
"0800057469746c65",
|
||||
),
|
||||
(
|
||||
"subtitle",
|
||||
cb::SET_SUBTITLE_TEXT,
|
||||
"137b2274657874223a227375627469746c65227d",
|
||||
"0800087375627469746c65",
|
||||
),
|
||||
(
|
||||
"system",
|
||||
cb::SYSTEM_CHAT,
|
||||
"117b2274657874223a2273797374656d227d01",
|
||||
"08000673797374656d01",
|
||||
),
|
||||
(
|
||||
"tab",
|
||||
cb::TAB_LIST,
|
||||
"117b2274657874223a22686561646572227d117b2274657874223a22666f6f746572227d",
|
||||
"080006686561646572080006666f6f746572",
|
||||
),
|
||||
(
|
||||
"boss_add",
|
||||
cb::BOSS_EVENT,
|
||||
"00000000000000000000000000000001000f7b2274657874223a22626f7373227d3f000000020305",
|
||||
"0000000000000000000000000000000100080004626f73733f000000020305",
|
||||
),
|
||||
(
|
||||
"boss_name",
|
||||
cb::BOSS_EVENT,
|
||||
"0000000000000000000000000000000103127b2274657874223a2272656e616d6564227d",
|
||||
"000000000000000000000000000000010308000772656e616d6564",
|
||||
),
|
||||
(
|
||||
"suggestions",
|
||||
cb::COMMAND_SUGGESTIONS,
|
||||
"2902030205666972737401127b2274657874223a22746f6f6c746970227d067365636f6e6400",
|
||||
"2902030205666972737401080007746f6f6c746970067365636f6e6400",
|
||||
),
|
||||
(
|
||||
"disguised",
|
||||
cb::DISGUISED_CHAT,
|
||||
"127b2274657874223a226d657373616765227d01117b2274657874223a2273656e646572227d01117b2274657874223a22746172676574227d",
|
||||
"0800076d6573736167650108000673656e64657201080006746172676574",
|
||||
),
|
||||
(
|
||||
"map",
|
||||
cb::MAP_ITEM_DATA,
|
||||
"11020101020201fe0701147b2274657874223a226d6170206c6162656c227d030304080001010203012a",
|
||||
"11020101020201fe07010800096d6170206c6162656c030304080001010203012a",
|
||||
),
|
||||
(
|
||||
"open",
|
||||
cb::OPEN_SCREEN,
|
||||
"05030f7b2274657874223a226d656e75227d",
|
||||
"05030800046d656e75",
|
||||
),
|
||||
(
|
||||
"combat",
|
||||
cb::PLAYER_COMBAT_KILL,
|
||||
"2a107b2274657874223a226465617468227d",
|
||||
"2a0800056465617468",
|
||||
),
|
||||
(
|
||||
"player_chat",
|
||||
cb::PLAYER_CHAT,
|
||||
"000000000000000000000000000000010201000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff105369676e6564207769726520626f647900000000000004d2000000000000162e020100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff01137b2274657874223a22756e7369676e6564227d0201000000000000000500117b2274657874223a2273656e646572227d01117b2274657874223a22746172676574227d",
|
||||
"000000000000000000000000000000010201000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff105369676e6564207769726520626f647900000000000004d2000000000000162e020100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff01080008756e7369676e6564020100000000000000050008000673656e64657201080006746172676574",
|
||||
),
|
||||
(
|
||||
"player_chat_plain",
|
||||
cb::PLAYER_CHAT,
|
||||
"0000000000000000000000000000000203000a506c61696e20626f64790000000000000000000000000000000000000002117b2274657874223a2273656e646572227d00",
|
||||
"0000000000000000000000000000000203000a506c61696e20626f6479000000000000000000000000000000000000000208000673656e64657200",
|
||||
),
|
||||
(
|
||||
"player_info",
|
||||
cb::PLAYER_INFO_UPDATE,
|
||||
"3f01000000000000000000000000000000010b486f72697a6f6e57697265010874657874757265730973796e746865746963000001012a01127b2274657874223a22646973706c6179227d",
|
||||
"3f01000000000000000000000000000000010b486f72697a6f6e57697265010874657874757265730973796e746865746963000001012a01080007646973706c6179",
|
||||
),
|
||||
(
|
||||
"player_info_session",
|
||||
cb::PLAYER_INFO_UPDATE,
|
||||
"220100000000000000000000000000000002010000000000000000000000000000000300000000000004d2a60230820122300d06092a864886f70d01010105000382010f003082010a0282010100d789f8cca32bffe5d2bb20630827fcb1c71e5f2d1f31decc702867e02b55a846f1fe5fb94357794970feb1861e1a45c46a7800b02d91c8fcd3f6a0a7b7f16c6da9796263ae4c38b423df331f81b4382e0b6aa5572b360d121d72c6541d360774f4bcb0040c698f2d8bc5ed8b933324962eb482bf3fb67b693395aeca5797f0e338173d56e0b018557da35e8bc4b5c356ecfd048d83f7fb61175f3763f6b1520c577e2dd4aa5e3c0f9d4c6ee8032a27fe79ab4b35b6a5ded0f0a79a2b9246c5786792695cb4395452259ec83c0435decef3d3e0a17ab1d11e3ebd692317c869a1566f73c566393101534f64af4d7abfd673cbcd6f8c1e10f104e4317e3afaf72b02030100010301020300",
|
||||
"220100000000000000000000000000000002010000000000000000000000000000000300000000000004d2a60230820122300d06092a864886f70d01010105000382010f003082010a0282010100d789f8cca32bffe5d2bb20630827fcb1c71e5f2d1f31decc702867e02b55a846f1fe5fb94357794970feb1861e1a45c46a7800b02d91c8fcd3f6a0a7b7f16c6da9796263ae4c38b423df331f81b4382e0b6aa5572b360d121d72c6541d360774f4bcb0040c698f2d8bc5ed8b933324962eb482bf3fb67b693395aeca5797f0e338173d56e0b018557da35e8bc4b5c356ecfd048d83f7fb61175f3763f6b1520c577e2dd4aa5e3c0f9d4c6ee8032a27fe79ab4b35b6a5ded0f0a79a2b9246c5786792695cb4395452259ec83c0435decef3d3e0a17ab1d11e3ebd692317c869a1566f73c566393101534f64af4d7abfd673cbcd6f8c1e10f104e4317e3afaf72b02030100010301020300",
|
||||
),
|
||||
(
|
||||
"server_data",
|
||||
cb::SERVER_DATA,
|
||||
"0f7b2274657874223a226d6f7464227d010301020301",
|
||||
"0800046d6f7464010301020301",
|
||||
),
|
||||
(
|
||||
"objective0",
|
||||
cb::SET_OBJECTIVE,
|
||||
"047769726500147b2274657874223a226f626a656374697665227d01",
|
||||
"0477697265000800096f626a65637469766501",
|
||||
),
|
||||
(
|
||||
"objective2",
|
||||
cb::SET_OBJECTIVE,
|
||||
"047769726502147b2274657874223a226f626a656374697665227d01",
|
||||
"0477697265020800096f626a65637469766501",
|
||||
),
|
||||
(
|
||||
"team",
|
||||
cb::SET_PLAYER_TEAM,
|
||||
"0477697265000f7b2274657874223a227465616d227d0306616c776179730e707573684f746865725465616d7304117b2274657874223a22707265666978227d117b2274657874223a22737566666978227d02055769726541055769726542",
|
||||
"0477697265000800047465616d0306616c776179730e707573684f746865725465616d730408000670726566697808000673756666697802055769726541055769726542",
|
||||
),
|
||||
(
|
||||
"advancements",
|
||||
cb::UPDATE_ADVANCEMENTS,
|
||||
"000108776972653a6164760109776972653a726f6f7401147b2274657874223a22616476207469746c65227d1a7b2274657874223a22616476206465736372697074696f6e227d0101020a0100045769726501000100000007396d696e6563726166743a74657874757265732f6775692f616476616e63656d656e74732f6261636b67726f756e64732f73746f6e652e706e673fa00000c02000000201036f6e65010374776f01010c776972653a72656d6f7665640108776972653a616476020374776f00036f6e650100000000000003e8",
|
||||
"000108776972653a6164760109776972653a726f6f7401080009616476207469746c6508000f616476206465736372697074696f6e0101020a0100045769726501000100000007396d696e6563726166743a74657874757265732f6775692f616476616e63656d656e74732f6261636b67726f756e64732f73746f6e652e706e673fa00000c02000000201036f6e65010374776f01010c776972653a72656d6f7665640108776972653a616476020374776f00036f6e650100000000000003e8",
|
||||
),
|
||||
(
|
||||
"resource",
|
||||
cb::RESOURCE_PACK_PUSH,
|
||||
"2068747470733a2f2f6578616d706c652e696e76616c69642f7061636b2e7a697028303030303030303030303030303030303030303030303030303030303030303030303030303030300101167b2274657874223a227061636b2070726f6d7074227d",
|
||||
"2068747470733a2f2f6578616d706c652e696e76616c69642f7061636b2e7a69702830303030303030303030303030303030303030303030303030303030303030303030303030303030010108000b7061636b2070726f6d7074",
|
||||
),
|
||||
] {
|
||||
let old = bytes(old);
|
||||
assert_eq!(
|
||||
clientbound(&session, State::Play, id, &old)
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
bytes(newer),
|
||||
"{name}"
|
||||
);
|
||||
assert!(
|
||||
clientbound(&session, State::Play, id, &old[..old.len() - 1]).is_err(),
|
||||
"{name}"
|
||||
);
|
||||
let mut trailing = old;
|
||||
trailing.push(99);
|
||||
assert!(
|
||||
clientbound(&session, State::Play, id, &trailing).is_err(),
|
||||
"{name}"
|
||||
);
|
||||
// The two shared common packets have the same body in configuration.
|
||||
let configuration_id = match name {
|
||||
"disconnect" => Some(ids::configuration::clientbound::DISCONNECT),
|
||||
"resource" => Some(ids::configuration::clientbound::RESOURCE_PACK_PUSH),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(id) = configuration_id {
|
||||
assert_eq!(
|
||||
clientbound(
|
||||
&session,
|
||||
State::Configuration,
|
||||
id,
|
||||
&trailing[..trailing.len() - 1]
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
bytes(newer)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_text_uses_the_field_codec_and_login_stays_json() {
|
||||
use crate::entity_data::{EntityData, Value, serializer};
|
||||
let session = Session::new(ServerVersion::V1_20_2);
|
||||
for kind in [serializer::COMPONENT, serializer::OPTIONAL_COMPONENT] {
|
||||
let mut old = Writer::new();
|
||||
old.u8(2);
|
||||
old.varint(kind);
|
||||
if kind == serializer::OPTIONAL_COMPONENT {
|
||||
old.bool(true);
|
||||
}
|
||||
old.string(r#"{"text":"Metadata"}"#);
|
||||
old.u8(255);
|
||||
old.u8(99);
|
||||
let mut r = Reader::new(&old.data);
|
||||
let entries = {
|
||||
let _up = context::renumbering(session.version.ids().unwrap(), context::Way::Up);
|
||||
EntityData::read(&mut r).unwrap()
|
||||
};
|
||||
let text = Tag::String("Metadata".into());
|
||||
assert_eq!(
|
||||
entries,
|
||||
vec![(
|
||||
2,
|
||||
kind,
|
||||
if kind == serializer::COMPONENT {
|
||||
Value::Text(text)
|
||||
} else {
|
||||
Value::OptText(Some(text))
|
||||
}
|
||||
)]
|
||||
);
|
||||
assert_eq!(r.u8().unwrap(), 99);
|
||||
}
|
||||
let mut login = Writer::new();
|
||||
login.string(r#"{"text":"Login remains JSON"}"#);
|
||||
assert!(
|
||||
clientbound(
|
||||
&session,
|
||||
State::Login,
|
||||
ids::login::clientbound::LOGIN_DISCONNECT,
|
||||
&login.data
|
||||
)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_syntax_matches_the_vanilla_gson_reader_boundary() {
|
||||
let parse = |json: &str| {
|
||||
let mut w = Writer::new();
|
||||
w.string(json);
|
||||
read_component(&mut Reader::new(&w.data))
|
||||
};
|
||||
// Actual 764 FriendlyByteBuf.readComponent accepts one strict value and ignores
|
||||
// following input. It coerces primitive literals and Style's primitive booleans.
|
||||
for (json, expected) in [
|
||||
(r#""x" junk"#, "x"),
|
||||
(r#"{"text":"x"}junk"#, "x"),
|
||||
("1 2", "1"),
|
||||
("42", "42"),
|
||||
("true", "true"),
|
||||
("TRUE", "true"),
|
||||
("true:", "true"),
|
||||
("true]", "true"),
|
||||
] {
|
||||
assert_eq!(parse(json).unwrap(), Tag::String(expected.into()), "{json}");
|
||||
}
|
||||
for (value, expected) in [
|
||||
(r#""true""#, 1),
|
||||
(r#""TRUE""#, 1),
|
||||
(r#""false""#, 0),
|
||||
(r#""anything""#, 0),
|
||||
("1", 0),
|
||||
] {
|
||||
let tag = parse(&format!(r#"{{"text":"x","bold":{value}}}"#)).unwrap();
|
||||
assert_eq!(tag.get("bold"), Some(&Tag::Byte(expected)), "{value}");
|
||||
}
|
||||
for json in [
|
||||
r#"{text:'x'}"#,
|
||||
r#"/* c */{"text":"x"}"#,
|
||||
r#"["a",]"#,
|
||||
r#"{"text":"x",}"#,
|
||||
"truejunk",
|
||||
"falsex",
|
||||
"nullx",
|
||||
"12junk",
|
||||
"1.2x",
|
||||
"true/comment",
|
||||
"null",
|
||||
"[]",
|
||||
"{}junk",
|
||||
"[]junk",
|
||||
r#"{"text":"x","bold":null}"#,
|
||||
r#"{"translate":"wire.key","with":[null]}"#,
|
||||
] {
|
||||
assert!(parse(json).is_err(), "{json}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advancement_item_nbt_is_copied_without_text_conversion() {
|
||||
let session = Session::new(ServerVersion::V1_20_2);
|
||||
let opaque = Tag::compound([("json", Tag::String(r#"{"text":"Opaque item tag"}"#.into()))]);
|
||||
let mut old = Writer::new();
|
||||
old.bool(false);
|
||||
old.varint(1);
|
||||
old.string("wire:opaque");
|
||||
old.bool(false);
|
||||
old.bool(true);
|
||||
old.string(r#"{"text":"Title"}"#);
|
||||
old.string(r#"{"text":"Description"}"#);
|
||||
old.bool(true);
|
||||
old.varint(1);
|
||||
old.i8(2);
|
||||
old.nbt(&opaque);
|
||||
old.varint(0);
|
||||
old.i32(0);
|
||||
old.f32(0.0);
|
||||
old.f32(0.0);
|
||||
old.varint(0);
|
||||
old.bool(false);
|
||||
old.varint(0);
|
||||
old.varint(0);
|
||||
let body = clientbound(
|
||||
&session,
|
||||
State::Play,
|
||||
ids::play::clientbound::UPDATE_ADVANCEMENTS,
|
||||
&old.data,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let mut r = Reader::new(&body);
|
||||
assert!(!r.bool().unwrap());
|
||||
assert_eq!(r.varint().unwrap(), 1);
|
||||
assert_eq!(r.string(32767).unwrap(), "wire:opaque");
|
||||
assert!(!r.bool().unwrap());
|
||||
assert!(r.bool().unwrap());
|
||||
assert_eq!(r.nbt().unwrap(), Tag::String("Title".into()));
|
||||
assert_eq!(r.nbt().unwrap(), Tag::String("Description".into()));
|
||||
assert!(r.bool().unwrap());
|
||||
assert_eq!(r.varint().unwrap(), 1);
|
||||
assert_eq!(r.i8().unwrap(), 2);
|
||||
assert_eq!(r.nbt().unwrap(), opaque);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn primitive_translation_arguments_keep_the_764_rendered_spellings() {
|
||||
// The real 764 writer turns primitive args into strings. Actual 765 writer/readback
|
||||
// of those strings preserves 764 getString (including true, 1.0, 1e2 and -0.0).
|
||||
for (name, json, nbt) in [
|
||||
(
|
||||
"mixed",
|
||||
"{\"translate\":\"wire.key\",\"fallback\":\"Args %s|%s|%s\",\"with\":[true,1.0,1e2]}",
|
||||
"0a0900047769746808000000030004747275650003312e30000331653208000866616c6c6261636b000d417267732025737c25737c25730800097472616e736c6174650008776972652e6b657900",
|
||||
),
|
||||
(
|
||||
"numeric",
|
||||
"{\"translate\":\"wire.key\",\"fallback\":\"Args %s|%s|%s|%s\",\"with\":[1,1.0,1e2,-0.0]}",
|
||||
"0a0900047769746808000000040001310003312e30000331653200042d302e3008000866616c6c6261636b0010417267732025737c25737c25737c25730800097472616e736c6174650008776972652e6b657900",
|
||||
),
|
||||
(
|
||||
"booleans",
|
||||
"{\"translate\":\"wire.key\",\"fallback\":\"Args %s|%s\",\"with\":[true,false]}",
|
||||
"0a090004776974680800000002000474727565000566616c736508000866616c6c6261636b000a417267732025737c25730800097472616e736c6174650008776972652e6b657900",
|
||||
),
|
||||
] {
|
||||
let mut wire = Writer::new();
|
||||
wire.string(json);
|
||||
let actual = read_component(&mut Reader::new(&wire.data)).unwrap();
|
||||
let expected = Reader::new(&bytes(nbt)).nbt().unwrap();
|
||||
assert_eq!(actual, expected, "{name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 281 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user