Compare commits

...
Author SHA1 Message Date
Kacper 159b5c4be6 Merge pull request #8718 from TheApple05/feature/older-servers-1.21.8
Older servers: experimental native adapters for 1.20.2–1.21.8
2026-11-08 00:25:05 +01:00
Ludovic Marie a392d4b3ba Merge pull request #12101 from horizon-rust/fix/anticheat-effect-attributes
🤖 Agent: the client keeps only the server's attribute modifiers, so Speed now ends one tick after RemoveMobEffect as in vanilla (ServerPlayer.doTick runs after sendDirtyEntityData); Paper+GrimAC bench 7-17 Simulation flags at expiry -> 0, 8 selected harnesses pass.
2026-11-08 00:07:47 +01:00
Ludovic Marie 491710cd2f Merge pull request #11880 from horizon-rust/agent/native-build-sign
🤖 Agent: verifies the per-mod-set build signature with its own Ed25519 key (separate from the release key) before the launcher runs a prebuilt binary; independent review found it sound; opt-in stays off until the rollout steps.
2026-11-07 23:53:00 +01:00
Ludovic Marie 0932cefc79 Merge pull request #12055 from horizon-rust/vanilla-split/extras-feature
🤖 Agent: the default build resolves the same features as before (390 third-party units with identical fingerprints); every new gate is cfg!(), so CI's existing --no-default-features clippy already compiles the no-extras side and no stub can drift.
2026-11-07 23:49:40 +01:00
Ludovic Marie 77af961663 Merge pull request #11444 from voidsipp/fix/round5-2-11340
🤖 Agent: A helmet is damaged by falling-block damage before the reduction, as LivingEntity.hurtHelmet does in damages_helmet.
2026-11-07 23:09:40 +01:00
Horizon agent 2f60199b75 Merge remote-tracking branch 'origin/main' into fix/anticheat-effect-attributes 2026-11-07 22:49:45 +01:00
Horizon agentandClaude Opus 5.5 033b4a9e60 Test: the player's base speed is 0.1F
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 22:49:44 +01:00
Ludovic Marie 6a0b9e435b Merge pull request #11549 from horizon-rust/pvp-2/item-view
🤖 Agent: item components read by reference instead of cloned per frame (frame prepare 217→193 µs); earlier parity failures were harness bugs since fixed on main; parity-main covers it after merge.
2026-11-07 22:46:44 +01:00
Ludovic Marie 485226a6fd Merge pull request #11545 from horizon-rust/pvp-2/gui-glint-lookup
🤖 Agent: HUD glint sprite looked up once per item instead of per quad (HUD 84→72 µs); render-only, packet harness 16/16; parity-main covers it after merge.
2026-11-07 22:46:41 +01:00
Horizon agentandClaude Opus 5.5 e4ce68e271 Client: take effect attribute modifiers from the server only (#12040)
A server's client applied its effects' attribute modifiers itself, so it
slowed down as soon as ClientboundRemoveMobEffectPacket arrived. Vanilla's
server removes a Speed that runs out in ServerPlayer.doTick, after the
tick's sendDirtyEntityData, so the movement speed without the effect's
modifier reaches the client a tick later and vanilla's client runs fast for
that tick. GrimAC's Simulation flagged the Horizon client on that tick
(0.051 off with Speed II, decaying over the next ticks).

LivingEntity.onEffectAdded/onEffectUpdated/onEffectsRemoved change
attributes on a server only; a server's client now keeps the modifiers
ClientboundUpdateAttributesPacket sends.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 22:35:44 +01:00
Ludovic Marie 151aa6c82b Merge pull request #11159 from Ragbie/fix/tnt-fuse-any-number
🤖 Agent: Minecart numeric NBT reads any numeric tag through NumericTag int/short/byte/double values.
2026-11-07 22:35:05 +01:00
TheApple05andCodex GPT-6 b5406e647a Preserve legacy item normalization across upstream integration
Cover dropped components, pot decorations, instrument holders and nesting limits in the combined codecs; retain cross-platform keepalive settings without lint warnings.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-07 13:14:23 -08:00
TheApple05andCodex GPT-6 2d72b0b260 Merge remote-tracking branch 'upstream/main' into codex/viahorizon-upstream
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-07 13:12:23 -08:00
TheApple05andCodex GPT-6 d79a36101f Merge upstream main and reconcile native protocol adapters
Preserve both older-server and older-client translation paths, consolidate protocol 766 tables, retain verified legacy codecs and resource-pack generations, and reject unsupported reverse protocols.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-07 13:12:16 -08:00
Ludovic Marie 35f3dad207 Merge pull request #11409 from ilguerro/fix/w151
🤖 Agent: Setting a lead holder clears the saved, still unresolved holder, as LeashData.setLeashHolder does.
2026-11-07 22:09:23 +01:00
Ludovic Marie 5de5b6fd91 Merge pull request #12046 from horizon-rust/mods/stamp-chunk-tickets
🤖 Agent: stamps the existing chunk_tickets fragments (API 412/413, NATIVE_HOOKS_API 413); tests.test_stamp_native_hooks 10 OK, verify green, and this exact head built into the SMP engine on playhorizon (rtp2), whose /rtp proof passes with no slow tick. The SMP 0.35.1 PR in horizon-mods needs it.
2026-11-07 22:01:20 +01:00
Horizon agent ca17c0c5ba Merge remote-tracking branch 'origin/main' into vanilla-split/extras-feature
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-07 21:55:27 +01:00
Ludovic Marie 23c3faedc1 Merge pull request #11947 from horizon-rust/docs/license-history-notices
🤖 Agent: notices list the three cases (Iris, OpenJDK math, OpenJDK HashMap); both clean-room rewrites (#11944, #12043) are merged, so the notice now describes main accurately.
2026-11-07 21:51:19 +01:00
Ludovic Marie ba3c3feebf Merge pull request #11951 from horizon-rust/split/resource-tables
🤖 Agent: regenerated from main after #11871/#11940/#11961 landed; still a pure move of the three impl blocks (199 lines), structs stay in game.rs.
2026-11-07 21:29:31 +01:00
Ludovic Marie ec298576f6 Merge pull request #12043 from horizon-rust/fix/java-hash-map-clean-room
🤖 Agent: clean-room java_hash_map (CLRS tree, slab); java_hash_map and java_identity_map oracles identical over 1.18M lines plus 3,028 edge fixtures; 11/12 caller harnesses pass (structure_layout lacks oracle data on the desktop).
2026-11-07 21:29:28 +01:00
Ludovic Marie 97c9d1a14f Merge pull request #11944 from horizon-rust/fix/jmath-fdlibm-provenance
🤖 Agent: clean-room jmath; JVM-identical on ~2.5M inputs per function (log/log10/cbrt), 5.9M hypot pairs, 3.05M sin/cos, all 2^32 floats for round_f32; parity-main covers the ~420 dependent harnesses after merge.
2026-11-07 21:29:24 +01:00
Horizon agentandClaude Opus 5.5 9099175f8a Move the shared tables impl blocks to game/resource_tables.rs
Pure move (#11857). Private methods and items become pub(super), the same reach they had in game.rs (the game module and its children). Port map rows, source-reading tests and the docs tables follow the code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 20:22:09 +00:00
Ludovic Marie e1e39361db Merge pull request #11961 from horizon-rust/split/loading-screen
🤖 Agent: pure move of 512 lines (Loading, App, run_event_loop, the start checks) to game/loading_screen.rs; the Windows-only hwnd stays in game.rs and stays visible to the moved code.
2026-11-07 21:20:15 +01:00
Ludovic Marie ef19179033 Merge pull request #11940 from horizon-rust/split/inventory-input
🤖 Agent: pure move of 506 lines (keys and clicks over a container screen) to game/inventory_input.rs; click_inventory stays because an open draft changes it.
2026-11-07 21:20:12 +01:00
Ludovic Marie b189c72fcc Merge pull request #11871 from horizon-rust/split/resource-load
🤖 Agent: pure move of the startup resource load (614 lines) to game/resource_load.rs; load_world_rest stays because #11574 changes it.
2026-11-07 21:20:08 +01:00
Horizon agentandClaude Opus 5.5 3a229da6b3 mod API: stamp chunk_tickets (412) and chunk_tickets_in_level (413)
python3 tools/stamp_native_hooks.py --only chunk_tickets chunk_tickets_in_level.
The SMP's /rtp moves from /forceload (which waits on the server thread
until its chunk is generated) to native_force_chunk_in, so it needs a
number to declare.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 21:19:46 +01:00
Horizon agentandClaude Opus 5.5 e22ccc17d2 Notices: add java_hash_map.rs, replaced in #12043
The same check for #6741 found that java_hash_map.rs was translated from
OpenJDK's HashMap.java. List it with the other two, and say so in
how-horizon-is-built.md.

Refs #6741

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 21:17:46 +01:00
Ludovic Marie 493dd75acf Merge pull request #12007 from horizon-rust/split/play-keys
🤖 Agent: pure move of key_pressed (290 lines) to game/play_keys.rs; AGENTS.md's Input row now names play_keys.rs for it.
2026-11-07 21:13:55 +01:00
Horizon agentandClaude Opus 5.5 f4481233b0 Move the loading screen and the pre-game App out of game.rs into game/loading_screen.rs
Pure move (#11857). Private methods and items become pub(super), the same reach they had in game.rs (the game module and its children). Port map rows, source-reading tests and the docs tables follow the code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 20:13:07 +00:00
Ludovic Marie 63cbec701e Merge pull request #12036 from horizon-rust/split/cauldrons
🤖 Agent: pure move of cauldron_outcome and use_on_cauldron (96 lines) to game/cauldrons.rs; cauldron_at and cauldron_extinguish stay because an open draft changes them.
2026-11-07 21:06:30 +01:00
Ludovic Marie a597387a66 Merge pull request #12035 from horizon-rust/split/key-codes
🤖 Agent: pure move of 117 lines (GLFW key/button codes, follow_named_keymap, the key routing rules) to game/key_codes.rs; game.rs re-imports the names other game modules reach through super::.
2026-11-07 21:06:26 +01:00
Horizon agentandClaude Opus 5.5 2d306dc52c Move container screen key and mouse input out of game.rs into game/inventory_input.rs
Pure move (#11857). Private methods and items become pub(super), the same reach they had in game.rs (the game module and its children). Port map rows, source-reading tests and the docs tables follow the code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 20:01:15 +00:00
Horizon agentandClaude Opus 5.5 4f6be12c2e Move the in-world key press handler out of game.rs into game/play_keys.rs
Pure move (#11857). Private methods and items become pub(super), the same reach they had in game.rs (the game module and its children). Port map rows, source-reading tests and the docs tables follow the code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 19:54:16 +00:00
Ludovic Marie 8c93f81143 Merge pull request #12015 from horizon-rust/split/core-tests
🤖 Agent: moves 575 lines of game.rs's inline tests to game/core_tests.rs with unchanged bodies; the horizon-client test count stays 1159, and the tests open PRs touch stay put.
2026-11-07 20:51:01 +01:00
Ludovic Marie a20e9ce2d0 Merge pull request #12013 from horizon-rust/split/player-spawn
🤖 Agent: pure move of spawn_player (117 lines) to game/player_spawn.rs; test_world_input_capture's loading-completion check reads it from the new file and still sees the player set before the mouse capture.
2026-11-07 20:50:57 +01:00
Horizon agentandClaude Opus 5.5 aba1a90e6f java_hash_map: clean-room rewrite (#6741)
The previous implementation was translated from OpenJDK's
java/util/HashMap.java (GPL-2.0), which Horizon's MIT licence cannot
carry. This one was written in a two-person clean room: the implementer
worked from a behaviour spec, the Java SE API documentation, CLRS
chapter 13 (red-black trees) and runs on a real JVM, and never read
OpenJDK's source or the old file.

Entries live in a slab of nodes addressed by u32 index. A bucket holds
the first node of its bin's list and, for a tree bin, the root of a
red-black tree over the same nodes, whose links sit in a side table the
map allocates with its first tree bin. The public API is unchanged.

Unit tests pin iteration orders printed by java.util.HashMap on OpenJDK
25: a doubling from 16 to 32 buckets, a tree bin made at 64 buckets
after two doublings below 64, a tree-bin insertion after its tree
parent, movable and cursor removals from a tree bin, copies, and the
cursor's error order.

Refs #6741

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 20:50:56 +01:00
Ludovic Marie 73a3c0348d Merge pull request #11995 from horizon-rust/split/item-actions
🤖 Agent: pure move of 190 lines (startUseItem, interactOn, the placement/bucket cancel hooks) to game/item_actions.rs; the Player actions docs rows now point fn use_item and fn attack at their files.
2026-11-07 20:50:54 +01:00
Horizon agentandClaude Opus 5.5 c95f24bc32 Move using an item on a cauldron out of game.rs into game/cauldrons.rs
Pure move (#11857). Private methods and items become pub(super), the same reach they had in game.rs (the game module and its children). Port map rows, source-reading tests and the docs tables follow the code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 19:41:54 +00:00
Horizon agentandClaude Opus 5.5 ca0b945857 Move the GLFW key and button code helpers out of game.rs into game/key_codes.rs
Pure move (#11857). Private methods and items become pub(super), the same reach they had in game.rs (the game module and its children). Port map rows, source-reading tests and the docs tables follow the code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 19:41:00 +00:00
Ludovic Marie 33be2a611a Merge pull request #12004 from horizon-rust/split/autosave
🤖 Agent: pure move of 90 lines (autosave interval, tick-rate change, player autosaves, level.dat metadata) to game/autosave.rs; save_game_with and save_level stay because open PRs change them.
2026-11-07 20:37:30 +01:00
Ludovic Marie 1199c117f2 Merge pull request #12003 from horizon-rust/split/render-settings
🤖 Agent: pure move of 109 lines (renderer_for, apply_settings, translucent layers, far terrain distances) to game/render_settings.rs; prepare_lod and all_sections_changed stay because an open draft changes them.
2026-11-07 20:37:27 +01:00
Ludovic Marie 2e749948f4 Merge pull request #11979 from horizon-rust/split/world-session
🤖 Agent: pure move of 234 lines (title actions, open_world, load_world, save_for_exit, close_world) to game/world_session.rs; finish_open_world and finish_close_world stay because an open draft changes them.
2026-11-07 20:37:24 +01:00
Horizon agent f5f96b1be4 Merge remote-tracking branch 'origin/main' into split/core-tests 2026-11-07 19:36:59 +00:00
Horizon agentandClaude Opus 5.5 522634ca7f Move placing the local player in a world out of game.rs into game/player_spawn.rs
Pure move (#11857). Private methods and items become pub(super), the same reach they had in game.rs (the game module and its children). Port map rows, source-reading tests and the docs tables follow the code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 19:31:48 +00:00
Horizon agentandClaude Opus 5.5 f7543ccffa Move using an item at nothing or at an entity out of game.rs into game/item_actions.rs
Pure move (#11857). Private methods and items become pub(super), the same reach they had in game.rs (the game module and its children). Port map rows, source-reading tests and the docs tables follow the code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 19:30:54 +00:00
Horizon agentandClaude Opus 5.5 1fd2f02081 Move most of game.rs's inline tests into game/core_tests.rs
Pure move (#11857): the tests no open PR changes go to their own test module; the ones open PRs touch (the module header, the scaffolding fall test, the integrated save test) and the tests next to them stay. Test bodies are unchanged; rustfmt re-indented them one level.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 19:30:29 +00:00
Ludovic Marie 8d8c250f48 Merge pull request #11996 from horizon-rust/split/block-particles
🤖 Agent: pure move of 110 lines (block face and particle sprites, honey particles, destroy particles) to game/block_particles.rs; particle_sprite and crack_particle stay because open PRs change them.
2026-11-07 20:24:09 +01:00
Ludovic Marie d21bc19b84 Merge pull request #11993 from horizon-rust/split/melee
🤖 Agent: pure move of 186 lines (attack strength, crits, sweeps, Player.attack) to game/melee.rs; melee_damage and attack_mob stay because open PRs change them.
2026-11-07 20:18:27 +01:00
Ludovic Marie 3afc51e740 Merge pull request #11972 from horizon-rust/split/window-handler
🤖 Agent: pure move of the game window's ApplicationHandler (295 lines) to game/window_handler.rs; the input tests read window_event/device_event from the new file first, since Loading and App have handlers with the same names.
2026-11-07 20:18:23 +01:00
Ludovic Marie 17778b9722 Merge pull request #11957 from horizon-rust/split/inventory-slots
🤖 Agent: pure move of 359 lines (slot layout, hit test, item and decoration drawing) to game/inventory_slots.rs; slots/slot_count/draw_inventory stay because open PRs change them.
2026-11-07 20:18:18 +01:00
Horizon agentandClaude Opus 5.5 abaa2a9f68 Put Horizon's extras behind a horizon-extras feature for forks
One Cargo feature, on by default, holds the entry points of Horizon's own
extras so a fork can build the game without them
(`--no-default-features --features audio,controllers`) or delete them:
cosmetics, emotes and the "Horizon player" presence (one gate,
game::cosmetics::enabled), the official and partner servers added to
servers.dat, the Options screen's Horizon Settings and Mods buttons, and
the existing discord, friends and heartbeat features it now turns on.

The default build resolves the same features as before and every gate is
cfg!(), so both sides compile in every build. A build without the extras
names the official build on Credits & Attribution. docs/forking.md lists
the extras, their files and their gates.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 20:18:11 +01:00
Horizon agentandClaude Opus 5.5 d1c1684fb5 Move opening and closing a local world out of game.rs into game/world_session.rs
Pure move (#11857). Private methods and items become pub(super), the same reach they had in game.rs (the game module and its children). Port map rows, source-reading tests and the docs tables follow the code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 19:17:05 +00:00
Ludovic Marie fdc87aecdb Merge pull request #11978 from horizon-rust/split/using-item
🤖 Agent: pure move of 237 lines (an item in use, tick by tick) to game/using_item.rs; apply_consume_effect and drop_bundle_content stay in game.rs because an open draft changes them.
2026-11-07 20:03:38 +01:00
Horizon agent 52343380b3 Merge remote-tracking branch 'origin/main' into split/melee 2026-11-07 18:58:14 +00:00
Horizon agent e2680a5450 Merge remote-tracking branch 'origin/main' into split/window-handler 2026-11-07 18:53:48 +00:00
Horizon agentandClaude Opus 5.5 5e7d248148 Move the autosave timer and world metadata save out of game.rs into game/autosave.rs
Pure move (#11857). Private methods and items become pub(super), the same reach they had in game.rs (the game module and its children). Port map rows, source-reading tests and the docs tables follow the code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 18:49:36 +00:00
Horizon agent 67f34e0fdd Merge remote-tracking branch 'origin/main' into split/inventory-slots 2026-11-07 18:49:36 +00:00
Horizon agentandClaude Opus 5.5 17a68d7b97 Move the renderer settings and far terrain distances out of game.rs into game/render_settings.rs
Pure move (#11857). Private methods and items become pub(super), the same reach they had in game.rs (the game module and its children). Port map rows, source-reading tests and the docs tables follow the code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 18:48:42 +00:00
Horizon agentandClaude Opus 5.5 90b30ee17b Move block particle sprites and destroy particles out of game.rs into game/block_particles.rs
Pure move (#11857). Private methods and items become pub(super), the same reach they had in game.rs (the game module and its children). Port map rows, source-reading tests and the docs tables follow the code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 18:47:47 +00:00
Ludovic Marie 5578753b58 Merge pull request #11981 from horizon-rust/perf/portal-ticking
🤖 Agent: requeued after merging main: the only conflict was the module list in game.rs (`pointer_capture` from main beside `portal_ticking_tests`), resolved by keeping both; no code change since the checks.
2026-11-07 19:46:09 +01:00
Horizon agentandClaude Opus 5.5 6faf3ef3f3 Move the player's melee attack out of game.rs into game/melee.rs
Pure move (#11857). Private methods and items become pub(super), the same reach they had in game.rs (the game module and its children). Port map rows, source-reading tests and the docs tables follow the code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 18:45:59 +00:00
Horizon agentandClaude Opus 5.5 ab44611814 Move the game window's ApplicationHandler out of game.rs into game/window_handler.rs
Pure move (#11857). Private methods and items become pub(super), the same reach they had in game.rs (the game module and its children). Port map rows, source-reading tests and the docs tables follow the code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 18:44:52 +00:00
Horizon agent 8c612bc687 Merge remote-tracking branch 'origin/main' into perf/portal-ticking
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-07 19:44:39 +01:00
Ludovic Marie 9debdf26d4 Merge pull request #11982 from horizon-rust/perf/mod-hook-spans
🤖 Agent: queued: spans only, no behaviour change; pr_ready --quick green. Point: the guards wrap the mods' hooks outside every level span, so the SMP's 1-2 s ticks get a name in horizon-report.
2026-11-07 19:43:54 +01:00
Horizon agentandClaude Opus 5.5 27a2eb565e Move container slot layout and drawing out of game.rs into game/inventory_slots.rs
Pure move (#11857). Private methods and items become pub(super), the same reach they had in game.rs (the game module and its children). Port map rows, source-reading tests and the docs tables follow the code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 18:43:45 +00:00
Ludovic Marie d7ae75b441 Merge pull request #11967 from horizon-rust/split/play-input
🤖 Agent: pure move of 390 lines (toggled keys, mapped keys, hotbar wheel, play_mouse) to game/play_input.rs; the two hook tests that compile mapped_key_event and the wheel branch read the new file.
2026-11-07 19:36:04 +01:00
Ludovic Marie bc017eec9b Merge pull request #11946 from horizon-rust/split/screen-backgrounds
🤖 Agent: pure move of 170 lines (title panorama, menu blur, border wall, the screens' render-post hooks) to game/screen_backgrounds.rs; regenerated on main after #11939 since the blocks were adjacent.
2026-11-07 19:36:01 +01:00
Horizon agentandClaude Opus 5.5 693178dc36 Move the startup resource load out of game.rs into game/resource_load.rs
Pure move (#11857). Private methods and items become pub(super), the same reach they had in game.rs (the game module and its children). Port map rows, source-reading tests and the docs tables follow the code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 18:34:57 +00:00
Horizon agentandClaude Opus 5.5 b19634003c Server tick: spans for the mods' tick hooks, the config poll and idle levels
A dedicated server's slow tick lines on the SMP show 1-2 s ticks whose
time is in no span: it is spent outside every level, where only the mods'
ServerTickStart/End hooks, the config poll and the idle-level close run.
Each gets a span, so a slow tick line and horizon-report name it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 18:17:09 +00:00
Horizon agent 8711aabd75 Merge remote-tracking branch 'origin/main' into split/play-input 2026-11-07 18:09:24 +00:00
Horizon agentandClaude Opus 5.5 76a5bd8a22 Move item use over time out of game.rs into game/using_item.rs
Pure move (#11857). Private methods and items become pub(super), the same reach they had in game.rs (the game module and its children). Port map rows, source-reading tests and the docs tables follow the code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 18:08:23 +00:00
Horizon agentandClaude Opus 5.5 453d8b89ce Move the menu backgrounds out of game.rs into game/screen_backgrounds.rs
Pure move (#11857). Private methods and items become pub(super), the same reach they had in game.rs (the game module and its children). Port map rows, source-reading tests and the docs tables follow the code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 18:05:14 +00:00
Ludovic Marie 845132a56b Merge pull request #11945 from horizon-rust/split/loading-window
🤖 Agent: pure move of 415 lines (first renderer, start_game) to game/loading_window.rs; the Windows-only hwnd stays in game.rs, so no cfg'd re-import is needed.
2026-11-07 19:03:20 +01:00
Horizon agentandClaude Opus 5.5 051e3dd546 Portals: entities past the simulation distance neither cross nor cool down
Entity.handlePortal runs in the entity's own tick, and ServerLevel.tick
ticks only the entities in entity-ticking chunks. handle_portals and
find_portals walked every loaded entity, so a mob in a nether portal past
the simulation distance went through it and its cooldown counted down.
They now skip entities outside entity-ticking chunks and keep the portal
state of the ones waiting there.

Fixes #11960

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 18:01:59 +00:00
Horizon agentandClaude Opus 5.5 ab5dfae985 Move play key and mouse mappings out of game.rs into game/play_input.rs
Pure move (#11857). Private methods and items become pub(super), the same reach they had in game.rs (the game module and its children). Port map rows, source-reading tests and the docs tables follow the code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 18:01:11 +00:00
Ludovic Marie 19a6e9b81b Merge pull request #11943 from horizon-rust/split/world-entry
🤖 Agent: pure move of the world entry impl block (367 lines) to game/world_entry.rs; the world_items!/world_recipes! macros stay defined before the mod list, so the moved code still sees them.
2026-11-07 18:54:04 +01:00
Ludovic Marie de118feb4e Merge pull request #11948 from horizon-rust/perf/entity-inside-ticking
🤖 Agent: merging #11948 (smp-200). The one risk was the filter cutting block contact for entities that do tick: it keys on the same entity_ticking_area test the mob and item ticks use. The new test shows a zombie inside simulation distance 4 still presses its plate while one past it doesn't (and fails without the change). server_entities, mob_ai, cauldron, raid and dedicated_server harnesses pass locally; CI parity covers the rest.
2026-11-07 18:51:29 +01:00
Horizon agentandClaude Opus 5.5 e508e7919f rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:49:45 +00:00
Ludovic Marie f222616f7e Merge pull request #11939 from horizon-rust/split/creative-screen
🤖 Agent: pure move of 440 lines (the creative screen) to game/creative_screen.rs; no open PR touches them, visibility stays within the game module.
2026-11-07 18:48:49 +01:00
Ludovic Marie 0f89fd7e00 Merge pull request #11937 from horizon-rust/split/pointer-capture
🤖 Agent: pure move of 324 lines to game/pointer_capture.rs; test_world_input_capture's method() now also finds pub(super) methods, so the capture fixture still compiles the real code.
2026-11-07 18:48:46 +01:00
Horizon agentandClaude Opus 5.5 6889d9366b Notices: point the OpenJDK entry at its replacement, #11944
Refs #6741

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 18:46:18 +01:00
Horizon agent 0804e2219a Merge remote-tracking branch 'origin/main' into split/loading-window 2026-11-07 17:35:51 +00:00
Horizon agentandClaude Opus 5.5 1c6a22db1a java_hash_map: remove the implementation translated from OpenJDK
JavaHashMap reproduced java.util.HashMap iteration order by translating
OpenJDK's java/util/HashMap.java (GPL-2.0 with the Classpath Exception):
its tree-bin routines, their signatures and their quirks (#6741). This
keeps the public API and removes the body, which panics with todo!() until
the next commit writes it again from a behaviour description, the Javadoc
and the real JVM's results, by an agent that has not read either source.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 18:34:10 +01:00
Horizon agent 8a9a6d5e3f Merge remote-tracking branch 'origin/main' into split/world-entry 2026-11-07 17:28:44 +00:00
Ludovic Marie d17e8fa343 Merge pull request #10330 from MSedgeMC/fix/bullet
🤖 Agent: kacpherek's Dir point is fixed (from3DDataValue wrap, Steps unclamped, tested); on top of MSedgeMC's main merges I only moved the no-target doc comment back onto its test; shulker_space and the save harnesses pass on the union of this gate's PRs on main 1d63e0a5a (playhorizon: fmt, clippy -D warnings, client + client-lib tests, 26 harnesses all pass).
2026-11-07 18:28:07 +01:00
Ludovic Marie 6f374959b7 Merge pull request #10288 from MSedgeMC/port_map/seven-dead-citations
🤖 Agent: port_ledger map-check on the four changed TSVs reads 519 rows, no problems, and kacpherek's PackSelectionScreen.Watcher row is restored (rdbtCVS's fix); TSV-only, no game code.
2026-11-07 18:28:03 +01:00
Horizon agentandClaude Opus 5.5 15a8b2a77c jmath: rewrite the fdlibm functions from Sun's C sources (#6741)
jmath.rs held ports of OpenJDK code: fdlibm::{log, log10, cbrt, hypot}
from OpenJDK's FdLibm.java (GPLv2 with the Classpath Exception),
aarch64::{sin, cos} from HotSpot's AArch64 trig intrinsics (GPLv2), and
round_f32 from OpenJDK's Math.round(float). Horizon is MIT.

Rewritten in a two-person clean room from Sun's fdlibm 5.3 C sources, a
behaviour-only description of the AArch64 sin/cos, and the Java SE API
documentation:

- jmath/fdlibm.rs (new, Sun's notice at the top): log, log10, cbrt and
  hypot ported from e_log.c, e_log10.c, s_cbrt.c and e_hypot.c; sin_fma
  and cos_fma, fdlibm's sin and cos (s_sin.c, s_cos.c, k_sin.c, k_cos.c,
  e_rem_pio2.c) with fused multiply-adds, as Math.sin/cos on AArch64.
- jmath.rs: round_f32 from the Java SE specification of
  Math.round(float); the module doc says what each platform computes;
  jmath::fdlibm and jmath::aarch64::{sin, cos} keep their paths.
- geometry/trig.rs: Sun's notice above its fdlibm asin/atan/atan2 ports.
- THIRD_PARTY_NOTICES.md: an fdlibm section.

Bit for bit against JDK 25: StrictMath log, log10, cbrt and hypot on
2.5M, 2.5M and 2.7M inputs and 5.9M pairs; Math.sin and Math.cos on an
Apple M4 on 3.05M arguments, compiled and -Xint; Math.round(float) on
all 2^32 floats.

Refs #6741

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 18:24:45 +01:00
Horizon agent 1206818cde Merge remote-tracking branch 'origin/main' into split/creative-screen 2026-11-07 17:17:00 +00:00
Ludovic Marie 12157a2d67 Merge pull request #11901 from horizon-rust/feat/shop-stand-in
🤖 Agent: the stand-in is painted from the website's own palette and boxes (no Mojang texture); test pins its face pixels and transparent outer layers.
2026-11-07 18:12:33 +01:00
Horizon agentandClaude Opus 5.5 b029b9d04f Docs: list code removed for its license, correct "No Minecraft files"
THIRD_PARTY_NOTICES.md gets a "Code removed for its license" section for
anyone holding an older version: the shader pack code written from Iris's
source (LGPL-3.0, removed in #1561) and the math functions translated from
OpenJDK and HotSpot (GPL-2.0, #6741). In those versions that code is not
covered by Horizon's MIT license.

CONTRIBUTING.md says OpenJDK's own source is GPL too, so it is off limits
like any GPL project; Java's behaviour comes from its documentation, a JVM's
results, or a permissive original such as Sun's fdlibm.

docs/how-horizon-is-built.md no longer says Horizon holds no Minecraft data
and never downloads it: the datafix tables come from vanilla's code, and the
launcher downloads 26.3 from Mojang for a signed-in account that owns it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 18:11:12 +01:00
Horizon agent 9b89ff00ee Merge remote-tracking branch 'origin/main' into split/pointer-capture 2026-11-07 17:10:59 +00:00
Horizon agentandClaude Opus 5.5 2e13076a93 List game/loading_window.rs in the docs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:10:21 +00:00
Horizon agentandClaude Opus 5.5 9974322832 Launcher Shop: dress Horizon's stand-in player when there is no skin
Without a skin the launcher can read (signed out, offline), the Shop's pictures
and its 3D view drew the plain grey figure. They now use the stand-in the
website's shop draws (its standInSkin, original art painted from rows, not
Mojang's Steve or Alex), so both shops show the same player.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 18:09:34 +01:00
Horizon agent b21f0c92f2 wip loading_window 2026-11-07 17:08:53 +00:00
Ludovic Marie 31545199d4 Merge pull request #11894 from horizon-rust/feat/shop-fortnite
🤖 Agent: pictures are drawn on background threads and cached by item hash and screen scale, so the Shop never renders on the UI thread; prices and the checkout call are the same rules as before (two live checkouts cancelled via Paddle).
2026-11-07 18:03:10 +01:00
Horizon agentandClaude Opus 5.5 9a8781468e rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 16:56:54 +00:00
Horizon agentandClaude Opus 5.5 1117b98d15 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 16:56:19 +00:00
Horizon agentandClaude Opus 5.5 8fe0e40d68 Block contact only for entities in entity-ticking chunks
Entity.applyEffectsFromBlocks runs in the entity's own tick, and
ServerLevel.tick ticks only entities in entity-ticking chunks, so a mob or
item past the simulation distance presses no plate and is not hurt by a
cactus. tick_blocks swept every loaded entity: 2.5 ms a tick on the SMP,
where ~2,900 of 4,600 overworld mobs sit outside entity-ticking chunks.

Fixes #11881

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 16:55:32 +00:00
Horizon agentandClaude Opus 5.5 f68c335eb2 Point the port map and source-reading tests at game/pointer_capture.rs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 16:53:05 +00:00
Horizon agent 12dad67f8c Merge remote-tracking branch 'origin/main' into feat/shop-fortnite
# Conflicts:
#	launcher/lang/af.json
#	launcher/lang/ar.json
#	launcher/lang/az.json
#	launcher/lang/ba.json
#	launcher/lang/be.json
#	launcher/lang/bg.json
#	launcher/lang/br.json
#	launcher/lang/bs.json
#	launcher/lang/ca.json
#	launcher/lang/cs.json
#	launcher/lang/cv.json
#	launcher/lang/cy.json
#	launcher/lang/da.json
#	launcher/lang/de.json
#	launcher/lang/el.json
#	launcher/lang/eo.json
#	launcher/lang/es.json
#	launcher/lang/et.json
#	launcher/lang/eu.json
#	launcher/lang/fa.json
#	launcher/lang/fi.json
#	launcher/lang/fil.json
#	launcher/lang/fo.json
#	launcher/lang/fr.json
#	launcher/lang/fur.json
#	launcher/lang/fy.json
#	launcher/lang/ga.json
#	launcher/lang/gd.json
#	launcher/lang/gl.json
#	launcher/lang/haw.json
#	launcher/lang/he.json
#	launcher/lang/hi.json
#	launcher/lang/hr.json
#	launcher/lang/hu.json
#	launcher/lang/hy.json
#	launcher/lang/id.json
#	launcher/lang/ig.json
#	launcher/lang/is.json
#	launcher/lang/it.json
#	launcher/lang/ja.json
#	launcher/lang/ka.json
#	launcher/lang/kk.json
#	launcher/lang/kn.json
#	launcher/lang/ko.json
#	launcher/lang/ky.json
#	launcher/lang/la.json
#	launcher/lang/lb.json
#	launcher/lang/li.json
#	launcher/lang/lmo.json
#	launcher/lang/lo.json
#	launcher/lang/lt.json
#	launcher/lang/lv.json
#	launcher/lang/mk.json
#	launcher/lang/mn.json
#	launcher/lang/ms.json
#	launcher/lang/mt.json
#	launcher/lang/nl.json
#	launcher/lang/no.json
#	launcher/lang/oc.json
#	launcher/lang/pl.json
#	launcher/lang/pt-br.json
#	launcher/lang/pt.json
#	launcher/lang/ro.json
#	launcher/lang/ru.json
#	launcher/lang/sah.json
#	launcher/lang/se.json
#	launcher/lang/sk.json
#	launcher/lang/sl.json
#	launcher/lang/so.json
#	launcher/lang/sq.json
#	launcher/lang/sr.json
#	launcher/lang/sv.json
#	launcher/lang/szl.json
#	launcher/lang/ta.json
#	launcher/lang/th.json
#	launcher/lang/tr.json
#	launcher/lang/tt.json
#	launcher/lang/uk.json
#	launcher/lang/ur.json
#	launcher/lang/uz.json
#	launcher/lang/vec.json
#	launcher/lang/vi.json
#	launcher/lang/yi.json
#	launcher/lang/yo.json
#	launcher/lang/zh.json
#	launcher/lang/zu.json
2026-11-07 17:49:15 +01:00
Horizon agentandClaude Opus 5.5 922f00f52e Launcher Shop: a game-style item shop with drawn items
Ludovic (2026-11-07): "In the launcher you just don't see the items." The Shop
now opens on the season pack across the top (its looks drawn side by side, name,
item count, price, Buy), then rows of big tiles: Looks, each collection's items,
the founder set and the Supporter badge. A tile is the item drawn, its name and its
price; it lifts under the pointer. A tile opens it big on the account's own player
in 3D (an emote plays), with a look's items, Buy and Gift.

- Pictures: the Wardrobe's CPU renderer, framed on the item (preview::fitted_view),
  at device pixels supersampled 2x, on background threads, kept by item hashes.
- Buy and Gift are one checkout of that offer through the same web checkout;
  prices still come from the catalogue and the service's set rules.
- Cut: the page's line, the basket and its notes; three new strings (Buy, Gift,
  Looks) in all 86 tables, nine unused ones removed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:48:35 +01:00
Horizon agentandClaude Opus 5.5 200615a30a Move world entry and the world content tables out of game.rs into game/world_entry.rs
Pure move (#11857): defer_world_entry to join_world_rest (the deferred world entry, world creation and rest polling, the world's item/recipe/station tables). Private methods become pub(super), the same reach they had in game.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 16:41:27 +00:00
Horizon agentandClaude Opus 5.5 1ba0db3c3c Move the creative inventory screen out of game.rs into game/creative_screen.rs
Pure move (#11857): the creative screen's tabs, pages, tooltips, trash, search and clicks. Private methods become pub(super), the same reach they had in game.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 16:41:27 +00:00
Horizon agentandClaude Opus 5.5 0ce0e67282 Move the mouse look and pointer grab out of game.rs into game/pointer_capture.rs
Pure move (#11857): look_options to regrab_on_refocus (mouse look, pause when inactive, held keys, the pointer grab and cursor icon). Private methods become pub(super), the same reach they had in game.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 16:41:27 +00:00
Horizon agentandClaude Opus 5.5 c794b87841 Signatures for prebuilt native-mod builds (#11859)
horizon-release-sign gets `native_build`: a build's request (target, base commit, each mod's
id, version and registry file SHA-256, sorted) and its key, the release and asset names in
horizon-mods, and the signed message `horizon native build <key> <target>\n<sha256>  <asset>\n`,
which no release or Nightly signature can pass for. The keys are their own
(native-build-keys.txt): horizon-mods' `native-builds` environment holds the private one,
because the registry compiles other people's code there.

tools/ci/sign_native_build.sh signs with OpenSSL as sign_release.sh does, refuses a key whose
public half isn't listed, and checks its own signature; tests sign with it and verify as the
launcher will.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:19:02 +01:00
Horizon agentandClaude Opus 5.5 0f8cf58043 Launcher: the Wardrobe preview can read a saved shop catalogue
HORIZON_COSMETICS_CATALOGUE=<file of GET /v1/catalogue> gives the development preview
real prices, sets and collections, so Shop screenshots show what players see.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:08:41 +01:00
Horizon agentandClaude Opus 5.5 dd93b0164a Merge #10290 into #10330 (both edit the same lines)
Conflicts kept from both sides: crates/horizon-client/src/entity_nbt.rs

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 15:41:11 +01:00
Horizon agentandClaude Opus 5.5 7622c02706 Put the no-target doc comment back on its own test
The Dir/Steps test had taken the doc comment of the no-target test; each test now has its own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 15:41:11 +01:00
Horizon agentandClaude Opus 5.5 7cf67fa99d Merge origin/main into #11159 (main builds again after #11767)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 14:10:28 +01:00
MSedgeMC ac02477732 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-07 13:09:30 +00:00
Horizon agentandClaude Opus 5.5 37cee5b4fd Merge origin/main into #11159 (keep both command_exec corpus groups)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 13:59:09 +01:00
MSedgeMC 0748416e9e Merge remote-tracking branch 'upstream/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-07 11:22:23 +00:00
Horizon agentandClaude Opus 5.5 073e47caed Merge origin/main into pvp-2/item-view
Picks up #11532 and 88a372f808 (four harnesses that failed under parity.py's defaults).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 10:32:55 +01:00
Horizon agentandClaude Opus 5.5 e36bbcfc63 Merge origin/main into pvp-2/gui-glint-lookup
Picks up #11532 and 88a372f808 (four harnesses that failed under parity.py's defaults).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 10:32:55 +01:00
Horizon agentandClaude Opus 5.5 a26743d278 Merge origin/main into #11444
Main's thorns check counts events from before the hurt; the PR's helmet
path runs after that count like the plain one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:50:13 +01:00
Horizon agentandClaude Opus 5.5 f834560860 Drop the unreachable second return in minecart_numeric_fields
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:26:59 +01:00
Horizon agentandClaude Opus 5.5 9ffa81a748 Items: read a stack's components in place instead of copying its item's defaults
ItemModels::components cloned the item's whole default component map (an
enchanted sword's: a few dozen JSON values and strings) for every stack
with a patch, and the model resolver did it on every draw: the hotbar,
each held item of every player in view, the first-person hand twice. A
PvP fight with enchanted gear paid it a dozen times a frame.

ComponentsView reads the patch, then the defaults, with no copy; the
resolver, the swap animation scale, attack range, use duration and the
use animation read through it. components() stays for what needs the
merged map (bundle fullness, still built only for bundles).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:09:17 +01:00
Horizon agentandClaude Opus 5.5 7ce65988b2 Items: look an enchanted GUI item's glint sprite up once per sprite, not per quad
submit_quads hashed the quad's sprite name into the glint atlas for every
quad of an enchanted item drawn in the GUI (a generated sword: hundreds of
side quads, one sprite). It now keeps the last sprite's glint rect, as it
already does for the item atlas rect: the same uvs, one lookup a sprite.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:08:35 +01:00
Evrim Cicek 02e7620924 Merge remote-tracking branch 'upstream/main' into fix/tnt-fuse-any-number
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-07 02:30:18 +03:00
voidsip 3cdf31772f Fix falling damage helmet durability order 2026-11-06 16:14:24 -06:00
rdbtCVSandClaude Opus 5.5 0fe1d0e15d Port map: keep PackSelectionScreen.Watcher on folder_stamp, which exists
Review feedback on #10288.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:16:42 +00:00
rdbtCVSandClaude Opus 5.5 3f38b2d0bd Merge origin/main into PR #10288
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:16:42 +00:00
effe6ea423 Forget a mob's unresolved saved lead holder when a player leads it
Leashable.setLeashedTo goes through LeashData.setLeashHolder, which
clears the delayed holder a save named. Horizon kept it, so 100 ticks
after loading the forgotten holder still dropped a lead item next to a
mob the player was leading, or a holder that loaded late took the mob
from the player. Mobs and boats both.

Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:33:37 +01:00
Evrim CicekandClaude Opus 5.5 c40c92e0bc Minecarts read their numeric NBT from any numeric tag, as vanilla's ValueInput does
AbstractMinecart, MinecartTNT, MinecartFurnace and MinecartHopper read
their fields through ValueInput's numeric getters, which take any
NumericTag: getIntOr (fuse, DisplayOffset), getShortOr (Fuel, the low 16
bits), getDoubleOr (PushX, PushZ) and getBooleanOr (FlippedRotation,
HasTicked, Enabled: byteValue() != 0). Cart::load took only byte, short
and int, and only double for the pushes, so {fuse:20L} left a TNT
minecart unprimed and {PushX:1} gave a furnace minecart no push.

Command harness group "minecart numeric fields": 3/18 -> 18/18.

Fixes #11146

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 16:40:54 +03:00
MSedgeMC e08c7a3ee0 port_map: seven citations name functions the tree does not have
A ledger build from today's upstream/main reports seven map gaps, all rows
whose where cell names a function that no longer exists: PrimedTnt.explode
points at Game::tick_tnt (it is tick_one_tnt), FlatLevelSource.createState at
FlatTerrain::structure_settings (from_settings), PacketProcessor's hand-off at
conn::read_loop (io_pool::Pool::push), three ServerCommonPacketListenerImpl
keep-alive rows at conn::write_loop (that loop is wait_alive), and
PackSelectionScreen.Watcher is a class row written as a port.
2026-11-05 22:29:55 +00:00
TheApple05andCodex cc3ee16e46 Merge upstream main after language-key validation fix
Preserve the existing adapter and maintainer history while including the
upstream removal of stale Menu Navigation translation keys.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-04 15:01:06 +00:00
TheApple05andCodex 42372aa760 Merge current upstream main after older-server review fixes
Preserve published history and upstream shader changes.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-04 12:59:28 +00:00
TheApple05andCodex d29c8eb63f Fix legacy attribute, llama carpet and block entity conversions
Select attribute and carpet registry names from the actual server release.
Upgrade incoming pre-component block entities on both update and chunk paths,
retaining tag identity and vanilla typed-field remainders.

Add independent registry and vanilla DFU regression fixtures, including
missing IDs, removed types and unchanged current-version tags.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-04 12:58:52 +00:00
TheApple05andCodex 8852c0e0b8 Merge current upstream main before older-server review fixes
Preserve published compatibility commits and newer maintainer changes before addressing the protocol-764 review. This merge alone claims no new compatibility or parity result.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-04 12:18:11 +00:00
TheApple05andCodex 21f7031e72 Merge upstream main before resuming ViaHorizon compatibility
Preserve the published older-server contribution and current upstream changes as separate parents before the next tested extension. No new compatibility or parity result is claimed by this merge.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-04 10:43:04 +00:00
TheApple05andCodex 7dde5f518c Add experimental native Minecraft 1.20.2 protocol adapter
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-04 05:15:58 +00:00
TheApple05andCodex b65d5f5dcd Fix resource-pack instance identity across replacement and reconnect
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-04 05:12:56 +00:00
TheApple05andCodex f88c50772f Merge upstream main during protocol 764 development
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-04 04:41:58 +00:00
TheApple05andCodex d064372d9d Merge upstream main before protocol 764 compatibility
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-04 04:13:35 +00:00
TheApple05andCodex ca0a90eee3 Add native protocol 765 compatibility for Minecraft 1.20.3 and 1.20.4
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-04 04:01:11 +00:00
TheApple05andCodex c6780b8daf test: establish TCP backpressure before disconnect assertions
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-04 04:01:10 +00:00
TheApple05andCodex aca15c91d4 Merge upstream main before adding 1.20.4 compatibility
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-04 02:40:26 +00:00
TheApple05andCodex 01166b25ae Add experimental Minecraft 1.20.5 and 1.20.6 server compatibility
Translate protocol 766 attributes, food, horse inventories, projectile power,
chat holders and item use into the pinned protocol. Supply the old static
enchantment and painting names, and normalize legacy wolf variant holders.

Reuse verified recipe and boat layouts and cover changed codecs with
independent vanilla wire fixtures, including full chat packet translations.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-04 02:29:55 +00:00
TheApple05andCodex 311cf0d31f Merge upstream main before adding 1.20.6 compatibility
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-04 01:54:44 +00:00
TheApple05andCodex 9e9078f17f Add experimental Minecraft 1.21 and 1.21.1 server compatibility
Translate protocol 767 packets, recipes, item components and generic boat
variants into the pinned protocol. Preserve old movement and interaction
layouts, and correct simple spell particles across the older adapters.

Add independent vanilla wire fixtures and document the experimental scope.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-04 01:39:23 +00:00
TheApple05andCodex d00c6bcd25 Merge upstream main before adding 1.21.1 compatibility
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-04 01:02:51 +00:00
TheApple05andCodex a96e04043e Extend native older-server support to Minecraft 1.21.2 and 1.21.3
Translate protocol 768 particles, player-list defaults, vehicle movement and
integer model data through the existing older-server pipeline. Preserve old
creaking heart states and transient entities through explicit registry aliases,
and keep legacy trail lifetimes in the client particle provider.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-04 00:50:10 +00:00
TheApple05andCodex 424f045246 Merge upstream main before adding 1.21.3 compatibility
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-04 00:29:05 +00:00
TheApple05andCodex dfd4b135ef Extend native older-server support to Minecraft 1.21.4
Translate legacy chunk framing, item patches, recipe previews, chat and
entity metadata into the pinned client representation. Preserve old click
operations through authoritative server resynchronization and cover older
stack/profile layouts with independent vanilla wire fixtures.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-04 00:04:47 +00:00
TheApple05andCodex baceff1b55 Merge upstream main before adding 1.21.4 compatibility
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-04 00:01:44 +00:00
TheApple05andCodex de808d2d30 Merge upstream main before adding 1.21.4 compatibility
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-03 23:20:25 +00:00
TheApple05andCodex 44338647fa Extend native older-server support to Minecraft 1.21.5
Map protocol 770's packet and registry ids, remove vanilla's unused chunk
padding, renumber command parsers before reading their properties, and
translate player actions, crouch transitions and hanging-entity metadata.
Convert pre-1.21.6 attribute and equippable components in both directions,
with independent vanilla wire fixtures and protocol regression coverage.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-03 23:15:23 +00:00
TheApple05andCodex 3f41fda7a1 Merge upstream main before adding 1.21.5 compatibility
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-03 22:57:14 +00:00
TheApple05andCodex 57bc596fe1 Extend the native older-server adapter to 1.21.6
Reuse the packet layouts shared with 1.21.7/1.21.8, map the item and sound
registry ids before the lava chicken insertion, and generate inherited tables
only where the vanilla reports differ. Cover shifted discs and sound holders
and exercise the shared metadata and packet conversion cases on both protocols.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-03 22:53:22 +00:00
TheApple05andCodex 4090de57e0 Merge upstream main before extending older-server support
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-03 22:35:53 +00:00
TheApple05andCodex 1d78698703 Add experimental native protocol support for 1.21.8
Generate vanilla packet and registry mappings, translate the changed layouts
and player metadata, preserve renamed chain states, and extend the offline
protocol probe to fail on decode errors and follow dimension changes.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-03 22:18:41 +00:00
281 changed files with 31241 additions and 12084 deletions

No files matched your search

+9 -9
View File
@@ -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
+4
View File
@@ -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.
+50
View File
@@ -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.
```
+55 -14
View File
@@ -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;
+92 -4
View File
@@ -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]);
+8 -1
View File
@@ -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": "高级",
+5
View File
@@ -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();
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);
+101
View File
@@ -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);
}
}
}
+10 -3
View File
@@ -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
}
}
+32 -13
View File
@@ -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();
+1 -1
View File
@@ -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();
}
+4 -4
View File
@@ -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(),
}
}
}
+8 -7
View File
@@ -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);
}
}
+130
View File
@@ -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.
+82 -2
View File
@@ -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"
);
}
}
+13 -2
View File
@@ -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))
}
+193
View File
@@ -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
}
}
+16 -9
View File
@@ -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(())
}
}
+295
View File
@@ -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));
}
+22 -6
View File
@@ -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.
+131 -1
View File
@@ -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).
+11 -5
View File
@@ -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)
}
}
+4
View File
@@ -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,
})
}
}
+12 -2
View File
@@ -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])
+55 -3
View File
@@ -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(
+24 -1
View File
@@ -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
+17
View File
@@ -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,
+266 -26
View File
@@ -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();
+10
View File
@@ -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 {
+37 -427
View File
@@ -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 [
+587
View File
@@ -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)]);
}
}
+107 -2
View File
@@ -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];
+2 -4
View File
@@ -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)
}
+83 -2
View File
@@ -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(())
}
+129 -146
View File
@@ -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);
+115 -4
View File
@@ -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());
}
}
}
+263 -19
View File
@@ -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, &current),
(cb::OPEN_SCREEN, current.clone()),
"{name} 765 regression"
);
let p = play::OpenScreen::read(&mut Reader::new(&current)).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